fix: pass --verbose with stream-json for Claude CLI 2.1.131+ (#3)

Claude CLI 2.1.131+ rejects `--output-format stream-json` unless `--verbose`
is also passed. Without it the CLI exits 1 with "When using --print,
--output-format=stream-json requires --verbose", breaking the Claude Code
backend.

Adds the flag in src/cli.ts and locks both the flag and full args contract
for Claude + Codex backends with new tests:
- testClaudeCodeArgs: extended with mcp-config / disallowed-tools / chrome
  / slash-commands assertions and --verbose position check
- testClaudeCodeStreamJsonResilience: banner-mixed NDJSON, missing result,
  empty stdout, multiple result events, content fallback
- testClaudeCodeVerboseRegressionCanary: simulates real CLI stderr on
  missing --verbose and asserts error pipeline preserves exitCode + stderr
- testCodexErrorPath: codex non-zero exit propagates structured error
- testCodexArgsMinimalContract: deepStrictEqual lock on codex args 5-tuple
- testClaudeBackendSurfacesVerboseError: smoke flow surfaces the canary
- testCodexBackendPropagatesVersionFailure: version probe failure blocks
  smoke instead of silently continuing

Also wires npm run lint:obsidian into CI alongside Biome.

Closes #3

Change-Id: I6dbaa1b55172eb0b5b773bd25d1741b0603a4e4e
This commit is contained in:
wujunchen 2026-05-14 10:15:50 +08:00
parent 50c76f528e
commit 617cc9b668
4 changed files with 301 additions and 0 deletions

View file

@ -24,6 +24,9 @@ jobs:
- name: Lint
run: npm run lint
- name: Lint (Obsidian review rules)
run: npm run lint:obsidian
- name: E2E gate
run: timeout 600 npm run e2e

View file

@ -366,6 +366,7 @@ export async function summarizeViaClaudeCode(
'-p',
'--output-format',
'stream-json',
'--verbose',
'--append-system-prompt',
system,
'--tools',

View file

@ -69,6 +69,10 @@ async function testClaudeBackendRunsVersionAndSmoke() {
'stream-json',
'Claude smoke uses stream-json for incremental output',
);
assert.ok(
smokeArgs.includes('--verbose'),
'Claude smoke must include --verbose (required alongside stream-json on CLI 2.1.131+)',
);
assert.strictEqual(smokeArgs[smokeArgs.indexOf('--tools') + 1], '', 'Claude smoke disables tools');
assert.ok(smokeArgs.includes('--strict-mcp-config'), 'Claude smoke ignores user/project MCP servers');
assert.strictEqual(
@ -133,9 +137,82 @@ async function testApiBackendUsesInjectedRequestUrl() {
assert.strictEqual(result, 'OpenAI / OpenAI Chat Completions', 'API backend test reports provider and format');
}
// Regression: smoke flow must propagate the canary stderr that real Claude CLI
// emits when --output-format=stream-json is passed without --verbose.
async function testClaudeBackendSurfacesVerboseError() {
const spawnImpl = (_cmd, args) => {
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
// smoke call: simulate the exact stderr the real CLI emits when --verbose is missing
return createFakeChild('', 'Error: When using --print, --output-format=stream-json requires --verbose', 1);
};
await assert.rejects(
() =>
t.testBackend(
{
...baseSettings,
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 300000,
model: 'claude-sonnet-4-6',
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
},
{ spawnImpl },
),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'verbose-regression smoke surfaces CliProcessError');
assert.match(err.details.stderrTail, /requires --verbose/, 'stderr tail preserves the verbose-flag hint');
assert.strictEqual(err.details.exitCode, 1, 'preserves CLI exit code so UI can classify');
return true;
},
'testBackend must propagate Claude smoke failures (including verbose-flag regression) as structured errors',
);
}
// Regression: if `<cli> --version` itself fails, testBackend must throw before reaching smoke,
// not silently report success with garbage version output.
async function testCodexBackendPropagatesVersionFailure() {
const calls = [];
const spawnImpl = (_cmd, args) => {
calls.push(args);
if (args.includes('--version')) return createFakeChild('', 'codex: command not found', 127);
return createFakeChild('{"cards":[]}');
};
await assert.rejects(
() =>
t.testBackend(
{
...baseSettings,
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 300000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
},
{ spawnImpl },
),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'version failure surfaces structured CliProcessError');
assert.strictEqual(err.details.exitCode, 127, 'preserves shell command-not-found exit code');
return true;
},
'testBackend must not swallow version-probe failures',
);
assert.strictEqual(calls.length, 1, 'smoke call must NOT run after version probe fails');
assert.deepStrictEqual(calls[0], ['--version'], 'only the version probe should have been spawned');
}
(async () => {
await testClaudeBackendRunsVersionAndSmoke();
await testClaudeBackendSurfacesVerboseError();
await testCodexBackendRunsVersionAndSmoke();
await testCodexBackendPropagatesVersionFailure();
await testApiBackendUsesInjectedRequestUrl();
console.log('backend-test tests passed');
})().catch((err) => {

View file

@ -263,12 +263,39 @@ async function testClaudeCodeArgs() {
'stream-json',
'claude output should stream JSON events',
);
assert.ok(
capturedArgs.includes('--verbose'),
'stream-json requires --verbose in Claude CLI 2.1.131+',
);
assert.ok(capturedArgs.includes('--append-system-prompt'), 'should include --append-system-prompt');
assert.ok(capturedArgs.includes('--tools'), 'should explicitly restrict Claude tools');
assert.strictEqual(capturedArgs[capturedArgs.indexOf('--tools') + 1], '', 'Claude tools should be disabled');
assert.ok(capturedArgs.includes('--disallowed-tools'), 'should include --disallowed-tools');
assert.ok(capturedArgs.includes('--no-session-persistence'), 'should avoid session persistence for plugin calls');
assert.ok(capturedArgs.includes('--disable-slash-commands'), 'should disable slash commands for plugin calls');
assert.ok(capturedArgs.includes('--no-chrome'), 'should suppress Claude TUI chrome');
assert.ok(capturedArgs.includes('--strict-mcp-config'), 'should ignore user/project MCP servers');
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--mcp-config') + 1],
'{"mcpServers":{}}',
'--mcp-config must pair with an empty MCP server map',
);
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--disallowed-tools') + 1],
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
'--disallowed-tools must list every tool that could exfiltrate or mutate',
);
// Ordering: -p must come before --output-format so Claude treats the run as print-mode.
assert.ok(
capturedArgs.indexOf('-p') < capturedArgs.indexOf('--output-format'),
'-p must appear before --output-format',
);
// --verbose must accompany --output-format stream-json (Claude CLI 2.1.131+ requirement).
// The CLI does not constrain position; we only check that --verbose is not consumed as the value of
// another flag — i.e. it must not appear at index `--output-format` + 1 (where 'stream-json' belongs).
const outputFmtIdx = capturedArgs.indexOf('--output-format');
const verboseIdx = capturedArgs.indexOf('--verbose');
assert.notStrictEqual(verboseIdx, outputFmtIdx + 1, '--verbose must not be parsed as the --output-format value');
// Verify --max-tokens is NOT present (it's not a valid claude CLI flag)
assert.ok(!capturedArgs.includes('--max-tokens'), 'should NOT include --max-tokens');
@ -466,6 +493,195 @@ async function testClaudeCodeArgsWithModel() {
assert.strictEqual(capturedArgs[modelIdx + 1], 'claude-opus-4-7', 'claude --model value matches settings.model');
}
// ── Claude stream-json parse resilience ──
async function runClaudeCodeWithStdout(stdoutText) {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
return t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn);
}
async function testClaudeCodeStreamJsonResilience() {
// B1: NDJSON with non-JSON banner line — must skip banner and still parse the result event.
const withBanner = [
'Claude Code 2.1.131',
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"B1","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
].join('\n');
const b1 = await runClaudeCodeWithStdout(withBanner);
assert.strictEqual(b1.length, 1, 'B1: banner line is skipped and result parsed');
assert.strictEqual(b1[0].title, 'B1', 'B1: card title preserved');
// B2: Missing result event — must throw a recognizable "no result" error (not a crash inside parser).
// Match both zh ("没有返回结果") and en ("returned no result") so the assertion survives translate() fallback differences.
const noResultRegex = /没有返回结果|returned no result/i;
const onlyInit = JSON.stringify({ type: 'system', subtype: 'init', tools: [] }) + '\n';
await assert.rejects(
() => runClaudeCodeWithStdout(onlyInit),
(err) => noResultRegex.test(err.message),
'B2: missing result event throws a descriptive "no result" error',
);
// B3: Empty stdout — same behavior: must throw, not crash on undefined / silent success.
await assert.rejects(
() => runClaudeCodeWithStdout(''),
(err) => noResultRegex.test(err.message),
'B3: empty stdout throws a descriptive "no result" error',
);
// B4: Multiple result events — last one wins (claudeResultText scans in reverse).
const twoResults = [
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"FIRST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"LAST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
].join('\n');
const b4 = await runClaudeCodeWithStdout(twoResults);
assert.strictEqual(b4.length, 1, 'B4: latest result event is used');
assert.strictEqual(b4[0].title, 'LAST', 'B4: last result wins over earlier ones');
// B5: Single-event object with `content` (not `result`) — covers the legacy fallback path
// where claudeResultText reads `events[0].content` when no `type: result` event exists.
const contentFallback = JSON.stringify({
content: '{"cards":[{"title":"B5","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
});
const b5 = await runClaudeCodeWithStdout(contentFallback);
assert.strictEqual(b5.length, 1, 'B5: events[0].content fallback parses card');
assert.strictEqual(b5[0].title, 'B5', 'B5: content-fallback card title preserved');
}
// ── Claude verbose-missing error-path plumbing ──
// This does NOT guard the --verbose flag itself (that is locked by
// `testClaudeCodeArgs`'s `capturedArgs.includes('--verbose')` assertion, which fails
// in fake-spawn before any stderr is even simulated).
// Instead, this test simulates the *real* stderr that Claude CLI emits when
// --verbose is missing, and asserts that the error-propagation pipeline preserves
// exit code + stderr tail so the UI can render a useful diagnostic. If we ever
// rework error handling and accidentally swallow CLI stderr, this catches it.
async function testClaudeCodeVerboseRegressionCanary() {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stderr.emit(
'data',
Buffer.from('Error: When using --print, --output-format=stream-json requires --verbose'),
);
child.emit('close', 1, null);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await assert.rejects(
() => t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'canary: throws CliProcessError on exit-nonzero');
assert.strictEqual(err.details.reason, 'exit-nonzero', 'canary: exit-nonzero reason');
assert.strictEqual(err.details.exitCode, 1, 'canary: surfaces CLI exit code');
assert.match(err.details.stderrTail, /requires --verbose/, 'canary: preserves verbose-flag hint in stderr');
return true;
},
'Claude CLI verbose-missing stderr surfaces as a structured CliProcessError',
);
}
// ── Codex error path ──
async function testCodexErrorPath() {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stderr.emit('data', Buffer.from('error: not authenticated, please run codex login'));
child.emit('close', 2, null);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await assert.rejects(
() => t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'codex: throws CliProcessError on exit-nonzero');
assert.strictEqual(err.details.exitCode, 2, 'codex: exit code preserved');
assert.match(err.details.stderrTail, /not authenticated/, 'codex: stderr tail preserves auth hint');
return true;
},
'codex non-zero exit propagates structured error',
);
}
// ── Codex args minimal contract ──
// Lock in the exact minimal arg shape: any drift means we are leaking new flags
// into a CLI that is intentionally kept minimal (no --model, no --cd, no -c).
async function testCodexArgsMinimalContract() {
let capturedArgs = null;
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn);
// Exact list lock: changes here are intentional and require updating the test.
assert.deepStrictEqual(
capturedArgs,
['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
'codex exec args must remain minimal (exec + skip-git + read-only sandbox + stdin marker)',
);
}
// ── classifyGenerationError ──
assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'auth');
@ -562,9 +778,13 @@ assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string
await testRunCliEdgeCases();
await testClaudeCodeArgs();
await testClaudeCodeStreamJsonOutput();
await testClaudeCodeStreamJsonResilience();
await testClaudeCodeVerboseRegressionCanary();
await testClaudeCodeArgsWithModel();
await testCodexArgs();
await testCodexArgsIgnoresClaudeDefaultModel();
await testCodexArgsMinimalContract();
await testCodexErrorPath();
console.log('cli tests passed');
})().catch((e) => {
console.error(e);