feat(setup): add Claude Desktop target and MCP-first agent setup (#114)

* feat(setup): add Claude Desktop target and MCP-first agent setup

Adds `ktx mcp stdio` and a `claude-desktop` setup target that generates a
local plugin ZIP wiring the analytics skill and a stdio MCP config. Replaces
the CLI-only agent install mode with MCP+analytics (default) and an optional
admin CLI skill, renames the research skill to analytics, and lets interactive
setup pick project vs global scope when every target supports it. Extracts a
shared MCP server factory used by both HTTP and stdio entrypoints.

* Add MCP agent client setup support

* Polish setup output formatting

* Add MCP tool polish design spec

Design for slimming the MCP-registered surface from 25 to 11 tools,
introducing memory_ingest, applying the per-tool polish kit (annotations,
outputSchema, .describe(), in-band error wrapping, union-drift fixes,
type-narrowed jsonToolResult), emitting progress notifications on
sql_execution + sl_query, and refining the ktx-analytics SKILL.md to
match.

* Refine MCP tool polish design spec after adversarial review iteration 1

* Refine MCP tool polish design spec after adversarial review iteration 2

* Refine MCP tool polish design spec after adversarial review iteration 3

* refactor(context): rename memory capture service to ingest

* feat(mcp): slim research tool surface

* refactor(mcp): remove admin ports from server factory

* refactor(cli): rename text ingest memory port

* docs: update analytics skill for memory ingest

* chore: verify mcp surface rename

* Add MCP tool polish v1 surface change plan

* feat(context): polish mcp tool metadata

* fix(context): enforce resolved semantic layer compute sources

* feat(context): emit mcp query progress stages

* fix(context): keep mcp progress event internal

* Add MCP tool polish v1 metadata & progress plan

* Fix CI snapshot and docs checks
This commit is contained in:
Andrey Avtomonov 2026-05-16 11:39:55 +02:00 committed by GitHub
parent a72fca2b32
commit e6d578c03f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 8092 additions and 3143 deletions

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 {
@ -7,17 +7,20 @@ import {
markKtxSetupStateStepComplete,
serializeKtxProjectConfig,
} 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';
export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal';
export type KtxAgentTarget = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'opencode' | 'universal';
export type KtxAgentScope = 'project' | 'global' | 'local';
export type KtxAgentInstallMode = 'cli';
export type KtxAgentInstallMode = 'mcp' | 'mcp-cli';
export interface KtxSetupAgentsArgs {
projectDir: string;
@ -47,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' | 'research-skill' }
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
| { kind: 'json-key'; path: string; jsonPath: string[] }
>;
}
@ -169,6 +172,14 @@ function opencodeSnippet(endpoint: KtxMcpEndpointInfo): string {
);
}
function universalMcpSnippet(endpoint: KtxMcpEndpointInfo): string {
return [
'Universal MCP endpoint:',
endpoint.url,
...(endpoint.tokenAuth ? ['Header: Authorization: Bearer ${KTX_MCP_TOKEN}'] : []),
].join('\n');
}
function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } {
const home = process.env.HOME ?? '';
if (scope === 'global') {
@ -188,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.');
}
@ -213,74 +271,141 @@ async function installMcpClientConfig(input: {
} 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'));
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)}`);
} else if (input.target === 'universal') {
snippets.push(universalMcpSnippet(endpoint));
}
return { entries, snippets, notices };
}
function plannedMcpJsonEntries(input: {
projectDir: string;
target: KtxAgentTarget;
scope: KtxAgentScope;
}): InstallEntry[] {
if (input.target === 'claude-code') {
const config = claudeConfigPath(input.projectDir, input.scope);
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
if (input.target === 'claude-desktop') {
const config = claudeDesktopConfigPath();
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
if (input.target === 'cursor') {
const config = cursorConfigPath(input.projectDir, input.scope);
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
return [];
}
export function agentInstallManifestPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/install-manifest.json');
}
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;
scope: KtxAgentScope;
mode: KtxAgentInstallMode;
}): InstallEntry[] {
const withAdminCli = input.mode === 'mcp-cli';
if (input.scope === 'global') {
if (input.target === 'claude-code') {
const home = process.env.HOME ?? '';
return [
{ kind: 'file', path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const },
{ kind: 'file', path: join(home, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' as const },
{ kind: 'file', path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const },
{ kind: 'file', path: join(home, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const },
...(withAdminCli
? [
{ kind: 'file' as const, path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const },
{ kind: 'file' as const, path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const },
]
: []),
];
}
if (input.target === 'codex') {
const codexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex');
return [
{ kind: 'file', path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const },
{ kind: 'file', path: join(codexHome, 'skills/ktx-research/SKILL.md'), role: 'research-skill' as const },
{ kind: 'file', path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const },
{ kind: 'file', path: join(codexHome, 'skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const },
...(withAdminCli
? [
{ kind: 'file' as const, path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const },
{ kind: 'file' as const, path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const },
]
: []),
];
}
if (input.target === 'cursor' || input.target === 'opencode') {
return [];
}
if (input.target === 'claude-desktop') {
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.`);
}
const root = resolve(input.projectDir);
const analyticsEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
'claude-code': [
{ kind: 'file', path: join(root, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
],
codex: [
{ kind: 'file', path: join(root, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
],
cursor: [
{ kind: 'file', path: join(root, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
],
opencode: [
{ kind: 'file', path: join(root, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' },
],
universal: [
{ kind: 'file', path: join(root, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
],
'claude-desktop': [],
};
const cliEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
'claude-code': [
{ kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(root, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' },
],
codex: [
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
],
cursor: [
{ kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') },
{ kind: 'file', path: join(root, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' },
],
opencode: [
{ kind: 'file', path: join(root, '.opencode/commands/ktx.md') },
{ kind: 'file', path: join(root, '.opencode/commands/ktx-research.md'), role: 'research-skill' },
],
universal: [
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') },
{ kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
],
'claude-desktop': [],
};
const ruleEntries: Partial<Record<KtxAgentTarget, InstallEntry>> = {
'claude-code': { kind: 'file', path: join(root, '.claude/rules/ktx.md'), role: 'rule' },
codex: { kind: 'file', path: join(root, '.codex/instructions/ktx.md'), role: 'rule' },
};
return [...(cliEntries[input.target] ?? []), ruleEntries[input.target]].filter(
return [
...(analyticsEntries[input.target] ?? []),
...(withAdminCli ? (cliEntries[input.target] ?? []) : []),
...(withAdminCli ? [ruleEntries[input.target]] : []),
].filter(
(entry): entry is InstallEntry => entry !== undefined,
);
}
@ -292,8 +417,8 @@ function ktxCliLauncher(): KtxCliLauncher {
};
}
async function readResearchSkillContent(): Promise<string> {
const path = fileURLToPath(new URL('./skills/research/SKILL.md', import.meta.url));
async function readAnalyticsSkillContent(): Promise<string> {
const path = fileURLToPath(new URL('./skills/analytics/SKILL.md', import.meta.url));
const content = await readFile(path, 'utf-8');
return content.endsWith('\n') ? content : `${content}\n`;
}
@ -305,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(' ');
}
@ -320,11 +449,14 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'',
'# KTX Local Context',
'',
'This is an admin/developer CLI helper. End-user data agents should use the KTX MCP tools when available.',
'',
`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`.',
'Agents must not print secrets, credential references, environment variable values, or file contents from ' +
'`.ktx/secrets`.',
'',
'Available commands:',
'',
@ -352,9 +484,132 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
].join('\n');
}
function claudePluginJsonContent(): string {
return `${JSON.stringify(
{
name: 'ktx',
version: '0.0.0-local',
description: 'KTX analytics workflow guidance and local MCP tools.',
},
null,
2,
)}\n`;
}
function claudePluginVersionContent(): string {
return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`;
}
function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string {
return [
'# KTX Claude Plugin',
'',
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
'',
`KTX project: \`${input.projectDir}\``,
'',
'Included:',
'',
'- `ktx-analytics` skill for the MCP analytics workflow',
...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []),
'',
'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;
mode: KtxAgentInstallMode;
launcher: KtxCliLauncher;
}): Promise<void> {
const withAdminCli = input.mode === 'mcp-cli';
const files: Record<string, Uint8Array> = {
'.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()),
'version.json': strToU8(claudePluginVersionContent()),
'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })),
};
if (withAdminCli) {
files['skills/ktx/SKILL.md'] = strToU8(
cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }),
);
}
await mkdir(dirname(input.path), { recursive: true });
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 (\`--project-dir ${input.projectDir}\`).`,
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` +
`(\`--project-dir ${input.projectDir}\`).`,
'',
'Use when the user asks about data schemas, metrics, dimensions, database structure, or wants to run SQL queries.',
'',
@ -390,7 +645,9 @@ async function writeManifest(projectDir: string, manifest: KtxAgentInstallManife
}
function entryKey(entry: InstallEntry): string {
return entry.kind === 'json-key' ? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}` : `${entry.kind}:${entry.path}`;
return entry.kind === 'json-key'
? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}`
: `${entry.kind}:${entry.path}`;
}
function mergeManifest(
@ -455,6 +712,7 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
const targetDisplayNames: Record<KtxAgentTarget, string> = {
'claude-code': 'Claude Code',
'claude-desktop': 'Claude Desktop',
codex: 'Codex',
cursor: 'Cursor',
opencode: 'OpenCode',
@ -463,12 +721,25 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
const fileEntryLabels: Record<KtxAgentTarget, string> = {
'claude-code': 'Skill installed',
'claude-desktop': 'Skill installed',
codex: 'Skill installed',
cursor: 'Rule installed',
opencode: 'Command installed',
universal: 'Skill installed',
};
function mcpEntryLabel(entry: Extract<InstallEntry, { kind: 'json-key' }>): string {
return `MCP config installed — connects client agents to KTX MCP tools (${entry.jsonPath.join('.')})`;
}
function targetSupportsGlobalScope(target: KtxAgentTarget): boolean {
return target === 'claude-code' || target === 'codex';
}
function effectiveInstallScope(target: KtxAgentTarget, requestedScope: KtxAgentScope): KtxAgentScope {
return target === 'claude-desktop' ? 'global' : requestedScope;
}
export function formatInstallSummary(
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
entries: InstallEntry[],
@ -486,11 +757,21 @@ export function formatInstallSummary(
entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)),
);
}
const mcpEntriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
for (const install of installs) {
const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, ...install }).map(entryKey));
mcpEntriesByTarget.set(
install.target,
entries.filter((entry) => entry.kind === 'json-key' && plannedMcpKeys.has(entryKey(entry))),
);
}
const fileHints: Record<string, string> = {
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',
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 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[] = [];
@ -498,16 +779,34 @@ export function formatInstallSummary(
const targetEntries = entriesByTarget.get(install.target) ?? [];
lines.push(` ${targetDisplayNames[install.target]}`);
for (const entry of targetEntries) {
const displayPath =
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 = entry.role === 'research-skill' ? 'Research skill installed' : isRule ? 'Rule installed' : fileEntryLabels[install.target];
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
.get(install.target)
?.filter((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key') ?? []) {
const displayPath = install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
lines.push(` + ${mcpEntryLabel(entry)}`);
lines.push(` ${displayPath}`);
}
}
return lines.join('\n');
}
@ -522,11 +821,24 @@ 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,
path: entry.path,
mode: input.mode,
launcher,
});
continue;
}
const content =
entry.role === 'rule'
? ruleInstructionContent({ projectDir: input.projectDir })
: entry.role === 'research-skill'
? await readResearchSkillContent()
: entry.role === 'analytics-skill'
? await readAnalyticsSkillContent()
: cliInstructionContent({ projectDir: input.projectDir, launcher });
await mkdir(dirname(entry.path), { recursive: true });
await writeFile(entry.path, content, 'utf-8');
@ -558,14 +870,13 @@ export async function runKtxSetupAgentsStep(
args.inputMode === 'disabled'
? args.mode
: ((await prompts.select({
message: 'How should agents use this KTX project?',
message: 'How should client agents connect to this KTX project?',
options: [
{ value: 'cli', label: 'CLI tools and skills' },
{ value: 'skip', label: 'Skip' },
{ value: 'mcp', label: 'MCP tools + analytics skill' },
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
],
})) as KtxAgentInstallMode | 'skip' | 'back');
})) as KtxAgentInstallMode | 'back');
if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir };
if (mode === 'skip') return { status: 'skipped', projectDir: args.projectDir };
const targets =
args.target !== undefined
@ -576,6 +887,7 @@ export async function runKtxSetupAgentsStep(
message: withMultiselectNavigation('Which agent targets should KTX install?'),
options: [
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'claude-desktop', label: 'Claude Desktop' },
{ value: 'codex', label: 'Codex' },
{ value: 'cursor', label: 'Cursor' },
{ value: 'opencode', label: 'OpenCode' },
@ -589,26 +901,80 @@ export async function runKtxSetupAgentsStep(
return { status: 'missing-input', projectDir: args.projectDir };
}
const installs = targets.map((target) => ({ target, scope: args.scope, mode }));
const scopeTargets = targets.filter((target) => target !== 'claude-desktop');
const selectedScope =
args.inputMode !== 'disabled' &&
args.scope === 'project' &&
scopeTargets.length > 0 &&
scopeTargets.every(targetSupportsGlobalScope)
? ((await prompts.select({
message: 'Where should KTX install supported agent config?',
options: [
{ value: 'project', label: 'Project' },
{ value: 'global', label: 'Global' },
],
})) as KtxAgentScope | 'back')
: args.scope;
if (selectedScope === 'back') return { status: 'back', projectDir: args.projectDir };
const installs = targets.map((target) => ({ target, scope: effectiveInstallScope(target, selectedScope), mode }));
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 mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, target: install.target, scope: install.scope });
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') {
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(args.projectDir, mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries));
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`);
const setupUi = createKtxSetupUiAdapter();
setupUi.note(
formatInstallSummary(installs, entries, args.projectDir),
'Agent integration complete',
io,
);
if (claudeDesktopTutorial) {
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io, {
format: (line) => line,
});
}
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) {