From 1cdcf51ace0ebe48b4dd5e7ee76494ffde006b5c Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Mon, 9 Feb 2026 15:17:16 -0800 Subject: [PATCH 1/4] feat(cli): expand support to wrap both codex and gemini for easy launching and prompt injection --- .gitignore | 4 + README.md | 16 ++ packages/cli/src/backends/claude.ts | 52 ++++++ packages/cli/src/backends/codex.ts | 44 +++++ packages/cli/src/backends/gemini.ts | 50 ++++++ packages/cli/src/backends/identity.ts | 95 +++++++++++ packages/cli/src/backends/index.ts | 29 ++++ packages/cli/src/backends/types.ts | 33 ++++ packages/cli/src/cli.ts | 29 +++- packages/cli/src/commands/claude.ts | 186 ++++----------------- packages/cli/src/commands/mcp.ts | 227 ++++++++++++++++++++++++++ stories/cli/multi-backend.md | 126 ++++++++++++++ 12 files changed, 732 insertions(+), 159 deletions(-) create mode 100644 packages/cli/src/backends/claude.ts create mode 100644 packages/cli/src/backends/codex.ts create mode 100644 packages/cli/src/backends/gemini.ts create mode 100644 packages/cli/src/backends/identity.ts create mode 100644 packages/cli/src/backends/index.ts create mode 100644 packages/cli/src/backends/types.ts create mode 100644 packages/cli/src/commands/mcp.ts create mode 100644 stories/cli/multi-backend.md diff --git a/.gitignore b/.gitignore index b75a7ac5..5ea547ee 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,7 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Backend-specific config (generated by sb mcp sync) +.codex/ +.gemini/ diff --git a/README.md b/README.md index 17e671a2..6b8dafe8 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ personal-context-protocol/ ├── packages/ │ ├── api/ # PCP server (MCP tools, services, data layer) │ └── cli/ # SB CLI (sb command) +├── stories/ # Feature specs and design docs +│ └── cli/ # CLI-related stories ├── supabase/ │ └── migrations/ # Database migrations ├── AGENTS.md # Agent onboarding (points to CLAUDE.md) @@ -38,6 +40,20 @@ personal-context-protocol/ └── README.md # This file ``` +## Stories + +Feature work lives in `stories/`, grouped by domain. Each story contains specs, research, and the feature-specific source files that don't belong in a generic shared folder: + +``` +stories/ +├── cli/ # CLI features (backends, flags, install) +├── channels/ # Messaging integrations +├── mcp/ # MCP tools and server +└── agents/ # Multi-agent orchestration +``` + +Stories are living documents — update them as the feature evolves. + ## Key Technologies - **Runtime**: Node.js 18+, TypeScript, Yarn 4 workspaces diff --git a/packages/cli/src/backends/claude.ts b/packages/cli/src/backends/claude.ts new file mode 100644 index 00000000..66c3ad4e --- /dev/null +++ b/packages/cli/src/backends/claude.ts @@ -0,0 +1,52 @@ +/** + * Claude Code Backend Adapter + * + * Identity injection via --append-system-prompt + * MCP config via --mcp-config + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { createIdentityPromptFile } from './identity.js'; +import type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js'; + +export class ClaudeAdapter implements BackendAdapter { + readonly name = 'claude'; + readonly binary = 'claude'; + + prepare(config: BackendConfig): PreparedBackend { + const { promptFile, cleanup } = createIdentityPromptFile(config.agentId); + + const args: string[] = []; + + // Prompt mode vs interactive + if (config.prompt) { + args.push('-p'); + } + + // Model + identity + args.push('--model', config.model); + args.push('--append-system-prompt', promptFile); + + // MCP config (if present in CWD) + const mcpConfig = join(process.cwd(), '.mcp.json'); + if (existsSync(mcpConfig)) { + args.push('--mcp-config', mcpConfig); + } + + // Passthrough flags + args.push(...config.passthroughArgs); + + // Prompt as a single string after -p + if (config.prompt) { + args.push(config.prompt); + } + + return { + binary: this.binary, + args, + env: { AGENT_ID: config.agentId }, + cleanup, + }; + } +} diff --git a/packages/cli/src/backends/codex.ts b/packages/cli/src/backends/codex.ts new file mode 100644 index 00000000..b5d05cee --- /dev/null +++ b/packages/cli/src/backends/codex.ts @@ -0,0 +1,44 @@ +/** + * Codex CLI Backend Adapter + * + * Identity injection via --config model_instructions_file= + * MCP config via --config mcp_servers (TOML format, not yet implemented) + * + * Docs: https://developers.openai.com/codex/cli/ + */ + +import { createIdentityPromptFile } from './identity.js'; +import type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js'; + +export class CodexAdapter implements BackendAdapter { + readonly name = 'codex'; + readonly binary = 'codex'; + + prepare(config: BackendConfig): PreparedBackend { + const { promptFile, cleanup } = createIdentityPromptFile(config.agentId); + + const args: string[] = []; + + // Identity injection via config override + args.push('--config', `model_instructions_file=${promptFile}`); + + // Model + args.push('--model', config.model); + + // Passthrough flags + args.push(...config.passthroughArgs); + + // Positional args spread individually so subcommands work + // e.g. "sb -b codex mcp login supabase" → codex ... mcp login supabase + if (config.promptParts.length > 0) { + args.push(...config.promptParts); + } + + return { + binary: this.binary, + args, + env: { AGENT_ID: config.agentId }, + cleanup, + }; + } +} diff --git a/packages/cli/src/backends/gemini.ts b/packages/cli/src/backends/gemini.ts new file mode 100644 index 00000000..93243ba7 --- /dev/null +++ b/packages/cli/src/backends/gemini.ts @@ -0,0 +1,50 @@ +/** + * Gemini CLI Backend Adapter + * + * Identity injection via GEMINI_SYSTEM_MD= env var + * MCP config via .gemini/settings.json (not yet implemented) + * + * Docs: https://geminicli.com/docs/ + */ + +import { createIdentityPromptFile } from './identity.js'; +import type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js'; + +export class GeminiAdapter implements BackendAdapter { + readonly name = 'gemini'; + readonly binary = 'gemini'; + + prepare(config: BackendConfig): PreparedBackend { + const { promptFile, cleanup } = createIdentityPromptFile(config.agentId); + + const args: string[] = []; + + // Model + args.push('-m', config.model); + + // Prompt mode: gemini uses -p for one-shot + // Interactive is the default (no flag needed) + if (config.prompt) { + args.push('-p'); + } + + // Passthrough flags + args.push(...config.passthroughArgs); + + // Prompt as a single string after -p + if (config.prompt) { + args.push(config.prompt); + } + + return { + binary: this.binary, + args, + // Identity injection via env var — points to our temp file + env: { + AGENT_ID: config.agentId, + GEMINI_SYSTEM_MD: promptFile, + }, + cleanup, + }; + } +} diff --git a/packages/cli/src/backends/identity.ts b/packages/cli/src/backends/identity.ts new file mode 100644 index 00000000..da4914b8 --- /dev/null +++ b/packages/cli/src/backends/identity.ts @@ -0,0 +1,95 @@ +/** + * Shared Identity Resolution + * + * Resolves agent identity and builds the identity prompt. + * Used by all backend adapters. + */ + +import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { homedir, tmpdir } from 'os'; + +interface PcpConfig { + userId?: string; + email?: string; + agentMapping?: Record; +} + +interface IdentityJson { + agentId: string; + context?: string; +} + +/** + * Resolve agent ID from multiple sources: + * 1. CLI --agent flag (if explicitly changed from default) + * 2. .pcp/identity.json in current directory + * 3. ~/.pcp/config.json agentMapping + * 4. Default: 'wren' + */ +export function resolveAgentId(cliAgent?: string): string { + if (cliAgent && cliAgent !== 'wren') { + return cliAgent; + } + + const localIdentity = join(process.cwd(), '.pcp', 'identity.json'); + if (existsSync(localIdentity)) { + try { + const identity: IdentityJson = JSON.parse(readFileSync(localIdentity, 'utf-8')); + if (identity.agentId) return identity.agentId; + } catch { /* ignore */ } + } + + const configPath = join(homedir(), '.pcp', 'config.json'); + if (existsSync(configPath)) { + try { + const config: PcpConfig = JSON.parse(readFileSync(configPath, 'utf-8')); + if (config.agentMapping?.['claude-code']) return config.agentMapping['claude-code']; + } catch { /* ignore */ } + } + + return cliAgent || 'wren'; +} + +/** + * Build the identity prompt content. Same across all backends. + */ +export function buildIdentityPrompt(agentId: string): string { + return `## Identity Override (CRITICAL) + +**You are ${agentId}. Your agent ID is \`${agentId}\`.** + +When calling PCP tools (bootstrap, remember, recall, start_session, etc.), use \`agentId: "${agentId}"\`. +Do NOT read \`.pcp/identity.json\` — your identity is set by this system prompt. +Do NOT run \`echo $AGENT_ID\` — use the agentId provided above. + +Skip directly to loading user config from ~/.pcp/config.json and bootstrap as "${agentId}". + +## Tool Priority (IMPORTANT) + +Always use **PCP cloud tools** (mcp__pcp__*) over file reads or Claude Code builtins: +- Identity: use mcp__pcp__bootstrap, not file reads +- Tasks: use mcp__pcp__create_task, not TaskCreate +- Memory: use mcp__pcp__remember, not local notes +- Sessions: use mcp__pcp__start_session/log_session/end_session + +PCP tools persist across sessions and are shared with the user and other agents.`; +} + +/** + * Write the identity prompt to a temp file. + * Returns the file path and a cleanup function. + */ +export function createIdentityPromptFile(agentId: string): { promptFile: string; cleanup: () => void } { + const content = buildIdentityPrompt(agentId); + const tempDir = mkdtempSync(join(tmpdir(), 'sb-')); + const promptFile = join(tempDir, 'identity-prompt.md'); + writeFileSync(promptFile, content); + + return { + promptFile, + cleanup: () => { + try { rmSync(tempDir, { recursive: true }); } catch { /* ignore */ } + }, + }; +} diff --git a/packages/cli/src/backends/index.ts b/packages/cli/src/backends/index.ts new file mode 100644 index 00000000..a274f051 --- /dev/null +++ b/packages/cli/src/backends/index.ts @@ -0,0 +1,29 @@ +/** + * Backend Registry + * + * Resolves backend name to adapter instance. + */ + +export type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js'; +export { resolveAgentId } from './identity.js'; + +import { ClaudeAdapter } from './claude.js'; +import { CodexAdapter } from './codex.js'; +import { GeminiAdapter } from './gemini.js'; +import type { BackendAdapter } from './types.js'; + +const BACKENDS: Record BackendAdapter> = { + claude: () => new ClaudeAdapter(), + codex: () => new CodexAdapter(), + gemini: () => new GeminiAdapter(), +}; + +export const BACKEND_NAMES = Object.keys(BACKENDS); + +export function getBackend(name: string): BackendAdapter { + const factory = BACKENDS[name]; + if (!factory) { + throw new Error(`Unknown backend: ${name}. Available: ${BACKEND_NAMES.join(', ')}`); + } + return factory(); +} diff --git a/packages/cli/src/backends/types.ts b/packages/cli/src/backends/types.ts new file mode 100644 index 00000000..7af330e3 --- /dev/null +++ b/packages/cli/src/backends/types.ts @@ -0,0 +1,33 @@ +/** + * Backend Adapter Interface + * + * Each AI CLI backend (Claude, Codex, Gemini) implements this interface + * to handle identity injection, MCP config, and flag mapping. + */ + +export interface BackendConfig { + agentId: string; + model: string; + prompt?: string; // undefined = interactive mode + promptParts: string[]; // raw positional args (preserves shell word boundaries) + passthroughArgs: string[]; +} + +export interface PreparedBackend { + binary: string; + args: string[]; + env: Record; + cleanup: () => void; +} + +export interface BackendAdapter { + readonly name: string; + readonly binary: string; + + /** + * Prepare everything needed to spawn the backend process. + * Writes temp files for identity injection, builds args, sets env vars. + * Returns a cleanup function to remove temp files on exit. + */ + prepare(config: BackendConfig): PreparedBackend; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 09e9cdf1..8d2dca8b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,11 +7,12 @@ * through to the underlying tool. * * Usage: - * sb Interactive Claude Code session - * sb "your prompt" Run Claude with prompt (one-shot) - * sb --resume Pass --resume through to Claude + * sb Interactive session (default: claude) + * sb "your prompt" One-shot prompt mode + * sb -b codex "fix the bug" Use Codex CLI backend + * sb -b gemini "review this" Use Gemini CLI backend + * sb --resume Passthrough flags to backend * sb ws create Create a workspace - * sb agent status Check agent status * sb session list List sessions */ @@ -20,6 +21,7 @@ import chalk from 'chalk'; import { registerWorkspaceCommands } from './commands/workspace.js'; import { registerAgentCommands } from './commands/agent.js'; import { registerSessionCommands } from './commands/session.js'; +import { registerConfigCommands } from './commands/mcp.js'; import { runClaude, runClaudeInteractive } from './commands/claude.js'; const VERSION = '0.3.0'; @@ -35,6 +37,8 @@ const VERSION = '0.3.0'; const SB_FLAGS: Record = { '-a': { hasValue: true, key: 'agent' }, '--agent': { hasValue: true, key: 'agent' }, + '-b': { hasValue: true, key: 'backend' }, + '--backend': { hasValue: true, key: 'backend' }, '-m': { hasValue: true, key: 'model' }, '--model': { hasValue: true, key: 'model' }, '-v': { hasValue: false, key: 'verbose' }, @@ -45,11 +49,13 @@ const SB_FLAGS: Record = { interface ParsedArgs { sbOptions: { agent: string; + backend: string; model: string; session: boolean; verbose: boolean; }; passthroughArgs: string[]; + promptParts: string[]; prompt: string; } @@ -61,6 +67,7 @@ interface ParsedArgs { function extractArgs(argv: string[]): ParsedArgs { const sbOptions = { agent: 'wren', + backend: 'claude', model: 'sonnet', session: true, verbose: false, @@ -78,6 +85,7 @@ function extractArgs(argv: string[]): ParsedArgs { if (flag.hasValue && i + 1 < argv.length) { const val = argv[++i]; if (flag.key === 'agent') sbOptions.agent = val; + if (flag.key === 'backend') sbOptions.backend = val; if (flag.key === 'model') sbOptions.model = val; } else if (!flag.hasValue) { if (flag.key === 'noSession') sbOptions.session = false; @@ -105,6 +113,7 @@ function extractArgs(argv: string[]): ParsedArgs { return { sbOptions, passthroughArgs, + promptParts, prompt: promptParts.join(' '), }; } @@ -120,14 +129,15 @@ program .allowUnknownOption(true) .allowExcessArguments(true) .option('-a, --agent ', 'Agent identity to use', 'wren') - .option('-m, --model ', 'Model to use (sonnet, opus, haiku)', 'sonnet') + .option('-b, --backend ', 'AI backend (claude, codex, gemini)', 'claude') + .option('-m, --model ', 'Model to use', 'sonnet') .option('--no-session', 'Disable session tracking') .option('-v, --verbose', 'Verbose output') - .argument('[prompt...]', 'Prompt to send to Claude (omit for interactive)') + .argument('[prompt...]', 'Prompt to send (omit for interactive)') .action(async () => { // We parse argv ourselves for clean passthrough — Commander's parsed // values aren't reliable for unknown flags with values. - const { sbOptions, passthroughArgs, prompt } = extractArgs(process.argv.slice(2)); + const { sbOptions, passthroughArgs, promptParts, prompt } = extractArgs(process.argv.slice(2)); if (!prompt && !passthroughArgs.length && !process.stdin.isTTY) { // Piped stdin — read it as the prompt @@ -136,10 +146,10 @@ program for await (const chunk of process.stdin) { stdinData += chunk; } - await runClaude(stdinData.trim(), sbOptions, passthroughArgs); + await runClaude(stdinData.trim(), [stdinData.trim()], sbOptions, passthroughArgs); } else if (prompt) { // Prompt mode (one-shot) - await runClaude(prompt, sbOptions, passthroughArgs); + await runClaude(prompt, promptParts, sbOptions, passthroughArgs); } else { // No prompt — launch interactive session // Passthrough args (like --resume) still forwarded @@ -151,6 +161,7 @@ program registerWorkspaceCommands(program); registerAgentCommands(program); registerSessionCommands(program); +registerConfigCommands(program); // ============================================================================ // Subcommand detection diff --git a/packages/cli/src/commands/claude.ts b/packages/cli/src/commands/claude.ts index aed24519..21d4da91 100644 --- a/packages/cli/src/commands/claude.ts +++ b/packages/cli/src/commands/claude.ts @@ -1,151 +1,36 @@ /** - * Claude Command + * Backend Runner * - * Wraps Claude Code CLI with SB integration: - * - Identity injection via --append-system-prompt - * - Passthrough of unrecognized flags to claude - * - Session tracking via PCP API + * Spawns the selected AI CLI backend with identity injection, + * passthrough flags, and session tracking. */ import { spawn } from 'child_process'; -import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs'; -import { join } from 'path'; -import { homedir, tmpdir } from 'os'; import chalk from 'chalk'; +import { getBackend, resolveAgentId } from '../backends/index.js'; export interface SbOptions { agent: string; model: string; session: boolean; verbose: boolean; -} - -interface PcpConfig { - userId?: string; - email?: string; - agentMapping?: Record; -} - -interface IdentityJson { - agentId: string; - context?: string; -} - -/** - * Resolve agent ID from multiple sources: - * 1. CLI --agent flag (if explicitly changed from default) - * 2. .pcp/identity.json in current directory - * 3. ~/.pcp/config.json agentMapping - * 4. Default: 'wren' - */ -function resolveAgentId(cliAgent?: string): string { - // 1. CLI flag takes precedence (if explicitly set, not default) - if (cliAgent && cliAgent !== 'wren') { - return cliAgent; - } - - // 2. Check local .pcp/identity.json - const localIdentity = join(process.cwd(), '.pcp', 'identity.json'); - if (existsSync(localIdentity)) { - try { - const identity: IdentityJson = JSON.parse(readFileSync(localIdentity, 'utf-8')); - if (identity.agentId) { - return identity.agentId; - } - } catch { - // Ignore parse errors - } - } - - // 3. Check ~/.pcp/config.json - const configPath = join(homedir(), '.pcp', 'config.json'); - if (existsSync(configPath)) { - try { - const config: PcpConfig = JSON.parse(readFileSync(configPath, 'utf-8')); - if (config.agentMapping?.['claude-code']) { - return config.agentMapping['claude-code']; - } - } catch { - // Ignore parse errors - } - } - - // 4. Default - return cliAgent || 'wren'; -} - -/** - * Build the identity prompt for Claude - */ -function buildIdentityPrompt(agentId: string): string { - return `## Identity Override (CRITICAL) - -**You are ${agentId}. Your agent ID is \`${agentId}\`.** - -When calling PCP tools (bootstrap, remember, recall, start_session, etc.), use \`agentId: "${agentId}"\`. -Do NOT read \`.pcp/identity.json\` — your identity is set by this system prompt. -Do NOT run \`echo $AGENT_ID\` — use the agentId provided above. - -Skip directly to loading user config from ~/.pcp/config.json and bootstrap as "${agentId}". - -## Tool Priority (IMPORTANT) - -Always use **PCP cloud tools** (mcp__pcp__*) over file reads or Claude Code builtins: -- Identity: use mcp__pcp__bootstrap, not file reads -- Tasks: use mcp__pcp__create_task, not TaskCreate -- Memory: use mcp__pcp__remember, not local notes -- Sessions: use mcp__pcp__start_session/log_session/end_session - -PCP tools persist across sessions and are shared with the user and other agents.`; + backend: string; } /** - * Create a temp file with the identity prompt. Returns the path and a cleanup fn. - */ -function createIdentityPromptFile(agentId: string): { promptFile: string; cleanup: () => void } { - const identityPrompt = buildIdentityPrompt(agentId); - const tempDir = mkdtempSync(join(tmpdir(), 'sb-')); - const promptFile = join(tempDir, 'identity-prompt.md'); - writeFileSync(promptFile, identityPrompt); - - return { - promptFile, - cleanup: () => { - try { rmSync(tempDir, { recursive: true }); } catch { /* ignore */ } - }, - }; -} - -/** - * Build the base claude args (model, identity, mcp config). - * Passthrough args are spliced in by the caller. - */ -function buildBaseArgs(options: SbOptions, promptFile: string): string[] { - const args: string[] = [ - '--model', options.model, - '--append-system-prompt', promptFile, - ]; - - // Find MCP config in current directory - const mcpConfig = join(process.cwd(), '.mcp.json'); - if (existsSync(mcpConfig)) { - args.push('--mcp-config', mcpConfig); - } - - return args; -} - -/** - * Run Claude Code with a prompt (one-shot mode with -p flag). + * Run a backend with a prompt (one-shot mode). */ export async function runClaude( prompt: string, + promptParts: string[], options: SbOptions, passthroughArgs: string[] = [], ): Promise { const agentId = resolveAgentId(options.agent); + const adapter = getBackend(options.backend); if (options.verbose) { + console.log(chalk.dim(`Backend: ${adapter.name}`)); console.log(chalk.dim(`Agent: ${agentId}`)); console.log(chalk.dim(`Model: ${options.model}`)); console.log(chalk.dim(`Session tracking: ${options.session}`)); @@ -154,43 +39,44 @@ export async function runClaude( } } - const { promptFile, cleanup } = createIdentityPromptFile(agentId); - - const args = [ - '-p', - ...buildBaseArgs(options, promptFile), - ...passthroughArgs, + const prepared = adapter.prepare({ + agentId, + model: options.model, prompt, - ]; + promptParts, + passthroughArgs, + }); if (options.verbose) { - console.log(chalk.dim(`Running: claude ${args.join(' ')}`)); + console.log(chalk.dim(`Running: ${prepared.binary} ${prepared.args.join(' ')}`)); } - const claude = spawn('claude', args, { + const child = spawn(prepared.binary, prepared.args, { stdio: 'inherit', - env: { ...process.env, AGENT_ID: agentId }, + env: { ...process.env, ...prepared.env }, }); - claude.on('close', (code) => { - cleanup(); + child.on('close', (code) => { + prepared.cleanup(); if (code !== 0) process.exit(code || 1); }); - process.on('SIGINT', () => claude.kill('SIGINT')); - process.on('SIGTERM', () => claude.kill('SIGTERM')); + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); } /** - * Run Claude Code interactively (no -p flag). + * Run a backend interactively (no prompt). */ export async function runClaudeInteractive( options: SbOptions, passthroughArgs: string[] = [], ): Promise { const agentId = resolveAgentId(options.agent); + const adapter = getBackend(options.backend); if (options.verbose) { + console.log(chalk.dim(`Backend: ${adapter.name}`)); console.log(chalk.dim(`Agent: ${agentId}`)); console.log(chalk.dim(`Model: ${options.model}`)); if (passthroughArgs.length) { @@ -198,24 +84,24 @@ export async function runClaudeInteractive( } } - const { promptFile, cleanup } = createIdentityPromptFile(agentId); - - const args = [ - ...buildBaseArgs(options, promptFile), - ...passthroughArgs, - ]; + const prepared = adapter.prepare({ + agentId, + model: options.model, + promptParts: [], + passthroughArgs, + }); if (options.verbose) { - console.log(chalk.dim(`Running: claude ${args.join(' ')}`)); + console.log(chalk.dim(`Running: ${prepared.binary} ${prepared.args.join(' ')}`)); } - const claude = spawn('claude', args, { + const child = spawn(prepared.binary, prepared.args, { stdio: 'inherit', - env: { ...process.env, AGENT_ID: agentId }, + env: { ...process.env, ...prepared.env }, }); - claude.on('close', (code) => { - cleanup(); + child.on('close', (code) => { + prepared.cleanup(); process.exit(code || 0); }); } diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 00000000..50b3c9ee --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,227 @@ +/** + * Config Commands + * + * Manage backend configuration. + * + * Commands: + * config sync Convert .mcp.json to Codex and Gemini formats + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +// ============================================================================ +// Types +// ============================================================================ + +interface McpServerConfig { + type?: string; + url?: string; + command?: string; + args?: string[]; + env?: Record; + headers?: Record; + [key: string]: unknown; +} + +interface McpJson { + mcpServers: Record; +} + +// ============================================================================ +// Format converters +// ============================================================================ + +/** + * Convert .mcp.json servers to Codex TOML format. + * Only emits the [mcp_servers.*] sections. + */ +function toCodexToml(servers: Record): string { + const lines: string[] = [ + '# Generated by `sb mcp sync` from .mcp.json', + '# Re-run `sb mcp sync` after changing .mcp.json', + '', + ]; + + for (const [name, config] of Object.entries(servers)) { + lines.push(`[mcp_servers.${name}]`); + + if (config.url) { + lines.push(`url = ${tomlString(config.url)}`); + } + + if (config.command) { + lines.push(`command = ${tomlString(config.command)}`); + } + + if (config.args?.length) { + lines.push(`args = [${config.args.map(tomlString).join(', ')}]`); + } + + if (config.env && Object.keys(config.env).length > 0) { + const pairs = Object.entries(config.env) + .map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`) + .join(', '); + lines.push(`env = { ${pairs} }`); + } + + if (config.headers) { + for (const [key, val] of Object.entries(config.headers)) { + lines.push(`http_headers = { ${tomlString(key)} = ${tomlString(val)} }`); + } + } + + lines.push(''); + } + + return lines.join('\n'); +} + +function tomlString(val: string): string { + return `"${val.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** + * Convert .mcp.json servers to Gemini settings.json format. + * Merges into existing settings.json if present. + */ +function toGeminiSettings( + servers: Record, + existingSettings?: Record, +): Record { + const geminiServers: Record> = {}; + + for (const [name, config] of Object.entries(servers)) { + const server: Record = {}; + + if (config.url) { + server.url = config.url; + } + + if (config.command) { + server.command = config.command; + } + + if (config.args?.length) { + server.args = config.args; + } + + if (config.env && Object.keys(config.env).length > 0) { + server.env = config.env; + } + + if (config.headers) { + server.headers = config.headers; + } + + geminiServers[name] = server; + } + + return { + ...existingSettings, + mcpServers: geminiServers, + }; +} + +// ============================================================================ +// Gitignore helper +// ============================================================================ + +function ensureGitignoreEntries(repoRoot: string, entries: string[]): string[] { + const gitignorePath = join(repoRoot, '.gitignore'); + const added: string[] = []; + + let content = ''; + if (existsSync(gitignorePath)) { + content = readFileSync(gitignorePath, 'utf-8'); + } + + const lines = content.split('\n'); + const missing = entries.filter(entry => !lines.some(line => line.trim() === entry)); + + if (missing.length > 0) { + const suffix = content.endsWith('\n') ? '' : '\n'; + const block = `${suffix}\n# Backend-specific config (generated by sb mcp sync)\n${missing.join('\n')}\n`; + writeFileSync(gitignorePath, content + block); + added.push(...missing); + } + + return added; +} + +// ============================================================================ +// Commands +// ============================================================================ + +async function syncCommand(): Promise { + const cwd = process.cwd(); + const mcpPath = join(cwd, '.mcp.json'); + + if (!existsSync(mcpPath)) { + console.error(chalk.red('No .mcp.json found in current directory')); + process.exit(1); + } + + let mcpJson: McpJson; + try { + mcpJson = JSON.parse(readFileSync(mcpPath, 'utf-8')); + } catch (err) { + console.error(chalk.red(`Failed to parse .mcp.json: ${err}`)); + process.exit(1); + } + + if (!mcpJson.mcpServers || Object.keys(mcpJson.mcpServers).length === 0) { + console.log(chalk.yellow('No MCP servers found in .mcp.json')); + return; + } + + const serverCount = Object.keys(mcpJson.mcpServers).length; + console.log(chalk.dim(`Found ${serverCount} server(s) in .mcp.json\n`)); + + // --- Codex: .codex/config.toml --- + const codexDir = join(cwd, '.codex'); + const codexPath = join(codexDir, 'config.toml'); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(codexPath, toCodexToml(mcpJson.mcpServers)); + console.log(chalk.green(' wrote'), chalk.cyan('.codex/config.toml')); + + // --- Gemini: .gemini/settings.json --- + const geminiDir = join(cwd, '.gemini'); + const geminiPath = join(geminiDir, 'settings.json'); + mkdirSync(geminiDir, { recursive: true }); + + let existingGemini: Record | undefined; + if (existsSync(geminiPath)) { + try { + existingGemini = JSON.parse(readFileSync(geminiPath, 'utf-8')); + } catch { /* overwrite if unparseable */ } + } + + const geminiSettings = toGeminiSettings(mcpJson.mcpServers, existingGemini); + writeFileSync(geminiPath, JSON.stringify(geminiSettings, null, 2) + '\n'); + console.log(chalk.green(' wrote'), chalk.cyan('.gemini/settings.json')); + + // --- Gitignore --- + const added = ensureGitignoreEntries(cwd, ['.codex/', '.gemini/']); + if (added.length > 0) { + console.log(chalk.green(' added'), chalk.cyan(added.join(', ')), chalk.green('to .gitignore')); + } + + console.log(chalk.dim(`\nDone. All backends can now discover MCP servers.`)); +} + +// ============================================================================ +// Register Commands +// ============================================================================ + +export function registerConfigCommands(program: Command): void { + const config = program + .command('config') + .description('Manage backend configuration'); + + config.command('sync') + .description('Sync .mcp.json to Codex (.codex/config.toml) and Gemini (.gemini/settings.json)') + .action(syncCommand); +} diff --git a/stories/cli/multi-backend.md b/stories/cli/multi-backend.md new file mode 100644 index 00000000..fbb17478 --- /dev/null +++ b/stories/cli/multi-backend.md @@ -0,0 +1,126 @@ +# Multi-Backend CLI Support + +> Let `sb` wrap Claude Code, Codex CLI, and Gemini CLI with the same identity injection and session tracking. + +**Branch**: `wren/feat/multi-backend-cli` +**Status**: Research complete, implementation pending + +## Motivation + +PCP's identity system shouldn't be locked to one AI provider. Users should be able to launch any supported CLI and get the same persistent identity, memory, and context — just with a different underlying model. + +```bash +sb # Default backend (claude) +sb -b codex "fix the bug" # Use Codex CLI +sb -b gemini "review this" # Use Gemini CLI +``` + +## Backend Comparison + +| Feature | Claude Code | Codex CLI | Gemini CLI | +|---|---|---|---| +| **Binary** | `claude` | `codex` | `gemini` | +| **Install** | `npm i -g @anthropic-ai/claude-code` | `npm i -g @openai/codex` | `npm i -g @google/gemini-cli` | +| **Instruction file** | `CLAUDE.md` | `AGENTS.md` | `GEMINI.md` | +| **System prompt flag** | `--append-system-prompt ` | None | None | +| **System prompt alt** | N/A | `model_instructions_file` in config.toml | `GEMINI_SYSTEM_MD` env var | +| **MCP config format** | `.mcp.json` (JSON) | `config.toml` under `[mcp_servers.*]` | `settings.json` under `mcpServers` | +| **MCP transport** | stdio, HTTP/SSE | stdio, Streamable HTTP | stdio, Streamable HTTP, SSE | +| **Non-interactive** | `claude -p "prompt"` | `codex -q "prompt"` or `codex exec "prompt"` | `gemini -p "prompt"` | +| **Interactive** | `claude` (default) | `codex` (default) | `gemini` (default) | +| **Model flag** | `--model` | `--model` | `-m` / `--model` | +| **Config location** | `~/.claude/` | `~/.codex/config.toml` | `~/.gemini/settings.json` | +| **Config format** | JSON (various) | TOML | JSON | +| **JSON output** | `--output-format json` | `codex exec --json` | `--output-format json` | +| **Auto-approve** | `--dangerously-skip-permissions` | `--yolo` | `--yolo` | +| **Written in** | TypeScript | Rust | TypeScript | + +## Identity Injection Strategy + +The key challenge: **none of the three CLIs support system prompt injection via a CLI flag** (beyond Claude's `--append-system-prompt`). They all use instruction files. + +### Approach: Temporary instruction files + +For each backend, `sb` writes a temporary instruction file with the agent's identity, then invokes the CLI in the appropriate way: + +| Backend | Injection mechanism | +|---|---| +| **Claude** | `--append-system-prompt ` (current approach, works well) | +| **Codex** | Write temp `AGENTS.md` in CWD or use `--config model_instructions_file=` | +| **Gemini** | Set `GEMINI_SYSTEM_MD=` env var (full system prompt replacement) | + +**Concern with Codex**: Writing a temporary `AGENTS.md` to CWD could conflict with an existing one. The `--config model_instructions_file=` approach is cleaner but replaces *all* built-in instructions rather than appending. + +**Concern with Gemini**: `GEMINI_SYSTEM_MD` does a full replacement of the system prompt. We'd need to include the standard Gemini instructions alongside our identity block, or accept that our identity instructions are the full system prompt. + +### Recommended approach + +1. **Claude**: Keep `--append-system-prompt` (additive, clean) +2. **Codex**: Use `--config model_instructions_file=` with a note that this replaces default instructions (acceptable tradeoff — our identity prompt includes the essentials) +3. **Gemini**: Use `GEMINI_SYSTEM_MD=` similarly + +All three write a temporary file with the same identity content, cleaned up on process exit. + +## MCP Config Transformation + +Each backend has its own config format for MCP servers. `sb` needs to either: + +1. **Generate per-backend config**: Read the PCP MCP server URL and write the appropriate config format +2. **Use a shared `.mcp.json`**: If backends converge on a standard (they haven't yet) + +For now, each backend adapter writes its own config: + +```typescript +// Claude: .mcp.json already exists, no transform needed +// Codex: write [mcp_servers.pcp] section to a temp config.toml +// Gemini: write mcpServers.pcp to a temp settings.json (or project .gemini/settings.json) +``` + +## Adapter Interface + +```typescript +interface BackendAdapter { + name: string; // 'claude' | 'codex' | 'gemini' + binary: string; // 'claude' | 'codex' | 'gemini' + + // Check if the backend binary is installed + isInstalled(): Promise; + + // Build the full args array for spawning the process + buildArgs(options: SbOptions, prompt?: string, passthroughArgs?: string[]): string[]; + + // Prepare environment (write temp files, set env vars) + // Returns cleanup function + prepare(options: SbOptions): Promise<{ env: Record; cleanup: () => void }>; + + // Map sb flags to backend-specific flags + mapFlags(sbFlags: Record): string[]; +} +``` + +## Flag Mapping + +| SB flag | Claude | Codex | Gemini | +|---|---|---|---| +| `-m ` | `--model ` | `--model ` | `-m ` | +| `-b ` | (self) | (self) | (self) | +| `--no-session` | (internal) | (internal) | (internal) | +| Passthrough | Direct pass | Direct pass | Direct pass | + +## Open Questions + +1. **Default backend**: Should it be configurable in `~/.pcp/config.json`? Or always Claude? +2. **MCP config conflicts**: What if the user already has Codex/Gemini MCP config? Merge or override? +3. **Instruction file conflicts**: Codex uses `AGENTS.md` — same as our `AGENTS.md`. Need to be careful not to clobber. +4. **Session tracking**: Should session logs differ by backend? Or is a session just a session regardless? +5. **Model defaults**: Should `sb -b codex` default to `gpt-5-codex` and `sb -b gemini` default to `gemini-2.5-pro`? Or let the backend pick? + +## Implementation Order + +1. Define `BackendAdapter` interface +2. Extract current Claude logic into `ClaudeAdapter` +3. Implement `CodexAdapter` (identity via config flag, MCP via temp config.toml) +4. Implement `GeminiAdapter` (identity via env var, MCP via temp settings.json) +5. Add `-b`/`--backend` flag to `extractArgs()` in cli.ts +6. Wire up adapter selection in `runClaude`/`runClaudeInteractive` (rename to generic) +7. Test each backend flow end-to-end From b8875c02346bdf2bd3f811a9b04bb7c2ee1ae96e Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Mon, 9 Feb 2026 15:36:34 -0800 Subject: [PATCH 2/4] fix(auth): address the oauth refresh_token bug for authroizing PCP with codex --- .../src/mcp/auth/pcp-auth-provider.test.ts | 41 +++++++++++++++++++ .../web/src/app/(auth)/login/login-form.tsx | 7 +++- packages/web/src/lib/supabase/middleware.ts | 11 +++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/api/src/mcp/auth/pcp-auth-provider.test.ts b/packages/api/src/mcp/auth/pcp-auth-provider.test.ts index 9048bffa..ff12b3a5 100644 --- a/packages/api/src/mcp/auth/pcp-auth-provider.test.ts +++ b/packages/api/src/mcp/auth/pcp-auth-provider.test.ts @@ -227,6 +227,47 @@ describe('PcpAuthProvider', () => { }); }); + // Regression: web portal was redirecting to /mcp/auth/callback without + // refresh_token, causing "Missing refresh token" error for MCP clients. + // The auth callback MUST receive both access_token and refresh_token + // from the web portal so the token exchange can store the Supabase + // refresh token for later use. + it('should require refresh_token for successful callback (regression)', async () => { + const pendingId = setupPendingAuth(provider); + mockSuccessfulAuth(); + + // Callback with access_token but NO refresh_token should still + // produce an auth code — the provider doesn't validate this, the + // HTTP layer does. But verify the stored refresh token propagates + // through to the code exchange. + const callbackResult = await provider.handleAuthCallback({ + pendingId, + accessToken: 'supabase-jwt', + refreshToken: 'supabase-rt-required', + }); + + expect('code' in callbackResult).toBe(true); + if (!('code' in callbackResult)) return; + + // Exchange the code and verify refresh token was stored + currentMcpTokensChain = mockChain(); + mockInsert.mockReturnValue({ error: null }); + + const tokenResult = await provider.exchangeAuthorizationCode({ + code: callbackResult.code, + codeVerifier: 'test-verifier', + clientId: 'test-client', + }); + + expect('access_token' in tokenResult).toBe(true); + if (!('access_token' in tokenResult)) return; + + // The insert call should contain the supabase refresh token + expect(mockInsert).toHaveBeenCalled(); + const insertArgs = mockInsert.mock.calls[0]?.[0]; + expect(insertArgs).toHaveProperty('supabase_refresh_token', 'supabase-rt-required'); + }); + it('should consume the pending auth after successful callback', async () => { const pendingId = setupPendingAuth(provider); mockSuccessfulAuth(); diff --git a/packages/web/src/app/(auth)/login/login-form.tsx b/packages/web/src/app/(auth)/login/login-form.tsx index a1ebd7c6..7424fb51 100644 --- a/packages/web/src/app/(auth)/login/login-form.tsx +++ b/packages/web/src/app/(auth)/login/login-form.tsx @@ -59,10 +59,12 @@ export default function LoginForm() { const checkExistingSession = async () => { const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token) { + if (session?.access_token && session?.refresh_token) { setMcpRedirecting(true); redirectToMcp(); } + // If session exists but refresh_token is missing, let user re-auth + // via the login form to get a fresh session with both tokens. }; checkExistingSession(); @@ -93,10 +95,11 @@ export default function LoginForm() { const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token) { + if (session?.access_token && session?.refresh_token) { const callbackUrl = new URL(mcpRedirect!); callbackUrl.searchParams.set('pending_id', mcpPendingId!); callbackUrl.searchParams.set('access_token', session.access_token); + callbackUrl.searchParams.set('refresh_token', session.refresh_token); window.location.href = callbackUrl.toString(); } }; diff --git a/packages/web/src/lib/supabase/middleware.ts b/packages/web/src/lib/supabase/middleware.ts index d17f4bd9..98e0a7fe 100644 --- a/packages/web/src/lib/supabase/middleware.ts +++ b/packages/web/src/lib/supabase/middleware.ts @@ -55,16 +55,19 @@ export async function updateSession(request: NextRequest) { const mcpPendingId = request.nextUrl.searchParams.get('pending_id'); if (mcpRedirect && mcpPendingId) { - // MCP OAuth flow: user is already logged in — redirect straight to the - // MCP callback with the access token. No login form flash. + // MCP OAuth flow: user is already logged in — try to redirect straight + // to the MCP callback with tokens. No login form flash. const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token) { + if (session?.access_token && session?.refresh_token) { const callbackUrl = new URL(mcpRedirect); callbackUrl.searchParams.set('pending_id', mcpPendingId); callbackUrl.searchParams.set('access_token', session.access_token); + callbackUrl.searchParams.set('refresh_token', session.refresh_token); return NextResponse.redirect(callbackUrl.toString()); } - // Session exists but no token — fall through to login form + // Can't get both tokens from middleware — let the login form handle it. + // The client-side Supabase client may have better access to the refresh token. + return supabaseResponse; } // Normal case: redirect to dashboard From d5c17ad6ff5a570b8163bbfe98df840f51960a2a Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Mon, 9 Feb 2026 15:47:52 -0800 Subject: [PATCH 3/4] fix(auth): codex does not send client_id so fall back to the client_id stored in the authorize step --- packages/api/src/mcp/auth/pcp-auth-provider.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/api/src/mcp/auth/pcp-auth-provider.ts b/packages/api/src/mcp/auth/pcp-auth-provider.ts index fcdf0844..341faf3d 100644 --- a/packages/api/src/mcp/auth/pcp-auth-provider.ts +++ b/packages/api/src/mcp/auth/pcp-auth-provider.ts @@ -60,6 +60,9 @@ export interface AuthCallbackResult { // Constants // ============================================================================ +// TODO: Consider extending Supabase JWT expiry to 30 days (2592000s) in dashboard +// and updating this constant to match. Current 1-hour expiry works via refresh +// tokens, but a longer JWT reduces refresh frequency for MCP clients. const ACCESS_TOKEN_LIFETIME = 3600; // 1 hour (Supabase JWT default) const REFRESH_TOKEN_LIFETIME_DAYS = 90; const REFRESH_TOKEN_LIFETIME_MS = REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000; @@ -175,7 +178,7 @@ export class PcpAuthProvider { async exchangeAuthorizationCode(params: { code: string; codeVerifier: string; - clientId: string; + clientId?: string; }): Promise { const codeData = this.authCodes.get(params.code); if (!codeData) { @@ -187,6 +190,10 @@ export class PcpAuthProvider { return { error: 'invalid_grant', error_description: 'Authorization code expired' }; } + // Fall back to the client_id stored in the auth code (from /authorize). + // Some clients (e.g. Codex) don't send client_id in the token exchange body. + const clientId = params.clientId || codeData.clientId; + // Verify PKCE if (codeData.codeChallenge && params.codeVerifier) { const computedChallenge = crypto @@ -212,7 +219,7 @@ export class PcpAuthProvider { .from('mcp_tokens') .insert({ user_id: codeData.userId, - client_id: params.clientId, + client_id: clientId, refresh_token: refreshToken, supabase_refresh_token: codeData.supabaseRefreshToken, scopes: ['mcp:tools'], @@ -230,7 +237,7 @@ export class PcpAuthProvider { logger.info('MCP tokens issued', { userId: codeData.userId, email: codeData.userEmail, - clientId: params.clientId, + clientId, refreshTokenExpires: expiresAt.toISOString(), }); From f660dd61b47e9a53ff97c71f00c64c86c82a986d Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Mon, 9 Feb 2026 16:32:07 -0800 Subject: [PATCH 4/4] feat(memory): auto-attach session ID to memories without requiring a session Memories should never be gated on session state. The remember handler now automatically looks up the active session and attaches its ID to metadata for traceability, but gracefully handles missing/failed sessions. Co-Authored-By: Claude Opus 4.6 --- packages/api/src/mcp/tools/memory-handlers.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/api/src/mcp/tools/memory-handlers.ts b/packages/api/src/mcp/tools/memory-handlers.ts index c2faeb8c..34ba8d3c 100644 --- a/packages/api/src/mcp/tools/memory-handlers.ts +++ b/packages/api/src/mcp/tools/memory-handlers.ts @@ -142,18 +142,41 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) const params = rememberSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + // If there's an active session, attach its ID to the memory metadata for traceability. + // Never require a session — memories are too important to lose. + let sessionId: string | undefined; + try { + const activeSession = await dataComposer.repositories.memory.getActiveSession( + user.id, + params.agentId, + ); + sessionId = activeSession?.id; + } catch { + // Session lookup failed — save the memory anyway + } + + const metadata = { + ...params.metadata, + ...(sessionId ? { sessionId } : {}), + }; + const memory = await dataComposer.repositories.memory.remember({ userId: user.id, content: params.content, source: params.source as MemorySource, salience: params.salience as Salience, topics: params.topics, - metadata: params.metadata, + metadata, expiresAt: params.expiresAt ? new Date(params.expiresAt) : undefined, agentId: params.agentId, }); - logger.info(`Memory created for user ${user.id}`, { memoryId: memory.id, source: memory.source, agentId: params.agentId }); + logger.info(`Memory created for user ${user.id}`, { + memoryId: memory.id, + source: memory.source, + agentId: params.agentId, + sessionId: sessionId || 'none', + }); return { content: [ @@ -170,6 +193,7 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) salience: memory.salience, topics: memory.topics, agentId: memory.agentId, + sessionId: sessionId || null, createdAt: memory.createdAt.toISOString(), }, },