diff --git a/README.md b/README.md index be4027e6..d9df7377 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index 82c25220..ecea5881 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -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 | diff --git a/packages/cli/src/commands/mcp-commands.test.ts b/packages/cli/src/commands/mcp-commands.test.ts index 9a54f22a..29235bce 100644 --- a/packages/cli/src/commands/mcp-commands.test.ts +++ b/packages/cli/src/commands/mcp-commands.test.ts @@ -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 '); + 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."'), ); }); diff --git a/packages/cli/src/commands/mcp-commands.ts b/packages/cli/src/commands/mcp-commands.ts index 9e583ce4..be7044a7 100644 --- a/packages/cli/src/commands/mcp-commands.ts +++ b/packages/cli/src/commands/mcp-commands.ts @@ -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 diff --git a/packages/cli/src/next-steps.test.ts b/packages/cli/src/next-steps.test.ts index d7904555..c2b15530 100644 --- a/packages/cli/src/next-steps.test.ts +++ b/packages/cli/src/next-steps.test.ts @@ -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:'); }); diff --git a/packages/cli/src/next-steps.ts b/packages/cli/src/next-steps.ts index c36c5591..06ef3412 100644 --- a/packages/cli/src/next-steps.ts +++ b/packages/cli/src/next-steps.ts @@ -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), ]; diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 3a073ce3..74ed362a 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -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'); }); }); diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index be66b8a9..487ef271 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -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 = { universal: 'Universal .agents', }; -const fileEntryLabels: Record = { - 'claude-code': 'Skill installed', - 'claude-desktop': 'Skill installed', - codex: 'Skill installed', - cursor: 'Rule installed', - opencode: 'Command installed', - universal: 'Skill installed', -}; - -function mcpEntryLabel(entry: Extract): 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['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(); for (const install of installs) { const plannedFilePaths = new Set( @@ -766,51 +807,193 @@ export function formatInstallSummary( ); } - const fileHints: Record = { - 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 => 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 => 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(); - 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 => - 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 }; diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index a802fb03..0b079f0d 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -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(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index c96e54e8..60713d51 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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>; + 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; }