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

@ -144,7 +144,7 @@ describe('setup agents', () => {
},
io.io,
),
).resolves.toEqual({
).resolves.toMatchObject({
status: 'ready',
projectDir: tempDir,
installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }],
@ -170,6 +170,63 @@ describe('setup agents', () => {
expect(io.stderr()).toBe('');
});
it('prints standalone agent next actions after successful installation', async () => {
const io = makeIo();
const result = await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
expect(result).toMatchObject({
status: 'ready',
nextActions: expect.stringContaining('Run this command before using Claude Code:'),
});
expect(io.stdout()).toContain('Required before using agents');
expect(io.stdout()).toContain('Run this command before using Claude Code:');
expect(io.stdout()).toContain('RUN:');
expect(io.stdout()).toContain(`ktx mcp start --project-dir ${tempDir}`);
expect(io.stdout()).toContain('If you need to stop MCP later:');
expect(io.stdout()).toContain(`ktx mcp stop --project-dir ${tempDir}`);
expect(io.stdout()).not.toContain('Finish agent setup');
expect(io.stdout()).not.toContain('Next actions');
});
it('can return agent next actions without printing them', async () => {
const io = makeIo();
const result = await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
showNextActions: false,
},
io.io,
);
expect(result).toMatchObject({
status: 'ready',
nextActions: expect.stringContaining(`ktx mcp start --project-dir ${tempDir}`),
});
expect(io.stdout()).toContain('Agent integration complete');
expect(io.stdout()).not.toContain('Required before using agents');
});
it('installs the analytics skill from the runtime asset', async () => {
const io = makeIo();
@ -252,7 +309,6 @@ describe('setup agents', () => {
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
entries: expect.arrayContaining([{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }]),
});
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
});
it('prompts for MCP-first client agent connection mode in interactive setup', async () => {
@ -283,10 +339,18 @@ describe('setup agents', () => {
});
expect(prompts.select).toHaveBeenCalledWith({
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.',
},
],
});
expect(prompts.multiselect).toHaveBeenCalledWith(
@ -330,10 +394,18 @@ describe('setup agents', () => {
});
expect(prompts.select).toHaveBeenCalledWith({
message: 'Where should KTX install supported agent config?',
message: `Where should KTX install supported agent config?\n\nKTX project: ${tempDir}`,
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.',
},
],
});
} finally {
@ -395,14 +467,18 @@ describe('setup agents', () => {
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('no manual plugin install step is required');
expect(setupMd).toContain('claude_desktop_config.json');
expect(setupMd).not.toContain('Install this plugin ZIP from Claude Desktop');
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('KTX MCP server registered');
expect(io.stdout()).toContain('Claude Desktop');
expect(io.stdout()).not.toContain('.ktx/agents/claude/ktx-plugin.zip');
expect(io.stdout()).toContain('claude_desktop_config.json');
expect(io.stdout()).toContain('Restart Claude Desktop');
expect(io.stdout()).toContain('Required before using agents');
expect(io.stdout()).toContain('1. Restart Claude Desktop');
expect(io.stdout()).toContain('Claude Desktop loads KTX after restart.');
expect(io.stdout()).not.toContain('install plugin');
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
} finally {
process.env.HOME = previousHome;
@ -568,6 +644,9 @@ describe('setup agents', () => {
);
expect(codexIo.stdout()).toContain('[mcp_servers.ktx]');
expect(codexIo.stdout()).toContain('url = "http://localhost:7878/mcp"');
expect(codexIo.stdout()).toContain('1. Configure Codex');
expect(codexIo.stdout()).toContain('Open ~/.codex/config.toml, then paste this block:');
expect(codexIo.stdout()).toContain('PASTE:');
const opencodeIo = makeIo();
await runKtxSetupAgentsStep(
@ -585,6 +664,8 @@ describe('setup agents', () => {
);
expect(opencodeIo.stdout()).toContain('"mcp"');
expect(opencodeIo.stdout()).toContain('"type": "remote"');
expect(opencodeIo.stdout()).toContain('1. Configure OpenCode');
expect(opencodeIo.stdout()).toContain('Open opencode.json, then paste this block:');
await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow();
const universalIo = makeIo();
@ -603,6 +684,8 @@ describe('setup agents', () => {
);
expect(universalIo.stdout()).toContain('Universal MCP endpoint:');
expect(universalIo.stdout()).toContain('http://localhost:7878/mcp');
expect(universalIo.stdout()).toContain('1. Configure unsupported MCP clients');
expect(universalIo.stdout()).toContain('Use this endpoint when setting up unsupported MCP clients:');
});
it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => {
@ -648,7 +731,9 @@ describe('setup agents', () => {
expect(rendered).toContain('http://127.0.0.1:8787/mcp');
expect(rendered).toContain('Bearer ${KTX_MCP_TOKEN}');
expect(rendered).not.toContain('secret-token');
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
expect(io.stdout()).toContain('Run this command before using Claude Code:');
expect(io.stdout()).toContain('RUN:');
expect(io.stdout()).toContain(`ktx mcp start --project-dir ${tempDir}`);
} finally {
if (previousToken === undefined) {
delete process.env.KTX_MCP_TOKEN;
@ -833,31 +918,35 @@ describe('setup agents', () => {
const output = io.stdout();
expect(output).toContain('Agent integration complete');
expect(output).toContain(`KTX project\n ${tempDir}`);
expect(output).toContain('Installed agents');
expect(output).toContain('Claude Code');
expect(output).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
expect(output).toContain('.claude/skills/ktx-analytics/SKILL.md');
expect(output).toContain('+ Skill installed — teaches admin agents which KTX CLI commands to run');
expect(output).toContain('.claude/skills/ktx/SKILL.md');
expect(output).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
expect(output).toContain('.claude/rules/ktx.md');
expect(output).toContain(`Project scope\n ${join(tempDir, '.mcp.json')}`);
expect(output).toContain('Requires MCP to be started');
expect(output).toContain('Analytics skill installed');
expect(output).toContain('Admin CLI skill installed');
expect(output).not.toContain('.claude/skills/ktx-analytics/SKILL.md');
expect(output).not.toContain('.claude/skills/ktx/SKILL.md');
expect(output).not.toContain('.claude/rules/ktx.md');
});
it('formats summary with relative paths for project scope', () => {
it('formats summary with explicit project-scoped config paths', () => {
const summary = formatInstallSummary(
[{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }],
[
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
{ kind: 'json-key', path: join(tempDir, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
],
tempDir,
);
expect(summary).toContain('Cursor');
expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
expect(summary).toContain('.cursor/rules/ktx-analytics.mdc');
expect(summary).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
expect(summary).toContain('.cursor/rules/ktx.mdc');
expect(summary).not.toContain(tempDir);
expect(summary).toContain(`Project scope\n ${join(tempDir, '.cursor/mcp.json')}`);
expect(summary).toContain('Requires MCP to be started');
expect(summary).toContain('Cursor rules installed');
expect(summary).not.toContain('.cursor/rules/ktx-analytics.mdc');
expect(summary).not.toContain('.cursor/rules/ktx.mdc');
});
it('formats summary with multiple agent targets', () => {
@ -870,6 +959,7 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' },
{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' },
@ -878,11 +968,159 @@ describe('setup agents', () => {
);
expect(summary).toContain('Claude Code');
expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
expect(summary).toContain('+ Skill installed — teaches admin agents which KTX CLI commands to run');
expect(summary).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
expect(summary).toContain('Project scope\n ');
expect(summary).toContain('Analytics skill installed');
expect(summary).toContain('Admin CLI skill installed');
expect(summary).toContain('\n\n Codex\n');
expect(summary).toContain('MCP config\n Add the snippet shown below to ~/.codex/config.toml.');
expect(summary).toContain('Codex');
expect(summary).toContain('.agents/skills/ktx-analytics/SKILL.md');
expect(summary).toContain('.agents/skills/ktx/SKILL.md');
expect(summary).toContain('Codex guidance installed');
expect(summary).not.toContain('.agents/skills/ktx-analytics/SKILL.md');
expect(summary).not.toContain('.agents/skills/ktx/SKILL.md');
});
it('prints one target-aware next actions block for mixed agent targets', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
const prompts = {
select: vi.fn(async ({ message }: { message: string }) =>
message.startsWith('Where should') ? 'project' : 'mcp',
),
multiselect: vi.fn(async () => ['claude-code', 'claude-desktop']),
cancel: vi.fn(),
};
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'auto',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
{ prompts },
),
).resolves.toMatchObject({
status: 'ready',
installs: [
{ target: 'claude-code', scope: 'project', mode: 'mcp' },
{ target: 'claude-desktop', scope: 'global', mode: 'mcp' },
],
});
const output = io.stdout();
expect(output).toContain('Required before using agents');
expect(output).not.toContain('Next actions');
expect(output).toContain('1. Start MCP');
expect(output).toContain('Run this command before using Claude Code:');
expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`);
expect(output).toContain(`ktx mcp stop --project-dir ${tempDir}\n\n2. Open Claude Code`);
expect(output).toContain('Open Claude Code from the KTX project directory');
expect(output).toContain('RUN:');
expect(output).toContain(`cd '${tempDir}'`);
expect(output).toContain('3. Restart Claude Desktop');
expect(output).toContain('Claude Desktop loads KTX after restart.');
expect(output).not.toContain('install plugin');
expect(output).not.toContain(join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'));
expect(output).not.toContain('Finish Claude Desktop setup');
expect(output).not.toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('does not tell global Claude Code installs to open from the project directory', 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-code',
scope: 'global',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-code', scope: 'global', mode: 'mcp' }],
});
const output = io.stdout();
expect(output).toContain('2. Open Claude Code');
expect(output).toContain('RUN:');
expect(output).toContain('claude');
expect(output).not.toContain('Open Claude Code from the KTX project directory');
expect(output).not.toContain(`cd '${tempDir}'`);
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('explains next actions for Codex, Cursor, OpenCode, and universal MCP targets', async () => {
const io = makeIo();
const prompts = {
select: vi.fn(async () => 'mcp-cli'),
multiselect: vi.fn(async () => ['codex', 'cursor', 'opencode', 'universal']),
cancel: vi.fn(),
};
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'auto',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
{ prompts },
),
).resolves.toMatchObject({
status: 'ready',
installs: [
{ target: 'codex', scope: 'project', mode: 'mcp-cli' },
{ target: 'cursor', scope: 'project', mode: 'mcp-cli' },
{ target: 'opencode', scope: 'project', mode: 'mcp-cli' },
{ target: 'universal', scope: 'project', mode: 'mcp-cli' },
],
});
const output = io.stdout();
expect(output).toContain('Required before using agents');
expect(output).toContain('1. Configure Codex');
expect(output).toContain('2. Configure OpenCode');
expect(output).toContain('3. Configure unsupported MCP clients');
expect(output).toContain('4. Start MCP');
expect(output).toContain('Run this command before using Codex, Cursor, OpenCode, and Universal .agents:');
expect(output).toContain('Open Cursor from the KTX project directory');
expect(output).toContain('Open ~/.codex/config.toml, then paste this block:\n\n PASTE:\n [mcp_servers.ktx]');
expect(output).toContain('Open opencode.json, then paste this block:');
expect(output).toContain('Use this endpoint when setting up unsupported MCP clients:');
expect(output).toContain('Codex guidance installed');
expect(output).toContain('Cursor rules installed');
expect(output).toContain('OpenCode commands installed');
expect(output).toContain('.agents guidance installed');
});
});