Merge remote-tracking branch 'origin/main' into luca-martial/connector-credential-paste-ux

# Conflicts:
#	packages/cli/src/setup-agents.ts
This commit is contained in:
Luca Martial 2026-05-11 00:34:43 -07:00
commit 2f729c9413
3 changed files with 107 additions and 15 deletions

View file

@ -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:`,

View file

@ -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(

View file

@ -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 <sourceName> --json --project-dir ${input.projectDir}\``,
`- \`ktx agent sl query --json --project-dir ${input.projectDir} --connection-id <id> --query-file <path> --execute --max-rows 100\``,
`- \`ktx agent wiki search <query> --json --project-dir ${input.projectDir}\``,
`- \`ktx agent wiki read <pageId> --json --project-dir ${input.projectDir}\``,
`- \`ktx agent sql execute --json --project-dir ${input.projectDir} --connection-id <id> --sql-file <path> --max-rows 100\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'context', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'list', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'read', '<sourceName>', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, [
'agent',
'sl',
'query',
...projectDirArgs,
'--connection-id',
'<id>',
'--query-file',
'<path>',
'--execute',
'--max-rows',
'100',
])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'search', '<query>', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, [
'agent',
'sql',
'execute',
...projectDirArgs,
'--connection-id',
'<id>',
'--sql-file',
'<path>',
'--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<string, unknown> {
function mcpConfig(projectDir: string, launcher: KtxCliLauncher): Record<string, unknown> {
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<InstallEntry[]> {
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;