import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { cancel, isCancel, multiselect, select } from '@clack/prompts'; import { loadKtxProject, markKtxSetupStepComplete, serializeKtxProjectConfig } from '@ktx/context/project'; import type { KtxCliIo } from './cli-runtime.js'; import { withMenuOptionsSpacing, withMultiselectNavigation } from './prompt-navigation.js'; import { withSetupInterruptConfirmation } from './setup-interrupt.js'; export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal'; export type KtxAgentScope = 'project' | 'global'; export type KtxAgentInstallMode = 'cli' | 'mcp' | 'both'; export interface KtxSetupAgentsArgs { projectDir: string; inputMode: 'auto' | 'disabled'; yes: boolean; agents: boolean; target?: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode; skipAgents: boolean; } export type KtxSetupAgentsResult = | { status: 'ready'; projectDir: string; installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; } | { status: 'skipped'; projectDir: string } | { status: 'back'; projectDir: string } | { status: 'missing-input'; projectDir: string } | { status: 'failed'; projectDir: string }; export interface KtxAgentInstallManifest { version: 1; projectDir: string; installedAt: string; installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; entries: Array<{ kind: 'file'; path: string } | { kind: 'json-key'; path: string; jsonPath: string[] }>; } type InstallEntry = KtxAgentInstallManifest['entries'][number]; export function agentInstallManifestPath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/install-manifest.json'); } export function plannedKtxAgentFiles(input: { projectDir: string; target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode; }): InstallEntry[] { if (input.scope === 'global') { if (input.target === 'claude-code') { return [{ kind: 'file', path: join(process.env.HOME ?? '', '.claude/skills/ktx/SKILL.md') }]; } if (input.target === 'codex') { return [ { kind: 'file', path: join(process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'), 'skills/ktx/SKILL.md') }, ]; } throw new Error(`Global ${input.target} installation is not supported; use --project.`); } const root = resolve(input.projectDir); const cliEntries: Partial> = { 'claude-code': { kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md') }, codex: { kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') }, cursor: { kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') }, opencode: { kind: 'file', path: join(root, '.opencode/commands/ktx.md') }, universal: { kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') }, }; const mcpEntries: Record = { 'claude-code': { kind: 'json-key', path: join(root, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }, codex: { kind: 'json-key', path: join(root, '.agents/mcp/ktx.json'), jsonPath: ['mcpServers', 'ktx'] }, cursor: { kind: 'json-key', path: join(root, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] }, opencode: { kind: 'json-key', path: join(root, '.opencode/mcp.json'), jsonPath: ['mcpServers', 'ktx'] }, universal: { kind: 'json-key', path: join(root, '.agents/mcp/ktx.json'), jsonPath: ['mcpServers', 'ktx'] }, }; return [ ...(input.mode === 'cli' || input.mode === 'both' ? [cliEntries[input.target]] : []), ...(input.mode === 'mcp' || input.mode === 'both' ? [mcpEntries[input.target]] : []), ].filter((entry): entry is InstallEntry => entry !== undefined); } function cliInstructionContent(input: { projectDir: string; target: KtxAgentTarget }): string { return [ '---', 'name: ktx', 'description: Use local KTX semantic context, wiki knowledge, and safe SQL execution for this project.', '---', '', '# KTX Local Context', '', `Use this project with \`--project-dir ${input.projectDir}\`.`, '', 'Agents must not print secrets, credential references, environment variable values, or file contents from `.ktx/secrets`.', '', 'Available commands:', '', `- \`ktx agent context --json --project-dir ${input.projectDir}\``, `- \`ktx agent sl list --json --project-dir ${input.projectDir}\``, `- \`ktx agent sl read --json --project-dir ${input.projectDir}\``, `- \`ktx agent sl query --json --project-dir ${input.projectDir} --connection-id --query-file --execute --max-rows 100\``, `- \`ktx agent wiki search --json --project-dir ${input.projectDir}\``, `- \`ktx agent wiki read --json --project-dir ${input.projectDir}\``, `- \`ktx agent sql execute --json --project-dir ${input.projectDir} --connection-id --sql-file --max-rows 100\``, '', 'SQL execution is read-only, requires an explicit row limit, and should use the smallest useful limit.', '', ].join('\n'); } function mcpConfig(projectDir: string): Record { return { command: 'ktx', args: ['--project-dir', projectDir, 'serve', '--mcp', 'stdio', '--semantic-compute', '--execute-queries'], env: {}, }; } async function writeJsonKey(path: string, jsonPath: string[], value: Record): Promise { let root: Record = {}; try { root = JSON.parse(await readFile(path, 'utf-8')) as Record; } catch { root = {}; } let cursor = root; for (const segment of jsonPath.slice(0, -1)) { const next = cursor[segment]; if (!next || typeof next !== 'object' || Array.isArray(next)) cursor[segment] = {}; cursor = cursor[segment] as Record; } cursor[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 removeJsonKey(path: string, jsonPath: string[]): Promise { const root = JSON.parse(await readFile(path, 'utf-8')) as Record; let cursor: Record = root; for (const segment of jsonPath.slice(0, -1)) { const next = cursor[segment]; if (!next || typeof next !== 'object' || Array.isArray(next)) return; cursor = next as Record; } delete cursor[jsonPath.at(-1) as string]; await writeFile(path, `${JSON.stringify(root, null, 2)}\n`, 'utf-8'); } export async function readKtxAgentInstallManifest(projectDir: string): Promise { try { return JSON.parse(await readFile(agentInstallManifestPath(projectDir), 'utf-8')) as KtxAgentInstallManifest; } catch { return null; } } async function writeManifest(projectDir: string, manifest: KtxAgentInstallManifest): Promise { const path = agentInstallManifestPath(projectDir); await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); } function entryKey(entry: InstallEntry): string { return entry.kind === 'json-key' ? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}` : `${entry.kind}:${entry.path}`; } function mergeManifest( projectDir: string, existing: KtxAgentInstallManifest | null, installs: KtxAgentInstallManifest['installs'], entries: InstallEntry[], ): KtxAgentInstallManifest { const installMap = new Map(); for (const install of [...(existing?.installs ?? []), ...installs]) { installMap.set(`${install.target}:${install.scope}:${install.mode}`, install); } const entryMap = new Map(); for (const entry of [...(existing?.entries ?? []), ...entries]) { entryMap.set(entryKey(entry), entry); } return { version: 1, projectDir, installedAt: new Date().toISOString(), installs: [...installMap.values()], entries: [...entryMap.values()], }; } export async function removeKtxAgentInstall(projectDir: string, io: KtxCliIo): Promise { const manifest = await readKtxAgentInstallManifest(projectDir); if (!manifest) { io.stdout.write('No KTX agent installation manifest found.\n'); return 0; } for (const entry of manifest.entries) { if (entry.kind === 'file') await rm(entry.path, { force: true }); if (entry.kind === 'json-key') await removeJsonKey(entry.path, entry.jsonPath).catch(() => undefined); } await rm(agentInstallManifestPath(projectDir), { force: true }); io.stdout.write('Removed KTX agent integration files from manifest.\n'); return 0; } export interface KtxSetupAgentsPromptAdapter { select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise; multiselect(options: { message: string; options: Array<{ value: string; label: string }>; required?: boolean; }): Promise; cancel(message: string): void; } export interface KtxSetupAgentsDeps { prompts?: KtxSetupAgentsPromptAdapter; } function createPromptAdapter(): KtxSetupAgentsPromptAdapter { return { async select(options) { const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options))); if (isCancel(value)) { cancel('Setup cancelled.'); return 'back'; } return String(value); }, async multiselect(options) { const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); if (isCancel(value)) { cancel('Setup cancelled.'); return ['back']; } return [...value] as string[]; }, cancel(message) { cancel(message); }, }; } async function installTarget(input: { projectDir: string; target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode; }): Promise { const entries = plannedKtxAgentFiles(input); for (const entry of entries) { if (entry.kind === 'file') { await mkdir(dirname(entry.path), { recursive: true }); await writeFile(entry.path, cliInstructionContent({ projectDir: input.projectDir, target: input.target }), 'utf-8'); } else { await writeJsonKey(entry.path, entry.jsonPath, mcpConfig(input.projectDir)); } } return entries; } async function markAgentsComplete(projectDir: string): Promise { const project = await loadKtxProject({ projectDir }); await writeFile(project.configPath, serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'agents')), 'utf-8'); } export async function runKtxSetupAgentsStep( args: KtxSetupAgentsArgs, io: KtxCliIo, deps: KtxSetupAgentsDeps = {}, ): Promise { if (args.skipAgents) { io.stdout.write('Agent integration skipped.\n'); return { status: 'skipped', projectDir: args.projectDir }; } if (!args.agents && args.inputMode === 'disabled') { return { status: 'skipped', projectDir: args.projectDir }; } const prompts = deps.prompts ?? createPromptAdapter(); const mode = args.inputMode === 'disabled' ? args.mode : ((await prompts.select({ message: 'How should agents use this KTX project?', options: [ { value: 'cli', label: 'CLI tools and skills' }, { value: 'mcp', label: 'MCP server config' }, { value: 'both', label: 'Both' }, { value: 'skip', label: 'Skip' }, { value: 'back', label: 'Back' }, ], })) as KtxAgentInstallMode | 'skip' | 'back'); if (mode === 'back') return { status: 'back', projectDir: args.projectDir }; if (mode === 'skip') return { status: 'skipped', projectDir: args.projectDir }; const targets = args.target !== undefined ? [args.target] : args.inputMode === 'disabled' ? [] : ((await prompts.multiselect({ message: withMultiselectNavigation('Which agent targets should KTX install?'), options: [ { value: 'claude-code', label: 'Claude Code' }, { value: 'codex', label: 'Codex' }, { value: 'cursor', label: 'Cursor' }, { value: 'opencode', label: 'OpenCode' }, { value: 'universal', label: 'Universal .agents' }, { value: 'back', label: 'Back' }, ], required: true, })) as KtxAgentTarget[]); if (targets.includes('back' as KtxAgentTarget)) return { status: 'back', projectDir: args.projectDir }; if (targets.length === 0) { io.stderr.write('Missing agent target: pass --target or use interactive setup.\n'); return { status: 'missing-input', projectDir: args.projectDir }; } const installs = targets.map((target) => ({ target, scope: args.scope, mode })); const entries: InstallEntry[] = []; try { for (const install of installs) entries.push(...(await installTarget({ projectDir: args.projectDir, ...install }))); await writeManifest(args.projectDir, mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries)); await markAgentsComplete(args.projectDir); io.stdout.write(`Agent integration installed for ${installs.map((install) => install.target).join(', ')}.\n`); return { status: 'ready', projectDir: args.projectDir, installs }; } catch (error) { io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return { status: 'failed', projectDir: args.projectDir }; } }