Add MCP agent client setup support

This commit is contained in:
Andrey Avtomonov 2026-05-16 00:12:36 +02:00
parent 658024dcf3
commit 0361449c8a
19 changed files with 745 additions and 146 deletions

View file

@ -186,6 +186,10 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
return true;
}
if (commandPathKey === 'ktx mcp stdio') {
return true;
}
if (
commandPathKey === 'ktx status' &&
typeof options.projectDir !== 'string' &&

View file

@ -56,6 +56,33 @@ describe('registerMcpCommands', () => {
expect(startDaemon).not.toHaveBeenCalled();
});
it('prints "already running" when startDaemon reports already-running', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const startDaemon = vi.fn().mockResolvedValue({
status: 'already-running',
url: 'http://127.0.0.1:7878/mcp',
state: {
schemaVersion: 1,
pid: 4242,
host: '127.0.0.1',
port: 7878,
tokenAuth: false,
projectDir: '/tmp/ktx-already',
startedAt: '2026-05-14T00:00:00.000Z',
logPath: '/tmp/ktx-already/.ktx/logs/mcp.log',
},
});
const context = makeContext({ deps: { mcp: { startDaemon } } });
registerMcpCommands(program, context);
await program.parseAsync(['--project-dir', '/tmp/ktx-already', 'mcp', 'start'], { from: 'user' });
expect(startDaemon).toHaveBeenCalledTimes(1);
expect(context.io.stdout.write).toHaveBeenCalledWith(
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n',
);
});
it('runs the stdio server with the resolved project directory', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const runStdioServer = vi.fn().mockResolvedValue(undefined);

View file

@ -82,7 +82,11 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
allowedOrigins: options.allowedOrigin,
binPath: binPath(),
});
context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`);
context.io.stdout.write(
result.status === 'started'
? `KTX MCP daemon started: ${result.url}\n`
: `KTX MCP daemon already running: ${result.url}\n`,
);
});
mcp

View file

@ -94,6 +94,78 @@ describe('managed MCP daemon lifecycle', () => {
);
});
it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
const spawnDaemon = vi.fn(() => child(9999));
const result = await startKtxMcpDaemon({
projectDir,
cliVersion: '0.0.0-test',
host: '127.0.0.1',
port: 7878,
allowedHosts: [],
allowedOrigins: [],
binPath: '/repo/packages/cli/dist/bin.js',
spawnDaemon,
processAlive: vi.fn(() => true),
portAvailable: vi.fn(async () => true),
});
expect(result.status).toBe('already-running');
expect(result.url).toBe('http://127.0.0.1:7878/mcp');
expect(result.state.pid).toBe(4242);
expect(spawnDaemon).not.toHaveBeenCalled();
});
it('throws when the recorded daemon uses a different host or port', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
const spawnDaemon = vi.fn(() => child(9999));
await expect(
startKtxMcpDaemon({
projectDir,
cliVersion: '0.0.0-test',
host: '127.0.0.1',
port: 9000,
allowedHosts: [],
allowedOrigins: [],
binPath: '/repo/packages/cli/dist/bin.js',
spawnDaemon,
processAlive: vi.fn(() => true),
portAvailable: vi.fn(async () => true),
}),
).rejects.toThrow(/different configuration[\s\S]*ktx mcp stop/);
expect(spawnDaemon).not.toHaveBeenCalled();
});
it('throws when token-auth presence differs from the recorded daemon', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(
join(projectDir, '.ktx/mcp.json'),
`${JSON.stringify(state(projectDir, { tokenAuth: false }), null, 2)}\n`,
);
const spawnDaemon = vi.fn(() => child(9999));
await expect(
startKtxMcpDaemon({
projectDir,
cliVersion: '0.0.0-test',
host: '127.0.0.1',
port: 7878,
token: 'secret-token',
allowedHosts: [],
allowedOrigins: [],
binPath: '/repo/packages/cli/dist/bin.js',
spawnDaemon,
processAlive: vi.fn(() => true),
portAvailable: vi.fn(async () => true),
}),
).rejects.toThrow(/different configuration/);
expect(spawnDaemon).not.toHaveBeenCalled();
});
it('reports running when the process is alive and health passes', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);

View file

@ -121,11 +121,25 @@ export async function startKtxMcpDaemon(options: {
portAvailable?: (host: string, port: number) => Promise<boolean>;
spawnDaemon?: typeof defaultSpawnDaemon;
now?: () => Date;
}): Promise<{ status: 'started'; state: KtxMcpDaemonState; url: string }> {
}): Promise<{ status: 'started' | 'already-running'; state: KtxMcpDaemonState; url: string }> {
const existing = await readState(options.projectDir).catch(() => undefined);
const processAlive = options.processAlive ?? defaultProcessAlive;
if (existing && processAlive(existing.pid)) {
throw new Error(`KTX MCP daemon is already recorded at http://${existing.host}:${existing.port}/mcp`);
const sameConfig =
existing.host === options.host &&
existing.port === options.port &&
existing.tokenAuth === Boolean(options.token);
if (sameConfig) {
return {
status: 'already-running',
state: existing,
url: `http://${existing.host}:${existing.port}/mcp`,
};
}
throw new Error(
`KTX MCP daemon is already running at http://${existing.host}:${existing.port}/mcp ` +
'with a different configuration. Run `ktx mcp stop` first, then start again.',
);
}
const portAvailable = options.portAvailable ?? defaultPortAvailable;
if (!(await portAvailable(options.host, options.port))) {

View file

@ -1,3 +1,4 @@
import process from 'node:process';
import type { Readable, Writable } from 'node:stream';
import { loadKtxProject } from '@ktx/context/project';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@ -32,14 +33,32 @@ export async function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions)
cliVersion: options.cliVersion ?? '0.0.0-private',
io: protocolIo,
}));
const transport = new StdioServerTransport(options.stdin, options.stdout);
const stdin = options.stdin ?? process.stdin;
const transport = new StdioServerTransport(stdin, options.stdout);
await new Promise<void>((resolve, reject) => {
transport.onclose = resolve;
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
stdin.off('end', closeTransport);
stdin.off('close', closeTransport);
callback();
};
const closeTransport = () => {
transport.close().catch((error: unknown) => {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
});
};
transport.onclose = () => settle(resolve);
transport.onerror = (error) => {
options.io?.stderr.write(`KTX MCP stdio transport error: ${error.message}\n`);
reject(error);
settle(() => reject(error));
};
createMcpServer().connect(transport).catch(reject);
stdin.once('end', closeTransport);
stdin.once('close', closeTransport);
createMcpServer().connect(transport).catch((error: unknown) => {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
});
});
}

View file

@ -32,6 +32,37 @@ async function readZipText(path: string, entry: string): Promise<string> {
return strFromU8(content);
}
function captureEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of keys) snapshot[key] = env[key];
return snapshot;
}
function clearEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): void {
for (const key of keys) delete env[key];
}
function captureKtxEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of Object.keys(env)) {
if (key.startsWith('KTX_')) snapshot[key] = env[key];
}
return snapshot;
}
function clearKtxEnv(env: NodeJS.ProcessEnv): void {
for (const key of Object.keys(env)) {
if (key.startsWith('KTX_')) delete env[key];
}
}
function restoreEnvKeys(env: NodeJS.ProcessEnv, snapshot: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) delete env[key];
else env[key] = value;
}
}
describe('setup agents', () => {
let tempDir: string;
@ -50,6 +81,7 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp' })).toEqual([
@ -90,6 +122,7 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
]);
});
@ -301,10 +334,14 @@ describe('setup agents', () => {
}
});
it('generates a Claude Desktop plugin zip with analytics skill and stdio MCP config', async () => {
it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
const ktxEnvSnapshot = captureKtxEnv(process.env);
process.env.HOME = home;
clearEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
clearKtxEnv(process.env);
try {
const io = makeIo();
@ -328,31 +365,62 @@ describe('setup agents', () => {
});
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
await expect(stat(pluginPath)).resolves.toBeDefined();
await expect(stat(join(home, 'Library/Application Support/Claude/claude_desktop_config.json'))).rejects.toThrow();
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
const mcpJson = JSON.parse(await readZipText(pluginPath, '.mcp.json')) as {
mcpServers: { ktx: { type: string; command: string; args: string[] } };
const launcherStat = await stat(launcherPath);
expect(launcherStat.mode & 0o111).not.toBe(0);
const launcher = await readFile(launcherPath, 'utf-8');
expect(launcher).toContain('KTX_CLI_BIN=');
expect(launcher).toContain('.nvm/versions/node');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } };
};
expect(mcpJson.mcpServers.ktx.type).toBe('stdio');
expect(mcpJson.mcpServers.ktx.command).toBe(process.execPath);
expect(mcpJson.mcpServers.ktx.args).toEqual(expect.arrayContaining(['--project-dir', tempDir, 'mcp', 'stdio']));
expect(config.mcpServers.ktx).toEqual({
command: launcherPath,
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
});
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
const setupMd = await readZipText(pluginPath, 'SETUP.md');
expect(setupMd).not.toContain('ktx mcp start');
expect(setupMd).toContain('claude_desktop_config.json');
await expect(readZipText(pluginPath, 'skills/ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
expect(io.stdout()).toContain('Claude plugin generated');
expect(io.stdout()).toContain('.ktx/agents/claude/ktx-plugin.zip');
expect(io.stdout()).toContain('Install the generated KTX plugin ZIP from Claude Desktop Plugins');
expect(io.stdout()).toContain('KTX MCP server registered');
expect(io.stdout()).toContain('claude_desktop_config.json');
expect(io.stdout()).toContain('Restart Claude Desktop');
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
} finally {
process.env.HOME = previousHome;
restoreEnvKeys(process.env, envSnapshot);
restoreEnvKeys(process.env, ktxEnvSnapshot);
await rm(home, { recursive: true, force: true });
}
});
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
it('captures KTX_*, OPENAI_API_KEY, and ANTHROPIC_API_KEY into the Claude Desktop MCP env block', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
const envSnapshot = captureEnvKeys(process.env, [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'KTX_LOG_LEVEL',
]);
const ktxEnvSnapshot = captureKtxEnv(process.env);
process.env.HOME = home;
clearKtxEnv(process.env);
process.env.OPENAI_API_KEY = 'sk-test-openai'; // pragma: allowlist secret
process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; // pragma: allowlist secret
process.env.KTX_LOG_LEVEL = 'debug';
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
@ -360,19 +428,65 @@ describe('setup agents', () => {
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
});
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
expect(await readZipText(pluginPath, 'skills/ktx/SKILL.md')).toContain(`--project-dir ${tempDir}`);
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { env?: Record<string, string> } };
};
expect(config.mcpServers.ktx.env).toEqual({
OPENAI_API_KEY: 'sk-test-openai', // pragma: allowlist secret
ANTHROPIC_API_KEY: 'sk-ant-test', // pragma: allowlist secret
KTX_LOG_LEVEL: 'debug',
});
} finally {
process.env.HOME = previousHome;
restoreEnvKeys(process.env, envSnapshot);
restoreEnvKeys(process.env, ktxEnvSnapshot);
await rm(home, { recursive: true, force: true });
}
});
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
});
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md');
expect(adminSkill).toContain(`--project-dir ${tempDir}`);
expect(adminSkill).toContain('status --json');
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('installs MCP client config and analytics skill without admin CLI files', async () => {
@ -592,27 +706,47 @@ describe('setup agents', () => {
});
it('removes generated Claude Desktop plugin from the manifest', async () => {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
await expect(stat(pluginPath)).resolves.toBeDefined();
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
await expect(stat(pluginPath)).resolves.toBeDefined();
await expect(stat(launcherPath)).resolves.toBeDefined();
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
expect(beforeConfig.mcpServers.ktx).toBeDefined();
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
await expect(stat(pluginPath)).rejects.toThrow();
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
await expect(stat(pluginPath)).rejects.toThrow();
await expect(stat(launcherPath)).rejects.toThrow();
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
expect(afterConfig.mcpServers.ktx).toBeUndefined();
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('treats cancel as skip in interactive mode', async () => {

View file

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
@ -9,9 +9,11 @@ import {
} from '@ktx/context/project';
import { strToU8, zipSync } from 'fflate';
import type { KtxCliIo } from './cli-runtime.js';
import { bold, dim, green } from './io/symbols.js';
import { withMultiselectNavigation } from './prompt-navigation.js';
import {
createKtxSetupPromptAdapter,
createKtxSetupUiAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
@ -48,7 +50,7 @@ export interface KtxAgentInstallManifest {
installedAt: string;
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
entries: Array<
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' }
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
| { kind: 'json-key'; path: string; jsonPath: string[] }
>;
}
@ -197,16 +199,63 @@ function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: str
};
}
function claudeDesktopConfigPath(): { path: string; jsonPath: string[] } {
const home = process.env.HOME ?? '';
const path =
process.platform === 'win32'
? join(process.env.APPDATA ?? join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json')
: join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
return { path, jsonPath: ['mcpServers', 'ktx'] };
}
const CLAUDE_DESKTOP_FORWARDED_ENV_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const;
export function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<string, string> {
const captured: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (value === undefined || value === '') continue;
if (key.startsWith('KTX_') || (CLAUDE_DESKTOP_FORWARDED_ENV_KEYS as readonly string[]).includes(key)) {
captured[key] = value;
}
}
return captured;
}
function claudeDesktopMcpEntry(input: {
launcherPath: string;
projectDir: string;
env?: NodeJS.ProcessEnv;
}): Record<string, unknown> {
const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env);
return {
command: input.launcherPath,
args: ['--project-dir', input.projectDir, 'mcp', 'stdio'],
...(Object.keys(captured).length > 0 ? { env: captured } : {}),
};
}
async function installMcpClientConfig(input: {
projectDir: string;
target: KtxAgentTarget;
scope: KtxAgentScope;
}): Promise<KtxMcpClientInstallResult> {
const endpoint = await resolveMcpEndpoint(input.projectDir);
const entries: InstallEntry[] = [];
const snippets: string[] = [];
const notices: string[] = [];
if (input.target === 'claude-desktop') {
const config = claudeDesktopConfigPath();
const launcherPath = claudeDesktopLauncherPath(input.projectDir);
await writeJsonKey(
config.path,
config.jsonPath,
claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }),
);
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
return { entries, snippets, notices };
}
const endpoint = await resolveMcpEndpoint(input.projectDir);
if (!endpoint.running) {
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
}
@ -244,7 +293,8 @@ function plannedMcpJsonEntries(input: {
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
if (input.target === 'claude-desktop') {
return [];
const config = claudeDesktopConfigPath();
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
if (input.target === 'cursor') {
const config = cursorConfigPath(input.projectDir, input.scope);
@ -261,6 +311,10 @@ function claudeDesktopPluginPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip');
}
function claudeDesktopLauncherPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh');
}
export function plannedKtxAgentFiles(input: {
projectDir: string;
target: KtxAgentTarget;
@ -298,7 +352,10 @@ export function plannedKtxAgentFiles(input: {
return [];
}
if (input.target === 'claude-desktop') {
return [{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const }];
return [
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const },
];
}
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
}
@ -373,6 +430,10 @@ function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function shellScriptQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function ktxCommandLine(launcher: KtxCliLauncher, args: string[]): string {
return [launcher.command, ...launcher.args, ...args].map(shellQuote).join(' ');
}
@ -436,27 +497,11 @@ function claudePluginVersionContent(): string {
return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`;
}
function claudePluginMcpContent(input: { projectDir: string; launcher: KtxCliLauncher }): string {
return `${JSON.stringify(
{
mcpServers: {
ktx: {
type: 'stdio',
command: input.launcher.command,
args: [...input.launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'],
},
},
},
null,
2,
)}\n`;
}
function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string {
return [
'# KTX Claude Plugin',
'',
'Install this plugin ZIP from Claude Desktop, then use KTX tools for local analytics questions.',
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
'',
`KTX project: \`${input.projectDir}\``,
'',
@ -464,13 +509,69 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
'',
'- `ktx-analytics` skill for the MCP analytics workflow',
...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []),
'- Local stdio MCP server launched through the KTX CLI',
'',
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
'',
'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.',
'',
].join('\n');
}
function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string {
const binPath = input.launcher.args[0];
if (!binPath) {
throw new Error('Expected KTX CLI launcher to include a bin path.');
}
const candidates = [
input.launcher.command,
'/opt/homebrew/bin/node',
'/usr/local/bin/node',
'/usr/bin/node',
];
return [
'#!/bin/sh',
'set -eu',
'',
`KTX_CLI_BIN=${shellScriptQuote(binPath)}`,
'',
'run_with_node() {',
' node_bin=$1',
' shift',
' exec "$node_bin" "$KTX_CLI_BIN" "$@"',
'}',
'',
'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then',
' run_with_node "$KTX_NODE" "$@"',
'fi',
'',
'if [ -x "$HOME/.volta/bin/node" ]; then',
' run_with_node "$HOME/.volta/bin/node" "$@"',
'fi',
'',
...candidates.map((candidate) =>
[
`if [ -x ${shellScriptQuote(candidate)} ]; then`,
` run_with_node ${shellScriptQuote(candidate)} "$@"`,
'fi',
].join('\n'),
),
'',
'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do',
' if [ -x "$candidate" ]; then',
' run_with_node "$candidate" "$@"',
' fi',
'done',
'',
'if command -v node >/dev/null 2>&1; then',
' run_with_node "$(command -v node)" "$@"',
'fi',
'',
'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2',
'exit 127',
'',
].join('\n');
}
async function writeClaudeDesktopPlugin(input: {
projectDir: string;
path: string;
@ -481,7 +582,6 @@ async function writeClaudeDesktopPlugin(input: {
const files: Record<string, Uint8Array> = {
'.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()),
'version.json': strToU8(claudePluginVersionContent()),
'.mcp.json': strToU8(claudePluginMcpContent({ projectDir: input.projectDir, launcher: input.launcher })),
'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })),
};
@ -494,6 +594,15 @@ async function writeClaudeDesktopPlugin(input: {
await writeFile(input.path, Buffer.from(zipSync(files)));
}
async function writeClaudeDesktopLauncher(input: {
path: string;
launcher: KtxCliLauncher;
}): Promise<void> {
await mkdir(dirname(input.path), { recursive: true });
await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8');
await chmod(input.path, 0o755);
}
function ruleInstructionContent(input: { projectDir: string }): string {
return [
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` +
@ -658,7 +767,8 @@ export function formatInstallSummary(
skill: 'teaches admin agents which KTX CLI commands to run',
rule: 'tells admin agents when to use KTX CLI',
'analytics-skill': 'teaches your agent the KTX MCP analytics workflow',
'claude-plugin': 'bundles KTX skills and local stdio MCP config for Claude Desktop',
'claude-plugin': 'bundles KTX skills for Claude Desktop (MCP server is registered in claude_desktop_config.json)',
launcher: 'runs the local KTX CLI with an available Node.js for Claude Desktop',
};
const lines: string[] = [];
@ -667,20 +777,24 @@ export function formatInstallSummary(
lines.push(` ${targetDisplayNames[install.target]}`);
for (const entry of targetEntries) {
if (entry.kind === 'file') {
const displayPath =
install.scope === 'global' && entry.role !== 'claude-plugin' ? entry.path : relative(projectDir, entry.path);
const isRule = entry.role === 'rule' || (!entry.role && fileEntryLabels[install.target] === 'Rule installed');
const label =
entry.role === 'analytics-skill'
? 'Analytics skill installed'
: entry.role === 'claude-plugin'
? 'Claude plugin generated'
: entry.role === 'launcher'
? 'Launcher installed'
: isRule
? 'Rule installed'
: fileEntryLabels[install.target];
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
lines.push(` + ${label}${hint}`);
lines.push(` ${displayPath}`);
if (entry.role !== 'claude-plugin') {
const displayPath =
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
lines.push(` ${displayPath}`);
}
}
}
for (const entry of mcpEntriesByTarget
@ -704,6 +818,10 @@ async function installTarget(input: {
const launcher = ktxCliLauncher();
for (const entry of entries) {
if (entry.kind !== 'file') continue;
if (entry.role === 'launcher') {
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
continue;
}
if (entry.role === 'claude-plugin') {
await writeClaudeDesktopPlugin({
projectDir: input.projectDir,
@ -800,20 +918,37 @@ export async function runKtxSetupAgentsStep(
const entries: InstallEntry[] = [];
const snippets: string[] = [];
const notices = new Set<string>();
let claudeDesktopTutorial: string | undefined;
try {
for (const install of installs) {
entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
entries.push(...targetEntries);
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);
if (install.target === 'claude-desktop') {
notices.add('Install the generated KTX plugin ZIP from Claude Desktop Plugins, then restart or reload Claude.');
} else {
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);
const pluginEntry = targetEntries.find(
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
entry.kind === 'file' && entry.role === 'claude-plugin',
);
const pluginPath = pluginEntry?.path ?? '';
const configPath = claudeDesktopConfigPath().path;
claudeDesktopTutorial = [
`${green('✓')} ${bold('KTX MCP server registered')}`,
` ${dim(configPath)}`,
'',
bold('1. Restart Claude Desktop'),
' Quit and reopen so it picks up the new MCP server.',
'',
bold('2. Install the KTX plugin'),
' Open Claude Desktop → Settings → Plugins and install from file:',
` ${dim(pluginPath)}`,
].join('\n');
}
}
await writeManifest(
@ -821,12 +956,20 @@ export async function runKtxSetupAgentsStep(
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`);
const setupUi = createKtxSetupUiAdapter();
setupUi.note(
formatInstallSummary(installs, entries, args.projectDir),
'Agent integration complete',
io,
);
if (claudeDesktopTutorial) {
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io);
}
for (const notice of notices) {
io.stdout.write(`\n${notice}\n`);
const nextStepBlocks: string[] = [];
for (const notice of notices) nextStepBlocks.push(notice);
for (const snippet of snippets) nextStepBlocks.push(snippet);
if (nextStepBlocks.length > 0) {
setupUi.note(nextStepBlocks.join('\n\n'), 'Next steps', io, { format: bold });
}
return { status: 'ready', projectDir: args.projectDir, installs };
} catch (error) {

View file

@ -138,9 +138,13 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
};
}
interface KtxSetupNoteOptions {
format?: (line: string) => string;
}
export interface KtxSetupUiAdapter {
intro(title: string, io: KtxCliIo): void;
note(message: string, title: string, io: KtxCliIo): void;
note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void;
}
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
@ -160,9 +164,12 @@ export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
}
io.stdout.write(`${title}\n`);
},
note(message, title, io) {
note(message, title, io, options) {
if (isWritableTtyOutput(io.stdout)) {
note(message, title, { output: io.stdout });
note(message, title, {
output: io.stdout,
...(options?.format ? { format: options.format } : {}),
});
return;
}
io.stdout.write(`\n${title}:\n`);

View file

@ -709,15 +709,18 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
const status = await readKtxSetupStatus(projectResult.projectDir);
io.stdout.write(formatKtxSetupStatus(status));
setupUi.note(
formatSetupNextStepLines({
setupReady: setupStatusReady(status),
hasContextTargets: setupHasContextTargets(status),
contextReady: setupContextReady(status),
agentIntegrationReady: status.agents.some((agent) => agent.ready),
}).join('\n'),
'What you can do next',
io,
);
const focusedOnAgents = args.agents || entryAction === 'agents';
if (!focusedOnAgents) {
setupUi.note(
formatSetupNextStepLines({
setupReady: setupStatusReady(status),
hasContextTargets: setupHasContextTargets(status),
contextReady: setupContextReady(status),
agentIntegrationReady: status.agents.some((agent) => agent.ready),
}).join('\n'),
'What you can do next',
io,
);
}
return 0;
}