diff --git a/.gitignore b/.gitignore index d1e379d9..ddaa4f01 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,6 @@ vite.config.ts.timestamp-* # Local PCP identity should be machine/user-specific .pcp/identity.json + +# PCP hook runtime state (transient, per-session) +.pcp/runtime/ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0ff699a..b78e6e44 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -24,6 +24,8 @@ import { registerAgentCommands } from './commands/agent.js'; import { registerSessionCommands } from './commands/session.js'; import { registerConfigCommands } from './commands/mcp.js'; import { registerAwakenCommand } from './commands/awaken.js'; +import { registerHooksCommands } from './commands/hooks.js'; +import { registerInitCommand } from './commands/init.js'; import { runClaude, runClaudeInteractive } from './commands/claude.js'; const VERSION = '0.3.0'; @@ -167,6 +169,8 @@ registerAgentCommands(program); registerSessionCommands(program); registerConfigCommands(program); registerAwakenCommand(program); +registerHooksCommands(program); +registerInitCommand(program); // ============================================================================ // Subcommand detection diff --git a/packages/cli/src/commands/hooks.test.ts b/packages/cli/src/commands/hooks.test.ts new file mode 100644 index 00000000..706bec72 --- /dev/null +++ b/packages/cli/src/commands/hooks.test.ts @@ -0,0 +1,355 @@ +/** + * Hooks Tests + * + * Tests for installHooks (Claude Code, Codex, Gemini), + * idempotency, conflict detection, and uninstall. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { installHooks } from './hooks.js'; + +const TEST_DIR = join(tmpdir(), 'pcp-hooks-test-' + Date.now()); + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +// ============================================================================ +// Claude Code Backend +// ============================================================================ + +describe('installHooks: Claude Code', () => { + it('should detect Claude Code when .claude/ exists', () => { + mkdirSync(join(TEST_DIR, '.claude'), { recursive: true }); + const { backend } = installHooks(TEST_DIR); + expect(backend.name).toBe('claude-code'); + }); + + it('should default to Claude Code when no backend dirs exist', () => { + const { backend } = installHooks(TEST_DIR); + expect(backend.name).toBe('claude-code'); + }); + + it('should install hooks into .claude/settings.local.json', () => { + const { result } = installHooks(TEST_DIR); + expect(result).toBe('installed'); + + const configPath = join(TEST_DIR, '.claude', 'settings.local.json'); + expect(existsSync(configPath)).toBe(true); + + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(config.hooks).toBeDefined(); + expect(config.hooks.PreCompact).toBeDefined(); + expect(config.hooks.SessionStart).toBeDefined(); + expect(config.hooks.UserPromptSubmit).toBeDefined(); + expect(config.hooks.Stop).toBeDefined(); + }); + + it('should write correct hook commands', () => { + installHooks(TEST_DIR); + const configPath = join(TEST_DIR, '.claude', 'settings.local.json'); + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + + // PreCompact + expect(config.hooks.PreCompact[0].hooks[0].command).toBe('sb hooks pre-compact'); + + // SessionStart — compact matcher + const compactEntry = config.hooks.SessionStart.find( + (e: Record) => e.matcher === 'compact' + ); + expect(compactEntry.hooks[0].command).toBe('sb hooks post-compact'); + + // SessionStart — startup matcher + const startupEntry = config.hooks.SessionStart.find( + (e: Record) => e.matcher === 'startup' + ); + expect(startupEntry.hooks[0].command).toBe('sb hooks on-session-start'); + + // UserPromptSubmit + expect(config.hooks.UserPromptSubmit[0].hooks[0].command).toBe('sb hooks on-prompt'); + + // Stop + expect(config.hooks.Stop[0].hooks[0].command).toBe('sb hooks on-stop'); + }); + + it('should preserve existing non-hooks settings', () => { + const configDir = join(TEST_DIR, '.claude'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'settings.local.json'), + JSON.stringify({ permissions: { allow: ['Bash(git:*)'] } }) + ); + + installHooks(TEST_DIR); + + const config = JSON.parse( + readFileSync(join(configDir, 'settings.local.json'), 'utf-8') + ); + expect(config.permissions.allow).toContain('Bash(git:*)'); + expect(config.hooks).toBeDefined(); + }); + + it('should return already-installed when PCP hooks match exactly', () => { + // First install + const first = installHooks(TEST_DIR); + expect(first.result).toBe('installed'); + + // Second install — should detect idempotency + const second = installHooks(TEST_DIR); + expect(second.result).toBe('already-installed'); + }); + + it('should return conflict when non-PCP hooks exist', () => { + const configDir = join(TEST_DIR, '.claude'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'settings.local.json'), + JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: 'custom-tool cleanup' }], + }, + ], + }, + }) + ); + + const { result } = installHooks(TEST_DIR); + expect(result).toBe('conflict'); + }); + + it('should overwrite conflict when force is true', () => { + const configDir = join(TEST_DIR, '.claude'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'settings.local.json'), + JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: 'custom-tool cleanup' }], + }, + ], + }, + }) + ); + + const { result } = installHooks(TEST_DIR, { force: true }); + expect(result).toBe('installed'); + + const config = JSON.parse( + readFileSync(join(configDir, 'settings.local.json'), 'utf-8') + ); + // Should now have PCP hooks, not the custom one + expect(config.hooks.Stop[0].hooks[0].command).toBe('sb hooks on-stop'); + }); + + it('should allow re-install over existing PCP hooks without force', () => { + // Install PCP hooks + installHooks(TEST_DIR); + + // Manually tweak the hooks slightly (simulate a version mismatch) + const configPath = join(TEST_DIR, '.claude', 'settings.local.json'); + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + // Add a new PCP-style hook entry + config.hooks.Stop.push({ + hooks: [{ type: 'command', command: 'sb hooks extra' }], + }); + writeFileSync(configPath, JSON.stringify(config, null, 2)); + + // Re-install should work (only PCP hooks present, so no conflict) + const { result } = installHooks(TEST_DIR); + // It won't match exactly, but all hooks are PCP, so it overwrites + expect(result).toBe('installed'); + }); +}); + +// ============================================================================ +// Gemini Backend +// ============================================================================ + +describe('installHooks: Gemini', () => { + it('should detect Gemini when .gemini/ exists', () => { + mkdirSync(join(TEST_DIR, '.gemini'), { recursive: true }); + const { backend } = installHooks(TEST_DIR); + expect(backend.name).toBe('gemini'); + }); + + it('should install hooks into .gemini/settings.json', () => { + const { result } = installHooks(TEST_DIR, { backend: 'gemini' }); + expect(result).toBe('installed'); + + const configPath = join(TEST_DIR, '.gemini', 'settings.json'); + expect(existsSync(configPath)).toBe(true); + + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(config.hooks.session_start[0].command).toBe('sb hooks on-session-start'); + expect(config.hooks.session_end[0].command).toBe('sb hooks on-stop'); + }); + + it('should preserve existing Gemini settings', () => { + const configDir = join(TEST_DIR, '.gemini'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify({ mcpServers: { pcp: { url: 'http://localhost:3001/mcp' } } }) + ); + + installHooks(TEST_DIR, { backend: 'gemini' }); + + const config = JSON.parse( + readFileSync(join(configDir, 'settings.json'), 'utf-8') + ); + expect(config.mcpServers.pcp.url).toBe('http://localhost:3001/mcp'); + expect(config.hooks).toBeDefined(); + }); + + it('should return already-installed on repeat', () => { + installHooks(TEST_DIR, { backend: 'gemini' }); + const { result } = installHooks(TEST_DIR, { backend: 'gemini' }); + expect(result).toBe('already-installed'); + }); + + it('should return conflict when non-PCP hooks exist', () => { + const configDir = join(TEST_DIR, '.gemini'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify({ hooks: { session_start: [{ command: 'other-tool start' }] } }) + ); + + const { result } = installHooks(TEST_DIR, { backend: 'gemini' }); + expect(result).toBe('conflict'); + }); +}); + +// ============================================================================ +// Codex Backend +// ============================================================================ + +describe('installHooks: Codex', () => { + it('should detect Codex when .codex/ exists', () => { + mkdirSync(join(TEST_DIR, '.codex'), { recursive: true }); + // .claude/ takes priority, so only put .codex/ + const { backend } = installHooks(TEST_DIR); + expect(backend.name).toBe('codex'); + }); + + it('should install hooks into .codex/config.toml', () => { + const { result } = installHooks(TEST_DIR, { backend: 'codex' }); + expect(result).toBe('installed'); + + const configPath = join(TEST_DIR, '.codex', 'config.toml'); + expect(existsSync(configPath)).toBe(true); + + const content = readFileSync(configPath, 'utf-8'); + expect(content).toContain('# pcp-managed'); + expect(content).toContain('[hooks]'); + expect(content).toContain('session_start = "sb hooks on-session-start"'); + expect(content).toContain('session_end = "sb hooks on-stop"'); + expect(content).toContain('# end pcp-managed'); + }); + + it('should return already-installed when PCP marker exists', () => { + installHooks(TEST_DIR, { backend: 'codex' }); + const { result } = installHooks(TEST_DIR, { backend: 'codex' }); + expect(result).toBe('already-installed'); + }); + + it('should return conflict when non-PCP [hooks] exists', () => { + const configDir = join(TEST_DIR, '.codex'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'config.toml'), + '[hooks]\nsession_start = "other-tool start"\n' + ); + + const { result } = installHooks(TEST_DIR, { backend: 'codex' }); + expect(result).toBe('conflict'); + }); + + it('should preserve existing TOML content outside hooks', () => { + const configDir = join(TEST_DIR, '.codex'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'config.toml'), + '[mcp_servers.pcp]\nurl = "http://localhost:3001/mcp"\n' + ); + + installHooks(TEST_DIR, { backend: 'codex' }); + + const content = readFileSync(join(configDir, 'config.toml'), 'utf-8'); + expect(content).toContain('[mcp_servers.pcp]'); + expect(content).toContain('# pcp-managed'); + }); + + it('should replace PCP section on re-install with force', () => { + installHooks(TEST_DIR, { backend: 'codex' }); + + // Force re-install + const { result } = installHooks(TEST_DIR, { backend: 'codex', force: true }); + expect(result).toBe('installed'); + + // Should have exactly one start marker and one end marker (no duplicates) + const content = readFileSync(join(TEST_DIR, '.codex', 'config.toml'), 'utf-8'); + const startMarkers = content.match(/# pcp-managed\n/g); + const endMarkers = content.match(/# end pcp-managed/g); + expect(startMarkers).toHaveLength(1); + expect(endMarkers).toHaveLength(1); + expect(content).toContain('session_start = "sb hooks on-session-start"'); + }); +}); + +// ============================================================================ +// Backend override +// ============================================================================ + +describe('installHooks: backend override', () => { + it('should use explicit backend even when .claude/ exists', () => { + mkdirSync(join(TEST_DIR, '.claude'), { recursive: true }); + const { backend } = installHooks(TEST_DIR, { backend: 'gemini' }); + expect(backend.name).toBe('gemini'); + }); + + it('should accept claude-code as backend name', () => { + const { backend } = installHooks(TEST_DIR, { backend: 'claude-code' }); + expect(backend.name).toBe('claude-code'); + }); + + it('should accept claude as backend name alias', () => { + const { backend } = installHooks(TEST_DIR, { backend: 'claude' }); + expect(backend.name).toBe('claude-code'); + }); +}); + +// ============================================================================ +// Backend detection priority +// ============================================================================ + +describe('installHooks: detection priority', () => { + it('should prefer .claude/ over .codex/', () => { + mkdirSync(join(TEST_DIR, '.claude'), { recursive: true }); + mkdirSync(join(TEST_DIR, '.codex'), { recursive: true }); + const { backend } = installHooks(TEST_DIR); + expect(backend.name).toBe('claude-code'); + }); + + it('should prefer .gemini/ over .codex/', () => { + mkdirSync(join(TEST_DIR, '.gemini'), { recursive: true }); + mkdirSync(join(TEST_DIR, '.codex'), { recursive: true }); + const { backend } = installHooks(TEST_DIR); + expect(backend.name).toBe('gemini'); + }); +}); diff --git a/packages/cli/src/commands/hooks.ts b/packages/cli/src/commands/hooks.ts new file mode 100644 index 00000000..96857793 --- /dev/null +++ b/packages/cli/src/commands/hooks.ts @@ -0,0 +1,956 @@ +/** + * Hooks Commands + * + * Bridge CLI coding agents (Claude Code, Codex, Gemini) with PCP's + * session/memory/inbox system via lifecycle hooks. + * + * Commands: + * hooks install Install PCP hooks into the detected backend + * hooks uninstall Remove PCP-managed hooks + * hooks status Show installed hook status + * hooks pre-compact Hook: pre-compaction reminder + * hooks post-compact Hook: post-compaction bootstrap + * hooks on-session-start Hook: session start bootstrap + * hooks on-prompt Hook: periodic inbox check + * hooks on-stop Hook: session nudge + inbox check + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { resolveAgentId } from '../backends/identity.js'; + +// ============================================================================ +// Types +// ============================================================================ + +interface HookCapabilities { + name: string; + configPath: string; + configFormat: 'json' | 'toml'; + events: { + sessionStart: string | null; + preCompact: string | null; + postCompact: string | null; + onPrompt: string | null; + onStop: string | null; + }; + supportsCompaction: boolean; + supportsPromptHook: boolean; +} + +const CLAUDE_CODE: HookCapabilities = { + name: 'claude-code', + configPath: '.claude/settings.local.json', + configFormat: 'json', + events: { + sessionStart: 'SessionStart', + preCompact: 'PreCompact', + postCompact: 'SessionStart', // uses "compact" matcher on SessionStart + onPrompt: 'UserPromptSubmit', + onStop: 'Stop', + }, + supportsCompaction: true, + supportsPromptHook: true, +}; + +const CODEX: HookCapabilities = { + name: 'codex', + configPath: '.codex/config.toml', + configFormat: 'toml', + events: { + sessionStart: 'session_start', + preCompact: null, + postCompact: null, + onPrompt: null, + onStop: 'session_end', + }, + supportsCompaction: false, + supportsPromptHook: false, +}; + +const GEMINI: HookCapabilities = { + name: 'gemini', + configPath: '.gemini/settings.json', + configFormat: 'json', + events: { + sessionStart: 'session_start', + preCompact: null, + postCompact: null, + onPrompt: null, + onStop: 'session_end', + }, + supportsCompaction: false, + supportsPromptHook: false, +}; + +interface PcpConfig { + userId?: string; + email?: string; +} + +// ============================================================================ +// Backend Detection +// ============================================================================ + +function detectBackend(cwd: string): HookCapabilities { + if (existsSync(join(cwd, '.claude'))) return CLAUDE_CODE; + if (existsSync(join(cwd, '.gemini'))) return GEMINI; + if (existsSync(join(cwd, 'codex.toml')) || existsSync(join(cwd, '.codex'))) return CODEX; + return CLAUDE_CODE; // default +} + +function getBackendByName(name: string): HookCapabilities { + switch (name.toLowerCase()) { + case 'claude': + case 'claude-code': + return CLAUDE_CODE; + case 'codex': + return CODEX; + case 'gemini': + return GEMINI; + default: + return CLAUDE_CODE; + } +} + +// ============================================================================ +// Stdin Parsing +// ============================================================================ + +async function readStdin(): Promise> { + // If stdin is a TTY (interactive), return empty object + if (process.stdin.isTTY) return {}; + + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => { + if (!data.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(data) as Record); + } catch { + resolve({}); + } + }); + // Timeout after 100ms if no data + setTimeout(() => { + if (!data) resolve({}); + }, 100); + }); +} + +// ============================================================================ +// PCP Client Helper +// ============================================================================ + +function getPcpConfig(): PcpConfig | null { + const configPath = join(homedir(), '.pcp', 'config.json'); + if (existsSync(configPath)) { + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return null; + } + } + return null; +} + +function getPcpServerUrl(): string { + return process.env.PCP_SERVER_URL || 'http://localhost:3001'; +} + +let jsonRpcId = 1; + +async function callPcpTool(tool: string, args: Record): Promise> { + const url = `${getPcpServerUrl()}/mcp`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: tool, arguments: args }, + id: jsonRpcId++, + }), + }); + + if (!response.ok) { + throw new Error(`PCP call failed (${response.status}): ${await response.text()}`); + } + + const payload = (await response.json()) as Record; + + // JSON-RPC error + if (payload.error) { + const err = payload.error as { message?: string; code?: number }; + throw new Error(`PCP tool error (${err.code}): ${err.message}`); + } + + // Unwrap JSON-RPC result → MCP tool response → content text + const result = payload.result as { content?: Array<{ text?: string }> } | undefined; + const mcpText = result?.content?.[0]?.text; + + if (typeof mcpText === 'string') { + try { + return JSON.parse(mcpText) as Record; + } catch { + return { text: mcpText }; + } + } + + return (result as Record) ?? payload; +} + +// ============================================================================ +// Runtime State Helpers +// ============================================================================ + +function getRuntimeDir(cwd: string): string { + return join(cwd, '.pcp', 'runtime'); +} + +function ensureRuntimeDir(cwd: string): string { + const dir = getRuntimeDir(cwd); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function readRuntimeFile(cwd: string, filename: string): string | null { + const filePath = join(getRuntimeDir(cwd), filename); + if (existsSync(filePath)) { + try { + return readFileSync(filePath, 'utf-8').trim(); + } catch { + return null; + } + } + return null; +} + +function writeRuntimeFile(cwd: string, filename: string, content: string): void { + const dir = ensureRuntimeDir(cwd); + writeFileSync(join(dir, filename), content); +} + +// ============================================================================ +// Install / Uninstall / Status +// ============================================================================ + +/** Marker used to identify PCP-managed hook entries */ +const PCP_MARKER = 'pcp-managed'; + +type InstallResult = 'installed' | 'already-installed' | 'conflict'; + +function buildClaudeCodeHooks(): Record { + return { + hooks: { + PreCompact: [ + { + hooks: [{ type: 'command', command: 'sb hooks pre-compact' }], + }, + ], + SessionStart: [ + { + matcher: 'compact', + hooks: [{ type: 'command', command: 'sb hooks post-compact' }], + }, + { + matcher: 'startup', + hooks: [{ type: 'command', command: 'sb hooks on-session-start' }], + }, + ], + UserPromptSubmit: [ + { + hooks: [{ type: 'command', command: 'sb hooks on-prompt' }], + }, + ], + Stop: [ + { + hooks: [{ type: 'command', command: 'sb hooks on-stop' }], + }, + ], + }, + }; +} + +/** Check if existing Claude Code hooks already match the PCP hooks we'd write */ +function claudeCodeHooksMatch(existing: Record): boolean { + const target = buildClaudeCodeHooks(); + const existingHooksStr = JSON.stringify(existing.hooks); + const targetHooksStr = JSON.stringify(target.hooks); + return existingHooksStr === targetHooksStr; +} + +function installClaudeCode(cwd: string, force: boolean): InstallResult { + const configPath = join(cwd, CLAUDE_CODE.configPath); + const configDir = join(cwd, '.claude'); + mkdirSync(configDir, { recursive: true }); + + let existing: Record = {}; + if (existsSync(configPath)) { + try { + existing = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + // overwrite if unparseable + } + } + + // Check for existing hooks + const existingHooks = existing.hooks as Record | undefined; + if (existingHooks && !force) { + // Check if PCP hooks already match exactly + if (claudeCodeHooksMatch(existing)) { + return 'already-installed'; + } + + // Check if any non-PCP hooks exist + const hasNonPcpHooks = Object.entries(existingHooks).some(([, entries]) => { + if (!Array.isArray(entries)) return false; + return entries.some((entry: Record) => { + const hooks = entry.hooks as Array> | undefined; + if (!hooks) return false; + return hooks.some((h) => { + const cmd = h.command as string | undefined; + return cmd && !cmd.startsWith('sb hooks '); + }); + }); + }); + + if (hasNonPcpHooks) { + return 'conflict'; + } + } + + const pcpHooks = buildClaudeCodeHooks(); + + // Merge: keep existing non-hooks settings, replace hooks + const merged = { ...existing, ...pcpHooks }; + writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n'); + return 'installed'; +} + +function installGemini(cwd: string, force: boolean): InstallResult { + const configDir = join(cwd, '.gemini'); + const configPath = join(cwd, GEMINI.configPath); + mkdirSync(configDir, { recursive: true }); + + let existing: Record = {}; + if (existsSync(configPath)) { + try { + existing = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + // overwrite + } + } + + if (existing.hooks && !force) { + // Check if our hooks are already there + const hooksObj = existing.hooks as Record; + const hasSessionStart = Array.isArray(hooksObj.session_start) && + (hooksObj.session_start as Array>).some( + (h) => h.command === 'sb hooks on-session-start' + ); + const hasSessionEnd = Array.isArray(hooksObj.session_end) && + (hooksObj.session_end as Array>).some( + (h) => h.command === 'sb hooks on-stop' + ); + if (hasSessionStart && hasSessionEnd) { + return 'already-installed'; + } + + return 'conflict'; + } + + const merged = { + ...existing, + hooks: { + session_start: [{ command: 'sb hooks on-session-start' }], + session_end: [{ command: 'sb hooks on-stop' }], + }, + }; + + writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n'); + return 'installed'; +} + +function installCodex(cwd: string, force: boolean): InstallResult { + const configDir = join(cwd, '.codex'); + const configPath = join(cwd, CODEX.configPath); + mkdirSync(configDir, { recursive: true }); + + let existingContent = ''; + if (existsSync(configPath)) { + existingContent = readFileSync(configPath, 'utf-8'); + } + + if (existingContent.includes(PCP_MARKER) && !force) { + return 'already-installed'; + } + + if (existingContent.includes('[hooks]') && !existingContent.includes(PCP_MARKER) && !force) { + return 'conflict'; + } + + // Remove existing PCP-managed hooks section if present + const cleaned = removePcpTomlSection(existingContent); + + const pcpSection = [ + '', + `# ${PCP_MARKER}`, + '[hooks]', + 'session_start = "sb hooks on-session-start"', + 'session_end = "sb hooks on-stop"', + `# end ${PCP_MARKER}`, + '', + ].join('\n'); + + writeFileSync(configPath, cleaned.trimEnd() + '\n' + pcpSection); + return 'installed'; +} + +function removePcpTomlSection(content: string): string { + const startMarker = `# ${PCP_MARKER}`; + const endMarker = `# end ${PCP_MARKER}`; + + const startIdx = content.indexOf(startMarker); + if (startIdx === -1) return content; + + const endIdx = content.indexOf(endMarker); + if (endIdx === -1) return content; + + const before = content.substring(0, startIdx); + const after = content.substring(endIdx + endMarker.length); + return before + after; +} + +/** + * Programmatic hooks installer. Returns the result without printing. + * Used by `sb hooks install`, `sb studio create`, and `sb init`. + */ +export function installHooks( + cwd: string, + options?: { backend?: string; force?: boolean } +): { result: InstallResult; backend: HookCapabilities } { + const backend = options?.backend ? getBackendByName(options.backend) : detectBackend(cwd); + let result: InstallResult = 'conflict'; + + switch (backend.name) { + case 'claude-code': + result = installClaudeCode(cwd, !!options?.force); + break; + case 'gemini': + result = installGemini(cwd, !!options?.force); + break; + case 'codex': + result = installCodex(cwd, !!options?.force); + break; + } + + return { result, backend }; +} + +async function installCommand(options: { + backend?: string; + local?: boolean; + force?: boolean; +}): Promise { + const cwd = process.cwd(); + const { result, backend } = installHooks(cwd, options); + + console.log(chalk.dim(`Backend: ${backend.name}`)); + + if (result === 'already-installed') { + console.log(chalk.green('\nPCP hooks already installed and up to date.')); + console.log(chalk.dim(`Config: ${backend.configPath}`)); + return; + } + + if (result === 'conflict') { + console.error(chalk.yellow('Existing non-PCP hooks detected. Use --force to overwrite.')); + process.exit(1); + } + + console.log(chalk.green('\nPCP hooks installed:')); + + const events = backend.events; + if (events.preCompact) console.log(chalk.dim(` ${events.preCompact} → sb hooks pre-compact`)); + if (events.postCompact) + console.log(chalk.dim(` ${events.postCompact} (compact) → sb hooks post-compact`)); + if (events.sessionStart) + console.log(chalk.dim(` ${events.sessionStart} (startup) → sb hooks on-session-start`)); + if (events.onPrompt) console.log(chalk.dim(` ${events.onPrompt} → sb hooks on-prompt`)); + if (events.onStop) console.log(chalk.dim(` ${events.onStop} → sb hooks on-stop`)); + + console.log(chalk.dim(`\nConfig: ${backend.configPath}`)); +} + +async function uninstallCommand(options: { backend?: string }): Promise { + const cwd = process.cwd(); + const backend = options.backend ? getBackendByName(options.backend) : detectBackend(cwd); + const configPath = join(cwd, backend.configPath); + + if (!existsSync(configPath)) { + console.log(chalk.yellow('No config file found. Nothing to uninstall.')); + return; + } + + switch (backend.name) { + case 'claude-code': + case 'gemini': { + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + delete config.hooks; + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + break; + } + case 'codex': { + const content = readFileSync(configPath, 'utf-8'); + const cleaned = removePcpTomlSection(content); + writeFileSync(configPath, cleaned); + break; + } + } + + console.log(chalk.green(`PCP hooks removed from ${backend.configPath}`)); +} + +async function statusCommand(options: { backend?: string }): Promise { + const cwd = process.cwd(); + const backend = options.backend ? getBackendByName(options.backend) : detectBackend(cwd); + const configPath = join(cwd, backend.configPath); + + console.log(chalk.bold(`\nHook Status (${backend.name}):\n`)); + console.log(chalk.dim(` Config: ${backend.configPath}`)); + + if (!existsSync(configPath)) { + console.log(chalk.yellow('\n No config file found. Hooks not installed.')); + console.log(chalk.dim(' Run: sb hooks install')); + return; + } + + let hasHooks = false; + + switch (backend.name) { + case 'claude-code': + case 'gemini': { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + const hooks = config.hooks as Record | undefined; + if (hooks && Object.keys(hooks).length > 0) { + hasHooks = true; + console.log(chalk.green('\n Hooks installed:')); + for (const [event, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + const entryObj = entry as Record; + const hookList = entryObj.hooks as Array> | undefined; + const matcher = entryObj.matcher as string | undefined; + const command = entryObj.command as string | undefined; + + if (hookList) { + for (const h of hookList) { + const cmd = h.command as string; + const matcherSuffix = matcher ? ` (${matcher})` : ''; + const isPcp = cmd?.startsWith('sb hooks '); + const icon = isPcp ? chalk.green('●') : chalk.dim('○'); + console.log(` ${icon} ${event}${matcherSuffix} → ${cmd}`); + } + } else if (command) { + // Gemini/simpler format + const isPcp = command.startsWith('sb hooks '); + const icon = isPcp ? chalk.green('●') : chalk.dim('○'); + console.log(` ${icon} ${event} → ${command}`); + } + } + } + } + } catch { + console.log(chalk.red('\n Failed to parse config file.')); + } + break; + } + case 'codex': { + const content = readFileSync(configPath, 'utf-8'); + if (content.includes(PCP_MARKER)) { + hasHooks = true; + console.log(chalk.green('\n PCP hooks installed (TOML)')); + if (content.includes('session_start')) + console.log(chalk.dim(' ● session_start → sb hooks on-session-start')); + if (content.includes('session_end')) + console.log(chalk.dim(' ● session_end → sb hooks on-stop')); + } + break; + } + } + + if (!hasHooks) { + console.log(chalk.yellow('\n No hooks installed.')); + console.log(chalk.dim(' Run: sb hooks install')); + } + + // Show capabilities + console.log(chalk.dim('\n Capabilities:')); + console.log( + chalk.dim(` Compaction: ${backend.supportsCompaction ? chalk.green('yes') : chalk.yellow('no')}`) + ); + console.log( + chalk.dim( + ` Prompt hook: ${backend.supportsPromptHook ? chalk.green('yes') : chalk.yellow('no')}` + ) + ); + + console.log(''); +} + +// ============================================================================ +// Hook Handlers +// ============================================================================ + +async function preCompactHandler(): Promise { + await readStdin(); // consume stdin but we don't need it + + // Output reminder to stdout — the backend injects this into the conversation + const output = [ + '## Pre-Compaction Reminder (PCP)', + '', + 'Context is about to be compacted. Before compaction completes:', + '', + '1. **Save critical decisions** — Use `mcp__pcp__log_session` to persist any important reasoning, decisions, or context that should survive compaction.', + '2. **Update memory** — If you discovered reusable patterns or key facts, use `mcp__pcp__remember` to save them.', + '3. **Note current task state** — Log where you are in the current task so you can resume smoothly after compaction.', + '', + 'This context will be lost after compaction unless you save it now.', + ].join('\n'); + + process.stdout.write(output); +} + +async function postCompactHandler(): Promise { + await readStdin(); + + const cwd = process.cwd(); + const config = getPcpConfig(); + const agentId = resolveAgentId(); + const lines: string[] = []; + + lines.push('## Post-Compaction Context (PCP)'); + lines.push(''); + lines.push(`Agent: ${agentId}`); + lines.push(''); + + // Bootstrap identity + try { + const bootstrap = await callPcpTool('bootstrap', { + email: config?.email, + agentId, + }); + + if (bootstrap.identity) { + lines.push('### Identity'); + lines.push('```json'); + lines.push(JSON.stringify(bootstrap.identity, null, 2)); + lines.push('```'); + lines.push(''); + } + } catch { + lines.push('*Could not reach PCP server for bootstrap.*'); + lines.push(''); + } + + // Check inbox + try { + const inbox = await callPcpTool('get_inbox', { + email: config?.email, + agentId, + }); + + const messages = inbox.messages as Array> | undefined; + if (messages && messages.length > 0) { + lines.push(`### Inbox (${messages.length} message${messages.length === 1 ? '' : 's'})`); + for (const msg of messages) { + lines.push(`- **${msg.from || 'unknown'}**: ${msg.content || msg.subject || '(no content)'}`); + } + lines.push(''); + } + + writeRuntimeFile(cwd, 'last-inbox-check', new Date().toISOString()); + } catch { + // Non-fatal + } + + process.stdout.write(lines.join('\n')); +} + +async function onSessionStartHandler(): Promise { + const stdin = await readStdin(); + + const cwd = process.cwd(); + const config = getPcpConfig(); + const agentId = resolveAgentId(); + const lines: string[] = []; + + lines.push('## Session Context (PCP)'); + lines.push(''); + lines.push(`Agent: **${agentId}**`); + + // Read workspace ID from identity.json + const identityPath = join(cwd, '.pcp', 'identity.json'); + let workspaceId: string | undefined; + if (existsSync(identityPath)) { + try { + const identity = JSON.parse(readFileSync(identityPath, 'utf-8')); + workspaceId = identity.workspaceId; + if (identity.workspace) { + lines.push(`Workspace: ${identity.workspace}`); + } + } catch { + // ignore + } + } + + lines.push(''); + + // Bootstrap + try { + const bootstrapArgs: Record = { + email: config?.email, + agentId, + }; + if (workspaceId) bootstrapArgs.workspaceId = workspaceId; + + const bootstrap = await callPcpTool('bootstrap', bootstrapArgs); + + if (bootstrap.identity) { + lines.push('### Identity'); + lines.push('```json'); + lines.push(JSON.stringify(bootstrap.identity, null, 2)); + lines.push('```'); + lines.push(''); + } + + if (bootstrap.recentMemories) { + const memories = bootstrap.recentMemories as Array>; + if (memories.length > 0) { + lines.push('### Recent Memories'); + for (const mem of memories.slice(0, 5)) { + lines.push(`- ${mem.content || mem.key || JSON.stringify(mem)}`); + } + lines.push(''); + } + } + + if (bootstrap.activeSessions) { + const sessions = bootstrap.activeSessions as Array>; + if (sessions.length > 0) { + lines.push('### Active Sessions'); + for (const s of sessions) { + lines.push(`- ${(s.id as string)?.substring(0, 8) || 'unknown'}: ${s.summary || s.status || 'active'}`); + } + lines.push(''); + } + } + } catch { + lines.push('*Could not reach PCP server for bootstrap.*'); + lines.push(''); + } + + // Check inbox + try { + const inbox = await callPcpTool('get_inbox', { + email: config?.email, + agentId, + }); + + const messages = inbox.messages as Array> | undefined; + if (messages && messages.length > 0) { + lines.push(`### Inbox (${messages.length} message${messages.length === 1 ? '' : 's'})`); + for (const msg of messages) { + lines.push(`- **${msg.from || 'unknown'}**: ${msg.content || msg.subject || '(no content)'}`); + } + lines.push(''); + } + + writeRuntimeFile(cwd, 'last-inbox-check', new Date().toISOString()); + } catch { + // Non-fatal + } + + // Store session ID if provided in stdin + if (stdin.session_id) { + writeRuntimeFile(cwd, 'session-id', String(stdin.session_id)); + } + + process.stdout.write(lines.join('\n')); +} + +async function onPromptHandler(): Promise { + await readStdin(); + + const cwd = process.cwd(); + const config = getPcpConfig(); + const agentId = resolveAgentId(); + + // Check if inbox check is stale (> 5 minutes) + const lastCheck = readRuntimeFile(cwd, 'last-inbox-check'); + const staleThresholdMs = 5 * 60 * 1000; + + if (lastCheck) { + const lastCheckTime = new Date(lastCheck).getTime(); + const elapsed = Date.now() - lastCheckTime; + if (elapsed < staleThresholdMs) { + // Fast path: inbox was checked recently, output nothing + return; + } + } + + // Inbox is stale or never checked — poll + try { + const inbox = await callPcpTool('get_inbox', { + email: config?.email, + agentId, + }); + + writeRuntimeFile(cwd, 'last-inbox-check', new Date().toISOString()); + + const messages = inbox.messages as Array> | undefined; + if (messages && messages.length > 0) { + const lines: string[] = []; + lines.push(``); + for (const msg of messages) { + lines.push(`- **${msg.from || 'unknown'}**: ${msg.content || msg.subject || '(no content)'}`); + } + lines.push(''); + process.stdout.write(lines.join('\n')); + } + } catch { + // Silent failure — don't interrupt the user's prompt + } +} + +async function onStopHandler(): Promise { + await readStdin(); + + const cwd = process.cwd(); + const config = getPcpConfig(); + const agentId = resolveAgentId(); + const lines: string[] = []; + + // Increment tool call counter + const countStr = readRuntimeFile(cwd, 'tool-count'); + const count = (countStr ? parseInt(countStr, 10) : 0) + 1; + writeRuntimeFile(cwd, 'tool-count', String(count)); + + // Every ~30 calls, nudge to log session + if (count % 30 === 0) { + lines.push(''); + lines.push( + `You have completed ~${count} tool calls this session. Consider using \`mcp__pcp__log_session\` to save a progress snapshot.` + ); + lines.push(''); + lines.push(''); + } + + // Check inbox if stale + const lastCheck = readRuntimeFile(cwd, 'last-inbox-check'); + const staleThresholdMs = 5 * 60 * 1000; + let shouldCheckInbox = !lastCheck; + if (lastCheck) { + const elapsed = Date.now() - new Date(lastCheck).getTime(); + shouldCheckInbox = elapsed >= staleThresholdMs; + } + + if (shouldCheckInbox) { + try { + const inbox = await callPcpTool('get_inbox', { + email: config?.email, + agentId, + }); + + writeRuntimeFile(cwd, 'last-inbox-check', new Date().toISOString()); + + const messages = inbox.messages as Array> | undefined; + if (messages && messages.length > 0) { + lines.push(``); + for (const msg of messages) { + lines.push( + `- **${msg.from || 'unknown'}**: ${msg.content || msg.subject || '(no content)'}` + ); + } + lines.push(''); + } + } catch { + // Silent + } + } + + if (lines.length > 0) { + process.stdout.write(lines.join('\n')); + } +} + +// ============================================================================ +// Register Commands +// ============================================================================ + +export function registerHooksCommands(program: Command): void { + const hooks = program + .command('hooks') + .description('Manage CLI lifecycle hooks for PCP integration'); + + hooks + .command('install') + .description('Install PCP hooks into the detected backend config') + .option('-b, --backend ', 'Backend to target (claude-code, codex, gemini)') + .option('--local', 'Write to local config (default for Claude Code)', true) + .option('-f, --force', 'Overwrite existing hooks') + .action(installCommand); + + hooks + .command('uninstall') + .description('Remove PCP-managed hooks from backend config') + .option('-b, --backend ', 'Backend to target') + .action(uninstallCommand); + + hooks + .command('status') + .description('Show installed hook status for the detected backend') + .option('-b, --backend ', 'Backend to check') + .action(statusCommand); + + // Hook handlers — invoked by the backend, not the user + hooks + .command('pre-compact') + .description('Hook: output pre-compaction reminder') + .action(preCompactHandler); + + hooks + .command('post-compact') + .description('Hook: post-compaction bootstrap and inbox check') + .action(postCompactHandler); + + hooks + .command('on-session-start') + .description('Hook: bootstrap identity and context at session start') + .action(onSessionStartHandler); + + hooks + .command('on-prompt') + .description('Hook: periodic inbox check on user prompt') + .action(onPromptHandler); + + hooks + .command('on-stop') + .description('Hook: session nudge and inbox check on stop') + .action(onStopHandler); +} diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts new file mode 100644 index 00000000..20bde31f --- /dev/null +++ b/packages/cli/src/commands/init.test.ts @@ -0,0 +1,269 @@ +/** + * Init Command Tests + * + * Tests for sb init: .pcp/ creation, .mcp.json setup, + * hooks installation, and idempotency. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { installHooks } from './hooks.js'; +import { syncMcpConfig } from './mcp.js'; + +const TEST_DIR = join(tmpdir(), 'pcp-init-test-' + Date.now()); + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +// ============================================================================ +// .pcp/ directory +// ============================================================================ + +describe('init: .pcp/ directory', () => { + it('should create .pcp/ if it does not exist', () => { + const pcpDir = join(TEST_DIR, '.pcp'); + expect(existsSync(pcpDir)).toBe(false); + mkdirSync(pcpDir, { recursive: true }); + expect(existsSync(pcpDir)).toBe(true); + }); + + it('should be idempotent if .pcp/ already exists', () => { + const pcpDir = join(TEST_DIR, '.pcp'); + mkdirSync(pcpDir, { recursive: true }); + writeFileSync(join(pcpDir, 'identity.json'), JSON.stringify({ agentId: 'wren' })); + + // Creating again should not clobber + mkdirSync(pcpDir, { recursive: true }); + expect(existsSync(join(pcpDir, 'identity.json'))).toBe(true); + const identity = JSON.parse(readFileSync(join(pcpDir, 'identity.json'), 'utf-8')); + expect(identity.agentId).toBe('wren'); + }); +}); + +// ============================================================================ +// .mcp.json setup +// ============================================================================ + +describe('init: .mcp.json', () => { + it('should create .mcp.json with pcp server when none exists', () => { + const mcpPath = join(TEST_DIR, '.mcp.json'); + expect(existsSync(mcpPath)).toBe(false); + + // Simulate init logic + const defaultMcp = { + mcpServers: { + pcp: { type: 'http', url: 'http://localhost:3001/mcp' }, + }, + }; + writeFileSync(mcpPath, JSON.stringify(defaultMcp, null, 2) + '\n'); + + expect(existsSync(mcpPath)).toBe(true); + const config = JSON.parse(readFileSync(mcpPath, 'utf-8')); + expect(config.mcpServers.pcp.url).toBe('http://localhost:3001/mcp'); + }); + + it('should add pcp server to existing .mcp.json without it', () => { + const mcpPath = join(TEST_DIR, '.mcp.json'); + writeFileSync( + mcpPath, + JSON.stringify({ + mcpServers: { + supabase: { type: 'http', url: 'https://supabase.example.com/mcp' }, + }, + }) + ); + + // Simulate init logic: add pcp server + const existing = JSON.parse(readFileSync(mcpPath, 'utf-8')); + if (!existing.mcpServers.pcp) { + existing.mcpServers.pcp = { type: 'http', url: 'http://localhost:3001/mcp' }; + writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n'); + } + + const config = JSON.parse(readFileSync(mcpPath, 'utf-8')); + expect(config.mcpServers.supabase.url).toBe('https://supabase.example.com/mcp'); + expect(config.mcpServers.pcp.url).toBe('http://localhost:3001/mcp'); + }); + + it('should not modify .mcp.json if pcp server already exists', () => { + const mcpPath = join(TEST_DIR, '.mcp.json'); + const original = { + mcpServers: { + pcp: { type: 'http', url: 'http://custom-server:4000/mcp' }, + }, + }; + writeFileSync(mcpPath, JSON.stringify(original, null, 2) + '\n'); + + // Simulate init logic: skip if pcp exists + const existing = JSON.parse(readFileSync(mcpPath, 'utf-8')); + expect(existing.mcpServers.pcp).toBeDefined(); + + // Verify it wasn't changed + const config = JSON.parse(readFileSync(mcpPath, 'utf-8')); + expect(config.mcpServers.pcp.url).toBe('http://custom-server:4000/mcp'); + }); +}); + +// ============================================================================ +// Hooks installation via init +// ============================================================================ + +describe('init: hooks installation', () => { + it('should install hooks as part of init flow', () => { + const { result, backend } = installHooks(TEST_DIR); + expect(result).toBe('installed'); + expect(backend.name).toBe('claude-code'); + + const configPath = join(TEST_DIR, '.claude', 'settings.local.json'); + expect(existsSync(configPath)).toBe(true); + }); + + it('should report already-installed on second run', () => { + installHooks(TEST_DIR); + const { result } = installHooks(TEST_DIR); + expect(result).toBe('already-installed'); + }); + + it('should report conflict and not overwrite non-PCP hooks', () => { + const claudeDir = join(TEST_DIR, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'custom-tool' }] }], + }, + }) + ); + + const { result } = installHooks(TEST_DIR); + expect(result).toBe('conflict'); + }); +}); + +// ============================================================================ +// Backend config sync via init +// ============================================================================ + +describe('init: backend config sync', () => { + it('should sync .mcp.json to .codex/ and .gemini/', () => { + writeFileSync( + join(TEST_DIR, '.mcp.json'), + JSON.stringify({ + mcpServers: { pcp: { type: 'http', url: 'http://localhost:3001/mcp' } }, + }) + ); + + const result = syncMcpConfig(TEST_DIR); + expect(result.codex).toBe(true); + expect(result.gemini).toBe(true); + + expect(existsSync(join(TEST_DIR, '.codex', 'config.toml'))).toBe(true); + expect(existsSync(join(TEST_DIR, '.gemini', 'settings.json'))).toBe(true); + }); + + it('should return false when no .mcp.json exists', () => { + const result = syncMcpConfig(TEST_DIR); + expect(result.codex).toBe(false); + expect(result.gemini).toBe(false); + }); +}); + +// ============================================================================ +// Full init flow (integration) +// ============================================================================ + +describe('init: full flow idempotency', () => { + function simulateInit(cwd: string): { + pcp: 'created' | 'exists'; + mcp: 'created' | 'exists' | 'updated'; + hooks: string; + sync: boolean; + } { + // Step 1: .pcp/ + const pcpDir = join(cwd, '.pcp'); + const pcpResult = existsSync(pcpDir) ? 'exists' as const : 'created' as const; + mkdirSync(pcpDir, { recursive: true }); + + // Step 2: .mcp.json + const mcpPath = join(cwd, '.mcp.json'); + let mcpResult: 'created' | 'exists' | 'updated'; + if (!existsSync(mcpPath)) { + writeFileSync( + mcpPath, + JSON.stringify({ + mcpServers: { pcp: { type: 'http', url: 'http://localhost:3001/mcp' } }, + }, null, 2) + '\n' + ); + mcpResult = 'created'; + } else { + const existing = JSON.parse(readFileSync(mcpPath, 'utf-8')); + if (existing.mcpServers?.pcp) { + mcpResult = 'exists'; + } else { + existing.mcpServers = { ...(existing.mcpServers || {}), pcp: { type: 'http', url: 'http://localhost:3001/mcp' } }; + writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n'); + mcpResult = 'updated'; + } + } + + // Step 3: Hooks + const { result: hooksResult } = installHooks(cwd); + + // Step 4: Sync + const syncResult = syncMcpConfig(cwd); + + return { + pcp: pcpResult, + mcp: mcpResult, + hooks: hooksResult, + sync: syncResult.codex || syncResult.gemini, + }; + } + + it('should create everything on first run', () => { + const result = simulateInit(TEST_DIR); + expect(result.pcp).toBe('created'); + expect(result.mcp).toBe('created'); + expect(result.hooks).toBe('installed'); + expect(result.sync).toBe(true); + }); + + it('should detect everything exists on second run', () => { + simulateInit(TEST_DIR); + const result = simulateInit(TEST_DIR); + expect(result.pcp).toBe('exists'); + expect(result.mcp).toBe('exists'); + expect(result.hooks).toBe('already-installed'); + // sync still returns true because it overwrites + expect(result.sync).toBe(true); + }); + + it('should add pcp to existing .mcp.json on first run', () => { + // Pre-existing .mcp.json without pcp + writeFileSync( + join(TEST_DIR, '.mcp.json'), + JSON.stringify({ + mcpServers: { supabase: { type: 'http', url: 'https://supabase.example.com/mcp' } }, + }) + ); + + const result = simulateInit(TEST_DIR); + expect(result.mcp).toBe('updated'); + + const config = JSON.parse(readFileSync(join(TEST_DIR, '.mcp.json'), 'utf-8')); + expect(config.mcpServers.supabase).toBeDefined(); + expect(config.mcpServers.pcp).toBeDefined(); + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 00000000..ee83e590 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,192 @@ +/** + * Init Command + * + * Set up a repo for PCP: install hooks, create default .mcp.json, + * ensure .pcp/ directory. Idempotent — skips steps already done. + * + * Commands: + * init Initialize PCP in the current repo + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { installHooks } from './hooks.js'; +import { syncMcpConfig } from './mcp.js'; + +// ============================================================================ +// Helpers +// ============================================================================ + +interface PcpConfig { + userId?: string; + email?: string; +} + +function getPcpConfig(): PcpConfig | null { + const configPath = join(homedir(), '.pcp', 'config.json'); + if (existsSync(configPath)) { + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return null; + } + } + return null; +} + +function getPcpServerUrl(): string { + return process.env.PCP_SERVER_URL || 'http://localhost:3001'; +} + +function buildDefaultMcpJson(serverUrl: string): Record { + return { + mcpServers: { + pcp: { + type: 'http', + url: `${serverUrl}/mcp`, + }, + }, + }; +} + +// ============================================================================ +// Init Steps +// ============================================================================ + +interface InitStepResult { + label: string; + status: 'created' | 'exists' | 'skipped' | 'updated'; + detail?: string; +} + +function ensurePcpDir(cwd: string): InitStepResult { + const pcpDir = join(cwd, '.pcp'); + if (existsSync(pcpDir)) { + return { label: '.pcp/', status: 'exists' }; + } + mkdirSync(pcpDir, { recursive: true }); + return { label: '.pcp/', status: 'created' }; +} + +function ensureMcpJson(cwd: string): InitStepResult { + const mcpPath = join(cwd, '.mcp.json'); + if (existsSync(mcpPath)) { + // Check if pcp server entry exists + try { + const existing = JSON.parse(readFileSync(mcpPath, 'utf-8')) as Record; + const servers = existing.mcpServers as Record | undefined; + if (servers?.pcp) { + return { label: '.mcp.json', status: 'exists', detail: 'pcp server configured' }; + } + // Add pcp server to existing config + const serverUrl = getPcpServerUrl(); + const updated = { + ...existing, + mcpServers: { + ...(servers || {}), + pcp: { type: 'http', url: `${serverUrl}/mcp` }, + }, + }; + writeFileSync(mcpPath, JSON.stringify(updated, null, 2) + '\n'); + return { label: '.mcp.json', status: 'updated', detail: 'added pcp server' }; + } catch { + return { label: '.mcp.json', status: 'exists', detail: 'unparseable, skipping' }; + } + } + + const serverUrl = getPcpServerUrl(); + writeFileSync(mcpPath, JSON.stringify(buildDefaultMcpJson(serverUrl), null, 2) + '\n'); + return { label: '.mcp.json', status: 'created', detail: `pcp → ${serverUrl}/mcp` }; +} + +function runInstallHooks(cwd: string, force?: boolean): InitStepResult { + const { result, backend } = installHooks(cwd, { force }); + + switch (result) { + case 'installed': + return { label: 'hooks', status: 'created', detail: `${backend.name} (${backend.configPath})` }; + case 'already-installed': + return { label: 'hooks', status: 'exists', detail: `${backend.name}` }; + case 'conflict': + return { label: 'hooks', status: 'skipped', detail: 'existing non-PCP hooks (use sb hooks install --force)' }; + } +} + +function syncBackendConfigs(cwd: string): InitStepResult { + if (!existsSync(join(cwd, '.mcp.json'))) { + return { label: 'backend configs', status: 'skipped', detail: 'no .mcp.json' }; + } + + const result = syncMcpConfig(cwd); + const synced: string[] = []; + if (result.codex) synced.push('.codex/'); + if (result.gemini) synced.push('.gemini/'); + + if (synced.length === 0) { + return { label: 'backend configs', status: 'skipped', detail: 'no servers to sync' }; + } + + return { label: 'backend configs', status: 'created', detail: synced.join(', ') }; +} + +// ============================================================================ +// Command +// ============================================================================ + +async function initCommand(options: { force?: boolean }): Promise { + const cwd = process.cwd(); + const config = getPcpConfig(); + + console.log(chalk.bold('\nInitializing PCP...\n')); + + if (config?.email) { + console.log(chalk.dim(` User: ${config.email}`)); + } else { + console.log(chalk.yellow(' No ~/.pcp/config.json found. Some features may not work.')); + console.log(chalk.dim(' Create one with: echo \'{"email":"you@example.com"}\' > ~/.pcp/config.json')); + } + console.log(''); + + const steps: InitStepResult[] = [ + ensurePcpDir(cwd), + ensureMcpJson(cwd), + runInstallHooks(cwd, options.force), + syncBackendConfigs(cwd), + ]; + + for (const step of steps) { + const icon = + step.status === 'created' || step.status === 'updated' + ? chalk.green('✓') + : step.status === 'exists' + ? chalk.dim('·') + : chalk.yellow('○'); + const statusText = + step.status === 'created' + ? chalk.green(step.status) + : step.status === 'updated' + ? chalk.cyan(step.status) + : step.status === 'exists' + ? chalk.dim(step.status) + : chalk.yellow(step.status); + const detail = step.detail ? chalk.dim(` (${step.detail})`) : ''; + console.log(` ${icon} ${step.label}: ${statusText}${detail}`); + } + + console.log(chalk.dim('\nDone.')); +} + +// ============================================================================ +// Register +// ============================================================================ + +export function registerInitCommand(program: Command): void { + program + .command('init') + .description('Initialize PCP in the current repo (hooks, .mcp.json, backend configs)') + .option('-f, --force', 'Overwrite existing hooks even if non-PCP hooks are present') + .action(initCommand); +} diff --git a/packages/cli/src/commands/workspace.ts b/packages/cli/src/commands/workspace.ts index b311e6cd..3df4a1e0 100644 --- a/packages/cli/src/commands/workspace.ts +++ b/packages/cli/src/commands/workspace.ts @@ -29,6 +29,7 @@ import { } from 'fs'; import { join, dirname, basename } from 'path'; import { homedir } from 'os'; +import { installHooks } from './hooks.js'; interface WorkspaceIdentity { agentId: string; @@ -455,6 +456,10 @@ async function createWorkspace( writeFileSync(join(pcpDir, 'identity.json'), JSON.stringify(identity, null, 2)); + // Auto-install PCP hooks + spinner.text = 'Installing PCP hooks...'; + const { result: hooksResult, backend: hooksBackend } = installHooks(wsPath); + spinner.succeed(`Studio created: ${name}`); console.log(''); console.log(chalk.dim(' Path: ') + wsPath); @@ -463,6 +468,16 @@ async function createWorkspace( if (configDirsList.length > 0) { console.log(chalk.dim(' Config: ') + configDirsList.join(', ')); } + if (hooksResult === 'installed') { + console.log(chalk.dim(' Hooks: ') + `${hooksBackend.name} (installed)`); + } else if (hooksResult === 'already-installed') { + console.log(chalk.dim(' Hooks: ') + `${hooksBackend.name} (already installed)`); + } else if (hooksResult === 'conflict') { + console.log( + chalk.yellow(' Hooks: ') + + `skipped — existing non-PCP hooks in ${hooksBackend.configPath}. Run: sb hooks install --force` + ); + } console.log(''); console.log(chalk.cyan('To start working:')); console.log(chalk.dim(` cd ${wsPath} && sb`));