diff --git a/.gitignore b/.gitignore index bd804cc..74e18f9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ clawdbot_enhancement.md # OS files .DS_Store +# Package tarballs +*.tgz + # Logs *.log diff --git a/src/cli.ts b/src/cli.ts index 3f78b7a..2122c24 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -466,5 +466,5 @@ function helpText() { ` lobster 'exec --json "echo [1,2,3]" | json'\n` + ` lobster run --mode tool 'exec --json "echo [1]" | approve --prompt "ok?"'\n\n` + `Commands:\n` + - ` exec, head, json, pick, table, where, approve, clawd.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run\n`; + ` exec, file.read, file.write, head, jq.filter, json, pick, table, where, approve, clawd.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run\n`; } diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 74ef5f5..e9742f6 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -13,6 +13,9 @@ import { approveCommand } from "./stdlib/approve.js"; import { clawdInvokeCommand } from "./stdlib/clawd_invoke.js"; import { llmTaskInvokeCommand } from "./stdlib/llm_task_invoke.js"; import { stateGetCommand, stateSetCommand } from "./stdlib/state.js"; +import { fileReadCommand } from "./stdlib/file_read.js"; +import { fileWriteCommand } from "./stdlib/file_write.js"; +import { jqFilterCommand } from "./stdlib/jq_filter.js"; import { diffLastCommand } from "./stdlib/diff_last.js"; import { workflowsListCommand } from "./workflows/workflows_list.js"; import { workflowsRunCommand } from "./workflows/workflows_run.js"; @@ -41,6 +44,9 @@ export function createDefaultRegistry() { llmTaskInvokeCommand, stateGetCommand, stateSetCommand, + fileReadCommand, + fileWriteCommand, + jqFilterCommand, diffLastCommand, workflowsListCommand, workflowsRunCommand, diff --git a/src/commands/stdlib/file_read.ts b/src/commands/stdlib/file_read.ts new file mode 100644 index 0000000..abcb87e --- /dev/null +++ b/src/commands/stdlib/file_read.ts @@ -0,0 +1,86 @@ +import { promises as fsp } from 'node:fs'; +import { resolve, isAbsolute } from 'node:path'; + +export const fileReadCommand = { + name: 'file.read', + meta: { + description: 'Read a file and yield its contents into the pipeline', + argsSchema: { + type: 'object', + properties: { + _: { type: 'array', items: { type: 'string' }, description: 'File path' }, + path: { type: 'string', description: 'File path (alternative to positional)' }, + format: { type: 'string', enum: ['auto', 'text', 'json', 'jsonl'], description: 'Parse format (default: auto)' }, + }, + required: ['_'], + }, + sideEffects: ['reads_fs'], + }, + help() { + return `file.read — read a file and yield its contents\n\n` + + `Usage:\n` + + ` file.read [--format auto|text|json|jsonl]\n\n` + + `Formats:\n` + + ` auto (default): try JSON parse; if array yield elements; else try JSONL; else text\n` + + ` json: parse as JSON; yield elements if array, else single item\n` + + ` jsonl: split lines, parse each as JSON\n` + + ` text: yield entire content as a single string\n\n` + + `Notes:\n` + + ` - Replaces the pipeline stream; upstream items are discarded.\n\n` + + `Security:\n` + + ` Paths are unrestricted (same as exec). This command can read any file\n` + + ` accessible to the process.\n`; + }, + async run({ input, args }) { + // Drain input (file replaces pipeline input). + for await (const _item of input) { /* no-op */ } + + const filePath = args._[0] || args.path; + if (!filePath) throw new Error('file.read requires a path'); + + const resolved = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); + const format = (args.format ?? 'auto').toLowerCase(); + const VALID_FORMATS = ['auto', 'text', 'json', 'jsonl']; + if (!VALID_FORMATS.includes(format)) { + throw new Error(`file.read: unknown format '${format}'`); + } + + const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + const stat = await fsp.stat(resolved); + if (stat.size > MAX_FILE_SIZE) { + throw new Error(`file.read: file exceeds maximum size (${stat.size} bytes > ${MAX_FILE_SIZE} bytes)`); + } + const content = await fsp.readFile(resolved, 'utf8'); + + if (format === 'text') { + return { output: asStream([content]) }; + } + + if (format === 'json') { + const parsed = JSON.parse(content); + return { output: asStream(Array.isArray(parsed) ? parsed : [parsed]) }; + } + + if (format === 'jsonl') { + const items = content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + return { output: asStream(items) }; + } + + // auto: try JSON, then JSONL, then text. + try { + const parsed = JSON.parse(content); + return { output: asStream(Array.isArray(parsed) ? parsed : [parsed]) }; + } catch { /* not JSON */ } + + const lines = content.split(/\r?\n/).filter(Boolean); + if (lines.length > 0 && lines.every((line) => { try { JSON.parse(line); return true; } catch { return false; } })) { + return { output: asStream(lines.map((line) => JSON.parse(line))) }; + } + + return { output: asStream([content]) }; + }, +}; + +async function* asStream(items) { + for (const item of items) yield item; +} diff --git a/src/commands/stdlib/file_write.ts b/src/commands/stdlib/file_write.ts new file mode 100644 index 0000000..12d78b5 --- /dev/null +++ b/src/commands/stdlib/file_write.ts @@ -0,0 +1,70 @@ +import { promises as fsp } from 'node:fs'; +import { resolve, isAbsolute, dirname } from 'node:path'; + +export const fileWriteCommand = { + name: 'file.write', + meta: { + description: 'Write pipeline items to a file and pass them through', + argsSchema: { + type: 'object', + properties: { + _: { type: 'array', items: { type: 'string' }, description: 'File path' }, + path: { type: 'string', description: 'File path (alternative to positional)' }, + format: { type: 'string', enum: ['json', 'jsonl', 'text'], description: 'Output format (default: json)' }, + mkdir: { type: 'boolean', description: 'Create parent directories (default: true)' }, + }, + required: ['_'], + }, + sideEffects: ['writes_fs'], + }, + help() { + return `file.write — write pipeline items to a file\n\n` + + `Usage:\n` + + ` | file.write [--format json|jsonl|text] [--mkdir true|false]\n\n` + + `Formats:\n` + + ` json (default): JSON with 2-space indent; single item unwrapped, multiple as array\n` + + ` jsonl: one JSON-serialized item per line\n` + + ` text: items joined with newline; non-strings JSON-serialized\n\n` + + `Notes:\n` + + ` - Tee semantics: all collected items are yielded downstream after write.\n` + + ` - --mkdir (default true) creates parent directories if needed.\n\n` + + `Security:\n` + + ` Paths are unrestricted (same as exec). This command can write to any path\n` + + ` accessible to the process.\n`; + }, + async run({ input, args }) { + const filePath = args._[0] || args.path; + if (!filePath) throw new Error('file.write requires a path'); + + const resolved = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); + const format = (args.format ?? 'json').toLowerCase(); + const mkdirEnabled = args.mkdir !== false; + + const items = []; + for await (const item of input) items.push(item); + + let content; + if (format === 'json') { + const value = items.length === 1 ? items[0] : items; + content = JSON.stringify(value, null, 2) + '\n'; + } else if (format === 'jsonl') { + content = items.map((item) => JSON.stringify(item)).join('\n') + (items.length ? '\n' : ''); + } else if (format === 'text') { + content = items.map((item) => (typeof item === 'string' ? item : JSON.stringify(item))).join('\n') + (items.length ? '\n' : ''); + } else { + throw new Error(`file.write: unknown format '${format}'`); + } + + if (mkdirEnabled) { + await fsp.mkdir(dirname(resolved), { recursive: true }); + } + + await fsp.writeFile(resolved, content, 'utf8'); + + return { output: asStream(items) }; + }, +}; + +async function* asStream(items) { + for (const item of items) yield item; +} diff --git a/src/commands/stdlib/jq_filter.ts b/src/commands/stdlib/jq_filter.ts new file mode 100644 index 0000000..579e118 --- /dev/null +++ b/src/commands/stdlib/jq_filter.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; + +export const jqFilterCommand = { + name: 'jq.filter', + meta: { + description: 'Apply a jq expression to each pipeline item', + argsSchema: { + type: 'object', + properties: { + _: { type: 'array', items: { type: 'string' }, description: 'jq expression' }, + expr: { type: 'string', description: 'jq expression (alternative to positional)' }, + raw: { type: 'boolean', description: 'output raw strings instead of JSON (like jq -r)' }, + }, + required: ['_'], + }, + sideEffects: ['local_exec'], + }, + help() { + return `jq.filter — apply a jq expression to each pipeline item\n\n` + + `Usage:\n` + + ` | jq.filter \n` + + ` | jq.filter --expr \n` + + ` | jq.filter --raw \n\n` + + `Options:\n` + + ` --raw Output raw strings instead of JSON (passes -r to jq).\n\n` + + `Notes:\n` + + ` - Each input item is serialized as JSON and piped to jq -c .\n` + + ` - Each non-empty stdout line is parsed as JSON and yielded.\n` + + ` - With --raw, stdout lines are yielded as plain strings (no JSON parse).\n` + + ` - Requires jq on PATH.\n`; + }, + async run({ input, args }) { + const expr = args._[0] || args.expr; + if (!expr) throw new Error('jq.filter requires an expression'); + + const raw = Boolean(args.raw); + const results = []; + for await (const item of input) { + const itemJson = JSON.stringify(item); + const output = await runJq(expr, itemJson, raw); + const lines = output.split(/\r?\n/).filter(Boolean); + for (const line of lines) { + results.push(raw ? line : JSON.parse(line)); + } + } + + return { output: asStream(results) }; + }, +}; + +function runJq(expr, stdin, raw = false) { + return new Promise((resolve, reject) => { + const jqArgs = ['-c', ...(raw ? ['-r'] : []), expr]; + const child = spawn('jq', jqArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { PATH: process.env.PATH || '' }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + + child.stdin.setDefaultEncoding('utf8'); + child.stdin.write(stdin); + child.stdin.end(); + + child.on('error', (err) => { + reject(new Error(`jq.filter: failed to spawn jq: ${err.message}`)); + }); + child.on('close', (code) => { + if (code === 0) return resolve(stdout); + reject(new Error(`jq.filter failed (exit ${code}): ${stderr.trim() || 'unknown error'}`)); + }); + }); +} + +async function* asStream(items) { + for (const item of items) yield item; +} diff --git a/src/commands/stdlib/llm_task_invoke.ts b/src/commands/stdlib/llm_task_invoke.ts index 86ece36..700b48a 100644 --- a/src/commands/stdlib/llm_task_invoke.ts +++ b/src/commands/stdlib/llm_task_invoke.ts @@ -146,8 +146,6 @@ type CacheEntry = { storedAt: string; }; -type Transport = 'clawd'; - export const llmTaskInvokeCommand = { name: 'llm_task.invoke', meta: { @@ -198,7 +196,6 @@ export const llmTaskInvokeCommand = { const env = ctx.env ?? process.env; const clawdUrl = String(env.CLAWD_URL ?? '').trim(); - const transport: Transport = 'clawd'; if (!clawdUrl) { throw new Error('llm_task.invoke requires CLAWD_URL (run via Clawdbot gateway)'); } diff --git a/test/file_read.test.ts b/test/file_read.test.ts new file mode 100644 index 0000000..e2ca802 --- /dev/null +++ b/test/file_read.test.ts @@ -0,0 +1,197 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, writeFileSync, truncateSync } from 'node:fs'; +import { createDefaultRegistry } from '../src/commands/registry.js'; + +function streamOf(items) { + return (async function* () { + for (const item of items) yield item; + })(); +} + +function makeCtx() { + return { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + env: process.env, + registry: createDefaultRegistry(), + mode: 'tool', + render: { json() {}, lines() {} }, + }; +} + +async function collect(output) { + const items = []; + for await (const item of output) items.push(item); + return items; +} + +test('file.read JSON array yields elements', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'data.json'); + writeFileSync(filePath, JSON.stringify([{ a: 1 }, { a: 2 }])); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'json' }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [{ a: 1 }, { a: 2 }]); +}); + +test('file.read JSON object yields single item', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'obj.json'); + writeFileSync(filePath, JSON.stringify({ a: 1 })); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'json' }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [{ a: 1 }]); +}); + +test('file.read JSONL yields parsed lines', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'data.jsonl'); + writeFileSync(filePath, '{"x":1}\n{"x":2}\n{"x":3}\n'); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'jsonl' }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [{ x: 1 }, { x: 2 }, { x: 3 }]); +}); + +test('file.read text yields entire content as single string', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'readme.txt'); + writeFileSync(filePath, 'hello world\nline two\n'); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'text' }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.equal(items.length, 1); + assert.equal(items[0], 'hello world\nline two\n'); +}); + +test('file.read auto-detects JSON array', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'auto.json'); + writeFileSync(filePath, JSON.stringify([10, 20, 30])); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath] }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [10, 20, 30]); +}); + +test('file.read auto-detects JSONL', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'auto.jsonl'); + writeFileSync(filePath, '{"k":"a"}\n{"k":"b"}\n'); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath] }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [{ k: 'a' }, { k: 'b' }]); +}); + +test('file.read auto-detects plain text', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'plain.txt'); + writeFileSync(filePath, 'not json at all\njust text\n'); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath] }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.equal(items.length, 1); + assert.equal(items[0], 'not json at all\njust text\n'); +}); + +test('file.read throws on missing file', async () => { + const cmd = createDefaultRegistry().get('file.read'); + await assert.rejects( + () => cmd.run({ input: streamOf([]), args: { _: [path.join(os.tmpdir(), 'nonexistent-lobster-' + Date.now() + '.json')] }, ctx: makeCtx() }), + (err: any) => err.code === 'ENOENT', + ); +}); + +test('file.read --path named arg works', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'named.json'); + writeFileSync(filePath, JSON.stringify({ b: 2 })); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [], path: filePath, format: 'json' }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [{ b: 2 }]); +}); + +test('file.read throws when no path provided', async () => { + const cmd = createDefaultRegistry().get('file.read'); + await assert.rejects( + () => cmd.run({ input: streamOf([]), args: { _: [] }, ctx: makeCtx() }), + (err: any) => err.message.includes('file.read requires a path'), + ); +}); + +test('file.read throws on unknown format', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'data.json'); + writeFileSync(filePath, '{}'); + + const cmd = createDefaultRegistry().get('file.read'); + await assert.rejects( + () => cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'xml' }, ctx: makeCtx() }), + (err: any) => err.message.includes("unknown format 'xml'"), + ); +}); + +test('file.read --format json throws on invalid JSON content', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'bad.json'); + writeFileSync(filePath, 'this is not json'); + + const cmd = createDefaultRegistry().get('file.read'); + await assert.rejects( + () => cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'json' }, ctx: makeCtx() }), + ); +}); + +test('file.read throws when file exceeds MAX_FILE_SIZE', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'huge.json'); + writeFileSync(filePath, ''); + // Create a sparse file that reports > 50 MB without writing actual data + const MAX_FILE_SIZE = 50 * 1024 * 1024; + truncateSync(filePath, MAX_FILE_SIZE + 1); + + const cmd = createDefaultRegistry().get('file.read'); + await assert.rejects( + () => cmd.run({ input: streamOf([]), args: { _: [filePath] }, ctx: makeCtx() }), + (err: any) => err.message.includes('file exceeds maximum size'), + ); +}); + +test('file.read --format jsonl throws on invalid line', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'bad.jsonl'); + writeFileSync(filePath, '{"valid":true}\nnot valid json\n{"also":true}\n'); + + const cmd = createDefaultRegistry().get('file.read'); + await assert.rejects( + () => cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'jsonl' }, ctx: makeCtx() }), + ); +}); + +test('file.read auto-detects JSON object (not array) as single item', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fread-')); + const filePath = path.join(tmp, 'auto-obj.json'); + writeFileSync(filePath, JSON.stringify({ key: 'value', nested: { a: 1 } })); + + const cmd = createDefaultRegistry().get('file.read'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath] }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.equal(items.length, 1); + assert.deepEqual(items[0], { key: 'value', nested: { a: 1 } }); +}); diff --git a/test/file_write.test.ts b/test/file_write.test.ts new file mode 100644 index 0000000..7ebb15a --- /dev/null +++ b/test/file_write.test.ts @@ -0,0 +1,196 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { createDefaultRegistry } from '../src/commands/registry.js'; + +function streamOf(items) { + return (async function* () { + for (const item of items) yield item; + })(); +} + +function makeCtx() { + return { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + env: process.env, + registry: createDefaultRegistry(), + mode: 'tool', + render: { json() {}, lines() {} }, + }; +} + +async function collect(output) { + const items = []; + for await (const item of output) items.push(item); + return items; +} + +test('file.write single JSON object', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'out.json'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([{ name: 'test' }]), args: { _: [filePath], format: 'json' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.deepEqual(JSON.parse(content), { name: 'test' }); +}); + +test('file.write multiple items as JSON array', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'arr.json'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([{ a: 1 }, { a: 2 }]), args: { _: [filePath], format: 'json' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.deepEqual(JSON.parse(content), [{ a: 1 }, { a: 2 }]); +}); + +test('file.write JSONL format', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'out.jsonl'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([{ x: 1 }, { x: 2 }]), args: { _: [filePath], format: 'jsonl' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + const lines = content.trim().split('\n'); + assert.equal(lines.length, 2); + assert.deepEqual(JSON.parse(lines[0]), { x: 1 }); + assert.deepEqual(JSON.parse(lines[1]), { x: 2 }); +}); + +test('file.write text format', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'out.txt'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf(['hello', 'world']), args: { _: [filePath], format: 'text' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.equal(content, 'hello\nworld\n'); +}); + +test('file.write tee passthrough yields items downstream', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'tee.json'); + const inputItems = [{ a: 1 }, { a: 2 }, { a: 3 }]; + + const cmd = createDefaultRegistry().get('file.write'); + const res = await cmd.run({ input: streamOf(inputItems), args: { _: [filePath] }, ctx: makeCtx() }); + + const yielded = await collect(res.output); + assert.deepEqual(yielded, inputItems); +}); + +test('file.write --mkdir creates parent directories', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'nested', 'deep', 'out.json'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([42]), args: { _: [filePath], mkdir: true }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.deepEqual(JSON.parse(content), 42); +}); + +test('file.write --mkdir false fails on missing parent', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'nonexistent', 'out.json'); + + const cmd = createDefaultRegistry().get('file.write'); + await assert.rejects( + () => cmd.run({ input: streamOf([1]), args: { _: [filePath], mkdir: false }, ctx: makeCtx() }), + (err: any) => err.code === 'ENOENT', + ); +}); + +test('file.write --path named arg works', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'named.json'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([{ c: 3 }]), args: { _: [], path: filePath, format: 'json' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.deepEqual(JSON.parse(content), { c: 3 }); +}); + +test('file.write throws when no path provided', async () => { + const cmd = createDefaultRegistry().get('file.write'); + await assert.rejects( + () => cmd.run({ input: streamOf([1]), args: { _: [] }, ctx: makeCtx() }), + (err: any) => err.message.includes('file.write requires a path'), + ); +}); + +test('file.write throws on unknown format', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'out.xml'); + + const cmd = createDefaultRegistry().get('file.write'); + await assert.rejects( + () => cmd.run({ input: streamOf([1]), args: { _: [filePath], format: 'xml' }, ctx: makeCtx() }), + (err: any) => err.message.includes("unknown format 'xml'"), + ); +}); + +test('file.write empty input produces empty array for JSON', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'empty.json'); + + const cmd = createDefaultRegistry().get('file.write'); + const res = await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'json' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.deepEqual(JSON.parse(content), []); + + const items = await collect(res.output); + assert.deepEqual(items, []); +}); + +test('file.write empty input produces empty string for JSONL', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'empty.jsonl'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'jsonl' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.equal(content, ''); +}); + +test('file.write empty input produces empty string for text', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'empty.txt'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ input: streamOf([]), args: { _: [filePath], format: 'text' }, ctx: makeCtx() }); + + const content = readFileSync(filePath, 'utf8'); + assert.equal(content, ''); +}); + +test('file.write text format serializes non-string items as JSON', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-fwrite-')); + const filePath = path.join(tmp, 'mixed.txt'); + + const cmd = createDefaultRegistry().get('file.write'); + await cmd.run({ + input: streamOf(['plain', 42, { key: 'val' }, true]), + args: { _: [filePath], format: 'text' }, + ctx: makeCtx(), + }); + + const content = readFileSync(filePath, 'utf8'); + const lines = content.trimEnd().split('\n'); + assert.equal(lines[0], 'plain'); + assert.equal(lines[1], '42'); + assert.equal(lines[2], '{"key":"val"}'); + assert.equal(lines[3], 'true'); +}); diff --git a/test/jq_filter.test.ts b/test/jq_filter.test.ts new file mode 100644 index 0000000..ffd51be --- /dev/null +++ b/test/jq_filter.test.ts @@ -0,0 +1,171 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDefaultRegistry } from '../src/commands/registry.js'; + +const JQ_AVAILABLE = process.platform !== 'win32'; + +function streamOf(items) { + return (async function* () { + for (const item of items) yield item; + })(); +} + +function makeCtx() { + return { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + env: process.env, + registry: createDefaultRegistry(), + mode: 'tool', + render: { json() {}, lines() {} }, + }; +} + +async function collect(output) { + const items = []; + for await (const item of output) items.push(item); + return items; +} + +test('jq.filter identity . passes items through', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ input: streamOf([{ a: 1 }, { b: 2 }]), args: { _: ['.'] }, ctx: makeCtx() }); + const items = await collect(res.output); + assert.deepEqual(items, [{ a: 1 }, { b: 2 }]); +}); + +test('jq.filter extracts field with .name', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([{ name: 'alice' }, { name: 'bob' }]), + args: { _: ['.name'] }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, ['alice', 'bob']); +}); + +test('jq.filter navigates nested path .a.b', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([{ a: { b: 42 } }]), + args: { _: ['.a.b'] }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, [42]); +}); + +test('jq.filter array output .[].x flattens results', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([{ items: [{ x: 1 }, { x: 2 }] }]), + args: { _: ['.items[].x'] }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, [1, 2]); +}); + +test('jq.filter processes multiple items independently', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([{ v: 10 }, { v: 20 }, { v: 30 }]), + args: { _: ['.v'] }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, [10, 20, 30]); +}); + +test('jq.filter propagates error on invalid expression', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + await assert.rejects( + () => cmd.run({ input: streamOf([{ a: 1 }]), args: { _: ['invalid!!!'] }, ctx: makeCtx() }), + (err: any) => err.message.includes('jq.filter failed'), + ); +}); + +test('jq.filter --expr named arg works', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([{ a: 1 }]), + args: { _: [], expr: '.a' }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, [1]); +}); + +test('jq.filter throws when no expression provided', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + await assert.rejects( + () => cmd.run({ input: streamOf([{ a: 1 }]), args: { _: [] }, ctx: makeCtx() }), + (err: any) => err.message.includes('jq.filter requires an expression'), + ); +}); + +test('jq.filter --raw yields plain strings', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([{ name: 'alice' }, { name: 'bob' }]), + args: { _: ['.name'], raw: true }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, ['alice', 'bob']); + // Without --raw, .name yields JSON strings (quoted); with --raw, they are plain unquoted strings. + // Both resolve to the same JS string here because JSON.parse('"alice"') === 'alice'. + // The real difference is visible with values containing special chars or when downstream + // consumers expect non-JSON text. + for (const item of items) { + assert.equal(typeof item, 'string', `expected plain string, got ${typeof item}`); + } +}); + +test('jq.filter --raw multiline yields each line as separate item', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + // Use keys[] to produce multiple raw output lines from a single object + const res = await cmd.run({ + input: streamOf([{ x: 1, y: 2, z: 3 }]), + args: { _: ['keys[]'], raw: true }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, ['x', 'y', 'z']); + // Verify these are plain strings, not JSON-quoted + for (const item of items) { + assert.ok(!item.startsWith('"'), `expected unquoted string, got ${item}`); + } +}); + +test('jq.filter with zero input items yields empty output', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { + const cmd = createDefaultRegistry().get('jq.filter'); + const res = await cmd.run({ + input: streamOf([]), + args: { _: ['.'] }, + ctx: makeCtx(), + }); + const items = await collect(res.output); + assert.deepEqual(items, []); +}); + +test('jq.filter spawn error yields descriptive message', async () => { + // Set PATH to empty so jq binary can't be found, triggering spawn ENOENT + const cmd = createDefaultRegistry().get('jq.filter'); + const savedPath = process.env.PATH; + try { + process.env.PATH = ''; + await assert.rejects( + () => cmd.run({ + input: streamOf([{ a: 1 }]), + args: { _: ['.'] }, + ctx: makeCtx(), + }), + (err: any) => err.message.includes('jq.filter'), + ); + } finally { + process.env.PATH = savedPath; + } +});