diff --git a/packages/cli/src/next-steps.ts b/packages/cli/src/next-steps.ts index d9c41dd3..a07f5cea 100644 --- a/packages/cli/src/next-steps.ts +++ b/packages/cli/src/next-steps.ts @@ -59,7 +59,7 @@ function commandLines(commands: ReadonlyArray<{ command: string; description: st export function formatNextStepLines(indent = ' '): string[] { return [ `${indent}KTX context is ready for agents.`, - `${indent}Preferred route: CLI + Skills; installed rules call \`ktx agent ...\` directly, so no MCP server is required.`, + `${indent}Preferred route: CLI + Skills; installed rules call the pinned local CLI directly, so no MCP server is required.`, `${indent}Direct CLI checks:`, ...commandLines(KTX_NEXT_STEP_DIRECT_COMMANDS, indent), `${indent}Optional MCP:`, diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index e41fc8a5..d5ced403 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -86,7 +86,7 @@ describe('setup agents', () => { const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8'); expect(skill).toContain(`--project-dir ${tempDir}`); expect(skill).toContain('must not print secrets'); - expect(skill).toContain('ktx agent sql execute'); + expect(skill).toContain('agent sql execute'); expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({ version: 1, projectDir: tempDir, @@ -96,6 +96,47 @@ describe('setup agents', () => { expect(io.stderr()).toBe(''); }); + it('writes PATH-independent launcher commands for skills and MCP configs', async () => { + const io = makeIo(); + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'universal', + scope: 'project', + mode: 'both', + skipAgents: false, + }, + io.io, + ), + ).resolves.toMatchObject({ status: 'ready' }); + + const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8'); + expect(skill).not.toContain('`ktx agent'); + expect(skill).toContain('agent context --json'); + expect(skill).toContain('agent sql execute'); + + const mcp = JSON.parse(await readFile(join(tempDir, '.agents/mcp/ktx.json'), 'utf-8')) as { + mcpServers?: { ktx?: { command?: string; args?: string[] } }; + }; + expect(mcp.mcpServers?.ktx?.command).toBe(process.execPath); + expect(mcp.mcpServers?.ktx?.args?.[0]).toMatch(/packages\/cli\/(src|dist)\/bin\.(ts|js)$/); + expect(mcp.mcpServers?.ktx?.args).toEqual([ + expect.stringMatching(/packages\/cli\/(src|dist)\/bin\.(ts|js)$/), + '--project-dir', + tempDir, + 'serve', + '--mcp', + 'stdio', + '--semantic-compute', + '--execute-queries', + ]); + }); + it('removes only manifest-listed files and JSON keys', async () => { const io = makeIo(); await runKtxSetupAgentsStep( diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 55bb9a76..67394861 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { cancel, isCancel, multiselect, select } from '@clack/prompts'; import { loadKtxProject, markKtxSetupStepComplete, serializeKtxProjectConfig } from '@ktx/context/project'; import type { KtxCliIo } from './cli-runtime.js'; @@ -45,6 +46,11 @@ export interface KtxAgentInstallManifest { type InstallEntry = KtxAgentInstallManifest['entries'][number]; +interface KtxCliLauncher { + command: string; + args: string[]; +} + export function agentInstallManifestPath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/install-manifest.json'); } @@ -98,7 +104,26 @@ export function plannedKtxAgentFiles(input: { ].filter((entry): entry is InstallEntry => entry !== undefined); } -function cliInstructionContent(input: { projectDir: string; target: KtxAgentTarget }): string { +function ktxCliLauncher(): KtxCliLauncher { + return { + command: process.execPath, + args: [fileURLToPath(new URL('./bin.js', import.meta.url))], + }; +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function ktxCommandLine(launcher: KtxCliLauncher, args: string[]): string { + return [launcher.command, ...launcher.args, ...args].map(shellQuote).join(' '); +} + +function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLauncher }): string { + const projectDirArgs = ['--json', '--project-dir', input.projectDir]; return [ '---', 'name: ktx', @@ -108,18 +133,43 @@ function cliInstructionContent(input: { projectDir: string; target: KtxAgentTarg '# KTX Local Context', '', `Use this project with \`--project-dir ${input.projectDir}\`.`, + 'Commands are pinned to the local KTX CLI path that created this file, so agents do not need `ktx` in PATH.', + 'If the CLI path no longer exists after moving this checkout or reinstalling KTX, rerun `ktx setup --agents`.', '', '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\``, + `- \`${ktxCommandLine(input.launcher, ['agent', 'context', ...projectDirArgs])}\``, + `- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'list', ...projectDirArgs])}\``, + `- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'read', '', ...projectDirArgs])}\``, + `- \`${ktxCommandLine(input.launcher, [ + 'agent', + 'sl', + 'query', + ...projectDirArgs, + '--connection-id', + '', + '--query-file', + '', + '--execute', + '--max-rows', + '100', + ])}\``, + `- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'search', '', ...projectDirArgs])}\``, + `- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'read', '', ...projectDirArgs])}\``, + `- \`${ktxCommandLine(input.launcher, [ + 'agent', + 'sql', + 'execute', + ...projectDirArgs, + '--connection-id', + '', + '--sql-file', + '', + '--max-rows', + '100', + ])}\``, '', 'SQL execution is read-only, requires an explicit row limit, and should use the smallest useful limit.', '', @@ -137,10 +187,10 @@ function ruleInstructionContent(input: { projectDir: string }): string { ].join('\n'); } -function mcpConfig(projectDir: string): Record { +function mcpConfig(projectDir: string, launcher: KtxCliLauncher): Record { return { - command: 'ktx', - args: ['--project-dir', projectDir, 'serve', '--mcp', 'stdio', '--semantic-compute', '--execute-queries'], + command: launcher.command, + args: [...launcher.args, '--project-dir', projectDir, 'serve', '--mcp', 'stdio', '--semantic-compute', '--execute-queries'], env: {}, }; } @@ -325,16 +375,17 @@ async function installTarget(input: { mode: KtxAgentInstallMode; }): Promise { const entries = plannedKtxAgentFiles(input); + const launcher = ktxCliLauncher(); for (const entry of entries) { if (entry.kind === 'file') { const content = entry.role === 'rule' ? ruleInstructionContent({ projectDir: input.projectDir }) - : cliInstructionContent({ projectDir: input.projectDir, target: input.target }); + : cliInstructionContent({ projectDir: input.projectDir, launcher }); await mkdir(dirname(entry.path), { recursive: true }); await writeFile(entry.path, content, 'utf-8'); } else { - await writeJsonKey(entry.path, entry.jsonPath, mcpConfig(input.projectDir)); + await writeJsonKey(entry.path, entry.jsonPath, mcpConfig(input.projectDir, launcher)); } } return entries;