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
+
@@ -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: