mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
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:
parent
50c76f528e
commit
617cc9b668
4 changed files with 301 additions and 0 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ export async function summarizeViaClaudeCode(
|
|||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--append-system-prompt',
|
||||
system,
|
||||
'--tools',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue