Improve KTX agent setup guidance (#137)

* feat(cli): clarify MCP start output

* feat(cli): improve agent setup guidance

* docs: update agent client setup guidance
This commit is contained in:
Luca Martial 2026-05-18 18:54:20 -04:00 committed by GitHub
parent b507ff171d
commit 1331e573dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 804 additions and 173 deletions

View file

@ -9,7 +9,6 @@ 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,
@ -31,6 +30,7 @@ export interface KtxSetupAgentsArgs {
scope: KtxAgentScope;
mode: KtxAgentInstallMode;
skipAgents: boolean;
showNextActions?: boolean;
}
export type KtxSetupAgentsResult =
@ -38,6 +38,7 @@ export type KtxSetupAgentsResult =
status: 'ready';
projectDir: string;
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
nextActions?: string;
}
| { status: 'skipped'; projectDir: string }
| { status: 'back'; projectDir: string }
@ -69,6 +70,8 @@ interface KtxMcpClientInstallResult {
notices: string[];
}
const MCP_DAEMON_REQUIRED_NOTICE = 'mcp-daemon-required';
interface KtxCliLauncher {
command: string;
args: string[];
@ -257,7 +260,7 @@ async function installMcpClientConfig(input: {
const endpoint = await resolveMcpEndpoint(input.projectDir);
if (!endpoint.running) {
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
notices.push(MCP_DAEMON_REQUIRED_NOTICE);
}
if (input.target === 'claude-code') {
@ -269,15 +272,15 @@ async function installMcpClientConfig(input: {
await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint));
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
} else if (input.target === 'codex') {
snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
snippets.push(`Add this Codex MCP snippet to ~/.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'));
snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`);
snippets.push(`Add this OpenCode MCP snippet to ${path}:\n${opencodeSnippet(endpoint)}`);
} else if (input.target === 'universal') {
snippets.push(universalMcpSnippet(endpoint));
snippets.push(`Use this universal MCP endpoint with unsupported MCP clients:\n${universalMcpSnippet(endpoint)}`);
}
return { entries, snippets, notices };
@ -504,7 +507,7 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
return [
'# KTX Claude Plugin',
'',
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
'This package is generated by KTX setup. Claude Desktop loads KTX through the registered `claude_desktop_config.json` entry after restart; no manual plugin install step is required.',
'',
`KTX project: \`${input.projectDir}\``,
'',
@ -515,7 +518,7 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
'',
'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.',
'If this checkout or project directory moves, rerun `ktx setup --agents` and restart Claude Desktop.',
'',
].join('\n');
}
@ -719,17 +722,8 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
universal: 'Universal .agents',
};
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('.')})`;
export function targetDisplayName(target: string): string {
return Object.hasOwn(targetDisplayNames, target) ? targetDisplayNames[target as KtxAgentTarget] : target;
}
function targetSupportsGlobalScope(target: KtxAgentTarget): boolean {
@ -740,11 +734,58 @@ function effectiveInstallScope(target: KtxAgentTarget, requestedScope: KtxAgentS
return target === 'claude-desktop' ? 'global' : requestedScope;
}
function scopeDisplayName(scope: KtxAgentScope): string {
if (scope === 'project') return 'Project scope';
if (scope === 'global') return 'Global scope';
return 'Local scope';
}
function targetUsesHttpMcpDaemon(target: KtxAgentTarget): boolean {
return target !== 'claude-desktop';
}
function manualMcpConfigInstruction(target: KtxAgentTarget, scope: KtxAgentScope): string {
if (target === 'codex') {
return 'Add the snippet shown below to ~/.codex/config.toml.';
}
if (target === 'opencode') {
return scope === 'global'
? 'Add the snippet shown below to ~/.config/opencode/opencode.json.'
: 'Add the snippet shown below to opencode.json.';
}
if (target === 'universal') {
return 'Use the printed endpoint with unsupported MCP clients.';
}
return 'Add the printed snippet manually.';
}
function guidanceInstallLine(target: KtxAgentTarget): string {
if (target === 'codex') return 'Codex guidance installed';
if (target === 'cursor') return 'Cursor rules installed';
if (target === 'opencode') return 'OpenCode commands installed';
if (target === 'universal') return '.agents guidance installed';
if (target === 'claude-desktop') return 'Claude Desktop skills bundled';
return 'Agent guidance installed';
}
function hasEntryRole(entries: InstallEntry[], role: Extract<InstallEntry, { kind: 'file' }>['role']): boolean {
return entries.some((entry) => entry.kind === 'file' && entry.role === role);
}
function hasAdminCliEntries(entries: InstallEntry[]): boolean {
return entries.some(
(entry) =>
entry.kind === 'file' &&
(entry.role === 'skill' || entry.role === 'rule' || entry.role === undefined),
);
}
export function formatInstallSummary(
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
entries: InstallEntry[],
projectDir: string,
): string {
const resolvedProjectDir = resolve(projectDir);
const entriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
for (const install of installs) {
const plannedFilePaths = new Set(
@ -766,51 +807,193 @@ export function formatInstallSummary(
);
}
const fileHints: Record<string, string> = {
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[] = [];
const lines: string[] = ['KTX project', ` ${resolvedProjectDir}`, '', 'Installed agents'];
for (const install of installs) {
const targetEntries = entriesByTarget.get(install.target) ?? [];
lines.push(` ${targetDisplayNames[install.target]}`);
for (const entry of targetEntries) {
if (entry.kind === 'file') {
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}`);
if (entry.role !== 'claude-plugin') {
const displayPath =
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
lines.push(` ${displayPath}`);
}
}
}
for (const entry of mcpEntriesByTarget
const mcpEntry = 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}`);
?.find((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key');
lines.push('', ` ${targetDisplayName(install.target)}`);
if (mcpEntry) {
lines.push(` ${scopeDisplayName(install.scope)}`);
lines.push(` ${mcpEntry.path}`);
} else if (install.target !== 'claude-desktop') {
lines.push(' MCP config');
lines.push(` ${manualMcpConfigInstruction(install.target, install.scope)}`);
}
if (targetUsesHttpMcpDaemon(install.target)) {
lines.push(' Requires MCP to be started');
}
const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill');
const hasAdmin = hasAdminCliEntries(targetEntries);
const hasPlugin = hasEntryRole(targetEntries, 'claude-plugin');
if (install.target === 'claude-code') {
if (hasAnalytics) {
lines.push(' Analytics skill installed');
}
if (hasAdmin) {
lines.push(' Admin CLI skill installed');
}
} else if (hasAnalytics || hasAdmin || hasPlugin) {
lines.push(` ${guidanceInstallLine(install.target)}`);
}
if (hasEntryRole(targetEntries, 'launcher')) {
lines.push(' Starts KTX over stdio from Claude Desktop');
}
}
return lines.join('\n');
}
function humanList(values: string[]): string {
if (values.length <= 2) {
return values.join(' and ');
}
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
}
function pushBlankLine(lines: string[]): void {
if (lines.length > 0 && lines[lines.length - 1] !== '') {
lines.push('');
}
}
function trimTrailingBlankLines(lines: string[]): void {
while (lines[lines.length - 1] === '') {
lines.pop();
}
}
function manualActionFromSnippet(snippet: string): {
title: string;
instruction: string;
marker: 'PASTE' | 'USE';
body: string[];
} {
const [label = '', ...body] = snippet.split('\n');
const codexPrefix = 'Add this Codex MCP snippet to ~/.codex/config.toml:';
if (label === codexPrefix) {
return {
title: 'Configure Codex',
instruction: 'Open ~/.codex/config.toml, then paste this block:',
marker: 'PASTE',
body,
};
}
const opencodeMatch = label.match(/^Add this OpenCode MCP snippet to (.+):$/);
if (opencodeMatch) {
return {
title: 'Configure OpenCode',
instruction: `Open ${opencodeMatch[1]}, then paste this block:`,
marker: 'PASTE',
body,
};
}
if (label === 'Use this universal MCP endpoint with unsupported MCP clients:') {
return {
title: 'Configure unsupported MCP clients',
instruction: 'Use this endpoint when setting up unsupported MCP clients:',
marker: 'USE',
body,
};
}
return {
title: 'Configure MCP client',
instruction: label,
marker: 'PASTE',
body,
};
}
function formatAgentNextActions(input: {
projectDir: string;
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
notices: string[];
snippets: string[];
}): string {
const projectDir = resolve(input.projectDir);
const lines: string[] = [];
let step = 1;
for (const snippet of input.snippets) {
const action = manualActionFromSnippet(snippet);
lines.push(`${step}. ${action.title}`);
lines.push(` ${action.instruction}`);
if (action.body.length > 0) {
lines.push('', ` ${action.marker}:`);
}
for (const line of action.body) {
lines.push(` ${line}`);
}
pushBlankLine(lines);
step += 1;
}
const httpTargets = input.installs
.filter((install) => targetUsesHttpMcpDaemon(install.target))
.map((install) => targetDisplayName(install.target));
if (input.notices.length > 0 && httpTargets.length > 0) {
lines.push(`${step}. Start MCP`);
lines.push(` Run this command before using ${humanList(httpTargets)}:`);
lines.push('');
lines.push(' RUN:');
lines.push(` ktx mcp start --project-dir ${projectDir}`);
lines.push('');
lines.push(' If you need to stop MCP later:');
lines.push(` ktx mcp stop --project-dir ${projectDir}`);
pushBlankLine(lines);
step += 1;
}
const claudeCodeInstall = input.installs.find((install) => install.target === 'claude-code');
if (claudeCodeInstall) {
lines.push(`${step}. Open Claude Code`);
if (claudeCodeInstall.scope === 'project') {
lines.push(' Open Claude Code from the KTX project directory:');
lines.push('');
lines.push(' RUN:');
lines.push(` cd ${shellScriptQuote(projectDir)}`);
lines.push(' claude');
} else {
lines.push(' RUN:');
lines.push(' claude');
}
pushBlankLine(lines);
step += 1;
}
const cursorInstall = input.installs.find((install) => install.target === 'cursor');
if (cursorInstall) {
lines.push(`${step}. Open Cursor`);
if (cursorInstall.scope === 'project') {
lines.push(' Open Cursor from the KTX project directory:');
lines.push('');
lines.push(' OPEN:');
lines.push(` ${projectDir}`);
} else {
lines.push(' Open Cursor.');
}
pushBlankLine(lines);
step += 1;
}
if (input.installs.some((install) => install.target === 'claude-desktop')) {
lines.push(`${step}. Restart Claude Desktop`);
lines.push(' Claude Desktop loads KTX after restart.');
pushBlankLine(lines);
step += 1;
}
if (lines.length === 0) {
lines.push('Open your configured agent and ask a data question.');
}
trimTrailingBlankLines(lines);
return lines.join('\n');
}
async function installTarget(input: {
projectDir: string;
target: KtxAgentTarget;
@ -870,10 +1053,18 @@ export async function runKtxSetupAgentsStep(
args.inputMode === 'disabled'
? args.mode
: ((await prompts.select({
message: 'How should client agents connect to this KTX project?',
message: 'What should agents be allowed to do with this KTX project?',
options: [
{ value: 'mcp', label: 'MCP tools + analytics skill' },
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
{
value: 'mcp',
label: 'Ask data questions with KTX MCP',
hint: 'Installs the MCP connection and analytics workflow skill. Best for normal use.',
},
{
value: 'mcp-cli',
label: 'Ask data questions + manage KTX with CLI commands',
hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.',
},
],
})) as KtxAgentInstallMode | 'back');
if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir };
@ -908,10 +1099,18 @@ export async function runKtxSetupAgentsStep(
scopeTargets.length > 0 &&
scopeTargets.every(targetSupportsGlobalScope)
? ((await prompts.select({
message: 'Where should KTX install supported agent config?',
message: `Where should KTX install supported agent config?\n\nKTX project: ${resolve(args.projectDir)}`,
options: [
{ value: 'project', label: 'Project' },
{ value: 'global', label: 'Global' },
{
value: 'project',
label: 'Project scope (KTX project directory)',
hint: 'Only agents opened from this KTX project path load the project-scoped config.',
},
{
value: 'global',
label: 'Global scope (user config)',
hint: 'Agents can load this KTX project from any working directory.',
},
],
})) as KtxAgentScope | 'back')
: args.scope;
@ -921,7 +1120,6 @@ 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) {
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
@ -934,25 +1132,6 @@ export async function runKtxSetupAgentsStep(
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,
@ -965,18 +1144,16 @@ export async function runKtxSetupAgentsStep(
'Agent integration complete',
io,
);
if (claudeDesktopTutorial) {
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io, {
format: (line) => line,
});
const nextActions = formatAgentNextActions({
projectDir: args.projectDir,
installs,
notices: [...notices],
snippets,
});
if (args.showNextActions !== false) {
setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line });
}
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 };
return { status: 'ready', projectDir: args.projectDir, installs, nextActions };
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return { status: 'failed', projectDir: args.projectDir };