From 671dcd66b39ddfb8ba690e0d8571ca140efbcc75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20F=C3=B6rster?= <103369858+sfo2001@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:29:04 +0100 Subject: [PATCH 1/3] fix(stdlib): remove unused Transport type and transport variable Dead code left over from the clawd-only refactor in 5679042. Co-Authored-By: Claude Opus 4.6 --- src/commands/stdlib/llm_task_invoke.ts | 3 --- 1 file changed, 3 deletions(-) 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)'); } From 1d88b0572eb028b71c5e73d87f76b899cb1697cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20F=C3=B6rster?= <103369858+sfo2001@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:13:56 +0100 Subject: [PATCH 2/3] feat(stdlib): add file I/O and jq-filter commands Three new pipeline commands for file I/O and jq-based filtering: - file.read: read files as JSON, JSONL, text, or auto-detect format - file.write: write pipeline items to files with tee semantics - jq-filter: apply jq expressions to each pipeline item Includes format validation, 50 MB file size guard, Windows platform skip for jq tests, and 30 tests covering all formats, edge cases, named arg alternatives, and error paths. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + src/cli.ts | 2 +- src/commands/registry.ts | 6 ++ src/commands/stdlib/file_read.ts | 81 +++++++++++++++ src/commands/stdlib/file_write.ts | 67 +++++++++++++ src/commands/stdlib/jq_filter.ts | 76 ++++++++++++++ test/file_read.test.ts | 159 ++++++++++++++++++++++++++++++ test/file_write.test.ts | 141 ++++++++++++++++++++++++++ test/jq_filter.test.ts | 107 ++++++++++++++++++++ 9 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 src/commands/stdlib/file_read.ts create mode 100644 src/commands/stdlib/file_write.ts create mode 100644 src/commands/stdlib/jq_filter.ts create mode 100644 test/file_read.test.ts create mode 100644 test/file_write.test.ts create mode 100644 test/jq_filter.test.ts 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..c43758d 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..22fac47 --- /dev/null +++ b/src/commands/stdlib/file_read.ts @@ -0,0 +1,81 @@ +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`; + }, + 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..fe605dc --- /dev/null +++ b/src/commands/stdlib/file_write.ts @@ -0,0 +1,67 @@ +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`; + }, + 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..12dfcd0 --- /dev/null +++ b/src/commands/stdlib/jq_filter.ts @@ -0,0 +1,76 @@ +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)' }, + }, + 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\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` + + ` - 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 results = []; + for await (const item of input) { + const itemJson = JSON.stringify(item); + const output = await runJq(expr, itemJson); + const lines = output.split(/\r?\n/).filter(Boolean); + for (const line of lines) { + results.push(JSON.parse(line)); + } + } + + return { output: asStream(results) }; + }, +}; + +function runJq(expr, stdin) { + return new Promise((resolve, reject) => { + const child = spawn('jq', ['-c', expr], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + 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/test/file_read.test.ts b/test/file_read.test.ts new file mode 100644 index 0000000..87a6947 --- /dev/null +++ b/test/file_read.test.ts @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, writeFileSync } 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() }), + ); +}); diff --git a/test/file_write.test.ts b/test/file_write.test.ts new file mode 100644 index 0000000..10e3997 --- /dev/null +++ b/test/file_write.test.ts @@ -0,0 +1,141 @@ +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'"), + ); +}); diff --git a/test/jq_filter.test.ts b/test/jq_filter.test.ts new file mode 100644 index 0000000..92b8bdb --- /dev/null +++ b/test/jq_filter.test.ts @@ -0,0 +1,107 @@ +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'), + ); +}); From 2e6230f3d4217ec6eca0e0547493b5d988f213f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20F=C3=B6rster?= <103369858+sfo2001@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:58:12 +0100 Subject: [PATCH 3/3] feat(stdlib): add --raw flag to jq-filter command Passes -r to jq and yields stdout lines as plain strings instead of JSON-parsed values. Mirrors jq's native raw output mode. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 2 +- src/commands/stdlib/file_read.ts | 7 ++- src/commands/stdlib/file_write.ts | 5 +- src/commands/stdlib/jq_filter.ts | 30 +++++---- test/file_read.test.ts | 40 +++++++++++- test/file_write.test.ts | 55 ++++++++++++++++ test/jq_filter.test.ts | 100 ++++++++++++++++++++++++------ 7 files changed, 206 insertions(+), 33 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c43758d..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, 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`; + ` 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/stdlib/file_read.ts b/src/commands/stdlib/file_read.ts index 22fac47..abcb87e 100644 --- a/src/commands/stdlib/file_read.ts +++ b/src/commands/stdlib/file_read.ts @@ -24,7 +24,12 @@ export const fileReadCommand = { ` 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`; + ` 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). diff --git a/src/commands/stdlib/file_write.ts b/src/commands/stdlib/file_write.ts index fe605dc..12d78b5 100644 --- a/src/commands/stdlib/file_write.ts +++ b/src/commands/stdlib/file_write.ts @@ -27,7 +27,10 @@ export const fileWriteCommand = { ` 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`; + ` - --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; diff --git a/src/commands/stdlib/jq_filter.ts b/src/commands/stdlib/jq_filter.ts index 12dfcd0..579e118 100644 --- a/src/commands/stdlib/jq_filter.ts +++ b/src/commands/stdlib/jq_filter.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; export const jqFilterCommand = { - name: 'jq-filter', + name: 'jq.filter', meta: { description: 'Apply a jq expression to each pipeline item', argsSchema: { @@ -9,32 +9,38 @@ export const jqFilterCommand = { 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` + + return `jq.filter — apply a jq expression to each pipeline item\n\n` + `Usage:\n` + - ` | jq-filter \n` + - ` | jq-filter --expr \n\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'); + 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); + const output = await runJq(expr, itemJson, raw); const lines = output.split(/\r?\n/).filter(Boolean); for (const line of lines) { - results.push(JSON.parse(line)); + results.push(raw ? line : JSON.parse(line)); } } @@ -42,10 +48,12 @@ export const jqFilterCommand = { }, }; -function runJq(expr, stdin) { +function runJq(expr, stdin, raw = false) { return new Promise((resolve, reject) => { - const child = spawn('jq', ['-c', expr], { + const jqArgs = ['-c', ...(raw ? ['-r'] : []), expr]; + const child = spawn('jq', jqArgs, { stdio: ['pipe', 'pipe', 'pipe'], + env: { PATH: process.env.PATH || '' }, }); let stdout = ''; @@ -62,11 +70,11 @@ function runJq(expr, stdin) { child.stdin.end(); child.on('error', (err) => { - reject(new Error(`jq-filter: failed to spawn jq: ${err.message}`)); + 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'}`)); + reject(new Error(`jq.filter failed (exit ${code}): ${stderr.trim() || 'unknown error'}`)); }); }); } diff --git a/test/file_read.test.ts b/test/file_read.test.ts index 87a6947..e2ca802 100644 --- a/test/file_read.test.ts +++ b/test/file_read.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import os from 'node:os'; import path from 'node:path'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, truncateSync } from 'node:fs'; import { createDefaultRegistry } from '../src/commands/registry.js'; function streamOf(items) { @@ -157,3 +157,41 @@ test('file.read --format json throws on invalid JSON content', async () => { () => 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 index 10e3997..7ebb15a 100644 --- a/test/file_write.test.ts +++ b/test/file_write.test.ts @@ -139,3 +139,58 @@ test('file.write throws on unknown format', async () => { (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 index 92b8bdb..ffd51be 100644 --- a/test/jq_filter.test.ts +++ b/test/jq_filter.test.ts @@ -28,15 +28,15 @@ async function collect(output) { return items; } -test('jq-filter identity . passes items through', { skip: !JQ_AVAILABLE && 'jq not available on Windows' }, async () => { - const cmd = createDefaultRegistry().get('jq-filter'); +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'); +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'] }, @@ -46,8 +46,8 @@ test('jq-filter extracts field with .name', { skip: !JQ_AVAILABLE && 'jq not ava 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'); +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'] }, @@ -57,8 +57,8 @@ test('jq-filter navigates nested path .a.b', { skip: !JQ_AVAILABLE && 'jq not av 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'); +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'] }, @@ -68,8 +68,8 @@ test('jq-filter array output .[].x flattens results', { skip: !JQ_AVAILABLE && ' 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'); +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'] }, @@ -79,16 +79,16 @@ test('jq-filter processes multiple items independently', { skip: !JQ_AVAILABLE & 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'); +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'), + (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'); +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' }, @@ -98,10 +98,74 @@ test('jq-filter --expr named arg works', { skip: !JQ_AVAILABLE && 'jq not availa 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'); +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'), + (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; + } +});