diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index bcf51aa54..9da3c1333 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -1342,6 +1342,10 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon throw new AIConfigError( `Anthropic has no embedding model. Use openai or google for embeddings.`, ); + case 'claude-cli': + throw new AIConfigError( + `claude-cli has no embedding model. Use openai or google for embeddings.`, + ); case 'openai-compatible': { // D12=A: unified auth via Recipe.resolveAuth (or default). const auth = applyResolveAuth(recipe, cfg, 'embedding'); @@ -2284,6 +2288,15 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon const baseURL = resolveNativeBaseUrl('anthropic', cfg); return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId); } + case 'claude-cli': { + // The CLI handles its own auth (OAuth session); spawn the subprocess + // directly via the same LanguageModelV2 implementation chat uses. There + // is no separate expansion path because claude-cli does not declare a + // separate expansion touchpoint — but routing here keeps the switch + // exhaustive and lets a future expansion touchpoint use the same code. + const { ClaudeCliLanguageModel } = require('./providers/claude-cli-language-model.ts'); + return new ClaudeCliLanguageModel(modelId); + } case 'openai-compatible': { // D12=A: unified auth via Recipe.resolveAuth (or default). const auth = applyResolveAuth(recipe, cfg, 'expansion'); @@ -2783,6 +2796,15 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig): const baseURL = resolveNativeBaseUrl('anthropic', cfg); return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId); } + case 'claude-cli': { + // The CLI handles its own auth (OAuth session managed by `claude` + // login). Subprocess-based LanguageModelV2 dispatches via the recipe + // path so per-call routing works: `claude-cli:claude-sonnet-4-6` lands + // here, while sibling `litellm:gpt-5.4` continues through the + // openai-compatible path below. No env-var switch, no global flag. + const { ClaudeCliLanguageModel } = require('./providers/claude-cli-language-model.ts'); + return new ClaudeCliLanguageModel(modelId); + } case 'openai-compatible': { // D12=A: unified auth via Recipe.resolveAuth (or default). const auth = applyResolveAuth(recipe, cfg, 'chat'); diff --git a/src/core/ai/providers/claude-cli-language-model.ts b/src/core/ai/providers/claude-cli-language-model.ts new file mode 100644 index 000000000..4ffaec0af --- /dev/null +++ b/src/core/ai/providers/claude-cli-language-model.ts @@ -0,0 +1,444 @@ +/** + * ai-sdk LanguageModelV2 implementation that dispatches via the `claude --print` + * CLI subprocess. Used by the `claude-cli` recipe to route gateway.toolLoop / + * gateway.chat calls through Claude Code's OAuth session instead of the + * Anthropic SDK + ANTHROPIC_API_KEY. + * + * Per-call routing is the contract: the gateway resolves the model string + * to this recipe based on the `claude-cli:` prefix, instantiates one of + * these objects per modelId, and dispatches doGenerate. Sibling subagent + * jobs with `litellm:gpt-5.4` continue routing through litellm-proxy in + * the same worker; no env-var switch, no global state. + * + * Tool use is supported via system-prompt-instructed JSON emission: + * The recipe injects a fenced instruction block into the system prompt + * that teaches the model the `[{id,name,input}, ...]` + * emission format. The adapter parses those blocks back into ai-sdk + * `tool-call` content parts. Parallel tool calls (multiple entries in + * the JSON array) round-trip cleanly — this is the case that breaks + * on the codex-proxy / litellm GPT-5.x bridge today. + * + * Context isolation: + * The subprocess is spawned from a dedicated tmpdir so claude-cli's + * CLAUDE.md auto-discovery has no local files to find. `--system-prompt` + * replaces the default system prompt; `--disable-slash-commands` skips + * skill resolution. User-level ~/.claude/CLAUDE.md still loads because + * the only way to skip it is `--bare`, which forces ANTHROPIC_API_KEY + * auth and defeats the whole point of this provider. The ~42k cached + * tokens from user-level instructions are accepted as a cost-trivial + * trade-off on the subscription path. + * + * doStream is not yet implemented; the model declares no streaming. Callers + * (gateway.toolLoop primarily) use doGenerate. + */ +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { + LanguageModelV2, + LanguageModelV2CallOptions, + LanguageModelV2Content, + LanguageModelV2FunctionTool, + LanguageModelV2Prompt, + LanguageModelV2Message, + LanguageModelV2ProviderDefinedTool, +} from '@ai-sdk/provider'; + +function claudeBin(): string { + return process.env.GBRAIN_CLAUDE_CLI_BIN ?? 'claude'; +} +const CLAUDE_CWD = join(tmpdir(), `gbrain-claude-cli-cwd-${process.pid}`); +let cwdEnsured = false; +function ensureCleanCwd(): string { + if (!cwdEnsured) { + mkdirSync(CLAUDE_CWD, { recursive: true }); + cwdEnsured = true; + } + return CLAUDE_CWD; +} + +/** Parsed shape of `claude --print --output-format json`. */ +interface ClaudeJsonResult { + type: 'result'; + subtype: 'success' | string; + is_error: boolean; + result: string; + stop_reason: string | null; + session_id: string; + num_turns: number; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; +} + +/** + * Build the system-prompt addendum that teaches the model the + * `...` emission format. Returns the empty string + * when no tools are registered for this turn so the model gets a normal + * text-completion prompt without protocol noise. + */ +function buildToolUseInstructions( + tools: ReadonlyArray | undefined, +): string { + if (!tools || tools.length === 0) return ''; + + const functionTools = tools.filter((t): t is LanguageModelV2FunctionTool => t.type === 'function'); + if (functionTools.length === 0) return ''; + + const toolSpecs = functionTools.map(t => ({ + name: t.name, + description: t.description ?? '', + input_schema: t.inputSchema ?? { type: 'object', properties: {} }, + })); + + return [ + '', + '## Tool Use Protocol', + '', + 'You have access to these tools:', + '', + '```json', + JSON.stringify(toolSpecs, null, 2), + '```', + '', + 'To call one or more tools in this turn, emit EXACTLY ONE block of this form, ' + + 'with no other text outside the block on its own lines:', + '', + '', + '[', + ' {"id": "", "name": "", "input": }', + ']', + '', + '', + 'Multiple tool calls go in the array. Tool results are returned to you on the ' + + 'next turn as [tool_result ] entries. You may then call more tools or emit a final response.', + '', + 'When you are ready to give a final answer instead of calling tools, respond with prose text only — ' + + 'do not include a block in that case.', + '', + ].join('\n'); +} + +/** + * Render the ai-sdk message array into a single text prompt for `claude --print` + * stdin. System messages are extracted up-front and concatenated into the + * `--system-prompt` flag value. Tool calls and tool results are rendered as + * placeholders so the model sees the conversation in a coherent shape even + * though the adapter does not natively round-trip tool calls through claude-cli. + */ +function renderPrompt(prompt: LanguageModelV2Prompt): { systemText: string; userPrompt: string } { + const systemParts: string[] = []; + const convo: string[] = []; + + for (const msg of prompt as ReadonlyArray) { + if (msg.role === 'system') { + systemParts.push(msg.content); + continue; + } + if (msg.role === 'user') { + const text = msg.content + .map(p => { + if (p.type === 'text') return p.text; + // File parts get a stub — multimodal is not supported via subprocess yet. + if (p.type === 'file') return `[file ${p.mediaType ?? 'unknown'}]`; + return ''; + }) + .filter(s => s.length > 0) + .join('\n'); + if (text) convo.push(`User: ${text}`); + continue; + } + if (msg.role === 'assistant') { + const rendered = msg.content + .map(p => { + if (p.type === 'text') return p.text; + if (p.type === 'reasoning') return ''; // dropped on replay + if (p.type === 'tool-call') { + return `[tool_use ${p.toolName}(${p.input})]`; + } + if (p.type === 'tool-result') { + const out = typeof p.output === 'string' ? p.output : JSON.stringify(p.output); + return `[tool_result ${out}]`; + } + return ''; + }) + .filter(s => s.length > 0) + .join('\n'); + if (rendered) convo.push(`Assistant: ${rendered}`); + continue; + } + if (msg.role === 'tool') { + const rendered = msg.content + .map(p => { + const out = typeof p.output === 'string' ? p.output : JSON.stringify(p.output); + return `[tool_result ${out}]`; + }) + .join('\n'); + if (rendered) convo.push(`User: ${rendered}`); + continue; + } + } + + return { systemText: systemParts.join('\n'), userPrompt: convo.join('\n\n') }; +} + +/** + * Spawn `claude --print` with the contamination-suppression flags and return + * the parsed `--output-format json` envelope. Aborts propagate to SIGTERM on + * the child. + */ +function runClaude( + systemPrompt: string, + userPrompt: string, + model: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const args = [ + '--print', + '--output-format', 'json', + '--model', model, + '--disable-slash-commands', + // Agent isolation: this subprocess must behave like a raw LLM, not a + // full Claude Code agent. `--tools ""` disables every built-in tool + // (Bash/Read/WebSearch/...); `--strict-mcp-config` ignores all user-level + // MCP servers (without it, each call would boot the user's MCP servers — + // including gbrain's own MCP → recursion + PGLite single-writer lock + // contention). Verified against claude CLI 2.1.145 --help. + '--tools', '', + '--strict-mcp-config', + ]; + if (systemPrompt) { + args.push('--system-prompt', systemPrompt); + } + // Env scrub: guarantee the CLI authenticates via its own OAuth session + // (subscription), never via an inherited API key. Without this, an + // ANTHROPIC_API_KEY in gbrain's env (the exact setup this recipe is meant + // to replace) silently flips billing to per-token API usage. + const env = { ...process.env }; + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_AUTH_TOKEN; + delete env.ANTHROPIC_BASE_URL; + const child = spawn(claudeBin(), args, { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: ensureCleanCwd(), + env, + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += String(chunk); }); + child.stderr.on('data', chunk => { stderr += String(chunk); }); + + const onAbort = () => { + child.kill('SIGTERM'); + reject(new Error('claude-cli adapter aborted')); + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + child.on('error', err => { + if (signal) signal.removeEventListener('abort', onAbort); + reject(new Error(`claude-cli spawn failed: ${err instanceof Error ? err.message : String(err)}`)); + }); + + child.on('close', code => { + if (signal) signal.removeEventListener('abort', onAbort); + if (code !== 0) { + reject(new Error(`claude-cli exited ${code}: ${stderr.trim() || stdout.trim()}`)); + return; + } + try { + let parsed = JSON.parse(stdout) as unknown; + // Compat: when the user has `"verbose": true` in ~/.claude/settings.json, + // `--print --output-format json` emits an ARRAY of events + // ([{type:"system",subtype:"init",...}, ..., {type:"result",...}]) + // instead of the bare result object. There is no CLI flag to force it + // off (no --no-verbose; --settings '{}' merges, does not replace), so + // tolerate both shapes and pick the result event. Verified on CLI 2.1.145. + if (Array.isArray(parsed)) { + const resultEvent = parsed.find( + (ev): ev is ClaudeJsonResult => + !!ev && typeof ev === 'object' && (ev as { type?: unknown }).type === 'result', + ); + if (!resultEvent) { + reject(new Error(`claude-cli JSON event array had no "result" event\n--- raw ---\n${stdout.slice(0, 500)}`)); + return; + } + parsed = resultEvent; + } + const envelope = parsed as ClaudeJsonResult; + if (envelope.is_error) { + reject(new Error(`claude-cli reported error: ${envelope.result || envelope.subtype}`)); + return; + } + resolve(envelope); + } catch (e) { + reject(new Error(`claude-cli output not JSON: ${e instanceof Error ? e.message : String(e)}\n--- raw ---\n${stdout.slice(0, 500)}`)); + } + }); + + // stdin error handler: if the binary does not exist (ENOENT) or the child + // dies before draining stdin, write/end can emit an unhandled 'error' + // (EPIPE) that would crash the worker. The spawn-level 'error' / non-zero + // 'close' handlers above already surface the real failure, so the stdin + // error itself is safe to swallow. + child.stdin.on('error', () => { /* surfaced via child 'error'/'close' */ }); + try { + child.stdin.write(userPrompt); + child.stdin.end(); + } catch (e) { + if (signal) signal.removeEventListener('abort', onAbort); + reject(new Error(`claude-cli stdin write failed (is the claude binary installed?): ${e instanceof Error ? e.message : String(e)}`)); + } + }); +} + +interface ParsedToolCall { + id: string; + name: string; + /** Stringified JSON, matching the ai-sdk LanguageModelV2ToolCall.input contract. */ + input: string; +} + +/** + * Locate and parse the `...` block in the assistant's + * raw text response. Returns the parsed tool calls plus whatever prose + * surrounded the block. Returns an empty `toolCalls` array when no block is + * present, malformed, or unterminated — the caller then treats the full + * raw text as a final text response. + */ +function extractToolCalls(raw: string): { + toolCalls: ParsedToolCall[]; + beforeText: string; + afterText: string; +} { + const openTag = ''; + const closeTag = ''; + const openIdx = raw.indexOf(openTag); + if (openIdx === -1) { + return { toolCalls: [], beforeText: raw.trim(), afterText: '' }; + } + const closeIdx = raw.indexOf(closeTag, openIdx + openTag.length); + if (closeIdx === -1) { + // Unterminated block — recover gracefully. + return { toolCalls: [], beforeText: raw.trim(), afterText: '' }; + } + + const beforeText = raw.slice(0, openIdx).trim(); + const afterText = raw.slice(closeIdx + closeTag.length).trim(); + let inner = raw.slice(openIdx + openTag.length, closeIdx).trim(); + + if (inner.startsWith('```')) { + inner = inner.replace(/^```(?:json|JSON)?\s*\n?/, '').replace(/\n?```$/, '').trim(); + } + + let parsed: unknown; + try { + parsed = JSON.parse(inner); + } catch { + return { toolCalls: [], beforeText: raw.trim(), afterText: '' }; + } + if (!Array.isArray(parsed)) { + return { toolCalls: [], beforeText: raw.trim(), afterText: '' }; + } + + const toolCalls: ParsedToolCall[] = []; + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + const name = typeof e.name === 'string' ? e.name : null; + if (!name) continue; + const id = typeof e.id === 'string' && e.id.length > 0 + ? e.id + : `toolu_claude_cli_${Math.random().toString(36).slice(2, 12)}`; + const inputJson = JSON.stringify(e.input ?? {}); + toolCalls.push({ id, name, input: inputJson }); + } + + return { toolCalls, beforeText, afterText }; +} + +/** + * Strip provider prefixes (`anthropic:`, `litellm:`, `claude-cli:`) that the + * underlying CLI does not understand. The gateway hands us a bare model id + * via `recipe.aliases` resolution, but defensive normalization here keeps + * direct LanguageModelV2 construction (in tests, for example) ergonomic. + */ +function normalizeModel(model: string): string { + const idx = model.indexOf(':'); + return idx >= 0 ? model.slice(idx + 1) : model; +} + +export class ClaudeCliLanguageModel implements LanguageModelV2 { + readonly specificationVersion = 'v2' as const; + readonly provider = 'claude-cli'; + readonly modelId: string; + readonly supportedUrls = {}; + + constructor(modelId: string) { + this.modelId = normalizeModel(modelId); + } + + async doGenerate(options: LanguageModelV2CallOptions): Promise<{ + content: LanguageModelV2Content[]; + finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown'; + usage: { inputTokens: number | undefined; outputTokens: number | undefined; totalTokens: number | undefined }; + warnings: never[]; + }> { + const { systemText, userPrompt } = renderPrompt(options.prompt); + const toolInstructions = buildToolUseInstructions(options.tools); + const systemPrompt = [systemText, toolInstructions].filter(s => s.length > 0).join('\n'); + + const result = await runClaude(systemPrompt, userPrompt, this.modelId, options.abortSignal); + const { toolCalls, beforeText, afterText } = extractToolCalls(result.result); + + const content: LanguageModelV2Content[] = []; + if (beforeText) content.push({ type: 'text', text: beforeText }); + for (const call of toolCalls) { + content.push({ + type: 'tool-call', + toolCallId: call.id, + toolName: call.name, + input: call.input, + }); + } + if (afterText) content.push({ type: 'text', text: afterText }); + if (content.length === 0) { + // Empty response — still hand the caller a well-formed content array. + content.push({ type: 'text', text: result.result ?? '' }); + } + + const finishReason = toolCalls.length > 0 ? 'tool-calls' as const : 'stop' as const; + const inputTokens = result.usage?.input_tokens; + const outputTokens = result.usage?.output_tokens; + const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0); + + return { + content, + finishReason, + usage: { + inputTokens, + outputTokens, + totalTokens: inputTokens !== undefined && outputTokens !== undefined ? totalTokens : undefined, + }, + warnings: [], + }; + } + + async doStream(): Promise { + throw new Error( + 'claude-cli LanguageModel does not support streaming. Use doGenerate or set ' + + 'the model on a non-streaming chat surface (gateway.toolLoop is non-streaming).', + ); + } +} diff --git a/src/core/ai/recipes/claude-cli.ts b/src/core/ai/recipes/claude-cli.ts new file mode 100644 index 000000000..2f1accbfe --- /dev/null +++ b/src/core/ai/recipes/claude-cli.ts @@ -0,0 +1,71 @@ +import type { Recipe } from '../types.ts'; + +/** + * Claude via the local `claude` CLI binary, using its built-in OAuth session + * (Claude Code / Claude Max subscription). No ANTHROPIC_API_KEY needed — the + * CLI manages its own auth state and the gateway dispatches via subprocess. + * + * Solves the #334 case where Max subscribers want Minions subagent dispatch + * to run against their existing subscription instead of paying per-token API + * charges. The recipe sits alongside the existing `anthropic` recipe so users + * pick per call: `anthropic:claude-sonnet-4-6` (API key + per-token billing) + * vs `claude-cli:claude-sonnet-4-6` (OAuth subscription, no API key). + * + * Chat-only. Claude has no first-party embedding model; users wanting an + * Anthropic chat path with embeddings still combine this with openai/google/ + * voyage for embedding the way the existing `anthropic` recipe documents. + * + * Auth: `auth_env.required: []` because the CLI handles auth itself. The + * `claude` binary on PATH (or `GBRAIN_CLAUDE_CLI_BIN`) IS the auth surface; + * there is nothing for the gateway to forward. + * + * Setup expectation: `claude` CLI installed and logged in (Claude Code + * onboarding does this), or `GBRAIN_CLAUDE_CLI_BIN` pointing at the binary. + */ +export const claudeCli: Recipe = { + id: 'claude-cli', + name: 'Claude (via CLI)', + tier: 'native', + implementation: 'claude-cli', + // The CLI owns auth; no env vars are required from the gateway side. + auth_env: { + required: [], + }, + touchpoints: { + // No embedding or expansion touchpoints — chat-only. + chat: { + models: [ + 'claude-opus-4-7', + 'claude-sonnet-4-6', + 'claude-haiku-4-5-20251001', + ], + supports_tools: true, + supports_subagent_loop: true, + // The CLI handles caching internally and does not surface it via the + // standard cache_control control plane. From the gateway's POV the + // model does not support prompt caching. + supports_prompt_cache: false, + max_context_tokens: 200000, + // Cost figures match the underlying Claude API tier, but the actual + // bill is borne by the subscription. We report them for the budget + // ledger's per-call accounting; operators on flat-rate subscriptions + // can treat the numbers as nominal. + cost_per_1m_input_usd: 3.0, + cost_per_1m_output_usd: 15.0, + price_last_verified: '2026-06-17', + }, + }, + // Friendly aliases mirror the `anthropic` recipe so config strings stay + // portable: switching `anthropic:claude-sonnet-4-6` to `claude-cli:claude-sonnet-4-6` + // is a one-token edit. Reverse aliases rewrite legacy IDs back to canonical. + aliases: { + 'claude-haiku-4-5': 'claude-haiku-4-5-20251001', + 'claude-sonnet-4-6-20250929': 'claude-sonnet-4-6', + 'sonnet': 'claude-sonnet-4-6', + 'haiku': 'claude-haiku-4-5-20251001', + 'opus': 'claude-opus-4-7', + }, + setup_hint: + 'Install Claude Code (`claude` CLI) and run `claude` once to log in. ' + + 'Set GBRAIN_CLAUDE_CLI_BIN if the binary is not on PATH.', +}; diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index eb751ec61..7931323bb 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -9,6 +9,7 @@ import type { Recipe } from '../types.ts'; import { openai } from './openai.ts'; import { google } from './google.ts'; import { anthropic } from './anthropic.ts'; +import { claudeCli } from './claude-cli.ts'; import { ollama } from './ollama.ts'; import { openrouter } from './openrouter.ts'; import { voyage } from './voyage.ts'; @@ -31,6 +32,7 @@ const ALL: Recipe[] = [ openai, google, anthropic, + claudeCli, ollama, openrouter, voyage, diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index 6b994c1fe..c96c6db21 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -22,7 +22,8 @@ export type Implementation = | 'native-openai' | 'native-google' | 'native-anthropic' - | 'openai-compatible'; + | 'openai-compatible' + | 'claude-cli'; export interface EmbeddingTouchpoint { models: string[]; diff --git a/test/claude-cli-recipe.test.ts b/test/claude-cli-recipe.test.ts new file mode 100644 index 000000000..26339b457 --- /dev/null +++ b/test/claude-cli-recipe.test.ts @@ -0,0 +1,535 @@ +/** + * Tests for the claude-cli LanguageModelV2 implementation that the + * `claude-cli` recipe instantiates. + * + * Strategy: a POSIX shell stub at GBRAIN_CLAUDE_CLI_BIN emits scripted + * --output-format json envelopes. Tests exercise the LanguageModelV2 + * doGenerate surface: text round trip, tool-call extraction (single + + * multiple parallel), abort semantics, context-isolation flags. No + * claude-cli installation or API credits required. + * + * Recipe registration is also smoke-tested: getRecipe('claude-cli') + * returns a chat-only Recipe with the right model list. + * + * Env isolation: GBRAIN_CLAUDE_CLI_BIN is set per-test via withEnv(), + * NOT in beforeAll. The provider reads the env var at spawn time so + * withEnv's save/restore in try/finally is sufficient; no leakage to + * sibling test files in the same bun-test process. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { writeFileSync, chmodSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { LanguageModelV2CallOptions } from '@ai-sdk/provider'; +import { withEnv } from './helpers/with-env.ts'; + +const stubDir = join(tmpdir(), `claude-cli-recipe-stub-${process.pid}`); +const stubBin = join(stubDir, 'claude'); +const stubResponsePath = join(stubDir, 'claude_response.json'); + +beforeAll(() => { + mkdirSync(stubDir, { recursive: true }); + const stub = [ + '#!/bin/sh', + 'cat > /dev/null', + 'case " $* " in', + ' *" --print "*) ;;', + ' *) echo "missing --print in argv: $*" >&2; exit 64 ;;', + 'esac', + `cat "${stubResponsePath}"`, + ].join('\n'); + writeFileSync(stubBin, stub); + chmodSync(stubBin, 0o755); +}); + +afterAll(() => { + rmSync(stubDir, { recursive: true, force: true }); +}); + +function withStubEnv(fn: () => T | Promise): Promise { + return withEnv({ GBRAIN_CLAUDE_CLI_BIN: stubBin }, fn); +} + +function stageResponse(envelope: Record): void { + writeFileSync(stubResponsePath, JSON.stringify(envelope)); +} + +function baseEnvelope(result: string, overrides: Record = {}): Record { + return { + type: 'result', + subtype: 'success', + is_error: false, + result, + stop_reason: 'end_turn', + session_id: 'test-session', + num_turns: 1, + usage: { + input_tokens: 12, + output_tokens: 34, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + ...overrides, + }; +} + +function userMessage(text: string): LanguageModelV2CallOptions['prompt'][number] { + return { role: 'user', content: [{ type: 'text', text }] }; +} + +describe('claude-cli recipe registration', () => { + test('getRecipe returns chat-only Recipe with the documented models', async () => { + const { getRecipe } = await import('../src/core/ai/recipes/index.ts'); + const recipe = getRecipe('claude-cli'); + expect(recipe).toBeDefined(); + expect(recipe!.id).toBe('claude-cli'); + expect(recipe!.implementation).toBe('claude-cli'); + expect(recipe!.touchpoints.chat).toBeDefined(); + expect(recipe!.touchpoints.chat!.supports_tools).toBe(true); + expect(recipe!.touchpoints.chat!.supports_subagent_loop).toBe(true); + expect(recipe!.touchpoints.chat!.models).toContain('claude-sonnet-4-6'); + expect(recipe!.touchpoints.embedding).toBeUndefined(); + expect(recipe!.touchpoints.expansion).toBeUndefined(); + }); + + test('recipe aliases map short names to canonical model ids', async () => { + const { getRecipe } = await import('../src/core/ai/recipes/index.ts'); + const recipe = getRecipe('claude-cli'); + expect(recipe!.aliases!['sonnet']).toBe('claude-sonnet-4-6'); + expect(recipe!.aliases!['haiku']).toBe('claude-haiku-4-5-20251001'); + }); +}); + +describe('claude-cli LanguageModel — text-only round trip', () => { + test('returns a single text content block with usage + stop finish reason', async () => { + await withStubEnv(async () => { + stageResponse(baseEnvelope('hello world')); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('hi')], + } as LanguageModelV2CallOptions); + + expect(result.finishReason).toBe('stop'); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ type: 'text', text: 'hello world' }); + expect(result.usage.inputTokens).toBe(12); + expect(result.usage.outputTokens).toBe(34); + }); + }); + + test('strips provider prefixes from the model id', async () => { + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('anthropic:claude-sonnet-4-6'); + expect(model.modelId).toBe('claude-sonnet-4-6'); + }); +}); + +describe('claude-cli LanguageModel — tool use', () => { + test('parses block into LanguageModelV2 tool-call content', async () => { + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + [ + 'I will look up the pattern first.', + '', + '[{"id": "toolu_01ABC", "name": "search", "input": {"query": "n+1 query"}}]', + '', + ].join('\n'), + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('find n+1 queries')], + tools: [ + { + type: 'function', + name: 'search', + description: 'Search the brain', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }, + ], + } as LanguageModelV2CallOptions); + + expect(result.finishReason).toBe('tool-calls'); + expect(result.content).toHaveLength(2); + expect(result.content[0]).toMatchObject({ type: 'text', text: 'I will look up the pattern first.' }); + expect(result.content[1]).toMatchObject({ + type: 'tool-call', + toolCallId: 'toolu_01ABC', + toolName: 'search', + input: '{"query":"n+1 query"}', + }); + }); + }); + + test('parses multiple parallel tool calls in a single block', async () => { + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + [ + '', + '[', + ' {"id": "toolu_A", "name": "search", "input": {"query": "foo"}},', + ' {"id": "toolu_B", "name": "get_page", "input": {"slug": "areas/x"}}', + ']', + '', + ].join('\n'), + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('multi')], + tools: [ + { type: 'function', name: 'search', description: 's', inputSchema: { type: 'object', properties: {} } }, + { type: 'function', name: 'get_page', description: 'g', inputSchema: { type: 'object', properties: {} } }, + ], + } as LanguageModelV2CallOptions); + + const calls = result.content.filter(c => c.type === 'tool-call'); + expect(calls).toHaveLength(2); + expect(calls.map(c => (c as { toolName: string }).toolName)).toEqual(['search', 'get_page']); + expect(result.finishReason).toBe('tool-calls'); + }); + }); + + test('tolerates fenced JSON inside ', async () => { + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + [ + '', + '```json', + '[{"id": "toolu_F", "name": "search", "input": {"q": "x"}}]', + '```', + '', + ].join('\n'), + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('fenced')], + tools: [{ type: 'function', name: 'search', description: '', inputSchema: { type: 'object', properties: {} } }], + } as LanguageModelV2CallOptions); + + const calls = result.content.filter(c => c.type === 'tool-call'); + expect(calls).toHaveLength(1); + }); + }); + + test('synthesizes an id when the model omits it', async () => { + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + [ + '', + '[{"name": "search", "input": {"q": "x"}}]', + '', + ].join('\n'), + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('no id')], + tools: [{ type: 'function', name: 'search', description: '', inputSchema: { type: 'object', properties: {} } }], + } as LanguageModelV2CallOptions); + + const call = result.content.find(c => c.type === 'tool-call') as { toolCallId: string } | undefined; + expect(call).toBeDefined(); + expect(call!.toolCallId).toMatch(/^toolu_claude_cli_/); + }); + }); + + test('falls back to text on malformed JSON', async () => { + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + [ + '', + 'not valid json', + '', + ].join('\n'), + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('malformed')], + tools: [{ type: 'function', name: 'search', description: '', inputSchema: { type: 'object', properties: {} } }], + } as LanguageModelV2CallOptions); + + expect(result.content.filter(c => c.type === 'tool-call')).toHaveLength(0); + expect(result.finishReason).toBe('stop'); + }); + }); + + test('returns text-only stop when tools are offered but model declines to call any', async () => { + // Real-world case: the model decides the user's request does not require + // a tool call, ignores the use_tools protocol, and answers directly. + // The recipe still must return clean LanguageModelV2 output so the + // caller (gateway.toolLoop) can treat the text as the final answer + // rather than wedge waiting for tool calls that never come. + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + 'I do not actually need to call any tools for this. The answer is 42.', + { stop_reason: 'end_turn' }, + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('what is the meaning of life? you may use tools but do not need to')], + tools: [{ type: 'function', name: 'compute', description: 'Compute things', inputSchema: { type: 'object', properties: {} } }], + } as LanguageModelV2CallOptions); + + // No tool-call content blocks; caller treats this as a final answer. + expect(result.content.filter(c => c.type === 'tool-call')).toHaveLength(0); + // Text block present with the full model reply. + const textBlocks = result.content.filter(c => c.type === 'text'); + expect(textBlocks).toHaveLength(1); + expect((textBlocks[0] as { text: string }).text).toContain('42'); + // finishReason 'stop' tells the gateway-loop this is terminal output, + // not a partial mid-tool-loop state. + expect(result.finishReason).toBe('stop'); + }); + }); + + test('drops the block when the close tag is missing', async () => { + await withStubEnv(async () => { + stageResponse( + baseEnvelope( + [ + '', + '[{"id": "toolu_X", "name": "search", "input": {}}', + ].join('\n'), + ), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('unterminated')], + tools: [{ type: 'function', name: 'search', description: '', inputSchema: { type: 'object', properties: {} } }], + } as LanguageModelV2CallOptions); + + expect(result.content.filter(c => c.type === 'tool-call')).toHaveLength(0); + expect(result.finishReason).toBe('stop'); + }); + }); +}); + +describe('claude-cli LanguageModel — context isolation', () => { + test('argv includes --disable-slash-commands + --system-prompt and cwd is the dedicated tmpdir', async () => { + await withStubEnv(async () => { + const argvLog = join(stubDir, 'argv.log'); + const cwdLog = join(stubDir, 'cwd.log'); + const recordStub = [ + '#!/bin/sh', + `printf "%s\\n" "$@" > "${argvLog}"`, + `pwd > "${cwdLog}"`, + 'cat > /dev/null', + `cat "${stubResponsePath}"`, + ].join('\n'); + writeFileSync(stubBin, recordStub); + chmodSync(stubBin, 0o755); + stageResponse(baseEnvelope('ok')); + + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await model.doGenerate({ + prompt: [ + { role: 'system', content: 'You are gbrain subagent.' }, + userMessage('hi'), + ], + } as LanguageModelV2CallOptions); + + const fs = require('node:fs'); + const argv = fs.readFileSync(argvLog, 'utf8').split('\n').filter(Boolean); + const cwd = fs.readFileSync(cwdLog, 'utf8').trim(); + + expect(argv).toContain('--print'); + expect(argv).toContain('--output-format'); + expect(argv).toContain('json'); + expect(argv).toContain('--disable-slash-commands'); + // Agent-isolation hardening: no built-in tools, no inherited MCP servers. + expect(argv).toContain('--tools'); + expect(argv).toContain('--strict-mcp-config'); + expect(argv).toContain('--system-prompt'); + expect(argv).toContain('You are gbrain subagent.'); + expect(cwd).toMatch(/gbrain-claude-cli-cwd-\d+$/); + + const fastStub = [ + '#!/bin/sh', + 'cat > /dev/null', + `cat "${stubResponsePath}"`, + ].join('\n'); + writeFileSync(stubBin, fastStub); + chmodSync(stubBin, 0o755); + }); + }); + + test('scrubs ANTHROPIC_* credentials from the child env (subscription-only auth)', async () => { + await withStubEnv(async () => { + await withEnv( + { + ANTHROPIC_API_KEY: 'sk-should-never-leak', + ANTHROPIC_AUTH_TOKEN: 'tok-should-never-leak', + ANTHROPIC_BASE_URL: 'https://proxy.should.never.leak', + }, + async () => { + const envLog = join(stubDir, 'env.log'); + const envStub = [ + '#!/bin/sh', + `printf "key=%s\\ntoken=%s\\nbase=%s\\n" "\${ANTHROPIC_API_KEY:-UNSET}" "\${ANTHROPIC_AUTH_TOKEN:-UNSET}" "\${ANTHROPIC_BASE_URL:-UNSET}" > "${envLog}"`, + 'cat > /dev/null', + `cat "${stubResponsePath}"`, + ].join('\n'); + writeFileSync(stubBin, envStub); + chmodSync(stubBin, 0o755); + stageResponse(baseEnvelope('ok')); + + try { + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await model.doGenerate({ + prompt: [userMessage('hi')], + } as LanguageModelV2CallOptions); + + const fs = require('node:fs'); + const seen = fs.readFileSync(envLog, 'utf8'); + expect(seen).toContain('key=UNSET'); + expect(seen).toContain('token=UNSET'); + expect(seen).toContain('base=UNSET'); + } finally { + const fastStub = [ + '#!/bin/sh', + 'cat > /dev/null', + `cat "${stubResponsePath}"`, + ].join('\n'); + writeFileSync(stubBin, fastStub); + chmodSync(stubBin, 0o755); + } + }, + ); + }); + }); +}); + +describe('claude-cli LanguageModel — abort + error envelopes', () => { + test('SIGTERMs the child on AbortSignal', async () => { + await withStubEnv(async () => { + const slowStub = [ + '#!/bin/sh', + 'cat > /dev/null', + 'sleep 30', + 'echo "{}"', + ].join('\n'); + writeFileSync(stubBin, slowStub); + chmodSync(stubBin, 0o755); + try { + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const ac = new AbortController(); + const promise = model.doGenerate({ + prompt: [userMessage('slow')], + abortSignal: ac.signal, + } as LanguageModelV2CallOptions); + setTimeout(() => ac.abort(), 30); + await expect(promise).rejects.toThrow(/aborted/); + } finally { + const fastStub = [ + '#!/bin/sh', + 'cat > /dev/null', + `cat "${stubResponsePath}"`, + ].join('\n'); + writeFileSync(stubBin, fastStub); + chmodSync(stubBin, 0o755); + } + }); + }); + + test('rejects when stub reports is_error: true', async () => { + await withStubEnv(async () => { + stageResponse({ ...baseEnvelope('boom'), is_error: true }); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await expect( + model.doGenerate({ prompt: [userMessage('x')] } as LanguageModelV2CallOptions), + ).rejects.toThrow(/claude-cli reported error/); + }); + }); + + test('rejects on non-JSON output', async () => { + await withStubEnv(async () => { + writeFileSync(stubResponsePath, 'this is not json'); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await expect( + model.doGenerate({ prompt: [userMessage('x')] } as LanguageModelV2CallOptions), + ).rejects.toThrow(/claude-cli output not JSON/); + }); + }); + + test('accepts a verbose-mode JSON event array and picks the result event', async () => { + // With `"verbose": true` in ~/.claude/settings.json the CLI emits an array + // of events instead of the bare result object (no CLI flag disables it). + await withStubEnv(async () => { + writeFileSync( + stubResponsePath, + JSON.stringify([ + { type: 'system', subtype: 'init', session_id: 'test-session', tools: [], mcp_servers: [] }, + baseEnvelope('hello from array'), + ]), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + const result = await model.doGenerate({ + prompt: [userMessage('hi')], + } as LanguageModelV2CallOptions); + expect(result.finishReason).toBe('stop'); + expect(result.content[0]).toEqual({ type: 'text', text: 'hello from array' }); + }); + }); + + test('rejects a verbose-mode event array that lacks a result event', async () => { + // Verbose mode emits an event array; a truncated stream (or one carrying + // only init/system events) has no result event to unwrap. + await withStubEnv(async () => { + writeFileSync( + stubResponsePath, + JSON.stringify([ + { type: 'system', subtype: 'init', session_id: 'test-session', tools: [], mcp_servers: [] }, + { type: 'assistant', message: { role: 'assistant', content: [] } }, + ]), + ); + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await expect( + model.doGenerate({ prompt: [userMessage('x')] } as LanguageModelV2CallOptions), + ).rejects.toThrow(/had no "result" event/); + }); + }); + + test('rejects cleanly when the claude binary is missing (no worker crash)', async () => { + // A missing binary must surface as a rejected promise via the spawn 'error' + // handler; the child stdin 'error' (EPIPE) handler swallows the pipe failure + // so it never escalates to an unhandled rejection that would down the worker. + await withEnv({ GBRAIN_CLAUDE_CLI_BIN: join(stubDir, 'nonexistent-claude') }, async () => { + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await expect( + model.doGenerate({ prompt: [userMessage('x')] } as LanguageModelV2CallOptions), + ).rejects.toThrow(/claude-cli spawn failed/); + }); + }); + + test('doStream throws not-supported', async () => { + const { ClaudeCliLanguageModel } = await import('../src/core/ai/providers/claude-cli-language-model.ts'); + const model = new ClaudeCliLanguageModel('claude-sonnet-4-6'); + await expect(model.doStream()).rejects.toThrow(/does not support streaming/); + }); +});