/** * @verevoir/llm/claude-cli — the Claude Code CLI (`claude -p`) as a * Reviewer-shaped provider, for callers that must run on a subscription * credential rather than a billed API key. * * WHY THIS EXISTS, RATHER THAN A SECOND CREDENTIAL ON THE ANTHROPIC * ADAPTER. The Anthropic Messages API accepts a subscription OAuth token * only for requests that present themselves AS Claude Code (see * anthropic/index.ts's `oauthSystemIdentity()`); this adapter instead * shells out to the real thing — `claude -p` — and lets it authenticate * itself, rather than this library forging that identity. * * PROVIDER ID IS `'claude-cli'`, DELIBERATELY DISTINCT FROM `'anthropic'`, * so `TokenUsage.provider` unambiguously names which substrate served a * call. NEVER REGISTERED into the shared model catalog / connection * registry (no `registerModelCatalog`, no `registerProviderConnection`) — * `resolveModel` / `resolveModelByTerm` must never be able to silently * substitute this adapter for the real API one; a caller wanting this * substrate imports and calls it directly, chosen deliberately, never * resolved by policy. * * ══════════════════════════════════════════════════════════════════════ * SECURITY CORRECTION (0.26.3) — READ THIS BEFORE TOUCHING THE FLAG LIST. * ══════════════════════════════════════════════════════════════════════ * Every published version from 0.25.0 through 0.26.2 passed `--tools ""` * and this file's own header claimed that "disabl[ed] tool use * substrate-wide". That was never true. Confirmed directly against the * CLI (the operator ran it and relayed the answer, the same discipline * every other confirmed fact in this file rests on): `--tools` takes a * SPACE-SEPARATED ALLOWLIST of tool names; an empty string is read as * NOT SET, not as an empty allowlist, so the flag silently did nothing * and every call fell through to the CLI's own default built-in tool * set the entire time this adapter has been published. This is the * exact property the "no-bypass" architecture depends on — the reason a * model must go through the caller's own tool belt rather than reading * files directly — and the published package never had it. * * THE FIX: `--disallowedTools "*"`. Confirmed (not relayed) to remove a * tool from context ENTIRELY when named bare (as opposed to a scoped * rule like `Bash(rm *)`, which leaves the tool advertised and denies * only matching calls) — "absent", the same failure shape this codebase * uses everywhere else, not "refused". `EndConversation` is documented * as deliberately exempt from deny rules while any other tool remains; * expect to see it survive and do not read that as the fix failing. * * `--allowedTools` WAS CONSIDERED AND REJECTED — it is CONFIRMED * ADDITIVE on top of the CLI's own default safe-list ("these run * without prompting"), not a restriction, and removes nothing. Using it * expecting an allowlist would have been a second, identically-shaped * silent fail-open. Grepped: not used anywhere in this codebase. * * GETTING A FLAG RIGHT ONCE IS NOT THE FIX — this is also why `chat()` * now speaks `stream-json` on BOTH stdin and stdout (see TOOL-SAFETY * VERIFICATION below) rather than trusting `--disallowedTools "*"` by * construction the way `--tools ""` was trusted. The CLI's own * `{"type":"system","subtype":"init",...}` event carries a `tools` * array — the only authoritative, checkable answer to what a run * actually has. A flag is an intention; the init event is the * observable. `chat()` asserts against it on every call, and refuses * rather than proceeding on a mismatch — the exact mechanism that would * have caught the `--tools ""` defect on day one instead of it shipping * silently for three minor versions. * * DELIBERATELY NOT TOUCHED HERE: whether `--safe-mode` actually * suppresses MCP-server loading is a SEPARATE, still-unconfirmed * question (see the ISOLATION paragraph below) — conflating that fix * with this one is exactly how the `--tools ""` defect nearly escaped a * second time layered under a different, harder-to-isolate change. This * adapter never passes `--mcp-config` at all (see ISOLATION), so the * question does not even arise for `chat()`'s own single-shot path. * * TOOL-SAFETY VERIFICATION — THE MECHANISM. `chat()` builds its stdin as * ONE `{"type":"user","message":{"role":"user","content":"..."}}` line * (the flattened turn text, unchanged) under `--input-format stream-json * --output-format stream-json --verbose` (the last is CONFIRMED * required by a real invocation: `-p --output-format stream-json` * without it fails immediately with "Error: When using --print, * --output-format=stream-json requires --verbose", exit 1, before * producing any stream-json line at all). On a successful exit, the * stdout stream is split into JSON-per-line events; the * `{"type":"system","subtype":"init"}` event's `tools` array MUST be * empty, matching `--disallowedTools "*"`'s promise of zero built-in * tools reachable. A NON-EMPTY array, or NO init event found in the * stream at all (parsing failed, the shape changed, anything this * adapter cannot make sense of), THROWS rather than returning a reply * that may have used capabilities this call never declared — refusing * to proceed on an unverifiable safety property, not merely an * incorrect one. * * PAYLOAD CONTRACT for the terminal `{"type":"result",...}` event, * confirmed against real invocations (history in CHANGELOG.md, not * repeated here). Field-for-field IDENTICAL to the previous * `--output-format json` envelope this file used to parse directly — * only the FRAMING changed (one line among several in a stream, not the * sole stdout payload). The reply text is a FLAT STRING under `result` * — not `content`, not `text`, not nested. `stop_reason` is a real * top-level field and maps directly to `ChatReply.stopReason`. `usage` * carries `input_tokens` / `output_tokens` / `cache_read_input_tokens` / * `cache_creation_input_tokens`. There is NO version field anywhere in * the payload — `resolveCliVersion()`'s memoized `claude --version` * spawn (below) is the ONLY source of `substrateVersion`. `is_error: * true` CAN APPEAR ALONGSIDE A ZERO EXIT CODE, and — confirmed by a real * invocation — CAN APPEAR ALONGSIDE `subtype: "success"`: `subtype` * describes how the request/response cycle ended, not whether the * content is a failure, so `chat()` reads only `is_error`, never * `subtype`, as the failure signal. A SINGLE CALL CAN INVOKE MORE THAN * ONE MODEL — `modelUsage` may name several; `determinePrimaryModel()` * (below) reports only the entry matching the top-level `usage` block as * `TokenUsage.model`, but every entry is named in a `console.warn` when * more than one is present, so a second model having run is never * silently dropped. * * `total_cost_usd` / per-model `modelUsage[].costUSD` ARE PRESENT IN THE * PAYLOAD AND DELIBERATELY NOT SURFACED ONTO `TokenUsage` — not recorded * elsewhere in this repository, so kept here rather than cut. The * payload gives no signal for whether that figure is billed or notional, * so asserting either would be an unbacked claim; what this adapter DOES * know for certain is that no billed credential was available to spend, * because {@link allowedEnv} never lets one reach the child. A settled * dual-cost design (billed vs. notional) is deliberate, separate, * out-of-scope work (`decisions/023`, aigency-governance). * * `chat()` ONLY in this first cut — no `chatWithTools` / * `chatWithToolLoop`. `/google` shipped the same way at its own v0.4.0 * release (chat() only), then added chatWithTools/chatWithToolLoop in * 0.13.0 — cited here as HISTORICAL precedent for a first cut, not as * `/google`'s current state, which already has both. `--disallowedTools * "*"` (always passed) makes a tool loop moot for THIS adapter's single- * shot purpose anyway, so a turn carrying a `tool_use` / `tool_result` * block is refused rather than silently flattened. * * `abortSignal` IS HONOURED AT ENTRY AND MID-CALL, not just an entry * check: aborting while the spawned child is still running kills it * (`child.kill()`) and rejects immediately with the signal's own reason, * rather than letting the subprocess finish in the background. The * memoized `claude --version` lookup is deliberately NOT wired to any * one call's signal, since every caller shares it. * * `onProgress` IS ACCEPTED, NEVER INVOKED. `--disallowedTools "*"` * disables every built-in tool a `report_progress` mechanism would * need, and the terminal `result` event still delivers the full reply * only once, not incrementally — there is no partial signal to narrate * from either way. A caller supplying `onProgress` will never see it * called; nothing here throws for supplying it. * * MODEL PINNING. `chat()` accepts `options.model` — an exact model id, * passed straight through as `--model ` — so a caller that must * compare this substrate against the direct API path (or across two * calls, e.g. a governance review of the same commit through both * transports) can force both to answer with the identical model rather * than each one's own default/class resolution silently diverging. See * `ChatOptions.model`'s own doc comment in the core for the full * rationale. `--model` is documented in Claude Code's own `--help` as * accepting either a class alias ("sonnet", "opus") or a full model id * ("claude-sonnet-4-20250514") — that documentation is what this flag * rests on; unlike the flags confirmed above against a real captured * payload, this one has NOT independently been re-verified against a * live invocation by this repository, and is stated here as relayed, * not confirmed, in the same spirit as the Bedrock/Vertex strips this * file used to carry. Omitting `options.model` leaves behaviour exactly * as before — the local install's own default, unpinned. * * `options.maxTokens` IS ACCEPTED, NEVER USED — disclosed rather than * silently ignored. There is no confirmed `claude -p` flag for an * output-token ceiling (unlike `--model`, nothing in the CLI's own * `--help` documents one), so this adapter has nothing to pass it to; a * caller relying on `maxTokens` to avoid a truncated reply on this * substrate has no lever here and should know that, not discover it by * the field quietly doing nothing. * * ISOLATION FROM PROJECT/LOCAL CONFIGURATION, on top of `--safe-mode`'s * own documented disabling of CLAUDE.md/skills/plugins/hooks/MCP * servers/custom commands. Two more, deliberate: `--strict-mcp-config` * is passed on every invocation, so even if `--safe-mode`'s MCP * suppression turns out narrower in practice than its `--help` states, * no MCP server from any configuration source reaches this call — only * servers passed via an explicit `--mcp-config` flag would, and this * adapter never passes that flag, so the effective set is always empty. * And the spawn's `cwd` (see `runClaudeCli`) is set explicitly to the * platform temp directory rather than inherited from whatever directory * the calling process happens to be running in — a project's * `CLAUDE.md` / `.mcp.json` live relative to cwd, and a governance * review process's cwd is typically the very repository it is * reviewing, which is precisely the content this call must not see. * Both are defense-in-depth alongside `--safe-mode`, not a claim that * `--safe-mode` alone was proven insufficient — this repository has not * independently re-confirmed `--safe-mode`'s own documented MCP/ * CLAUDE.md suppression by direct observation, so it is not relied on * alone, and whether it actually holds remains a SEPARATE, open * question this change does not touch (see the SECURITY CORRECTION * paragraph above). * * ═════════════════════════════════════════════════════════════ * SESSION-HOLDING TRANSPORT (wave 3) — chatWithToolLoop, chatWithTools, * createSession/closeSession, and chat()'s optional `session`. * ═════════════════════════════════════════════════════════════ * Everything above this paragraph describes the single-shot `chat()` * path — spawn once, one exchange, exit. `session.ts` (spawn-once, * held-across-turns) and `mcp-bridge.ts` (the embedded, authenticated * MCP tool bridge a tool-bearing session spawns as its own subprocess) * are what this section wires into a public surface. See those two * files' own headers for the full mechanism and its confirmed-vs-relayed * verification history; this paragraph covers only what wiring them here * adds. * * THE HANDLE IS THE CALLER'S TO HOLD. This package has no concept of a * conversation, so a session's lifetime cannot be keyed on one without * this package making policy it has no basis to reason about (when to * evict, what if the key never returns). `createSession()`/ * `closeSession()` hand the caller an opaque `ClaudeCliSessionHandle`; * the caller decides how long to keep it. Presenting a handle whose * process has died, been evicted, or was never used behaves identically * — start fresh, the same shape as a 401 (see session.ts's DEAD-HANDLE * SAFETY paragraph). This is safe specifically because the session is an * ACCELERATOR over a stateless truth: the caller's own `turns` remain * the ground truth, exactly as with every other adapter's stateless * `chat()`/`chatWithToolLoop()` — losing a session costs a token- * spending cold rebuild, never lost work. * * `chatWithTools()` REFUSES on this transport, unconditionally — this is * deliberate, not a gap. There is no point in this transport's design * where a `tool_use` exists without already having been executed: a * tool-bearing turn runs entirely inside one `claude` invocation via the * embedded MCP bridge, which calls the caller's own executor itself. * There is no "hand back the tool_use, let the caller run it and call * again" step for this function to perform. Use `chatWithToolLoop()`, * with the executor supplied up front. * * `chatWithToolLoop()`'s "loop" IS NOT A LOOP OF SEPARATE MODEL CALLS, * UNLIKE EVERY OTHER ADAPTER'S. Anthropic/Google/the OpenAI-compatible * factory loop by making several separate requests, feeding each tool * result back as the next request's input. Here, the looping — the * model deciding to call a tool, seeing the result, deciding whether to * call another — happens INSIDE one `claude -p` turn, driven by `claude` * itself through the MCP bridge; this function makes exactly one call to * `runSessionTurn` per invocation. `maxIterations` (default 5, matching * every other adapter's own default) maps onto that turn's tool-call * BUDGET (`session.ts`/`mcp-bridge.ts`'s `maxToolCalls`) rather than a * count of calls this function makes — once spent, the bridge refuses * further calls with a "budget exhausted, answer now" result rather than * this function issuing a second request. `iterations` on the returned * result is `SessionTurnResult.assistantEventCount` — an OBSERVED COUNT * of `assistant` stream events, this transport's only available proxy * for "how many model iterations happened" inside that one turn, not a * count of separate calls this function made (it always makes exactly * one). `onIteration`, correspondingly, fires at most once per call — * there is no per-round-trip moment to fire it from partway through a * turn this adapter cannot see inside of. * * `options.session` OMITTED: a throwaway session is opened and closed * for this one call, so `chatWithToolLoop()`'s result is self-contained, * exactly like every other adapter's. `options.session` SUPPLIED: the * turn runs on that held session, and prompt-cache warmth + tool * availability persist to the NEXT call on the same handle — but per * session.ts's THE SYSTEM PROMPT, MODEL, AND TOOL SET ARE FIXED AT SPAWN * paragraph, `options.turns` on a call supplying `session` must carry * only the ONE new message, never the full history again — the session * already holds it. `chat()`'s session branch (`chatOnSession`, below) * carries the identical restriction, for the identical reason. * * PERMISSION DENIALS are surfaced as DATA on * `ClaudeCliChatWithToolLoopResult.permissionDenials`, never thrown — * see `PermissionDenial`'s own doc comment in session.ts for what an * empty array does and does not mean, and why it must always be read * alongside `toolUses`, never alone. * * RECONSTRUCTION DISCLOSURE. The session-holding transport was first * built as one omnibus pull request (#55, 2,394 lines) and rejected by * review for its size, with no split attempted. Splitting it required * re-cutting from `main` after the pieces that DID land independently * (`mcp-bridge.ts` at 0.26.6, `env.ts` at 0.26.7, `session.ts` in two * waves at 0.26.8/0.26.9) diverged too far from #55's own branch history * for a mechanical rebase. THIS WIRING — the additions in this section * — was RECONSTRUCTED from the withdrawn #55 branch via targeted greps * of its surviving copy, not transplanted byte-for-byte, and is * evidenced behaviourally rather than assumed faithful: * `chat.session.test.ts`'s own new tests exercise every path added here * directly against the real, already-reviewed `session.ts`/ * `mcp-bridge.ts` contracts (mocked at the module boundary, the same way * `chat.test.ts` mocks `node:child_process`), and a reader should weigh * this section's correctness on that evidence, not on the reconstruction * being exact. */ import type { ChatOptions, ChatReply, ChatWithToolLoopOptions, ChatWithToolLoopResult, ChatWithToolsOptions, ChatWithToolsResult } from '../index.js'; import { ALLOWED_ENV_VARS, allowedEnv, CLAUDE_CLI_CREDENTIAL_ENV_VAR, PROVIDER } from './env.js'; import { closeSession, createSession, resetClaudeCliSessionsForTests, SESSION_IDLE_TIMEOUT_MS, SESSION_MAX_HELD, SESSION_TURN_TIMEOUT_MS, type ClaudeCliSessionHandle, type PermissionDenial } from './session.js'; export { ALLOWED_ENV_VARS, allowedEnv, CLAUDE_CLI_CREDENTIAL_ENV_VAR, PROVIDER }; export { closeSession, createSession, resetClaudeCliSessionsForTests, SESSION_IDLE_TIMEOUT_MS, SESSION_MAX_HELD, SESSION_TURN_TIMEOUT_MS, type ClaudeCliSessionHandle, type PermissionDenial, }; /** * Test-only: clears the process-wide version cache so tests can observe * `resolveCliVersion()`'s spawn-and-cache behaviour in isolation rather * than inheriting a value memoized by an earlier test in the same run. * Never called from production code — the whole point of the cache is * that a real process only needs the version resolved once. */ export declare function resetClaudeCliVersionCacheForTests(): void; /** {@link ChatOptions}, plus an optional `session` — see this file's * header SESSION-HOLDING TRANSPORT section for the full contract. * Supplying `session` means `turns` must carry only the ONE new message, * never the full history again; omitting it leaves `chat()` exactly as * it always was — stateless, spawn-per-call, `turns` resent in full. */ export interface ClaudeCliChatOptions extends ChatOptions { session?: ClaudeCliSessionHandle; } /** {@link ChatWithToolsOptions}, plus the same optional `session` field * — present only for type-shape parity with * {@link ClaudeCliChatWithToolLoopOptions}, never read: see * {@link chatWithTools}'s own doc comment for why this function refuses * on this transport regardless of whether `session` is set. */ export interface ClaudeCliChatWithToolsOptions extends ChatWithToolsOptions { session?: ClaudeCliSessionHandle; } /** {@link ChatWithToolLoopOptions}, plus the same optional `session` * field as {@link ClaudeCliChatOptions} — see this file's header for the * full contract, including why this transport's "loop" is not a loop of * separate model calls the way every other adapter's is. */ export interface ClaudeCliChatWithToolLoopOptions extends ChatWithToolLoopOptions { session?: ClaudeCliSessionHandle; } /** {@link ChatWithToolLoopResult}, plus `permissionDenials` — see * `PermissionDenial`'s own doc comment in session.ts for what this is * and, just as importantly, what it does and does not mean when empty. */ export interface ClaudeCliChatWithToolLoopResult extends ChatWithToolLoopResult { permissionDenials: PermissionDenial[]; } /** * Single-shot call through `claude -p` — or, when `options.session` is * supplied, one turn on a held session (see this file's header). See the * file header for every flag's justification and every * refuse-rather-than-substitute mechanism. */ export declare function chat(options: ClaudeCliChatOptions): Promise; /** * REFUSES, unconditionally — see this file's header SESSION-HOLDING * TRANSPORT section for why: this transport has no point where a * `tool_use` exists without already having been executed, so there is * nothing for a single-shot "return the tool_use, let the caller run * it" function to do. Use {@link chatWithToolLoop} instead, with your * executor supplied up front. */ export declare function chatWithTools(_options: ClaudeCliChatWithToolsOptions): Promise; /** * Run the model with tools available, through the embedded MCP bridge — * see this file's header SESSION-HOLDING TRANSPORT section for how this * differs from every other adapter's `chatWithToolLoop` (one `claude` * turn, not a loop of separate model calls). */ export declare function chatWithToolLoop(options: ClaudeCliChatWithToolLoopOptions): Promise; export declare const claudeCli: { PROVIDER: string; chat: typeof chat; chatWithTools: typeof chatWithTools; chatWithToolLoop: typeof chatWithToolLoop; createSession: typeof createSession; closeSession: typeof closeSession; }; //# sourceMappingURL=index.d.ts.map