From 960e23b1c3edb229ff4c829bdbd52b021fc30a7e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 15 May 2026 15:50:16 +0200 Subject: [PATCH] feat(setup): add Claude Desktop target and MCP-first agent setup Adds `ktx mcp stdio` and a `claude-desktop` setup target that generates a local plugin ZIP wiring the analytics skill and a stdio MCP config. Replaces the CLI-only agent install mode with MCP+analytics (default) and an optional admin CLI skill, renames the research skill to analytics, and lets interactive setup pick project vs global scope when every target supports it. Extracts a shared MCP server factory used by both HTTP and stdio entrypoints. --- README.md | 29 +- .../docs/integrations/agent-clients.mdx | 154 ++++++-- packages/cli/package.json | 1 + packages/cli/src/cli-runtime.ts | 1 + .../cli/src/commands/mcp-commands.test.ts | 18 + packages/cli/src/commands/mcp-commands.ts | 12 + packages/cli/src/commands/setup-commands.ts | 1 + packages/cli/src/mcp-http-server.ts | 67 +--- packages/cli/src/mcp-server-factory.ts | 65 ++++ packages/cli/src/mcp-stdio-server.ts | 45 +++ packages/cli/src/setup-agents.test.ts | 360 ++++++++++++++++-- packages/cli/src/setup-agents.ts | 303 +++++++++++++-- packages/cli/src/setup-demo-tour.test.ts | 2 +- packages/cli/src/setup-demo-tour.ts | 2 +- packages/cli/src/setup.test.ts | 21 +- packages/cli/src/setup.ts | 11 +- .../skills/{research => analytics}/SKILL.md | 20 +- pnpm-lock.yaml | 8 + 18 files changed, 909 insertions(+), 211 deletions(-) create mode 100644 packages/cli/src/mcp-server-factory.ts create mode 100644 packages/cli/src/mcp-stdio-server.ts rename packages/cli/src/skills/{research => analytics}/SKILL.md (62%) diff --git a/README.md b/README.md index 92a2a8dd..e9bc520c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ The context layer for analytics agents

+

by Kaelio

+

npm version Codecov @@ -161,11 +163,23 @@ source packages for development, not public release artifacts. ## Use KTX with agents -KTX integrates with coding agents through CLI skills. The setup wizard -configures this automatically. +KTX exposes context to end-user agents through MCP tools. The CLI remains the +admin surface for setup, ingest, status, daemon lifecycle, and debugging. -**CLI skills** - the agent calls `ktx` commands directly through a skill file -installed in your agent's config (e.g., `.claude/skills/ktx/SKILL.md`): +```bash +ktx mcp start +ktx setup --agents +``` + +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. + +The analytics skill teaches client agents the MCP workflow: discover data, +prefer semantic-layer measures, inspect entity details before raw SQL, and +capture durable learnings. Admin CLI skills call `ktx` commands directly +through a skill file installed in your agent's config, for example +`.claude/skills/ktx/SKILL.md`: ```bash ktx sl query --measure orders.revenue --dimension orders.status --format sql @@ -173,8 +187,11 @@ ktx wiki search "revenue definition" ktx sl validate orders ``` -Supported agents: Claude Code, Codex, Cursor, OpenCode, and any agent that -reads `.agents/` skills. +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 generates `.ktx/agents/claude/ktx-plugin.zip`, which +bundles the analytics skill and a local `ktx mcp stdio` server config for +Claude Desktop's plugin installer. ## Workspace packages diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index de628197..62297067 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -1,18 +1,26 @@ --- title: Agent Clients -description: Set up KTX with Claude Code, Cursor, Codex, and OpenCode. +description: Set up KTX with Claude Code, Claude Desktop, Cursor, Codex, and OpenCode. --- -KTX integrates with coding agents through CLI skills and command files. These -files teach agents to call public `ktx` commands directly from the terminal for -semantic-layer context and wiki knowledge. +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 agent targets, or configure manually using the -snippets below. Setup pins generated skill files to the KTX CLI path that -created them, so agents do not need `ktx` on `PATH`. +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. ## Install with setup +Start the MCP server before connecting an end-user agent: + +```bash +ktx mcp start +``` + +Then install client integration: + ```bash ktx setup --agents ``` @@ -23,7 +31,8 @@ Use `--target` for one target: ktx setup --agents --target codex ``` -Use `--global` only with `claude-code` or `codex`: +Use `--global` only with `claude-code` or `codex`. Claude Desktop always +generates a project-local plugin ZIP: ```bash ktx setup --agents --target claude-code --global @@ -34,19 +43,54 @@ KTX records installed files in `.ktx/agents/install-manifest.json`. That manifest lets status checks report agent readiness and lets future cleanup 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 +└ + +◆ Which agent targets should KTX install? +│ ◻ Claude Code +│ ◻ Claude Desktop +│ ◻ Codex +│ ◻ Cursor +│ ◻ OpenCode +│ ◻ Universal .agents +└ +``` + +When every selected target supports both project and global setup, the command +also asks where to install supported agent config: + +```txt +◆ Where should KTX install supported agent config? +│ ○ Project +│ ○ Global +└ +``` + ## Generated files -| Target | Project-scoped files | Global files | -|--------|----------------------|--------------| -| Claude Code | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` | `~/.claude/skills/ktx/SKILL.md`, `~/.claude/rules/ktx.md` | -| Codex | `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` | `$CODEX_HOME/skills/ktx/SKILL.md`, `$CODEX_HOME/instructions/ktx.md` | -| Cursor | `.cursor/rules/ktx.mdc` | Not supported | -| OpenCode | `.opencode/commands/ktx.md` | Not supported | -| Universal `.agents` | `.agents/skills/ktx/SKILL.md` | Not supported | +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**. -Skill files list pinned `ktx` commands. Rule files tell the agent when KTX is -appropriate, such as data schemas, metrics, dimensions, database structure, and -SQL questions. +| Target | MCP tools + analytics skill | Adds with admin CLI skill | +|--------|------------------------------|---------------------------| +| Claude Code | `.mcp.json`, `.claude/skills/ktx-analytics/SKILL.md` | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` | +| Claude Desktop | `.ktx/agents/claude/ktx-plugin.zip` with analytics skill and local stdio MCP config | Adds `skills/ktx/SKILL.md` inside the plugin ZIP | +| 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` | +| Universal `.agents` | Printed MCP endpoint, `.agents/skills/ktx-analytics/SKILL.md` | `.agents/skills/ktx/SKILL.md` | + +MCP config gives agents access to KTX context tools such as discovery, +semantic-layer queries, wiki search, SQL execution, and memory capture. The +analytics skill explains how to use those tools for semantic-layer-first +analysis. Optional admin skill and rule files list pinned CLI commands for +developer or operator agents. ## Claude Code @@ -56,13 +100,16 @@ During setup, select **Claude Code** from the agent targets. KTX writes: | Scope | Files | |-------|-------| -| Project | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` | -| Global | `~/.claude/skills/ktx/SKILL.md`, `~/.claude/rules/ktx.md` | +| Project | `.mcp.json`, `.claude/skills/ktx-analytics/SKILL.md`; optional `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` | +| Global | `~/.claude.json`, `~/.claude/skills/ktx-analytics/SKILL.md`; optional `~/.claude/skills/ktx/SKILL.md`, `~/.claude/rules/ktx.md` | Both project-scoped and global installations are supported. ### Manual CLI skills configuration +Use manual CLI skills only for developer or operator agents that need admin +commands. End-user data agents use MCP. + Create `.claude/skills/ktx/SKILL.md`: ```markdown title=".claude/skills/ktx/SKILL.md" @@ -75,13 +122,14 @@ Available commands: - `ktx status --json --project-dir /path/to/project` - `ktx sl list --json --project-dir /path/to/project` - `ktx sl search '' --json --project-dir /path/to/project --connection-id ''` -- `ktx sl query --json --project-dir /path/to/project --connection-id '' --query-file '' --execute --max-rows 100` +- `ktx sl query --json --project-dir /path/to/project --connection-id ''` - `ktx wiki search '' --json --project-dir /path/to/project --limit 10` ``` ### Workflow tips - Claude Code discovers skills automatically from `.claude/skills/`. +- Claude Code reads MCP config from `.mcp.json` for project-scoped MCP tools. - Claude rules in `.claude/rules/` tell Claude when KTX should be used. - Global installation makes KTX available in all projects without per-project setup. - Keep generated skills committed only when your team wants project-local agent instructions in git. @@ -96,13 +144,19 @@ During setup, select **Cursor** from the agent targets. KTX writes: | Mode | File | |------|------| -| CLI rules | `.cursor/rules/ktx.mdc` | +| MCP tools + analytics skill | `.cursor/mcp.json`, `.cursor/rules/ktx-analytics.mdc` | +| Admin CLI rules | `.cursor/rules/ktx.mdc` | Cursor supports project-scoped installation only. ### Manual CLI rules configuration -Create `.cursor/rules/ktx.mdc` with the same content structure as the Claude Code `SKILL.md` file. Cursor rules use the `.mdc` extension but support the same markdown command definitions. +Use manual CLI rules only for developer or operator agents that need admin +commands. End-user data agents use MCP. + +Create `.cursor/rules/ktx.mdc` with the same content structure as the Claude +Code `SKILL.md` file. Cursor rules use the `.mdc` extension but support the +same markdown command definitions. ### Workflow tips @@ -111,6 +165,25 @@ Create `.cursor/rules/ktx.mdc` with the same content structure as the Claude Cod --- +## Claude Desktop + +During setup, select **Claude Desktop** from the agent targets. KTX generates a +local Claude plugin ZIP at `.ktx/agents/claude/ktx-plugin.zip`. The plugin +contains the KTX analytics skill and a local stdio MCP config that runs: + +```bash +ktx --project-dir /path/to/project mcp stdio +``` + +If you choose **MCP tools + analytics skill + admin CLI skill**, the plugin ZIP +also includes the admin `ktx` skill. + +Install the generated ZIP from Claude Desktop's plugin UI. If you move the KTX +checkout or project directory, rerun `ktx setup --agents` and reinstall the +regenerated plugin. + +--- + ## Codex ### Install via `ktx setup` @@ -119,15 +192,19 @@ During setup, select **Codex** from the agent targets. KTX writes: | Scope | Files | |-------|-------| -| Project | `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` | -| Global | `$CODEX_HOME/skills/ktx/SKILL.md`, `$CODEX_HOME/instructions/ktx.md` | +| Project | MCP snippet, `.agents/skills/ktx-analytics/SKILL.md`; optional `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` | +| Global | MCP snippet, `$CODEX_HOME/skills/ktx-analytics/SKILL.md`; optional `$CODEX_HOME/skills/ktx/SKILL.md`, `$CODEX_HOME/instructions/ktx.md` | Both project-scoped and global installations are supported. `CODEX_HOME` defaults to `~/.codex`. ### Manual CLI skills configuration -Create `.agents/skills/ktx/SKILL.md` with the same content structure as Claude Code's `SKILL.md`. +Use manual CLI skills only for developer or operator agents that need admin +commands. End-user data agents use MCP. + +Create `.agents/skills/ktx/SKILL.md` with the same content structure as Claude +Code's `SKILL.md`. ### Workflow tips @@ -146,13 +223,18 @@ During setup, select **OpenCode** from the agent targets. KTX writes: | Mode | File | |------|------| -| CLI commands | `.opencode/commands/ktx.md` | +| MCP tools + analytics skill | Snippet for `opencode.json`, `.opencode/commands/ktx-analytics.md` | +| Admin CLI commands | `.opencode/commands/ktx.md` | OpenCode supports project-scoped installation only. ### Manual CLI commands configuration -Create `.opencode/commands/ktx.md` with the same command definitions as Claude Code's `SKILL.md`. +Use manual CLI commands only for developer or operator agents that need admin +commands. End-user data agents use MCP. + +Create `.opencode/commands/ktx.md` with the same command definitions as Claude +Code's `SKILL.md`. ### Workflow tips @@ -163,7 +245,7 @@ Create `.opencode/commands/ktx.md` with the same command definitions as Claude C ## Command reference -All supported agent clients call the same KTX CLI commands: +Admin CLI skills call the same KTX CLI commands: | Command | Description | |---------|-------------| @@ -183,9 +265,11 @@ All supported agent clients call the same KTX CLI commands: ## Comparison -| | Claude Code | Cursor | Codex | OpenCode | -|---|---|---|---|---| -| CLI skills | Yes | Yes (.mdc) | Yes | Yes | -| Global install | Yes | No | Yes | No | -| Rule or instruction file | `.claude/rules/ktx.md` | `.cursor/rules/ktx.mdc` | `.codex/instructions/ktx.md` | `.opencode/commands/ktx.md` | -| Skill file | `.claude/skills/ktx/SKILL.md` | Not separate | `.agents/skills/ktx/SKILL.md` | Not separate | +| | Claude Code | Claude Desktop | Cursor | Codex | OpenCode | +|---|---|---|---|---|---| +| MCP tools | Yes | Local stdio via plugin | 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 | +| 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/package.json b/packages/cli/package.json index 2f1c4313..a93b9b6c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "@ktx/llm": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "commander": "14.0.3", + "fflate": "^0.8.2", "ink": "^7.0.2", "react": "^19.2.6", "zod": "^4.4.3" diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index e8be6be7..af5ed449 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -39,6 +39,7 @@ export interface KtxCliDeps { stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon; readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus; runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer; + runStdioServer?: typeof import('./mcp-stdio-server.js').runKtxMcpStdioServer; }; } diff --git a/packages/cli/src/commands/mcp-commands.test.ts b/packages/cli/src/commands/mcp-commands.test.ts index f31996f2..cda68156 100644 --- a/packages/cli/src/commands/mcp-commands.test.ts +++ b/packages/cli/src/commands/mcp-commands.test.ts @@ -35,6 +35,7 @@ describe('registerMcpCommands', () => { 'serve-internal', 'start', 'status', + 'stdio', 'stop', ]); expect( @@ -54,4 +55,21 @@ describe('registerMcpCommands', () => { ); expect(startDaemon).not.toHaveBeenCalled(); }); + + it('runs the stdio server with the resolved project directory', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const runStdioServer = vi.fn().mockResolvedValue(undefined); + const context = makeContext({ deps: { mcp: { runStdioServer } } }); + registerMcpCommands(program, context); + + await expect(program.parseAsync(['--project-dir', '/tmp/ktx6', 'mcp', 'stdio'], { from: 'user' })).resolves.toBe( + program, + ); + + expect(runStdioServer).toHaveBeenCalledWith({ + projectDir: '/tmp/ktx6', + cliVersion: '0.0.0-test', + io: context.io, + }); + }); }); diff --git a/packages/cli/src/commands/mcp-commands.ts b/packages/cli/src/commands/mcp-commands.ts index 7880f608..349ca27b 100644 --- a/packages/cli/src/commands/mcp-commands.ts +++ b/packages/cli/src/commands/mcp-commands.ts @@ -15,6 +15,7 @@ import { stopKtxMcpDaemon, } from '../managed-mcp-daemon.js'; import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js'; +import { runKtxMcpStdioServer } from '../mcp-stdio-server.js'; function tokenFromOption(value: string | undefined): string | undefined { return value ?? process.env.KTX_MCP_TOKEN; @@ -27,6 +28,17 @@ function binPath(): string { export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void { const mcp = program.command('mcp').description('Run the KTX MCP HTTP server'); + mcp + .command('stdio') + .description('Run the KTX MCP server over stdio') + .action(async (_options, command) => { + await (context.deps.mcp?.runStdioServer ?? runKtxMcpStdioServer)({ + projectDir: resolveCommandProjectDir(command), + cliVersion: context.packageInfo.version, + io: context.io, + }); + }); + mcp .command('start') .description('Start the KTX MCP HTTP server') diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index d09f8149..a2b2a050 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -218,6 +218,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo .addOption( new Option('--target ', 'Agent target').choices([ 'claude-code', + 'claude-desktop', 'codex', 'cursor', 'opencode', diff --git a/packages/cli/src/mcp-http-server.ts b/packages/cli/src/mcp-http-server.ts index 68e8eb3b..0d4607f0 100644 --- a/packages/cli/src/mcp-http-server.ts +++ b/packages/cli/src/mcp-http-server.ts @@ -1,16 +1,11 @@ import { randomUUID } from 'node:crypto'; import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp'; -import { createLocalProjectMemoryCapture } from '@ktx/context/memory'; -import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project'; +import { loadKtxProject } from '@ktx/context/project'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; -import { createKtxCliScanConnector } from './local-scan-connectors.js'; -import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js'; -import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js'; +import { createKtxMcpServerFactory } from './mcp-server-factory.js'; const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'] as const; @@ -124,13 +119,6 @@ export interface RunKtxMcpHttpServerOptions extends McpSecurityConfigInput { loadProject?: typeof loadKtxProject; } -function noopIo(): KtxCliIo { - return { - stdout: { write() {} }, - stderr: { write() {} }, - }; -} - function writeJson(res: ServerResponse, status: number, body: object): void { const payload = `${JSON.stringify(body)}\n`; res.writeHead(status, { @@ -159,55 +147,6 @@ async function readJsonBody(req: IncomingMessage): Promise { return raw.trim().length === 0 ? undefined : (JSON.parse(raw) as unknown); } -async function defaultMcpServerFactory(input: { - project: KtxLocalProject; - projectDir: string; - cliVersion: string; - io?: KtxCliIo; -}): Promise<() => McpServer> { - const io = input.io ?? noopIo(); - const queryExecutor = createKtxCliIngestQueryExecutor(input.project); - const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({ - cliVersion: input.cliVersion, - installPolicy: 'auto', - io, - }); - const sqlAnalysis = createManagedDaemonSqlAnalysisPort({ - cliVersion: input.cliVersion, - projectDir: input.projectDir, - installPolicy: 'auto', - io, - }); - const contextTools = createLocalProjectMcpContextPorts(input.project, { - semanticLayerCompute, - queryExecutor, - sqlAnalysis, - localScan: { - createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId), - }, - localIngest: { - semanticLayerCompute, - queryExecutor, - }, - }); - - let memoryCapture: ReturnType | undefined; - try { - memoryCapture = createLocalProjectMemoryCapture(input.project, { semanticLayerCompute, queryExecutor }); - } catch (error) { - input.io?.stderr.write(`KTX MCP memory_capture disabled: ${error instanceof Error ? error.message : String(error)}\n`); - } - - return () => - createDefaultKtxMcpServer({ - name: 'ktx', - version: input.cliVersion, - userContext: { userId: 'local' }, - contextTools, - memoryCapture, - }); -} - function listenerPort(server: Server, fallback: number): number { const address = server.address(); return typeof address === 'object' && address ? address.port : fallback; @@ -233,7 +172,7 @@ export async function runKtxMcpHttpServer(options: RunKtxMcpHttpServerOptions): : undefined; const createMcpServer = options.createMcpServer ?? - (await defaultMcpServerFactory({ + (await createKtxMcpServerFactory({ project: project!, projectDir: options.projectDir, cliVersion: options.cliVersion ?? '0.0.0-private', diff --git a/packages/cli/src/mcp-server-factory.ts b/packages/cli/src/mcp-server-factory.ts new file mode 100644 index 00000000..2a0c40ee --- /dev/null +++ b/packages/cli/src/mcp-server-factory.ts @@ -0,0 +1,65 @@ +import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp'; +import { createLocalProjectMemoryCapture } from '@ktx/context/memory'; +import type { KtxLocalProject } from '@ktx/context/project'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { KtxCliIo } from './cli-runtime.js'; +import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; +import { createKtxCliScanConnector } from './local-scan-connectors.js'; +import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js'; +import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js'; + +function noopMcpIo(): KtxCliIo { + return { + stdout: { write() {} }, + stderr: { write() {} }, + }; +} + +export async function createKtxMcpServerFactory(input: { + project: KtxLocalProject; + projectDir: string; + cliVersion: string; + io?: KtxCliIo; +}): Promise<() => McpServer> { + const io = input.io ?? noopMcpIo(); + const queryExecutor = createKtxCliIngestQueryExecutor(input.project); + const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({ + cliVersion: input.cliVersion, + installPolicy: 'auto', + io, + }); + const sqlAnalysis = createManagedDaemonSqlAnalysisPort({ + cliVersion: input.cliVersion, + projectDir: input.projectDir, + installPolicy: 'auto', + io, + }); + const contextTools = createLocalProjectMcpContextPorts(input.project, { + semanticLayerCompute, + queryExecutor, + sqlAnalysis, + localScan: { + createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId), + }, + localIngest: { + semanticLayerCompute, + queryExecutor, + }, + }); + + let memoryCapture: ReturnType | undefined; + try { + memoryCapture = createLocalProjectMemoryCapture(input.project, { semanticLayerCompute, queryExecutor }); + } catch (error) { + input.io?.stderr.write(`KTX MCP memory_capture disabled: ${error instanceof Error ? error.message : String(error)}\n`); + } + + return () => + createDefaultKtxMcpServer({ + name: 'ktx', + version: input.cliVersion, + userContext: { userId: 'local' }, + contextTools, + memoryCapture, + }); +} diff --git a/packages/cli/src/mcp-stdio-server.ts b/packages/cli/src/mcp-stdio-server.ts new file mode 100644 index 00000000..4596f91e --- /dev/null +++ b/packages/cli/src/mcp-stdio-server.ts @@ -0,0 +1,45 @@ +import type { Readable, Writable } from 'node:stream'; +import { loadKtxProject } from '@ktx/context/project'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { KtxCliIo } from './cli-runtime.js'; +import { createKtxMcpServerFactory } from './mcp-server-factory.js'; + +export interface RunKtxMcpStdioServerOptions { + projectDir: string; + cliVersion?: string; + io?: KtxCliIo; + createMcpServer?: () => McpServer; + loadProject?: typeof loadKtxProject; + stdin?: Readable; + stdout?: Writable; +} + +export async function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions): Promise { + const project = + options.createMcpServer === undefined + ? await (options.loadProject ?? loadKtxProject)({ projectDir: options.projectDir }) + : undefined; + const protocolIo: KtxCliIo = { + stdout: { write() {} }, + stderr: options.io?.stderr ?? { write() {} }, + }; + const createMcpServer = + options.createMcpServer ?? + (await createKtxMcpServerFactory({ + project: project!, + projectDir: options.projectDir, + cliVersion: options.cliVersion ?? '0.0.0-private', + io: protocolIo, + })); + const transport = new StdioServerTransport(options.stdin, options.stdout); + + await new Promise((resolve, reject) => { + transport.onclose = resolve; + transport.onerror = (error) => { + options.io?.stderr.write(`KTX MCP stdio transport error: ${error.message}\n`); + reject(error); + }; + createMcpServer().connect(transport).catch(reject); + }); +} diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 9fb6903a..05d24901 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { readKtxSetupState } from '@ktx/context/project'; +import { strFromU8, unzipSync } from 'fflate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { formatInstallSummary, @@ -24,6 +25,13 @@ function makeIo() { }; } +async function readZipText(path: string, entry: string): Promise { + const archive = unzipSync(new Uint8Array(await readFile(path))); + const content = archive[entry]; + if (!content) throw new Error(`Missing zip entry: ${entry}`); + return strFromU8(content); +} + describe('setup agents', () => { let tempDir: string; @@ -37,28 +45,52 @@ describe('setup agents', () => { await rm(tempDir, { recursive: true, force: true }); }); - it('plans project-scoped CLI and research files for every target', () => { - expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'cli' })).toEqual([ + it('plans project-scoped MCP analytics files for every target', () => { + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp' })).toEqual([ + { kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + ]); + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([ + { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' }, + ]); + 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' }, + ]); + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp' })).toEqual([ + { kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' }, + ]); + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'mcp' })).toEqual([ + { kind: 'file', path: join(tempDir, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' }, + ]); + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'mcp' })).toEqual([ + { kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + ]); + }); + + it('plans project-scoped admin CLI files for every target when requested', () => { + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp-cli' })).toEqual([ + { 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/skills/ktx-research/SKILL.md'), role: 'research-skill' }, { kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' }, ]); - expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'cli' })).toEqual([ + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp-cli' })).toEqual([ + { 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, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' }, { kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' }, ]); - expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'cli' })).toEqual([ + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp-cli' })).toEqual([ + { kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' }, { kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') }, - { kind: 'file', path: join(tempDir, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' }, ]); - expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'cli' })).toEqual([ + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'mcp-cli' })).toEqual([ + { kind: 'file', path: join(tempDir, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' }, { kind: 'file', path: join(tempDir, '.opencode/commands/ktx.md') }, - { kind: 'file', path: join(tempDir, '.opencode/commands/ktx-research.md'), role: 'research-skill' }, ]); - expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'cli' })).toEqual([ + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'mcp-cli' })).toEqual([ + { kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, { kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') }, - { kind: 'file', path: join(tempDir, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' }, + ]); + expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([ + { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' }, ]); }); @@ -74,7 +106,7 @@ describe('setup agents', () => { agents: true, target: 'universal', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -82,7 +114,7 @@ describe('setup agents', () => { ).resolves.toEqual({ status: 'ready', projectDir: tempDir, - installs: [{ target: 'universal', scope: 'project', mode: 'cli' }], + installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }], }); await expect(stat(join(tempDir, '.agents/skills/ktx/SKILL.md'))).resolves.toBeDefined(); @@ -96,13 +128,13 @@ describe('setup agents', () => { expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({ version: 1, projectDir: tempDir, - installs: [{ target: 'universal', scope: 'project', mode: 'cli' }], + installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }], }); expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] }); expect(io.stderr()).toBe(''); }); - it('installs the research skill from the runtime asset', async () => { + it('installs the analytics skill from the runtime asset', async () => { const io = makeIo(); await expect( @@ -114,17 +146,17 @@ describe('setup agents', () => { agents: true, target: 'universal', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, ), ).resolves.toMatchObject({ status: 'ready' }); - const researchSkill = await readFile(join(tempDir, '.agents/skills/ktx-research/SKILL.md'), 'utf-8'); - expect(researchSkill).toContain('name: ktx-research'); - expect(researchSkill).toContain('Always run `discover_data` before writing SQL.'); - expect(researchSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.'); + const analyticsSkill = await readFile(join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), 'utf-8'); + expect(analyticsSkill).toContain('name: ktx-analytics'); + expect(analyticsSkill).toContain('Always run `discover_data` before writing SQL.'); + expect(analyticsSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.'); }); it('writes PATH-independent launcher commands for skills', async () => { @@ -139,7 +171,7 @@ describe('setup agents', () => { agents: true, target: 'universal', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -165,7 +197,7 @@ describe('setup agents', () => { agents: true, target: 'claude-code', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -182,6 +214,198 @@ describe('setup agents', () => { 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 () => { + const io = makeIo(); + const prompts = { + select: vi.fn(async ({ message }: { message: string }) => (message.startsWith('Where') ? 'project' : 'mcp')), + multiselect: vi.fn(async () => ['claude-code']), + 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' }], + }); + + expect(prompts.select).toHaveBeenCalledWith({ + message: 'How should client agents connect to this KTX project?', + options: [ + { value: 'mcp', label: 'MCP tools + analytics skill' }, + { value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' }, + ], + }); + expect(prompts.multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([{ value: 'claude-desktop', label: 'Claude Desktop' }]), + }), + ); + }); + + it('prompts for global scope when every selected target supports it', 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') ? 'global' : 'mcp', + ), + multiselect: vi.fn(async () => ['claude-code']), + 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: 'global', mode: 'mcp' }], + }); + + expect(prompts.select).toHaveBeenCalledWith({ + message: 'Where should KTX install supported agent config?', + options: [ + { value: 'project', label: 'Project' }, + { value: 'global', label: 'Global' }, + ], + }); + } finally { + process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } + }); + + it('generates a Claude Desktop plugin zip with analytics skill and stdio MCP config', 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-desktop', + scope: 'project', + mode: 'mcp', + skipAgents: false, + }, + io.io, + ), + ).resolves.toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }], + }); + + const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'); + await expect(stat(pluginPath)).resolves.toBeDefined(); + await expect(stat(join(home, 'Library/Application Support/Claude/claude_desktop_config.json'))).rejects.toThrow(); + expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"'); + const mcpJson = JSON.parse(await readZipText(pluginPath, '.mcp.json')) as { + mcpServers: { ktx: { type: string; command: string; args: string[] } }; + }; + expect(mcpJson.mcpServers.ktx.type).toBe('stdio'); + expect(mcpJson.mcpServers.ktx.command).toBe(process.execPath); + expect(mcpJson.mcpServers.ktx.args).toEqual(expect.arrayContaining(['--project-dir', tempDir, 'mcp', 'stdio'])); + expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); + 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('Install the generated KTX plugin ZIP from Claude Desktop Plugins'); + } finally { + process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } + }); + + it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => { + const io = makeIo(); + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-desktop', + scope: 'project', + mode: 'mcp-cli', + skipAgents: false, + }, + io.io, + ), + ).resolves.toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }], + }); + + const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'); + expect(await readZipText(pluginPath, 'skills/ktx/SKILL.md')).toContain(`--project-dir ${tempDir}`); + expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill'); + }); + + it('installs MCP client config and analytics skill without admin CLI files', async () => { + const io = makeIo(); + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'mcp', + skipAgents: false, + }, + io.io, + ), + ).resolves.toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }], + }); + + const mcpJson = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as { + mcpServers: { ktx: { type: string; url: string } }; + }; + expect(mcpJson.mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' }); + await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined(); + await expect(stat(join(tempDir, '.claude/skills/ktx/SKILL.md'))).rejects.toThrow(); + await expect(stat(join(tempDir, '.claude/rules/ktx.md'))).rejects.toThrow(); + }); + it('writes Cursor project MCP config', async () => { const io = makeIo(); @@ -193,7 +417,7 @@ describe('setup agents', () => { agents: true, target: 'cursor', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -205,7 +429,7 @@ describe('setup agents', () => { expect(cursorJson.mcpServers.ktx).toEqual({ url: 'http://localhost:7878/mcp' }); }); - it('prints Codex and opencode snippets without mutating printed-only config files', async () => { + it('prints Codex, opencode, and universal snippets without mutating printed-only config files', async () => { const codexIo = makeIo(); await runKtxSetupAgentsStep( { @@ -215,7 +439,7 @@ describe('setup agents', () => { agents: true, target: 'codex', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, codexIo.io, @@ -232,7 +456,7 @@ describe('setup agents', () => { agents: true, target: 'opencode', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, opencodeIo.io, @@ -240,6 +464,23 @@ describe('setup agents', () => { expect(opencodeIo.stdout()).toContain('"mcp"'); expect(opencodeIo.stdout()).toContain('"type": "remote"'); await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow(); + + const universalIo = makeIo(); + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'universal', + scope: 'project', + mode: 'mcp', + skipAgents: false, + }, + universalIo.io, + ); + expect(universalIo.stdout()).toContain('Universal MCP endpoint:'); + expect(universalIo.stdout()).toContain('http://localhost:7878/mcp'); }); it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => { @@ -275,7 +516,7 @@ describe('setup agents', () => { agents: true, target: 'claude-code', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -309,7 +550,7 @@ describe('setup agents', () => { agents: true, target: 'claude-code', scope: 'local', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -335,7 +576,7 @@ describe('setup agents', () => { agents: true, target: 'claude-code', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -350,6 +591,30 @@ describe('setup agents', () => { await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null); }); + it('removes generated Claude Desktop plugin from the manifest', async () => { + const io = makeIo(); + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-desktop', + scope: 'project', + mode: 'mcp-cli', + skipAgents: false, + }, + io.io, + ); + const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'); + await expect(stat(pluginPath)).resolves.toBeDefined(); + + await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0); + + await expect(stat(pluginPath)).rejects.toThrow(); + await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null); + }); + it('treats cancel as skip in interactive mode', async () => { const io = makeIo(); const prompts = { @@ -366,7 +631,7 @@ describe('setup agents', () => { yes: false, agents: true, scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -378,7 +643,7 @@ describe('setup agents', () => { it('explains how to select multiple agent targets in interactive mode', async () => { const io = makeIo(); const prompts = { - select: vi.fn(async () => 'cli'), + select: vi.fn(async () => 'mcp-cli'), multiselect: vi.fn(async () => ['back']), cancel: vi.fn(), }; @@ -391,7 +656,7 @@ describe('setup agents', () => { yes: false, agents: true, scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -418,7 +683,7 @@ describe('setup agents', () => { agents: true, target: 'claude-code', scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io.io, @@ -427,21 +692,28 @@ describe('setup agents', () => { const output = io.stdout(); expect(output).toContain('Agent integration complete'); expect(output).toContain('Claude Code'); - expect(output).toContain('+ Skill installed — teaches your agent which KTX commands to run'); + 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 your agent when to use KTX'); + expect(output).toContain('+ Rule installed — tells admin agents when to use KTX CLI'); expect(output).toContain('.claude/rules/ktx.md'); }); it('formats summary with relative paths for project scope', () => { const summary = formatInstallSummary( - [{ target: 'cursor', scope: 'project', mode: 'cli' }], - [{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') }], + [{ 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') }, + ], tempDir, ); expect(summary).toContain('Cursor'); - expect(summary).toContain('+ Rule installed — tells your agent when to use KTX'); + 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); }); @@ -449,12 +721,14 @@ describe('setup agents', () => { it('formats summary with multiple agent targets', () => { const summary = formatInstallSummary( [ - { target: 'claude-code', scope: 'project', mode: 'cli' }, - { target: 'codex', scope: 'project', mode: 'cli' }, + { target: 'claude-code', scope: 'project', mode: 'mcp-cli' }, + { target: 'codex', scope: 'project', mode: 'mcp-cli' }, ], [ + { 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: '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' }, ], @@ -462,9 +736,11 @@ describe('setup agents', () => { ); expect(summary).toContain('Claude Code'); - expect(summary).toContain('+ Skill installed — teaches your agent which KTX commands to run'); - expect(summary).toContain('+ Rule installed — tells your agent when to use KTX'); + 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('Codex'); + expect(summary).toContain('.agents/skills/ktx-analytics/SKILL.md'); expect(summary).toContain('.agents/skills/ktx/SKILL.md'); }); }); diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index a065fc41..aa050500 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -7,6 +7,7 @@ import { markKtxSetupStateStepComplete, serializeKtxProjectConfig, } from '@ktx/context/project'; +import { strToU8, zipSync } from 'fflate'; import type { KtxCliIo } from './cli-runtime.js'; import { withMultiselectNavigation } from './prompt-navigation.js'; import { @@ -15,9 +16,9 @@ import { } from './setup-prompts.js'; import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js'; -export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal'; +export type KtxAgentTarget = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'opencode' | 'universal'; export type KtxAgentScope = 'project' | 'global' | 'local'; -export type KtxAgentInstallMode = 'cli'; +export type KtxAgentInstallMode = 'mcp' | 'mcp-cli'; export interface KtxSetupAgentsArgs { projectDir: string; @@ -47,7 +48,7 @@ export interface KtxAgentInstallManifest { installedAt: string; installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; entries: Array< - | { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'research-skill' } + | { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' } | { kind: 'json-key'; path: string; jsonPath: string[] } >; } @@ -169,6 +170,14 @@ function opencodeSnippet(endpoint: KtxMcpEndpointInfo): string { ); } +function universalMcpSnippet(endpoint: KtxMcpEndpointInfo): string { + return [ + 'Universal MCP endpoint:', + endpoint.url, + ...(endpoint.tokenAuth ? ['Header: Authorization: Bearer ${KTX_MCP_TOKEN}'] : []), + ].join('\n'); +} + function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } { const home = process.env.HOME ?? ''; if (scope === 'global') { @@ -213,74 +222,133 @@ async function installMcpClientConfig(input: { } else if (input.target === 'codex') { snippets.push(`Codex MCP snippet for ~/.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')); + 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)}`); + } else if (input.target === 'universal') { + snippets.push(universalMcpSnippet(endpoint)); } return { entries, snippets, notices }; } +function plannedMcpJsonEntries(input: { + projectDir: string; + target: KtxAgentTarget; + scope: KtxAgentScope; +}): InstallEntry[] { + if (input.target === 'claude-code') { + const config = claudeConfigPath(input.projectDir, input.scope); + return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }]; + } + if (input.target === 'claude-desktop') { + return []; + } + if (input.target === 'cursor') { + const config = cursorConfigPath(input.projectDir, input.scope); + return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }]; + } + return []; +} + 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'); +} + export function plannedKtxAgentFiles(input: { projectDir: string; target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode; }): InstallEntry[] { + const withAdminCli = input.mode === 'mcp-cli'; + if (input.scope === 'global') { if (input.target === 'claude-code') { const home = process.env.HOME ?? ''; return [ - { kind: 'file', path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const }, - { kind: 'file', path: join(home, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' as const }, - { kind: 'file', path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const }, + { kind: 'file', path: join(home, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const }, + ...(withAdminCli + ? [ + { kind: 'file' as const, path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const }, + { kind: 'file' as const, path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const }, + ] + : []), ]; } if (input.target === 'codex') { const codexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'); return [ - { kind: 'file', path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const }, - { kind: 'file', path: join(codexHome, 'skills/ktx-research/SKILL.md'), role: 'research-skill' as const }, - { kind: 'file', path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const }, + { kind: 'file', path: join(codexHome, 'skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const }, + ...(withAdminCli + ? [ + { kind: 'file' as const, path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const }, + { kind: 'file' as const, path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const }, + ] + : []), ]; } if (input.target === 'cursor' || input.target === 'opencode') { return []; } + if (input.target === 'claude-desktop') { + return [{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const }]; + } throw new Error(`Global ${input.target} installation is not supported; omit --global.`); } const root = resolve(input.projectDir); + const analyticsEntries: Partial> = { + 'claude-code': [ + { kind: 'file', path: join(root, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + ], + codex: [ + { kind: 'file', path: join(root, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + ], + cursor: [ + { kind: 'file', path: join(root, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' }, + ], + opencode: [ + { kind: 'file', path: join(root, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' }, + ], + universal: [ + { kind: 'file', path: join(root, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + ], + 'claude-desktop': [], + }; const cliEntries: Partial> = { 'claude-code': [ { kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md'), role: 'skill' }, - { kind: 'file', path: join(root, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' }, ], codex: [ { kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md'), role: 'skill' }, - { kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' }, ], cursor: [ { kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') }, - { kind: 'file', path: join(root, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' }, ], opencode: [ { kind: 'file', path: join(root, '.opencode/commands/ktx.md') }, - { kind: 'file', path: join(root, '.opencode/commands/ktx-research.md'), role: 'research-skill' }, ], universal: [ { kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') }, - { kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' }, ], + 'claude-desktop': [], }; const ruleEntries: Partial> = { 'claude-code': { kind: 'file', path: join(root, '.claude/rules/ktx.md'), role: 'rule' }, codex: { kind: 'file', path: join(root, '.codex/instructions/ktx.md'), role: 'rule' }, }; - return [...(cliEntries[input.target] ?? []), ruleEntries[input.target]].filter( + return [ + ...(analyticsEntries[input.target] ?? []), + ...(withAdminCli ? (cliEntries[input.target] ?? []) : []), + ...(withAdminCli ? [ruleEntries[input.target]] : []), + ].filter( (entry): entry is InstallEntry => entry !== undefined, ); } @@ -292,8 +360,8 @@ function ktxCliLauncher(): KtxCliLauncher { }; } -async function readResearchSkillContent(): Promise { - const path = fileURLToPath(new URL('./skills/research/SKILL.md', import.meta.url)); +async function readAnalyticsSkillContent(): Promise { + const path = fileURLToPath(new URL('./skills/analytics/SKILL.md', import.meta.url)); const content = await readFile(path, 'utf-8'); return content.endsWith('\n') ? content : `${content}\n`; } @@ -319,11 +387,14 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun '', '# KTX Local Context', '', + 'This is an admin/developer CLI helper. End-user data agents should use the KTX MCP tools when available.', + '', `Use this project with \`--project-dir ${input.projectDir}\`.`, 'Commands are pinned to the local KTX CLI path that created this file, so agents do not need `ktx` in PATH.', 'If the CLI path no longer exists after moving this checkout or reinstalling KTX, rerun `ktx setup --agents`.', '', - 'Agents must not print secrets, credential references, environment variable values, or file contents from `.ktx/secrets`.', + 'Agents must not print secrets, credential references, environment variable values, or file contents from ' + + '`.ktx/secrets`.', '', 'Available commands:', '', @@ -349,9 +420,84 @@ 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 claudePluginMcpContent(input: { projectDir: string; launcher: KtxCliLauncher }): string { + return `${JSON.stringify( + { + mcpServers: { + ktx: { + type: 'stdio', + command: input.launcher.command, + args: [...input.launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'], + }, + }, + }, + null, + 2, + )}\n`; +} + +function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string { + return [ + '# KTX Claude Plugin', + '', + 'Install this plugin ZIP from Claude Desktop, then use KTX tools for local analytics questions.', + '', + `KTX project: \`${input.projectDir}\``, + '', + 'Included:', + '', + '- `ktx-analytics` skill for the MCP analytics workflow', + ...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []), + '- Local stdio MCP server launched through the KTX CLI', + '', + 'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.', + '', + ].join('\n'); +} + +async function writeClaudeDesktopPlugin(input: { + projectDir: string; + path: string; + mode: KtxAgentInstallMode; + launcher: KtxCliLauncher; +}): Promise { + const withAdminCli = input.mode === 'mcp-cli'; + const files: Record = { + '.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()), + 'version.json': strToU8(claudePluginVersionContent()), + '.mcp.json': strToU8(claudePluginMcpContent({ projectDir: input.projectDir, launcher: input.launcher })), + 'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()), + 'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })), + }; + 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 ruleInstructionContent(input: { projectDir: string }): string { return [ - `Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project (\`--project-dir ${input.projectDir}\`).`, + `Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` + + `(\`--project-dir ${input.projectDir}\`).`, '', 'Use when the user asks about data schemas, metrics, dimensions, database structure, or wants to run SQL queries.', '', @@ -387,7 +533,9 @@ async function writeManifest(projectDir: string, manifest: KtxAgentInstallManife } function entryKey(entry: InstallEntry): string { - return entry.kind === 'json-key' ? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}` : `${entry.kind}:${entry.path}`; + return entry.kind === 'json-key' + ? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}` + : `${entry.kind}:${entry.path}`; } function mergeManifest( @@ -452,6 +600,7 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter { const targetDisplayNames: Record = { 'claude-code': 'Claude Code', + 'claude-desktop': 'Claude Desktop', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', @@ -460,12 +609,25 @@ const targetDisplayNames: Record = { 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('.')})`; +} + +function targetSupportsGlobalScope(target: KtxAgentTarget): boolean { + return target === 'claude-code' || target === 'codex'; +} + +function effectiveInstallScope(target: KtxAgentTarget, requestedScope: KtxAgentScope): KtxAgentScope { + return target === 'claude-desktop' ? 'global' : requestedScope; +} + export function formatInstallSummary( installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, entries: InstallEntry[], @@ -483,11 +645,20 @@ export function formatInstallSummary( entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)), ); } + const mcpEntriesByTarget = new Map(); + for (const install of installs) { + const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, ...install }).map(entryKey)); + mcpEntriesByTarget.set( + install.target, + entries.filter((entry) => entry.kind === 'json-key' && plannedMcpKeys.has(entryKey(entry))), + ); + } const fileHints: Record = { - skill: 'teaches your agent which KTX commands to run', - rule: 'tells your agent when to use KTX', - 'research-skill': 'teaches your agent the KTX MCP research workflow', + 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 and local stdio MCP config for Claude Desktop', }; const lines: string[] = []; @@ -495,16 +666,30 @@ export function formatInstallSummary( const targetEntries = entriesByTarget.get(install.target) ?? []; lines.push(` ${targetDisplayNames[install.target]}`); for (const entry of targetEntries) { - const displayPath = - install.scope === 'global' ? entry.path : relative(projectDir, entry.path); if (entry.kind === 'file') { - const isRule = entry.role === 'rule' || fileEntryLabels[install.target] === 'Rule installed'; - const label = entry.role === 'research-skill' ? 'Research skill installed' : isRule ? 'Rule installed' : fileEntryLabels[install.target]; + const displayPath = + install.scope === 'global' && entry.role !== 'claude-plugin' ? entry.path : relative(projectDir, entry.path); + 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' + : isRule + ? 'Rule installed' + : fileEntryLabels[install.target]; const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? ''; lines.push(` + ${label} — ${hint}`); lines.push(` ${displayPath}`); } } + for (const entry of 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}`); + } } return lines.join('\n'); } @@ -519,11 +704,20 @@ async function installTarget(input: { const launcher = ktxCliLauncher(); for (const entry of entries) { if (entry.kind !== 'file') continue; + if (entry.role === 'claude-plugin') { + await writeClaudeDesktopPlugin({ + projectDir: input.projectDir, + path: entry.path, + mode: input.mode, + launcher, + }); + continue; + } const content = entry.role === 'rule' ? ruleInstructionContent({ projectDir: input.projectDir }) - : entry.role === 'research-skill' - ? await readResearchSkillContent() + : entry.role === 'analytics-skill' + ? await readAnalyticsSkillContent() : cliInstructionContent({ projectDir: input.projectDir, launcher }); await mkdir(dirname(entry.path), { recursive: true }); await writeFile(entry.path, content, 'utf-8'); @@ -555,14 +749,13 @@ export async function runKtxSetupAgentsStep( args.inputMode === 'disabled' ? args.mode : ((await prompts.select({ - message: 'How should agents use this KTX project?', + message: 'How should client agents connect to this KTX project?', options: [ - { value: 'cli', label: 'CLI tools and skills' }, - { value: 'skip', label: 'Skip' }, + { value: 'mcp', label: 'MCP tools + analytics skill' }, + { value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' }, ], - })) as KtxAgentInstallMode | 'skip' | 'back'); + })) as KtxAgentInstallMode | 'back'); if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir }; - if (mode === 'skip') return { status: 'skipped', projectDir: args.projectDir }; const targets = args.target !== undefined @@ -573,6 +766,7 @@ export async function runKtxSetupAgentsStep( message: withMultiselectNavigation('Which agent targets should KTX install?'), options: [ { value: 'claude-code', label: 'Claude Code' }, + { value: 'claude-desktop', label: 'Claude Desktop' }, { value: 'codex', label: 'Codex' }, { value: 'cursor', label: 'Cursor' }, { value: 'opencode', label: 'OpenCode' }, @@ -586,19 +780,46 @@ export async function runKtxSetupAgentsStep( return { status: 'missing-input', projectDir: args.projectDir }; } - const installs = targets.map((target) => ({ target, scope: args.scope, mode })); + const scopeTargets = targets.filter((target) => target !== 'claude-desktop'); + const selectedScope = + args.inputMode !== 'disabled' && + args.scope === 'project' && + scopeTargets.length > 0 && + scopeTargets.every(targetSupportsGlobalScope) + ? ((await prompts.select({ + message: 'Where should KTX install supported agent config?', + options: [ + { value: 'project', label: 'Project' }, + { value: 'global', label: 'Global' }, + ], + })) as KtxAgentScope | 'back') + : args.scope; + if (selectedScope === 'back') return { status: 'back', projectDir: args.projectDir }; + + const installs = targets.map((target) => ({ target, scope: effectiveInstallScope(target, selectedScope), mode })); const entries: InstallEntry[] = []; const snippets: string[] = []; const notices = new Set(); try { for (const install of installs) { entries.push(...(await installTarget({ projectDir: args.projectDir, ...install }))); - const mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, target: install.target, scope: install.scope }); - 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') { + notices.add('Install the generated KTX plugin ZIP from Claude Desktop Plugins, then restart or reload Claude.'); + } else { + const mcpResult = await installMcpClientConfig({ + projectDir: args.projectDir, + target: install.target, + scope: install.scope, + }); + entries.push(...mcpResult.entries); + for (const snippet of mcpResult.snippets) snippets.push(snippet); + for (const notice of mcpResult.notices) notices.add(notice); + } } - await writeManifest(args.projectDir, mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries)); + await writeManifest( + args.projectDir, + mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries), + ); await markAgentsComplete(args.projectDir); io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`); for (const snippet of snippets) { diff --git a/packages/cli/src/setup-demo-tour.test.ts b/packages/cli/src/setup-demo-tour.test.ts index f571f91b..1d57b010 100644 --- a/packages/cli/src/setup-demo-tour.test.ts +++ b/packages/cli/src/setup-demo-tour.test.ts @@ -184,7 +184,7 @@ describe('runDemoTour', () => { const mockAgents = vi.fn().mockResolvedValue({ status: 'ready', projectDir: '/tmp/test', - installs: [{ target: 'claude-code', scope: 'project', mode: 'cli' }], + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli' }], } satisfies KtxSetupAgentsResult); const navigation = vi.fn().mockResolvedValue('forward'); diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index 35640026..b2acb9f1 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -375,7 +375,7 @@ export async function runDemoTour( yes: false, agents: true, scope: 'project', - mode: 'cli', + mode: 'mcp-cli', skipAgents: false, }, io, diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index ff1261c3..efc44441 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -232,7 +232,10 @@ describe('setup status', () => { version: 1, projectDir: tempDir, installedAt: '2026-05-07T00:00:00.000Z', - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [ + { target: 'codex', scope: 'project', mode: 'mcp' }, + { target: 'codex', scope: 'project', mode: 'mcp-cli' }, + ], entries: [], }, null, @@ -1466,7 +1469,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], }; }, }, @@ -1518,7 +1521,7 @@ describe('setup status', () => { agents: async () => ({ status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], }), }, ), @@ -1569,7 +1572,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], }; }, }, @@ -1584,7 +1587,7 @@ describe('setup status', () => { const agents = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, - installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'cli' as const }], + installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'mcp-cli' as const }], })); await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8'); @@ -1687,7 +1690,7 @@ describe('setup status', () => { version: 1, projectDir: tempDir, installedAt: '2026-05-07T00:00:00.000Z', - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], entries: [], }, null, @@ -1750,7 +1753,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], }; }, }, @@ -1842,7 +1845,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], }; }, }, @@ -1859,7 +1862,7 @@ describe('setup status', () => { const agents = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, - installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'cli' as const }], + installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'mcp-cli' as const }], })); await expect( diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index cf458f1d..16ccaea9 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -309,12 +309,15 @@ export async function readKtxSetupStatus(projectDir: string): Promise ({ + const agentMap = new Map(); + for (const install of manifest?.installs ?? []) { + agentMap.set(`${install.target}:${install.scope}`, { target: install.target, scope: install.scope, ready: true, - })) ?? []; + }); + } + const agents = [...agentMap.values()]; return { project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir }, @@ -696,7 +699,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup agents: true, ...(args.target ? { target: args.target } : {}), scope: args.agentScope ?? 'project', - mode: 'cli', + mode: 'mcp', skipAgents: false, }, io, diff --git a/packages/cli/src/skills/research/SKILL.md b/packages/cli/src/skills/analytics/SKILL.md similarity index 62% rename from packages/cli/src/skills/research/SKILL.md rename to packages/cli/src/skills/analytics/SKILL.md index e8e354a3..ae358e4b 100644 --- a/packages/cli/src/skills/research/SKILL.md +++ b/packages/cli/src/skills/analytics/SKILL.md @@ -1,23 +1,25 @@ --- -name: ktx-research -description: Use when answering a question that needs data from a KTX-connected database - investigating, analyzing, "how many", "show me", "what's the breakdown of", finding records by value, exploring tables, comparing periods, or any data-investigation request. Triggers even when the user does not say "research"; if the answer requires querying a configured KTX connection, this skill applies. +name: ktx-analytics +description: Use when answering a question that needs data from a KTX-connected database - investigating, analyzing, "how many", "show me", "what's the breakdown of", finding records by value, exploring tables, comparing periods, explaining metrics, or any data-analysis request. Triggers even when the user does not say "analytics"; if the answer requires querying a configured KTX connection, this skill applies. --- -# KTX Research Workflow +# KTX Analytics Workflow -You have access to KTX MCP tools for investigating data. Follow this workflow. +You have access to KTX MCP tools for data discovery, semantic-layer analysis, raw read-only SQL, wiki context, and memory capture. Follow this workflow. -1. **Discover** - call `discover_data` first to see what exists across wiki, semantic-layer sources, and raw tables. Returns refs only. +1. **Discover** - call `discover_data` first to see what exists across wiki pages, semantic-layer sources, metrics, dimensions, raw tables, and columns. Returns refs only. 2. **Inspect top hits in parallel** - for each promising ref: - `kind: 'wiki'` -> `wiki_read` - `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source` - `kind: 'table'` or `kind: 'column'` -> `entity_details` -3. **Resolve literals** - if the user named a value such as "Acme Corp" or "status=shipped", call `dictionary_search` to find which column holds it. -4. **Query** - +3. **Resolve business values** - if the user named a value such as "Acme Corp", "enterprise", or "status=shipped", call `dictionary_search` to find which column holds it. +4. **Plan the analysis** - identify the grain, metrics, dimensions, filters, time window, and expected row limits before querying. +5. **Query** - - Prefer `sl_query` when the semantic layer covers the question. - Use `sql_execution` only for questions the semantic layer does not cover. -5. **Capture learnings** - at the end of the turn, call `memory_capture` so future turns benefit. Skip when the answer carries no durable knowledge. +6. **Validate and explain** - sanity-check totals, filters, null handling, and time zones. State the source tables or semantic-layer objects used. +7. **Capture durable learnings** - at the end of the turn, call `memory_capture` when the investigation produced reusable business context, metric definitions, or schema knowledge. @@ -26,6 +28,8 @@ You have access to KTX MCP tools for investigating data. Follow this workflow. - Read entity details before writing SQL against an unfamiliar table. Do not assume column names. - Treat `sql_execution` as read-only. Writes are rejected by the server. - Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent. +- Show compact result tables for small outputs. For broad results, summarize the top findings and mention the applied limit. +- Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22e0c035..39b25c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: commander: specifier: 14.0.3 version: 14.0.3 + fflate: + specifier: ^0.8.2 + version: 0.8.2 ink: specifier: ^7.0.2 version: 7.0.2(@types/react@19.2.14)(react@19.2.6) @@ -3191,6 +3194,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -8355,6 +8361,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} + file-uri-to-path@1.0.0: {} finalhandler@2.1.1: