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

@ -108,9 +108,16 @@ ktx wiki search "refund policy" --json
ktx sl query --connection-id warehouse --measure orders.revenue --format sql
```
During agent setup, choose **MCP tools + analytics skill** for client agents.
Choose **MCP tools + analytics skill + admin CLI skill** only when a developer
or operator agent also needs pinned `ktx` admin commands.
During agent setup, choose **Ask data questions with KTX MCP** for client
agents. Choose **Ask data questions + manage KTX with CLI commands** only when
a developer or operator agent also needs pinned `ktx` admin commands.
After setup, KTX prints **Required before using agents**. Complete those steps
before opening the configured agent. If it shows `ktx mcp start --project-dir ...`,
run that command before using Claude Code, Codex, Cursor, OpenCode, or generic
MCP clients. The same output also prints the matching `ktx mcp stop` command
for when you want to stop MCP later. Claude Desktop uses its own launcher and
only needs a restart.
The analytics skill teaches client agents the MCP workflow: discover data,
prefer semantic-layer measures, inspect entity details before raw SQL, and
@ -127,7 +134,8 @@ Supported client agents: Claude Code, Claude Desktop, Codex, Cursor, OpenCode,
and clients that can use the printed MCP endpoint or `.agents` admin skills.
Claude Desktop setup registers a local `ktx mcp stdio` server in Claude
Desktop's config and generates `.ktx/agents/claude/ktx-plugin.zip` with the
analytics skill.
analytics skill. Restart Claude Desktop after setup; no manual plugin install
step is required.
The release artifact manifest contains the public npm tarball and the bundled
`kaelio-ktx` runtime wheel. The `python/ktx-sl` and `python/ktx-daemon`

View file

@ -7,9 +7,9 @@ KTX exposes context to end-user agents through MCP tools. The CLI remains the
admin surface for setup, ingest, status, daemon lifecycle, and debugging.
Run `ktx setup` and select your client agent targets, or configure manually
using the snippets below. Choose **MCP tools + analytics skill** for client
agents. Choose **MCP tools + analytics skill + admin CLI skill** only when a
developer or operator agent also needs pinned `ktx` admin commands.
using the snippets below. Choose **Ask data questions with KTX MCP** for client
agents. Choose **Ask data questions + manage KTX with CLI commands** only when
a developer or operator agent also needs pinned `ktx` admin commands.
## Install with setup
@ -31,8 +31,8 @@ Use `--target` for one target:
ktx setup --agents --target codex
```
Use `--global` only with `claude-code` or `codex`. Claude Desktop always
generates a project-local plugin ZIP:
Use `--global` only with `claude-code` or `codex`. Claude Desktop always writes
global Claude Desktop config and generates a project-local plugin package:
```bash
ktx setup --agents --target claude-code --global
@ -46,9 +46,9 @@ remove only files KTX installed.
The interactive command asks two questions:
```txt
How should client agents connect to this KTX project?
│ ○ MCP tools + analytics skill
│ ○ MCP tools + analytics skill + admin CLI skill
What should agents be allowed to do with this KTX project?
│ ○ Ask data questions with KTX MCP
│ ○ Ask data questions + manage KTX with CLI commands
◆ Which agent targets should KTX install?
@ -66,18 +66,28 @@ also asks where to install supported agent config:
```txt
◆ Where should KTX install supported agent config?
│ ○ Project
│ ○ Global
│ KTX project: /path/to/your/ktx-project
│ ○ Project scope (KTX project directory)
│ ○ Global scope (user config)
```
## Generated files
KTX writes MCP client configuration and an analytics skill by default. It writes
admin CLI skills only when you choose **MCP tools + analytics skill + admin CLI
skill**.
KTX writes MCP client configuration and analytics guidance by default. It writes
admin CLI guidance only when you choose **Ask data questions + manage KTX with
CLI commands**.
| Target | MCP tools + analytics skill | Adds with admin CLI skill |
After setup, KTX prints **Required before using agents**. Complete those steps
before opening the configured agent. If it shows `ktx mcp start --project-dir ...`,
run that command before using Claude Code, Codex, Cursor, OpenCode, or generic
MCP clients. The same output also prints the matching `ktx mcp stop` command
for when you want to stop MCP later. Claude Desktop uses its own launcher and
only needs a restart.
| Target | Ask data questions with KTX MCP | Adds when agents can manage KTX with CLI |
|--------|------------------------------|---------------------------|
| Claude Code | `.mcp.json`, `.claude/skills/ktx-analytics/SKILL.md` | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` |
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` stdio entry + `.ktx/agents/claude/ktx-plugin.zip` with analytics skill | Adds `skills/ktx/SKILL.md` inside the plugin ZIP |
@ -144,7 +154,7 @@ During setup, select **Cursor** from the agent targets. KTX writes:
| Mode | File |
|------|------|
| MCP tools + analytics skill | `.cursor/mcp.json`, `.cursor/rules/ktx-analytics.mdc` |
| Ask data questions with KTX MCP | `.cursor/mcp.json`, `.cursor/rules/ktx-analytics.mdc` |
| Admin CLI rules | `.cursor/rules/ktx.mdc` |
Cursor supports project-scoped installation only.
@ -168,8 +178,8 @@ same markdown command definitions.
## Claude Desktop
During setup, select **Claude Desktop** from the agent targets. KTX writes the
MCP server entry directly into Claude Desktop's config and generates a separate
plugin ZIP for the analytics skill:
MCP server entry directly into Claude Desktop's config and prepares the
Claude Desktop skill package for the analytics workflow:
- `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%AppData%/Claude/claude_desktop_config.json` (Windows) gets an
@ -178,13 +188,13 @@ plugin ZIP for the analytics skill:
a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn
the server without needing `node` in PATH.
- `.ktx/agents/claude/ktx-plugin.zip` contains the `ktx-analytics` skill (and
the admin `ktx` skill if you choose **MCP tools + analytics skill + admin
CLI skill**). Install the ZIP from Claude Desktop's plugin UI to load the
skill.
the admin `ktx` skill if you choose **Ask data questions + manage KTX with
CLI commands**). This package is generated by KTX setup; no manual plugin
install step is required.
After `ktx setup`, restart Claude Desktop so it picks up the new MCP server
entry, then install the plugin ZIP. No daemon needs to be running - Claude
Desktop spawns the MCP server itself per session.
entry and bundled KTX skills. No daemon needs to be running — Claude Desktop
spawns the MCP server itself per session.
Claude Desktop does not introspect local stdio MCP servers, so the per-tool
"Connector"-style UI is not rendered for KTX. The tools are still callable
@ -192,7 +202,7 @@ from any Claude Desktop chat.
If you move the KTX checkout or project directory, rerun `ktx setup --agents`
to refresh the absolute paths in `claude_desktop_config.json` and the launcher
shim, then reinstall the regenerated plugin ZIP.
shim, then restart Claude Desktop.
---
@ -235,7 +245,7 @@ During setup, select **OpenCode** from the agent targets. KTX writes:
| Mode | File |
|------|------|
| MCP tools + analytics skill | Snippet for `opencode.json`, `.opencode/commands/ktx-analytics.md` |
| Ask data questions with KTX MCP | Snippet for `opencode.json`, `.opencode/commands/ktx-analytics.md` |
| Admin CLI commands | `.opencode/commands/ktx.md` |
OpenCode supports project-scoped installation only.
@ -282,6 +292,6 @@ Admin CLI skills call the same KTX CLI commands:
| MCP tools | Yes | Local stdio via `claude_desktop_config.json` | Yes | Snippet | Snippet |
| Analytics skill | `.claude/skills/ktx-analytics/SKILL.md` | Included in plugin ZIP | `.cursor/rules/ktx-analytics.mdc` | `.agents/skills/ktx-analytics/SKILL.md` | `.opencode/commands/ktx-analytics.md` |
| Admin CLI skills | Optional | Optional in plugin ZIP | Optional (.mdc) | Optional | Optional |
| Global install | Yes | Project-local ZIP | No | Yes | No |
| Global install | Yes | Claude Desktop config | No | Yes | No |
| Rule or instruction file | `.claude/rules/ktx.md` | Plugin `SETUP.md` | `.cursor/rules/ktx.mdc` | `.codex/instructions/ktx.md` | `.opencode/commands/ktx.md` |
| Skill file | `.claude/skills/ktx/SKILL.md` | `skills/ktx/SKILL.md` in plugin ZIP | Not separate | `.agents/skills/ktx/SKILL.md` | Not separate |

View file

@ -79,7 +79,43 @@ describe('registerMcpCommands', () => {
expect(startDaemon).toHaveBeenCalledTimes(1);
expect(context.io.stdout.write).toHaveBeenCalledWith(
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n',
[
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp',
'',
'KTX is ready for configured agents.',
'Open your agent for this KTX project and ask a data question, for example:',
' "Use KTX to show me the available tables and metrics."',
'',
].join('\n'),
);
});
it('prints a friendly next step after starting the daemon', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const startDaemon = vi.fn().mockResolvedValue({
status: 'started',
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-started',
startedAt: '2026-05-14T00:00:00.000Z',
logPath: '/tmp/ktx-started/.ktx/logs/mcp.log',
},
});
const context = makeContext({ deps: { mcp: { startDaemon } } });
registerMcpCommands(program, context);
await program.parseAsync(['--project-dir', '/tmp/ktx-started', 'mcp', 'start'], { from: 'user' });
expect(context.io.stdout.write).toHaveBeenCalledWith(
expect.stringContaining('KTX MCP daemon started: http://127.0.0.1:7878/mcp\n\nKTX is ready for configured agents.'),
);
expect(context.io.stdout.write).toHaveBeenCalledWith(
expect.stringContaining('"Use KTX to show me the available tables and metrics."'),
);
});

View file

@ -25,6 +25,17 @@ function binPath(): string {
return fileURLToPath(new URL('../bin.js', import.meta.url));
}
function formatMcpStartResultMessage(input: { status: 'started' | 'already-running'; url: string }): string {
return [
input.status === 'started' ? `KTX MCP daemon started: ${input.url}` : `KTX MCP daemon already running: ${input.url}`,
'',
'KTX is ready for configured agents.',
'Open your agent for this KTX project and ask a data question, for example:',
' "Use KTX to show me the available tables and metrics."',
'',
].join('\n');
}
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
@ -82,11 +93,7 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
allowedOrigins: options.allowedOrigin,
binPath: binPath(),
});
context.io.stdout.write(
result.status === 'started'
? `KTX MCP daemon started: ${result.url}\n`
: `KTX MCP daemon already running: ${result.url}\n`,
);
context.io.stdout.write(formatMcpStartResultMessage({ status: result.status, url: result.url }));
});
mcp

View file

@ -49,8 +49,10 @@ describe('KTX demo next steps', () => {
const rendered = formatNextStepLines().join('\n');
expect(rendered).toContain('KTX context is ready for agents.');
expect(rendered).toContain('KTX project directory');
expect(rendered).toContain('ask a data question');
expect(rendered).toContain('Verify with:');
expect(rendered).not.toContain('this directory');
expect(rendered).not.toContain('Preferred route');
expect(rendered).not.toContain('Optional MCP:');
});

View file

@ -43,7 +43,7 @@ function commandLines(commands: ReadonlyArray<{ command: string; description: st
export function formatNextStepLines(indent = ' '): string[] {
return [
`${indent}KTX context is ready for agents. Open your coding agent in this directory and ask a data question.`,
`${indent}KTX context is ready for agents. Open your coding agent from the KTX project directory and ask a data question.`,
`${indent}Verify with:`,
...commandLines(KTX_NEXT_STEP_DIRECT_COMMANDS, indent),
];

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');
});
});

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 };

View file

@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js';
import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
import { formatKtxSetupCompletionSummary, formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
vi.mock('./setup-demo-tour.js', () => ({
runDemoTour: vi.fn(async () => 0),
@ -424,6 +424,108 @@ describe('setup status', () => {
expect(rendered).not.toContain('No KTX project found.');
});
it('formats a concise ready summary for completed agent setup', () => {
const rendered = formatKtxSetupCompletionSummary(
{
project: { path: tempDir, ready: true },
llm: { ready: true, model: 'sonnet' },
embeddings: { ready: true, model: 'text-embedding-3-small' },
databases: [{ connectionId: 'postgres-warehouse', ready: true }],
sources: [{ connectionId: 'dbt-main', type: 'dbt', ready: true }],
runtime: { required: true, ready: true, features: ['core'] },
context: { ready: true, status: 'completed' },
agents: [
{ target: 'claude-code', scope: 'project', ready: true },
{ target: 'claude-desktop', scope: 'global', ready: true },
],
},
{
agentNextActions: [
'1. Start MCP',
' Run this command before using Claude Code:',
'',
' RUN:',
` ktx mcp start --project-dir ${tempDir}`,
'',
' If you need to stop MCP later:',
` ktx mcp stop --project-dir ${tempDir}`,
'',
'2. Open Claude Code',
' Open Claude Code from the KTX project directory:',
'',
' RUN:',
` cd '${tempDir}'`,
' claude',
].join('\n'),
},
);
expect(rendered).toContain(`Project\n ${tempDir}`);
expect(rendered).toContain('Context\n built');
expect(rendered).toContain('Agents configured\n Claude Code, Claude Desktop');
expect(rendered).toContain('REQUIRED BEFORE USING AGENTS\n\n 1. Start MCP');
expect(rendered).toContain(' Run this command before using Claude Code:');
expect(rendered).toContain(' RUN:');
expect(rendered).toContain(' If you need to stop MCP later:');
expect(rendered).toContain(`ktx mcp stop --project-dir ${tempDir}`);
expect(rendered).toContain('After that, try\n Ask your agent: "Use KTX to show me the available tables."');
expect(rendered).not.toContain('Verify');
expect(rendered).not.toContain('Project ready: yes');
expect(rendered).not.toContain('What you can do next');
});
it('prints agent next actions inside the final ready summary during full setup', async () => {
const testIo = makeIo();
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'new',
agents: false,
target: 'claude-code',
skipAgents: false,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: true,
skipEmbeddings: true,
skipDatabases: true,
skipSources: true,
databaseSchemas: [],
},
testIo.io,
{
runtime: async () => runtimeReady(tempDir),
context: async () => {
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-test',
status: 'completed',
primarySourceConnectionIds: [],
contextSourceConnectionIds: [],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-test'),
});
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'context'] });
return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
},
},
),
).resolves.toBe(0);
const output = testIo.stdout();
expect(output).toContain('Agent integration complete');
expect(output).toContain('Finish KTX agent setup');
expect(output).not.toContain('KTX project ready');
expect(output).toContain('REQUIRED BEFORE USING AGENTS');
expect(output).toContain('Run this command before using Claude Code:');
expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`);
expect(output).not.toContain('Finish agent setup');
});
it('prints the setup shell intro for auto-created run mode', async () => {
const testIo = makeIo();

View file

@ -19,6 +19,7 @@ import {
type KtxSetupAgentsDeps,
readKtxAgentInstallManifest,
runKtxSetupAgentsStep,
targetDisplayName,
} from './setup-agents.js';
import {
type KtxSetupDatabaseDriver,
@ -435,6 +436,35 @@ export function formatKtxSetupStatus(status: KtxSetupStatus): string {
return `${lines.join('\n')}\n`;
}
export function formatKtxSetupCompletionSummary(
status: KtxSetupStatus,
options: { agentNextActions?: string } = {},
): string {
const readyAgents = status.agents.filter((agent) => agent.ready).map((agent) => targetDisplayName(agent.target));
const lines = [
'Project',
` ${status.project.path}`,
'',
'Context',
` ${status.context.ready ? 'built' : formatContextBuilt(status.context)}`,
'',
'Agents configured',
` ${readyAgents.length > 0 ? readyAgents.join(', ') : 'not installed'}`,
];
const agentNextActions = options.agentNextActions?.trim();
if (agentNextActions) {
lines.push(
'',
'REQUIRED BEFORE USING AGENTS',
'',
...agentNextActions.split('\n').map((line) => (line ? ` ${line}` : '')),
);
}
lines.push('', agentNextActions ? 'After that, try' : 'Try it');
lines.push(' Ask your agent: "Use KTX to show me the available tables."');
return lines.join('\n');
}
function setupStatusReady(status: KtxSetupStatus): boolean {
if (!status.project.ready) {
return false;
@ -459,6 +489,10 @@ function setupContextReady(status: KtxSetupStatus): boolean {
return status.context.ready;
}
function shouldPrintConciseReadySummary(status: KtxSetupStatus): boolean {
return setupStatusReady(status) && setupContextReady(status) && status.agents.some((agent) => agent.ready);
}
function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
io.stderr.write('KTX context is not ready for agents.\n\n');
io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
@ -493,6 +527,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
setupUi.intro('KTX setup', io);
let entryAction: KtxSetupEntryAction | undefined;
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;
let agentNextActions: string | undefined;
const canShowEntryMenu =
args.showEntryMenu === true &&
args.inputMode !== 'disabled' &&
@ -724,7 +759,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
} else {
const agentsRunner =
deps.agents ?? ((agentArgs, agentIo) => runKtxSetupAgentsStep(agentArgs, agentIo, deps.agentsDeps));
stepResult = await agentsRunner(
const agentResult = await agentsRunner(
{
projectDir: projectResult.projectDir,
inputMode: args.inputMode,
@ -734,9 +769,14 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
scope: args.agentScope ?? 'project',
mode: 'mcp',
skipAgents: false,
showNextActions: agentsRequested,
},
io,
);
stepResult = agentResult;
if (agentResult.status === 'ready') {
agentNextActions = agentResult.nextActions;
}
}
if (stepResult.status === 'failed' || stepResult.status === 'missing-input') {
@ -779,19 +819,30 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
const status = await readKtxSetupStatus(projectResult.projectDir, { cliVersion: args.cliVersion });
const focusedOnAgents = args.agents || entryAction === 'agents';
if (!focusedOnAgents) {
setupUi.note(formatKtxSetupStatus(status).trimEnd(), 'Project status', io, {
format: (line) => line,
});
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,
);
if (shouldPrintConciseReadySummary(status)) {
setupUi.note(
formatKtxSetupCompletionSummary(status, { agentNextActions }),
agentNextActions ? 'Finish KTX agent setup' : 'KTX project ready',
io,
{
format: (line) => line,
},
);
} else {
setupUi.note(formatKtxSetupStatus(status).trimEnd(), 'Project status', io, {
format: (line) => line,
});
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;
}