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/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/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(), }); 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(), }, }, 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/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 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