diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 325d6279..e8a894a2 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -153,6 +153,144 @@ describe('setup agents', () => { expect(skill).not.toContain('sql execute'); }); + it('writes Claude Code project MCP config and tracks the json key', async () => { + const io = makeIo(); + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'cli', + skipAgents: false, + }, + io.io, + ), + ).resolves.toMatchObject({ status: 'ready' }); + + const mcpJson = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as { + mcpServers: { ktx: { type: string; url: string; headers?: Record } }; + }; + expect(mcpJson.mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' }); + expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({ + entries: expect.arrayContaining([{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }]), + }); + expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.'); + }); + + it('writes Cursor project MCP config', async () => { + const io = makeIo(); + + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'cursor', + scope: 'project', + mode: 'cli', + skipAgents: false, + }, + io.io, + ); + + const cursorJson = JSON.parse(await readFile(join(tempDir, '.cursor/mcp.json'), 'utf-8')) as { + mcpServers: { ktx: { url: string; headers?: Record } }; + }; + expect(cursorJson.mcpServers.ktx).toEqual({ url: 'http://localhost:7878/mcp' }); + }); + + it('prints Codex and opencode snippets without mutating printed-only config files', async () => { + const codexIo = makeIo(); + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'codex', + scope: 'project', + mode: 'cli', + skipAgents: false, + }, + codexIo.io, + ); + expect(codexIo.stdout()).toContain('[mcp_servers.ktx]'); + expect(codexIo.stdout()).toContain('url = "http://localhost:7878/mcp"'); + + const opencodeIo = makeIo(); + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'opencode', + scope: 'project', + mode: 'cli', + skipAgents: false, + }, + opencodeIo.io, + ); + expect(opencodeIo.stdout()).toContain('"mcp"'); + expect(opencodeIo.stdout()).toContain('"type": "remote"'); + await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow(); + }); + + it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => { + await mkdir(join(tempDir, '.ktx'), { recursive: true }); + await writeFile( + join(tempDir, '.ktx/mcp.json'), + `${JSON.stringify( + { + schemaVersion: 1, + pid: 999999, + host: '127.0.0.1', + port: 8787, + tokenAuth: true, + projectDir: tempDir, + startedAt: '2026-05-14T00:00:00.000Z', + logPath: join(tempDir, '.ktx/logs/mcp.log'), + }, + null, + 2, + )}\n`, + 'utf-8', + ); + const io = makeIo(); + const previousToken = process.env.KTX_MCP_TOKEN; + process.env.KTX_MCP_TOKEN = 'secret-token'; + + try { + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'cli', + skipAgents: false, + }, + io.io, + ); + + const rendered = JSON.stringify(JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8'))); + expect(rendered).toContain('http://127.0.0.1:8787/mcp'); + expect(rendered).toContain('Bearer ${KTX_MCP_TOKEN}'); + expect(rendered).not.toContain('secret-token'); + expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.'); + } finally { + process.env.KTX_MCP_TOKEN = previousToken; + } + }); + it('removes only manifest-listed files', async () => { const io = makeIo(); await runKtxSetupAgentsStep( diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 429f36a4..e5e02c3b 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -12,6 +13,7 @@ import { createKtxSetupPromptAdapter, type KtxSetupPromptOption, } from './setup-prompts.js'; +import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js'; export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal'; export type KtxAgentScope = 'project' | 'global'; @@ -52,11 +54,169 @@ export interface KtxAgentInstallManifest { type InstallEntry = KtxAgentInstallManifest['entries'][number]; +interface KtxMcpEndpointInfo { + url: string; + tokenAuth: boolean; + running: boolean; +} + +interface KtxMcpClientInstallResult { + entries: InstallEntry[]; + snippets: string[]; + notices: string[]; +} + interface KtxCliLauncher { command: string; args: string[]; } +async function readJsonObject(path: string): Promise> { + if (!existsSync(path)) return {}; + const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Expected JSON object in ${path}`); + } + return parsed as Record; +} + +function objectAtPath(root: Record, jsonPath: string[]): Record { + let cursor = root; + for (const segment of jsonPath) { + const current = cursor[segment]; + if (!current || typeof current !== 'object' || Array.isArray(current)) { + cursor[segment] = {}; + } + cursor = cursor[segment] as Record; + } + return cursor; +} + +async function writeJsonKey(path: string, jsonPath: string[], value: unknown): Promise { + const root = await readJsonObject(path); + const parent = objectAtPath(root, jsonPath.slice(0, -1)); + parent[jsonPath.at(-1) as string] = value; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(root, null, 2)}\n`, 'utf-8'); +} + +async function resolveMcpEndpoint(projectDir: string): Promise { + const status = await readKtxMcpDaemonStatus({ projectDir }).catch(() => null); + if (status?.kind === 'running') { + return { + url: status.url, + tokenAuth: status.state.tokenAuth, + running: true, + }; + } + if (status?.kind === 'stale' && status.state) { + return { + url: `http://${status.state.host}:${status.state.port}/mcp`, + tokenAuth: status.state.tokenAuth || Boolean(process.env.KTX_MCP_TOKEN), + running: false, + }; + } + return { + url: 'http://localhost:7878/mcp', + tokenAuth: Boolean(process.env.KTX_MCP_TOKEN), + running: false, + }; +} + +function tokenHeaders(endpoint: KtxMcpEndpointInfo): Record | undefined { + return endpoint.tokenAuth ? { Authorization: 'Bearer ${KTX_MCP_TOKEN}' } : undefined; +} + +function claudeMcpEntry(endpoint: KtxMcpEndpointInfo): Record { + return { + type: 'http', + url: endpoint.url, + ...(tokenHeaders(endpoint) ? { headers: tokenHeaders(endpoint) } : {}), + }; +} + +function cursorMcpEntry(endpoint: KtxMcpEndpointInfo): Record { + return { + url: endpoint.url, + ...(tokenHeaders(endpoint) ? { headers: tokenHeaders(endpoint) } : {}), + }; +} + +function codexSnippet(endpoint: KtxMcpEndpointInfo): string { + if (endpoint.tokenAuth) { + return [ + 'Codex MCP config does not currently document HTTP headers.', + 'Run KTX on loopback without token auth for Codex, or configure headers after Codex documents support.', + ].join('\n'); + } + return [`[mcp_servers.ktx]`, `url = "${endpoint.url}"`].join('\n'); +} + +function opencodeSnippet(endpoint: KtxMcpEndpointInfo): string { + return JSON.stringify( + { + mcp: { + ktx: { + type: 'remote', + url: endpoint.url, + enabled: true, + ...(tokenHeaders(endpoint) ? { headers: tokenHeaders(endpoint) } : {}), + }, + }, + }, + null, + 2, + ); +} + +function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } { + const home = process.env.HOME ?? ''; + if (scope === 'global') { + return { path: join(home, '.claude.json'), jsonPath: ['mcpServers', 'ktx'] }; + } + return { path: join(resolve(projectDir), '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }; +} + +function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } { + const home = process.env.HOME ?? ''; + return { + path: scope === 'global' ? join(home, '.cursor/mcp.json') : join(resolve(projectDir), '.cursor/mcp.json'), + jsonPath: ['mcpServers', 'ktx'], + }; +} + +async function installMcpClientConfig(input: { + projectDir: string; + target: KtxAgentTarget; + scope: KtxAgentScope; +}): Promise { + const endpoint = await resolveMcpEndpoint(input.projectDir); + const entries: InstallEntry[] = []; + const snippets: string[] = []; + const notices: string[] = []; + + if (!endpoint.running) { + notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.'); + } + + if (input.target === 'claude-code') { + const config = claudeConfigPath(input.projectDir, input.scope); + await writeJsonKey(config.path, config.jsonPath, claudeMcpEntry(endpoint)); + entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); + } else if (input.target === 'cursor') { + const config = cursorConfigPath(input.projectDir, input.scope); + await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint)); + entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); + } else if (input.target === 'codex') { + snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`); + } else if (input.target === 'opencode') { + const path = input.scope === 'global' ? '~/.config/opencode/opencode.json' : relative(input.projectDir, join(input.projectDir, 'opencode.json')); + snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`); + } + + return { entries, snippets, notices }; +} + export function agentInstallManifestPath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/install-manifest.json'); } @@ -309,16 +469,22 @@ export function formatInstallSummary( projectDir: string, ): string { const entriesByTarget = new Map(); - let idx = 0; for (const install of installs) { - const planned = plannedKtxAgentFiles({ projectDir, ...install }); - entriesByTarget.set(install.target, entries.slice(idx, idx + planned.length)); - idx += planned.length; + const plannedFilePaths = new Set( + plannedKtxAgentFiles({ projectDir, ...install }) + .filter((entry) => entry.kind === 'file') + .map((entry) => entry.path), + ); + entriesByTarget.set( + install.target, + entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)), + ); } const fileHints: Record = { skill: 'teaches your agent which KTX commands to run', rule: 'tells your agent when to use KTX', + 'research-skill': 'teaches your agent the KTX MCP research workflow', }; const lines: string[] = []; @@ -330,7 +496,7 @@ export function formatInstallSummary( install.scope === 'global' ? entry.path : relative(projectDir, entry.path); if (entry.kind === 'file') { const isRule = entry.role === 'rule' || fileEntryLabels[install.target] === 'Rule installed'; - const label = isRule ? 'Rule installed' : fileEntryLabels[install.target]; + const label = entry.role === 'research-skill' ? 'Research skill installed' : isRule ? 'Rule installed' : fileEntryLabels[install.target]; const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? ''; lines.push(` + ${label} — ${hint}`); lines.push(` ${displayPath}`); @@ -419,11 +585,25 @@ export async function runKtxSetupAgentsStep( const installs = targets.map((target) => ({ target, scope: args.scope, mode })); const entries: InstallEntry[] = []; + const snippets: string[] = []; + const notices = new Set(); try { - for (const install of installs) entries.push(...(await installTarget({ projectDir: args.projectDir, ...install }))); + for (const install of installs) { + entries.push(...(await installTarget({ projectDir: args.projectDir, ...install }))); + const mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, target: install.target, scope: install.scope }); + entries.push(...mcpResult.entries); + for (const snippet of mcpResult.snippets) snippets.push(snippet); + for (const notice of mcpResult.notices) notices.add(notice); + } await writeManifest(args.projectDir, mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries)); await markAgentsComplete(args.projectDir); io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`); + for (const snippet of snippets) { + io.stdout.write(`\n${snippet}\n`); + } + for (const notice of notices) { + io.stdout.write(`\n${notice}\n`); + } return { status: 'ready', projectDir: args.projectDir, installs }; } catch (error) { io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);