diff --git a/README.md b/README.md index 0a032543..77775b43 100644 --- a/README.md +++ b/README.md @@ -119,8 +119,8 @@ 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. +for when you want to stop MCP later. Claude Desktop uses its own launcher for +MCP and prints separate skill upload steps. The analytics skill teaches client agents the MCP workflow: discover data, prefer semantic-layer measures, inspect entity details before raw SQL, and @@ -136,9 +136,10 @@ ktx sl validate orders 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. Restart Claude Desktop after setup; no manual plugin install -step is required. +Desktop's config and generates one uploadable ZIP per Claude Desktop skill +under `.ktx/agents/claude/`. Restart Claude Desktop after setup, then upload +each ZIP from **Customize** > **Skills** > **+** > **Create skill** > +**Upload a skill**. 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 2b096640..ffb67b59 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -32,7 +32,7 @@ ktx setup --agents --target codex ``` Use `--global` only with `claude-code` or `codex`. Claude Desktop always writes -global Claude Desktop config and generates a project-local plugin package: +global Claude Desktop config and generates project-local skill ZIPs: ```bash ktx setup --agents --target claude-code --global @@ -85,12 +85,12 @@ 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. +prints separate skill upload steps. | 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 | +| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` stdio entry + `.ktx/agents/claude/ktx-analytics.zip` upload | Adds `.ktx/agents/claude/ktx.zip` upload | | Codex | Printed snippet for `~/.codex/config.toml`, `.agents/skills/ktx-analytics/SKILL.md` | `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` | | Cursor | `.cursor/mcp.json`, `.cursor/rules/ktx-analytics.mdc` | `.cursor/rules/ktx.mdc` | | OpenCode | Printed snippet for `opencode.json`, `.opencode/commands/ktx-analytics.md` | `.opencode/commands/ktx.md` | @@ -178,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 prepares the -Claude Desktop skill package for the analytics workflow: +MCP server entry directly into Claude Desktop's config and prepares uploadable +Claude Desktop skill packages for the KTX workflows: - `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%AppData%/Claude/claude_desktop_config.json` (Windows) gets an @@ -187,14 +187,22 @@ Claude Desktop skill package for the analytics workflow: launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates 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 **Ask data questions + manage KTX with - CLI commands**). This package is generated by KTX setup; no manual plugin - install step is required. +- `.ktx/agents/claude/ktx-analytics.zip` contains the `ktx-analytics` skill. + If you choose **Ask data questions + manage KTX with CLI commands**, KTX also + generates `.ktx/agents/claude/ktx.zip` with the admin `ktx` skill. Claude + Desktop requires each uploaded ZIP to contain exactly one skill folder. After `ktx setup`, restart Claude Desktop so it picks up the new MCP server -entry and bundled KTX skills. No daemon needs to be running — Claude Desktop -spawns the MCP server itself per session. +entry. No daemon needs to be running -- Claude Desktop spawns the MCP server +itself per session. + +Upload each generated skill ZIP from Claude Desktop: + +1. Open **Customize** > **Skills**. +2. Click **+** > **Create skill** > **Upload a skill**. +3. Upload `.ktx/agents/claude/ktx-analytics.zip`. +4. If generated, upload `.ktx/agents/claude/ktx.zip`. +5. Toggle the uploaded KTX skills on. 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 @@ -202,7 +210,8 @@ 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 restart Claude Desktop. +shim, regenerate the skill ZIPs, then restart Claude Desktop and upload the new +ZIPs. --- @@ -290,8 +299,8 @@ Admin CLI skills call the same KTX CLI commands: | | Claude Code | Claude Desktop | Cursor | Codex | OpenCode | |---|---|---|---|---|---| | 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 | +| Analytics skill | `.claude/skills/ktx-analytics/SKILL.md` | Upload `.ktx/agents/claude/ktx-analytics.zip` | `.cursor/rules/ktx-analytics.mdc` | `.agents/skills/ktx-analytics/SKILL.md` | `.opencode/commands/ktx-analytics.md` | +| Admin CLI skills | Optional | Optional `.ktx/agents/claude/ktx.zip` upload | Optional (.mdc) | Optional | Optional | | 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 | +| Rule or instruction file | `.claude/rules/ktx.md` | Not separate | `.cursor/rules/ktx.mdc` | `.codex/instructions/ktx.md` | `.opencode/commands/ktx.md` | +| Skill file | `.claude/skills/ktx/SKILL.md` | `ktx/SKILL.md` inside `ktx.zip` | Not separate | `.agents/skills/ktx/SKILL.md` | Not separate | diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 74ed362a..07459713 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -5,7 +5,8 @@ import { readKtxSetupState } from '@ktx/context/project'; import { strFromU8, unzipSync } from 'fflate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - formatInstallSummary, + createAgentNextActionsLineFormatter, + formatInstallSummaryLines, plannedKtxAgentFiles, readKtxAgentInstallManifest, removeKtxAgentInstall, @@ -82,7 +83,11 @@ describe('setup agents', () => { ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([ { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, - { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' }, + { + kind: 'file', + path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), + role: 'claude-desktop-skill-bundle', + }, ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp' })).toEqual([ { kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, @@ -123,7 +128,16 @@ describe('setup agents', () => { ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([ { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, - { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' }, + { + kind: 'file', + path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), + role: 'claude-desktop-skill-bundle', + }, + { + kind: 'file', + path: join(tempDir, '.ktx/agents/claude/ktx.zip'), + role: 'claude-desktop-skill-bundle', + }, ]); }); @@ -197,6 +211,7 @@ describe('setup agents', () => { 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()).toContain('All set.'); expect(io.stdout()).not.toContain('Finish agent setup'); expect(io.stdout()).not.toContain('Next actions'); }); @@ -223,8 +238,10 @@ describe('setup agents', () => { status: 'ready', nextActions: expect.stringContaining(`ktx mcp start --project-dir ${tempDir}`), }); - expect(io.stdout()).toContain('Agent integration complete'); + expect(io.stdout()).toContain('Claude Code · Project scope'); + expect(io.stdout()).not.toContain('Agent integration complete'); expect(io.stdout()).not.toContain('Required before using agents'); + expect(io.stdout()).not.toContain('All set.'); }); it('installs the analytics skill from the runtime asset', async () => { @@ -414,7 +431,7 @@ describe('setup agents', () => { } }); - it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => { + it('registers Claude Desktop MCP and ships an uploadable analytics skill zip', async () => { const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); const previousHome = process.env.HOME; const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']); @@ -444,9 +461,11 @@ describe('setup agents', () => { installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }], }); - const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'); + const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'); + const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip'); const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'); - await expect(stat(pluginPath)).resolves.toBeDefined(); + await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); + await expect(stat(adminSkillPath)).rejects.toThrow(); const launcherStat = await stat(launcherPath); expect(launcherStat.mode & 0o111).not.toBe(0); const launcher = await readFile(launcherPath, 'utf-8'); @@ -462,23 +481,24 @@ describe('setup agents', () => { args: ['--project-dir', tempDir, 'mcp', 'stdio'], }); - expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"'); - await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry'); - 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(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); + await expect(readZipText(analyticsSkillPath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(analyticsSkillPath, '.claude-plugin/plugin.json')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(analyticsSkillPath, 'skills/ktx-analytics/SKILL.md')).rejects.toThrow( + 'Missing zip entry', + ); expect(io.stdout()).toContain('Claude Desktop'); - expect(io.stdout()).not.toContain('.ktx/agents/claude/ktx-plugin.zip'); + expect(io.stdout()).toContain(analyticsSkillPath); + expect(io.stdout()).not.toContain(adminSkillPath); expect(io.stdout()).toContain('claude_desktop_config.json'); 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()).toContain('Claude Desktop loads KTX MCP after restart.'); + expect(io.stdout()).toContain('2. Upload Claude Desktop skills'); + expect(io.stdout()).toContain('Customize > Skills > + > Create skill > Upload a skill'); + expect(io.stdout()).toContain('Upload this file:'); + expect(io.stdout()).toContain('Toggle the uploaded KTX skills on.'); expect(io.stdout()).not.toContain('Run `ktx mcp start`'); } finally { process.env.HOME = previousHome; @@ -535,7 +555,7 @@ describe('setup agents', () => { } }); - it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => { + it('includes an uploadable admin CLI skill zip for Claude Desktop when requested', async () => { const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); const previousHome = process.env.HOME; process.env.HOME = home; @@ -561,12 +581,18 @@ describe('setup agents', () => { installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }], }); - const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'); - const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md'); + const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'); + const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip'); + expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); + await expect(readZipText(analyticsSkillPath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry'); + const adminSkill = await readZipText(adminSkillPath, 'ktx/SKILL.md'); expect(adminSkill).toContain(`--project-dir ${tempDir}`); expect(adminSkill).toContain('status --json'); - expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill'); - await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(adminSkillPath, '.mcp.json')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(adminSkillPath, 'ktx-analytics/SKILL.md')).rejects.toThrow('Missing zip entry'); + expect(io.stdout()).toContain(analyticsSkillPath); + expect(io.stdout()).toContain(adminSkillPath); + expect(io.stdout()).toContain('Upload each file separately:'); } finally { process.env.HOME = previousHome; await rm(home, { recursive: true, force: true }); @@ -798,7 +824,7 @@ describe('setup agents', () => { await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null); }); - it('removes generated Claude Desktop plugin from the manifest', async () => { + it('removes generated Claude Desktop skill zips from the manifest', async () => { const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); const previousHome = process.env.HOME; process.env.HOME = home; @@ -817,10 +843,12 @@ describe('setup agents', () => { }, io.io, ); - const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'); + const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'); + const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip'); const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'); const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); - await expect(stat(pluginPath)).resolves.toBeDefined(); + await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); + await expect(stat(adminSkillPath)).resolves.toBeDefined(); await expect(stat(launcherPath)).resolves.toBeDefined(); const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; @@ -829,7 +857,8 @@ describe('setup agents', () => { await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0); - await expect(stat(pluginPath)).rejects.toThrow(); + await expect(stat(analyticsSkillPath)).rejects.toThrow(); + await expect(stat(adminSkillPath)).rejects.toThrow(); await expect(stat(launcherPath)).rejects.toThrow(); const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; @@ -867,7 +896,7 @@ describe('setup agents', () => { ).resolves.toEqual({ status: 'skipped', projectDir: tempDir }); }); - it('explains how to select multiple agent targets in interactive mode', async () => { + it('prints one navigation hint before interactive agent target prompts', async () => { const io = makeIo(); const prompts = { select: vi.fn(async () => 'mcp-cli'), @@ -891,10 +920,11 @@ describe('setup agents', () => { ), ).resolves.toEqual({ status: 'back', projectDir: tempDir }); + expect(io.stdout()).toContain('Space to select, Enter to confirm, Esc to go back.'); + expect(io.stdout().match(/Space to select/g)).toHaveLength(1); expect(prompts.multiselect).toHaveBeenCalledWith( expect.objectContaining({ - message: - 'Which agent targets should KTX install?\nUse Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + message: 'Which agent targets should KTX install?', }), ); }); @@ -917,21 +947,21 @@ 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(`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).toContain('Claude Code · Project scope'); + expect(output).toContain(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('Agent integration complete'); + expect(output).not.toContain(`KTX project\n ${tempDir}`); + expect(output).not.toContain('Installed agents'); 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 explicit project-scoped config paths', () => { - const summary = formatInstallSummary( + const summary = formatInstallSummaryLines( [{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }], [ { kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' }, @@ -941,16 +971,20 @@ describe('setup agents', () => { tempDir, ); - expect(summary).toContain('Cursor'); - 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'); + expect(summary).toEqual([ + { + title: 'Cursor · Project scope', + lines: [ + join(tempDir, '.cursor/mcp.json'), + 'Requires MCP to be started.', + 'Cursor rules installed.', + ], + }, + ]); }); it('formats summary with multiple agent targets', () => { - const summary = formatInstallSummary( + const summary = formatInstallSummaryLines( [ { target: 'claude-code', scope: 'project', mode: 'mcp-cli' }, { target: 'codex', scope: 'project', mode: 'mcp-cli' }, @@ -967,16 +1001,25 @@ describe('setup agents', () => { tempDir, ); - expect(summary).toContain('Claude Code'); - 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('Codex guidance installed'); - expect(summary).not.toContain('.agents/skills/ktx-analytics/SKILL.md'); - expect(summary).not.toContain('.agents/skills/ktx/SKILL.md'); + expect(summary).toEqual([ + { + title: 'Claude Code · Project scope', + lines: [ + join(tempDir, '.mcp.json'), + 'Requires MCP to be started.', + 'Analytics skill installed.', + 'Admin CLI skill installed.', + ], + }, + { + title: 'Codex · Project scope', + lines: [ + 'Add the snippet shown below to ~/.codex/config.toml.', + 'Requires MCP to be started.', + 'Codex guidance installed.', + ], + }, + ]); }); it('prints one target-aware next actions block for mixed agent targets', async () => { @@ -1026,9 +1069,13 @@ describe('setup agents', () => { 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).toContain('Claude Desktop loads KTX MCP after restart.'); + expect(output).toContain('4. Upload Claude Desktop skills'); + expect(output).toContain('Customize > Skills > + > Create skill > Upload a skill'); + expect(output).toContain(join(tempDir, '.ktx/agents/claude/ktx-analytics.zip')); + expect(output).not.toContain(join(tempDir, '.ktx/agents/claude/ktx.zip')); + expect(output).toContain('Upload this file:'); + expect(output).toContain('All set.'); expect(output).not.toContain('Finish Claude Desktop setup'); expect(output).not.toContain('Run `ktx mcp start` to enable the configured KTX MCP server.'); } finally { @@ -1123,4 +1170,63 @@ describe('setup agents', () => { expect(output).toContain('OpenCode commands installed'); expect(output).toContain('.agents guidance installed'); }); + + describe('createAgentNextActionsLineFormatter', () => { + function makeColorStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } { + return { write: () => true, hasColors: () => true }; + } + + function makePlainStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } { + return { write: () => true, hasColors: () => false }; + } + + const ESC = String.fromCharCode(27); + + it('returns the line untouched when the stream cannot render colors', () => { + const format = createAgentNextActionsLineFormatter(makePlainStdout()); + expect(format('2. Upload Claude Desktop skills')).toBe('2. Upload Claude Desktop skills'); + expect(format(' /tmp/ktx/.ktx/agents/claude/ktx.zip')).toBe(' /tmp/ktx/.ktx/agents/claude/ktx.zip'); + }); + + it('styles step headings and aligns sub-prose under the title', () => { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const heading = format('2. Upload Claude Desktop skills'); + expect(heading).toContain(ESC); + expect(heading).toContain('2'); + expect(heading).toContain('Upload Claude Desktop skills'); + expect(heading).not.toMatch(/^2\. /); + + const sub = format(' Toggle the uploaded KTX skills on.'); + expect(sub).toMatch(/^ {3}/); + expect(sub).toContain('Toggle the uploaded KTX skills on.'); + }); + + it('renders skill bundle .zip paths as bullets and shortens HOME to ~', () => { + const previousHome = process.env.HOME; + process.env.HOME = '/tmp/test-home'; + try { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const line = format(' /tmp/test-home/.ktx/agents/claude/ktx-analytics.zip'); + expect(line).toContain('•'); + expect(line).toContain('~/.ktx/agents/claude/ktx-analytics.zip'); + expect(line).not.toContain('/tmp/test-home/'); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + } + }); + + it('replaces breadcrumb separators with a typographic chevron', () => { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const line = format(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.'); + expect(line).toContain('›'); + expect(line).not.toContain(' > '); + }); + + it('leaves already-styled lines untouched to avoid double-wrapping', () => { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const preStyled = `${ESC}[1m2. Already styled${ESC}[22m`; + expect(format(preStyled)).toBe(preStyled); + }); + }); }); diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 487ef271..f1e40638 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,7 +1,10 @@ import { existsSync } from 'node:fs'; import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { styleText } from 'node:util'; +import { log, outro } from '@clack/prompts'; import { loadKtxProject, markKtxSetupStateStepComplete, @@ -9,7 +12,6 @@ import { } from '@ktx/context/project'; import { strToU8, zipSync } from 'fflate'; import type { KtxCliIo } from './cli-runtime.js'; -import { withMultiselectNavigation } from './prompt-navigation.js'; import { createKtxSetupPromptAdapter, createKtxSetupUiAdapter, @@ -51,7 +53,11 @@ export interface KtxAgentInstallManifest { installedAt: string; installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; entries: Array< - | { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' } + | { + kind: 'file'; + path: string; + role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle' | 'launcher'; + } | { kind: 'json-key'; path: string; jsonPath: string[] } >; } @@ -77,6 +83,88 @@ interface KtxCliLauncher { args: string[]; } +function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable { + return ( + output.isTTY === true && + typeof (output as { on?: unknown }).on === 'function' && + typeof (output as { columns?: unknown }).columns !== 'undefined' + ); +} + +function writeSetupInfo(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + log.info(message, { output: io.stdout }); + return; + } + io.stdout.write(`${message}\n`); +} + +function writeSetupStep(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + log.step(message, { output: io.stdout }); + return; + } + io.stdout.write(`\n${message}\n`); +} + +function writeSetupOutro(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + outro(message, { output: io.stdout }); + return; + } + io.stdout.write(`\n${message}\n`); +} + +const STEP_HEADING_RE = /^(\d+)\. (.+)$/; +const ACTION_MARKER_RE = /^(RUN|PASTE|USE|OPEN):$/; + +export function createAgentNextActionsLineFormatter( + stdout: KtxCliIo['stdout'], +): (line: string) => string { + const maybeHasColors = (stdout as { hasColors?: unknown }).hasColors; + const supportsColor = typeof maybeHasColors === 'function' && Boolean(maybeHasColors.call(stdout)); + if (!supportsColor) return (line) => line; + + const homeDir = process.env.HOME ? resolve(process.env.HOME) : ''; + const styleOptions = { validateStream: false } as const; + const dim = (s: string) => styleText('dim', s, styleOptions); + const bold = (s: string) => styleText('bold', s, styleOptions); + const cyanBold = (s: string) => styleText(['cyan', 'bold'], s, styleOptions); + const dimCyan = (s: string) => styleText(['dim', 'cyan'], s, styleOptions); + const shortenPath = (path: string): string => { + if (!homeDir) return path; + if (path === homeDir) return '~'; + if (path.startsWith(`${homeDir}/`)) return `~/${path.slice(homeDir.length + 1)}`; + return path; + }; + + return (rawLine: string): string => { + if (rawLine.length === 0 || rawLine.includes('[')) return rawLine; + + const heading = rawLine.match(STEP_HEADING_RE); + if (heading) { + return `${cyanBold(heading[1])} ${bold(heading[2])}`; + } + + if (!rawLine.startsWith(' ')) return rawLine; + const body = rawLine.slice(2); + + if (ACTION_MARKER_RE.test(body)) { + return ` ${dim(body)}`; + } + + if (body.endsWith('.zip') && (body.startsWith('/') || body.startsWith('~'))) { + return ` ${dimCyan('•')} ${shortenPath(body)}`; + } + + if (body.includes(' > ')) { + return ` ${body.replaceAll(' > ', ` ${dim('›')} `)}`; + } + + return ` ${dim(body)}`; + }; +} + async function readJsonObject(path: string): Promise> { if (!existsSync(path)) return {}; const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown; @@ -310,8 +398,12 @@ export function agentInstallManifestPath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/install-manifest.json'); } -function claudeDesktopPluginPath(projectDir: string): string { - return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip'); +function claudeDesktopAnalyticsSkillBundlePath(projectDir: string): string { + return join(resolve(projectDir), '.ktx/agents/claude/ktx-analytics.zip'); +} + +function claudeDesktopAdminSkillBundlePath(projectDir: string): string { + return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip'); } function claudeDesktopLauncherPath(projectDir: string): string { @@ -357,7 +449,20 @@ export function plannedKtxAgentFiles(input: { if (input.target === 'claude-desktop') { return [ { kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const }, - { kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const }, + { + kind: 'file', + path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir), + role: 'claude-desktop-skill-bundle' as const, + }, + ...(withAdminCli + ? [ + { + kind: 'file' as const, + path: claudeDesktopAdminSkillBundlePath(input.projectDir), + role: 'claude-desktop-skill-bundle' as const, + }, + ] + : []), ]; } throw new Error(`Global ${input.target} installation is not supported; omit --global.`); @@ -487,43 +592,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun ].join('\n'); } -function claudePluginJsonContent(): string { - return `${JSON.stringify( - { - name: 'ktx', - version: '0.0.0-local', - description: 'KTX analytics workflow guidance and local MCP tools.', - }, - null, - 2, - )}\n`; -} - -function claudePluginVersionContent(): string { - return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`; -} - -function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string { - return [ - '# KTX Claude Plugin', - '', - '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}\``, - '', - 'Included:', - '', - '- `ktx-analytics` skill for the MCP analytics workflow', - ...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []), - '', - 'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.', - '', - 'If this checkout or project directory moves, rerun `ktx setup --agents` and restart Claude Desktop.', - '', - ].join('\n'); -} - -function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string { +function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): string { const binPath = input.launcher.args[0]; if (!binPath) { throw new Error('Expected KTX CLI launcher to include a bin path.'); @@ -572,40 +641,45 @@ function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): strin ' run_with_node "$(command -v node)" "$@"', 'fi', '', - 'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2', + 'echo "KTX Claude Desktop launcher could not find Node.js. Set KTX_NODE to a Node executable and rerun ktx setup --agents." >&2', 'exit 127', '', ].join('\n'); } -async function writeClaudeDesktopPlugin(input: { +async function writeClaudeDesktopSkillBundle(input: { projectDir: string; path: string; - mode: KtxAgentInstallMode; + skillName: 'ktx-analytics' | 'ktx'; launcher: KtxCliLauncher; }): Promise { - const withAdminCli = input.mode === 'mcp-cli'; + const content = + input.skillName === 'ktx-analytics' + ? await readAnalyticsSkillContent() + : cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }); const files: Record = { - '.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()), - 'version.json': strToU8(claudePluginVersionContent()), - 'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()), - 'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })), + [`${input.skillName}/SKILL.md`]: strToU8(content), }; - if (withAdminCli) { - files['skills/ktx/SKILL.md'] = strToU8( - cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }), - ); - } await mkdir(dirname(input.path), { recursive: true }); await writeFile(input.path, Buffer.from(zipSync(files))); } +function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx' { + if (path.endsWith('/ktx-analytics.zip')) { + return 'ktx-analytics'; + } + if (path.endsWith('/ktx.zip')) { + return 'ktx'; + } + throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`); +} + async function writeClaudeDesktopLauncher(input: { path: string; launcher: KtxCliLauncher; }): Promise { await mkdir(dirname(input.path), { recursive: true }); - await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8'); + await writeFile(input.path, claudeDesktopLauncherContent({ launcher: input.launcher }), 'utf-8'); await chmod(input.path, 0o755); } @@ -764,7 +838,6 @@ function guidanceInstallLine(target: KtxAgentTarget): string { 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'; } @@ -780,12 +853,27 @@ function hasAdminCliEntries(entries: InstallEntry[]): boolean { ); } -export function formatInstallSummary( +export interface InstallSummaryEntry { + title: string; + lines: string[]; +} + +function formatInlinePath(path: string): string { + const home = process.env.HOME; + if (!home) return path; + const resolvedHome = resolve(home); + if (path === resolvedHome) return '~'; + if (path.startsWith(`${resolvedHome}/`)) { + return `~/${relative(resolvedHome, path)}`; + } + return path; +} + +export function formatInstallSummaryLines( installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, entries: InstallEntry[], projectDir: string, -): string { - const resolvedProjectDir = resolve(projectDir); +): InstallSummaryEntry[] { const entriesByTarget = new Map(); for (const install of installs) { const plannedFilePaths = new Set( @@ -807,41 +895,71 @@ export function formatInstallSummary( ); } - const lines: string[] = ['KTX project', ` ${resolvedProjectDir}`, '', 'Installed agents']; - for (const install of installs) { + return installs.map((install) => { const targetEntries = entriesByTarget.get(install.target) ?? []; const mcpEntry = mcpEntriesByTarget .get(install.target) ?.find((entry): entry is Extract => entry.kind === 'json-key'); - lines.push('', ` ${targetDisplayName(install.target)}`); + const lines: string[] = []; + if (mcpEntry) { - lines.push(` ${scopeDisplayName(install.scope)}`); - lines.push(` ${mcpEntry.path}`); + lines.push(formatInlinePath(mcpEntry.path)); } else if (install.target !== 'claude-desktop') { - lines.push(' MCP config'); - lines.push(` ${manualMcpConfigInstruction(install.target, install.scope)}`); + lines.push(manualMcpConfigInstruction(install.target, install.scope)); } + if (targetUsesHttpMcpDaemon(install.target)) { - lines.push(' Requires MCP to be started'); + lines.push('Requires MCP to be started.'); } + const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill'); const hasAdmin = hasAdminCliEntries(targetEntries); - const hasPlugin = hasEntryRole(targetEntries, 'claude-plugin'); + const claudeDesktopSkillBundles = targetEntries.filter( + (entry): entry is Extract => + entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle', + ); + if (install.target === 'claude-code') { if (hasAnalytics) { - lines.push(' Analytics skill installed'); + lines.push('Analytics skill installed.'); } if (hasAdmin) { - lines.push(' Admin CLI skill installed'); + lines.push('Admin CLI skill installed.'); } - } else if (hasAnalytics || hasAdmin || hasPlugin) { - lines.push(` ${guidanceInstallLine(install.target)}`); + } else if (install.target === 'claude-desktop') { + if (claudeDesktopSkillBundles.length > 0) { + lines.push('Skill bundles:'); + for (const bundle of claudeDesktopSkillBundles) { + lines.push(` ${bundle.path}`); + } + } + } else if (hasAnalytics || hasAdmin) { + lines.push(`${guidanceInstallLine(install.target)}.`); } + if (hasEntryRole(targetEntries, 'launcher')) { - lines.push(' Starts KTX over stdio from Claude Desktop'); + lines.push('Starts KTX over stdio from Claude Desktop.'); } - } - return lines.join('\n'); + + return { + title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, + lines, + }; + }); +} + +function claudeDesktopSkillBundlePathsForInstalls( + projectDir: string, + installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, +): string[] { + return installs + .filter((install) => install.target === 'claude-desktop') + .flatMap((install) => plannedKtxAgentFiles({ projectDir, ...install })) + .filter( + (entry): entry is Extract => + entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle', + ) + .map((entry) => entry.path); } function humanList(values: string[]): string { @@ -981,9 +1099,22 @@ function formatAgentNextActions(input: { if (input.installs.some((install) => install.target === 'claude-desktop')) { lines.push(`${step}. Restart Claude Desktop`); - lines.push(' Claude Desktop loads KTX after restart.'); + lines.push(' Claude Desktop loads KTX MCP after restart.'); pushBlankLine(lines); step += 1; + + const skillBundlePaths = claudeDesktopSkillBundlePathsForInstalls(projectDir, input.installs); + if (skillBundlePaths.length > 0) { + lines.push(`${step}. Upload Claude Desktop skills`); + lines.push(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.'); + lines.push(skillBundlePaths.length === 1 ? ' Upload this file:' : ' Upload each file separately:'); + for (const path of skillBundlePaths) { + lines.push(` ${path}`); + } + lines.push(' Toggle the uploaded KTX skills on.'); + pushBlankLine(lines); + step += 1; + } } if (lines.length === 0) { @@ -1008,11 +1139,11 @@ async function installTarget(input: { await writeClaudeDesktopLauncher({ path: entry.path, launcher }); continue; } - if (entry.role === 'claude-plugin') { - await writeClaudeDesktopPlugin({ + if (entry.role === 'claude-desktop-skill-bundle') { + await writeClaudeDesktopSkillBundle({ projectDir: input.projectDir, path: entry.path, - mode: input.mode, + skillName: claudeDesktopSkillNameForBundle(entry.path), launcher, }); continue; @@ -1049,6 +1180,9 @@ export async function runKtxSetupAgentsStep( } const prompts = deps.prompts ?? createPromptAdapter(); + if (args.inputMode === 'auto' && args.target === undefined) { + writeSetupInfo(io, 'Space to select, Enter to confirm, Esc to go back.'); + } const mode = args.inputMode === 'disabled' ? args.mode @@ -1075,7 +1209,7 @@ export async function runKtxSetupAgentsStep( : args.inputMode === 'disabled' ? [] : ((await prompts.multiselect({ - message: withMultiselectNavigation('Which agent targets should KTX install?'), + message: 'Which agent targets should KTX install?', options: [ { value: 'claude-code', label: 'Claude Code' }, { value: 'claude-desktop', label: 'Claude Desktop' }, @@ -1139,11 +1273,12 @@ export async function runKtxSetupAgentsStep( ); await markAgentsComplete(args.projectDir); const setupUi = createKtxSetupUiAdapter(); - setupUi.note( - formatInstallSummary(installs, entries, args.projectDir), - 'Agent integration complete', - io, - ); + for (const summary of formatInstallSummaryLines(installs, entries, args.projectDir)) { + writeSetupStep( + io, + summary.lines.length > 0 ? `${summary.title}\n${summary.lines.join('\n')}` : summary.title, + ); + } const nextActions = formatAgentNextActions({ projectDir: args.projectDir, installs, @@ -1151,7 +1286,10 @@ export async function runKtxSetupAgentsStep( snippets, }); if (args.showNextActions !== false) { - setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line }); + setupUi.note(nextActions, 'Required before using agents', io, { + format: createAgentNextActionsLineFormatter(io.stdout), + }); + writeSetupOutro(io, 'All set.'); } return { status: 'ready', projectDir: args.projectDir, installs, nextActions }; } catch (error) { diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index 722b7e71..3d0a44a5 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -517,7 +517,11 @@ describe('setup status', () => { ).resolves.toBe(0); const output = testIo.stdout(); - expect(output).toContain('Agent integration complete'); + expect(output).toContain('Claude Code · Project scope'); + expect(output).toContain(join(tempDir, '.mcp.json')); + expect(output).toContain('Requires MCP to be started.'); + expect(output).toContain('Analytics skill installed.'); + expect(output).not.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');