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.
This commit is contained in:
Andrey Avtomonov 2026-05-15 15:50:16 +02:00
parent 2de4dd2c1b
commit 960e23b1c3
18 changed files with 909 additions and 211 deletions

View file

@ -6,6 +6,8 @@
<strong>The context layer for analytics agents</strong> <strong>The context layer for analytics agents</strong>
</p> </p>
<p align="center">by Kaelio</p>
<p align="center"> <p align="center">
<a href="https://www.npmjs.com/package/@kaelio/ktx"><img src="https://img.shields.io/npm/v/@kaelio/ktx?style=flat-square&color=f97316" alt="npm version" /></a> <a href="https://www.npmjs.com/package/@kaelio/ktx"><img src="https://img.shields.io/npm/v/@kaelio/ktx?style=flat-square&color=f97316" alt="npm version" /></a>
<a href="https://codecov.io/gh/Kaelio/ktx"><img src="https://codecov.io/gh/Kaelio/ktx/branch/main/graph/badge.svg" alt="Codecov" /></a> <a href="https://codecov.io/gh/Kaelio/ktx"><img src="https://codecov.io/gh/Kaelio/ktx/branch/main/graph/badge.svg" alt="Codecov" /></a>
@ -161,11 +163,23 @@ source packages for development, not public release artifacts.
## Use KTX with agents ## Use KTX with agents
KTX integrates with coding agents through CLI skills. The setup wizard KTX exposes context to end-user agents through MCP tools. The CLI remains the
configures this automatically. admin surface for setup, ingest, status, daemon lifecycle, and debugging.
**CLI skills** - the agent calls `ktx` commands directly through a skill file ```bash
installed in your agent's config (e.g., `.claude/skills/ktx/SKILL.md`): 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 ```bash
ktx sl query --measure orders.revenue --dimension orders.status --format sql ktx sl query --measure orders.revenue --dimension orders.status --format sql
@ -173,8 +187,11 @@ ktx wiki search "revenue definition"
ktx sl validate orders ktx sl validate orders
``` ```
Supported agents: Claude Code, Codex, Cursor, OpenCode, and any agent that Supported client agents: Claude Code, Claude Desktop, Codex, Cursor, OpenCode,
reads `.agents/` skills. 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 ## Workspace packages

View file

@ -1,18 +1,26 @@
--- ---
title: Agent Clients 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 KTX exposes context to end-user agents through MCP tools. The CLI remains the
files teach agents to call public `ktx` commands directly from the terminal for admin surface for setup, ingest, status, daemon lifecycle, and debugging.
semantic-layer context and wiki knowledge.
Run `ktx setup` and select your agent targets, or configure manually using the Run `ktx setup` and select your client agent targets, or configure manually
snippets below. Setup pins generated skill files to the KTX CLI path that using the snippets below. Choose **MCP tools + analytics skill** for client
created them, so agents do not need `ktx` on `PATH`. 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 ## Install with setup
Start the MCP server before connecting an end-user agent:
```bash
ktx mcp start
```
Then install client integration:
```bash ```bash
ktx setup --agents ktx setup --agents
``` ```
@ -23,7 +31,8 @@ Use `--target` for one target:
ktx setup --agents --target codex 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 ```bash
ktx setup --agents --target claude-code --global 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 manifest lets status checks report agent readiness and lets future cleanup
remove only files KTX installed. 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 ## Generated files
| Target | Project-scoped files | Global files | KTX writes MCP client configuration and an analytics skill by default. It writes
|--------|----------------------|--------------| admin CLI skills only when you choose **MCP tools + analytics skill + admin CLI
| Claude Code | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` | `~/.claude/skills/ktx/SKILL.md`, `~/.claude/rules/ktx.md` | skill**.
| 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 |
Skill files list pinned `ktx` commands. Rule files tell the agent when KTX is | Target | MCP tools + analytics skill | Adds with admin CLI skill |
appropriate, such as data schemas, metrics, dimensions, database structure, and |--------|------------------------------|---------------------------|
SQL questions. | 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 ## Claude Code
@ -56,13 +100,16 @@ During setup, select **Claude Code** from the agent targets. KTX writes:
| Scope | Files | | Scope | Files |
|-------|-------| |-------|-------|
| Project | `.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/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. Both project-scoped and global installations are supported.
### Manual CLI skills configuration ### 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`: Create `.claude/skills/ktx/SKILL.md`:
```markdown title=".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 status --json --project-dir /path/to/project`
- `ktx sl list --json --project-dir /path/to/project` - `ktx sl list --json --project-dir /path/to/project`
- `ktx sl search '<text>' --json --project-dir /path/to/project --connection-id '<id>'` - `ktx sl search '<text>' --json --project-dir /path/to/project --connection-id '<id>'`
- `ktx sl query --json --project-dir /path/to/project --connection-id '<id>' --query-file '<path>' --execute --max-rows 100` - `ktx sl query --json --project-dir /path/to/project --connection-id '<id>'`
- `ktx wiki search '<query>' --json --project-dir /path/to/project --limit 10` - `ktx wiki search '<query>' --json --project-dir /path/to/project --limit 10`
``` ```
### Workflow tips ### Workflow tips
- Claude Code discovers skills automatically from `.claude/skills/`. - 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. - Claude rules in `.claude/rules/` tell Claude when KTX should be used.
- Global installation makes KTX available in all projects without per-project setup. - 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. - 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 | | 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. Cursor supports project-scoped installation only.
### Manual CLI rules configuration ### 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 ### 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 ## Codex
### Install via `ktx setup` ### Install via `ktx setup`
@ -119,15 +192,19 @@ During setup, select **Codex** from the agent targets. KTX writes:
| Scope | Files | | Scope | Files |
|-------|-------| |-------|-------|
| Project | `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` | | Project | MCP snippet, `.agents/skills/ktx-analytics/SKILL.md`; optional `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` |
| Global | `$CODEX_HOME/skills/ktx/SKILL.md`, `$CODEX_HOME/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` Both project-scoped and global installations are supported. `CODEX_HOME`
defaults to `~/.codex`. defaults to `~/.codex`.
### Manual CLI skills configuration ### 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 ### Workflow tips
@ -146,13 +223,18 @@ During setup, select **OpenCode** from the agent targets. KTX writes:
| Mode | File | | 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. OpenCode supports project-scoped installation only.
### Manual CLI commands configuration ### 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 ### Workflow tips
@ -163,7 +245,7 @@ Create `.opencode/commands/ktx.md` with the same command definitions as Claude C
## Command reference ## Command reference
All supported agent clients call the same KTX CLI commands: Admin CLI skills call the same KTX CLI commands:
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
@ -183,9 +265,11 @@ All supported agent clients call the same KTX CLI commands:
## Comparison ## Comparison
| | Claude Code | Cursor | Codex | OpenCode | | | Claude Code | Claude Desktop | Cursor | Codex | OpenCode |
|---|---|---|---|---| |---|---|---|---|---|---|
| CLI skills | Yes | Yes (.mdc) | Yes | Yes | | MCP tools | Yes | Local stdio via plugin | Yes | Snippet | Snippet |
| Global install | Yes | No | Yes | No | | 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` |
| Rule or instruction file | `.claude/rules/ktx.md` | `.cursor/rules/ktx.mdc` | `.codex/instructions/ktx.md` | `.opencode/commands/ktx.md` | | Admin CLI skills | Optional | Optional in plugin ZIP | Optional (.mdc) | Optional | Optional |
| Skill file | `.claude/skills/ktx/SKILL.md` | Not separate | `.agents/skills/ktx/SKILL.md` | Not separate | | 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 |

View file

@ -48,6 +48,7 @@
"@ktx/llm": "workspace:*", "@ktx/llm": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"commander": "14.0.3", "commander": "14.0.3",
"fflate": "^0.8.2",
"ink": "^7.0.2", "ink": "^7.0.2",
"react": "^19.2.6", "react": "^19.2.6",
"zod": "^4.4.3" "zod": "^4.4.3"

View file

@ -39,6 +39,7 @@ export interface KtxCliDeps {
stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon; stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon;
readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus; readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus;
runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer; runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer;
runStdioServer?: typeof import('./mcp-stdio-server.js').runKtxMcpStdioServer;
}; };
} }

View file

@ -35,6 +35,7 @@ describe('registerMcpCommands', () => {
'serve-internal', 'serve-internal',
'start', 'start',
'status', 'status',
'stdio',
'stop', 'stop',
]); ]);
expect( expect(
@ -54,4 +55,21 @@ describe('registerMcpCommands', () => {
); );
expect(startDaemon).not.toHaveBeenCalled(); expect(startDaemon).not.toHaveBeenCalled();
}); });
it('runs the stdio server with the resolved project directory', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
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,
});
});
}); });

View file

@ -15,6 +15,7 @@ import {
stopKtxMcpDaemon, stopKtxMcpDaemon,
} from '../managed-mcp-daemon.js'; } from '../managed-mcp-daemon.js';
import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js'; import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js';
import { runKtxMcpStdioServer } from '../mcp-stdio-server.js';
function tokenFromOption(value: string | undefined): string | undefined { function tokenFromOption(value: string | undefined): string | undefined {
return value ?? process.env.KTX_MCP_TOKEN; return value ?? process.env.KTX_MCP_TOKEN;
@ -27,6 +28,17 @@ function binPath(): string {
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void { export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server'); 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 mcp
.command('start') .command('start')
.description('Start the KTX MCP HTTP server') .description('Start the KTX MCP HTTP server')

View file

@ -218,6 +218,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.addOption( .addOption(
new Option('--target <target>', 'Agent target').choices([ new Option('--target <target>', 'Agent target').choices([
'claude-code', 'claude-code',
'claude-desktop',
'codex', 'codex',
'cursor', 'cursor',
'opencode', 'opencode',

View file

@ -1,16 +1,11 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp'; import { loadKtxProject } from '@ktx/context/project';
import { createLocalProjectMemoryCapture } from '@ktx/context/memory';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; import { createKtxMcpServerFactory } from './mcp-server-factory.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'] as const; const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'] as const;
@ -124,13 +119,6 @@ export interface RunKtxMcpHttpServerOptions extends McpSecurityConfigInput {
loadProject?: typeof loadKtxProject; loadProject?: typeof loadKtxProject;
} }
function noopIo(): KtxCliIo {
return {
stdout: { write() {} },
stderr: { write() {} },
};
}
function writeJson(res: ServerResponse, status: number, body: object): void { function writeJson(res: ServerResponse, status: number, body: object): void {
const payload = `${JSON.stringify(body)}\n`; const payload = `${JSON.stringify(body)}\n`;
res.writeHead(status, { res.writeHead(status, {
@ -159,55 +147,6 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
return raw.trim().length === 0 ? undefined : (JSON.parse(raw) as unknown); 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<typeof createLocalProjectMemoryCapture> | 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 { function listenerPort(server: Server, fallback: number): number {
const address = server.address(); const address = server.address();
return typeof address === 'object' && address ? address.port : fallback; return typeof address === 'object' && address ? address.port : fallback;
@ -233,7 +172,7 @@ export async function runKtxMcpHttpServer(options: RunKtxMcpHttpServerOptions):
: undefined; : undefined;
const createMcpServer = const createMcpServer =
options.createMcpServer ?? options.createMcpServer ??
(await defaultMcpServerFactory({ (await createKtxMcpServerFactory({
project: project!, project: project!,
projectDir: options.projectDir, projectDir: options.projectDir,
cliVersion: options.cliVersion ?? '0.0.0-private', cliVersion: options.cliVersion ?? '0.0.0-private',

View file

@ -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<typeof createLocalProjectMemoryCapture> | 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,
});
}

View file

@ -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<void> {
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<void>((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);
});
}

View file

@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { readKtxSetupState } from '@ktx/context/project'; import { readKtxSetupState } from '@ktx/context/project';
import { strFromU8, unzipSync } from 'fflate';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
formatInstallSummary, formatInstallSummary,
@ -24,6 +25,13 @@ function makeIo() {
}; };
} }
async function readZipText(path: string, entry: string): Promise<string> {
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', () => { describe('setup agents', () => {
let tempDir: string; let tempDir: string;
@ -37,28 +45,52 @@ describe('setup agents', () => {
await rm(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
}); });
it('plans project-scoped CLI and research files for every target', () => { it('plans project-scoped MCP analytics files for every target', () => {
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'cli' })).toEqual([ 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/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' }, { 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/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' }, { 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.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.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/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, agents: true,
target: 'universal', target: 'universal',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -82,7 +114,7 @@ describe('setup agents', () => {
).resolves.toEqual({ ).resolves.toEqual({
status: 'ready', status: 'ready',
projectDir: tempDir, 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(); await expect(stat(join(tempDir, '.agents/skills/ktx/SKILL.md'))).resolves.toBeDefined();
@ -96,13 +128,13 @@ describe('setup agents', () => {
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({ expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
version: 1, version: 1,
projectDir: tempDir, 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(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] });
expect(io.stderr()).toBe(''); 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(); const io = makeIo();
await expect( await expect(
@ -114,17 +146,17 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'universal', target: 'universal',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
), ),
).resolves.toMatchObject({ status: 'ready' }); ).resolves.toMatchObject({ status: 'ready' });
const researchSkill = await readFile(join(tempDir, '.agents/skills/ktx-research/SKILL.md'), 'utf-8'); const analyticsSkill = await readFile(join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), 'utf-8');
expect(researchSkill).toContain('name: ktx-research'); expect(analyticsSkill).toContain('name: ktx-analytics');
expect(researchSkill).toContain('Always run `discover_data` before writing SQL.'); expect(analyticsSkill).toContain('Always run `discover_data` before writing SQL.');
expect(researchSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.'); expect(analyticsSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.');
}); });
it('writes PATH-independent launcher commands for skills', async () => { it('writes PATH-independent launcher commands for skills', async () => {
@ -139,7 +171,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'universal', target: 'universal',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -165,7 +197,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'claude-code', target: 'claude-code',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -182,6 +214,198 @@ describe('setup agents', () => {
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.'); 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 () => { it('writes Cursor project MCP config', async () => {
const io = makeIo(); const io = makeIo();
@ -193,7 +417,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'cursor', target: 'cursor',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -205,7 +429,7 @@ describe('setup agents', () => {
expect(cursorJson.mcpServers.ktx).toEqual({ url: 'http://localhost:7878/mcp' }); 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(); const codexIo = makeIo();
await runKtxSetupAgentsStep( await runKtxSetupAgentsStep(
{ {
@ -215,7 +439,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'codex', target: 'codex',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
codexIo.io, codexIo.io,
@ -232,7 +456,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'opencode', target: 'opencode',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
opencodeIo.io, opencodeIo.io,
@ -240,6 +464,23 @@ describe('setup agents', () => {
expect(opencodeIo.stdout()).toContain('"mcp"'); expect(opencodeIo.stdout()).toContain('"mcp"');
expect(opencodeIo.stdout()).toContain('"type": "remote"'); expect(opencodeIo.stdout()).toContain('"type": "remote"');
await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow(); 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 () => { it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => {
@ -275,7 +516,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'claude-code', target: 'claude-code',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -309,7 +550,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'claude-code', target: 'claude-code',
scope: 'local', scope: 'local',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -335,7 +576,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'claude-code', target: 'claude-code',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -350,6 +591,30 @@ describe('setup agents', () => {
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null); 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 () => { it('treats cancel as skip in interactive mode', async () => {
const io = makeIo(); const io = makeIo();
const prompts = { const prompts = {
@ -366,7 +631,7 @@ describe('setup agents', () => {
yes: false, yes: false,
agents: true, agents: true,
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -378,7 +643,7 @@ describe('setup agents', () => {
it('explains how to select multiple agent targets in interactive mode', async () => { it('explains how to select multiple agent targets in interactive mode', async () => {
const io = makeIo(); const io = makeIo();
const prompts = { const prompts = {
select: vi.fn(async () => 'cli'), select: vi.fn(async () => 'mcp-cli'),
multiselect: vi.fn(async () => ['back']), multiselect: vi.fn(async () => ['back']),
cancel: vi.fn(), cancel: vi.fn(),
}; };
@ -391,7 +656,7 @@ describe('setup agents', () => {
yes: false, yes: false,
agents: true, agents: true,
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -418,7 +683,7 @@ describe('setup agents', () => {
agents: true, agents: true,
target: 'claude-code', target: 'claude-code',
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io.io, io.io,
@ -427,21 +692,28 @@ describe('setup agents', () => {
const output = io.stdout(); const output = io.stdout();
expect(output).toContain('Agent integration complete'); expect(output).toContain('Agent integration complete');
expect(output).toContain('Claude Code'); 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('.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'); expect(output).toContain('.claude/rules/ktx.md');
}); });
it('formats summary with relative paths for project scope', () => { it('formats summary with relative paths for project scope', () => {
const summary = formatInstallSummary( const summary = formatInstallSummary(
[{ target: 'cursor', scope: 'project', mode: 'cli' }], [{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }],
[{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') }], [
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
],
tempDir, tempDir,
); );
expect(summary).toContain('Cursor'); 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).toContain('.cursor/rules/ktx.mdc');
expect(summary).not.toContain(tempDir); expect(summary).not.toContain(tempDir);
}); });
@ -449,12 +721,14 @@ describe('setup agents', () => {
it('formats summary with multiple agent targets', () => { it('formats summary with multiple agent targets', () => {
const summary = formatInstallSummary( const summary = formatInstallSummary(
[ [
{ target: 'claude-code', scope: 'project', mode: 'cli' }, { target: 'claude-code', scope: 'project', mode: 'mcp-cli' },
{ target: 'codex', scope: 'project', mode: '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/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' }, { 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, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' }, { 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('Claude Code');
expect(summary).toContain('+ Skill installed — teaches your agent which KTX commands to run'); expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
expect(summary).toContain('+ Rule installed — tells your agent when to use KTX'); 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('Codex');
expect(summary).toContain('.agents/skills/ktx-analytics/SKILL.md');
expect(summary).toContain('.agents/skills/ktx/SKILL.md'); expect(summary).toContain('.agents/skills/ktx/SKILL.md');
}); });
}); });

View file

@ -7,6 +7,7 @@ import {
markKtxSetupStateStepComplete, markKtxSetupStateStepComplete,
serializeKtxProjectConfig, serializeKtxProjectConfig,
} from '@ktx/context/project'; } from '@ktx/context/project';
import { strToU8, zipSync } from 'fflate';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { withMultiselectNavigation } from './prompt-navigation.js'; import { withMultiselectNavigation } from './prompt-navigation.js';
import { import {
@ -15,9 +16,9 @@ import {
} from './setup-prompts.js'; } from './setup-prompts.js';
import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.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 KtxAgentScope = 'project' | 'global' | 'local';
export type KtxAgentInstallMode = 'cli'; export type KtxAgentInstallMode = 'mcp' | 'mcp-cli';
export interface KtxSetupAgentsArgs { export interface KtxSetupAgentsArgs {
projectDir: string; projectDir: string;
@ -47,7 +48,7 @@ export interface KtxAgentInstallManifest {
installedAt: string; installedAt: string;
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
entries: Array< 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[] } | { 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[] } { function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } {
const home = process.env.HOME ?? ''; const home = process.env.HOME ?? '';
if (scope === 'global') { if (scope === 'global') {
@ -213,74 +222,133 @@ async function installMcpClientConfig(input: {
} else if (input.target === 'codex') { } else if (input.target === 'codex') {
snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`); snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
} else if (input.target === 'opencode') { } 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)}`); snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`);
} else if (input.target === 'universal') {
snippets.push(universalMcpSnippet(endpoint));
} }
return { entries, snippets, notices }; 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 { export function agentInstallManifestPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/install-manifest.json'); 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: { export function plannedKtxAgentFiles(input: {
projectDir: string; projectDir: string;
target: KtxAgentTarget; target: KtxAgentTarget;
scope: KtxAgentScope; scope: KtxAgentScope;
mode: KtxAgentInstallMode; mode: KtxAgentInstallMode;
}): InstallEntry[] { }): InstallEntry[] {
const withAdminCli = input.mode === 'mcp-cli';
if (input.scope === 'global') { if (input.scope === 'global') {
if (input.target === 'claude-code') { if (input.target === 'claude-code') {
const home = process.env.HOME ?? ''; const home = process.env.HOME ?? '';
return [ return [
{ kind: 'file', path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const }, { kind: 'file', path: join(home, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const },
{ kind: 'file', path: join(home, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' as const }, ...(withAdminCli
{ kind: 'file', path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const }, ? [
{ 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') { if (input.target === 'codex') {
const codexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'); const codexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex');
return [ return [
{ kind: 'file', path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const }, { kind: 'file', path: join(codexHome, 'skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const },
{ kind: 'file', path: join(codexHome, 'skills/ktx-research/SKILL.md'), role: 'research-skill' as const }, ...(withAdminCli
{ kind: 'file', path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const }, ? [
{ 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') { if (input.target === 'cursor' || input.target === 'opencode') {
return []; 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.`); throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
} }
const root = resolve(input.projectDir); const root = resolve(input.projectDir);
const analyticsEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
'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<Record<KtxAgentTarget, InstallEntry[]>> = { const cliEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
'claude-code': [ 'claude-code': [
{ kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md'), role: 'skill' }, { 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: [ codex: [
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md'), role: 'skill' }, { 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: [ cursor: [
{ kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') }, { kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') },
{ kind: 'file', path: join(root, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' },
], ],
opencode: [ opencode: [
{ kind: 'file', path: join(root, '.opencode/commands/ktx.md') }, { kind: 'file', path: join(root, '.opencode/commands/ktx.md') },
{ kind: 'file', path: join(root, '.opencode/commands/ktx-research.md'), role: 'research-skill' },
], ],
universal: [ universal: [
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') }, { 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<Record<KtxAgentTarget, InstallEntry>> = { const ruleEntries: Partial<Record<KtxAgentTarget, InstallEntry>> = {
'claude-code': { kind: 'file', path: join(root, '.claude/rules/ktx.md'), role: 'rule' }, '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' }, 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, (entry): entry is InstallEntry => entry !== undefined,
); );
} }
@ -292,8 +360,8 @@ function ktxCliLauncher(): KtxCliLauncher {
}; };
} }
async function readResearchSkillContent(): Promise<string> { async function readAnalyticsSkillContent(): Promise<string> {
const path = fileURLToPath(new URL('./skills/research/SKILL.md', import.meta.url)); const path = fileURLToPath(new URL('./skills/analytics/SKILL.md', import.meta.url));
const content = await readFile(path, 'utf-8'); const content = await readFile(path, 'utf-8');
return content.endsWith('\n') ? content : `${content}\n`; return content.endsWith('\n') ? content : `${content}\n`;
} }
@ -319,11 +387,14 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'', '',
'# KTX Local Context', '# 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}\`.`, `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.', '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`.', '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:', 'Available commands:',
'', '',
@ -349,9 +420,84 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
].join('\n'); ].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<void> {
const withAdminCli = input.mode === 'mcp-cli';
const files: Record<string, Uint8Array> = {
'.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 { function ruleInstructionContent(input: { projectDir: string }): string {
return [ 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.', '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 { 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( function mergeManifest(
@ -452,6 +600,7 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
const targetDisplayNames: Record<KtxAgentTarget, string> = { const targetDisplayNames: Record<KtxAgentTarget, string> = {
'claude-code': 'Claude Code', 'claude-code': 'Claude Code',
'claude-desktop': 'Claude Desktop',
codex: 'Codex', codex: 'Codex',
cursor: 'Cursor', cursor: 'Cursor',
opencode: 'OpenCode', opencode: 'OpenCode',
@ -460,12 +609,25 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
const fileEntryLabels: Record<KtxAgentTarget, string> = { const fileEntryLabels: Record<KtxAgentTarget, string> = {
'claude-code': 'Skill installed', 'claude-code': 'Skill installed',
'claude-desktop': 'Skill installed',
codex: 'Skill installed', codex: 'Skill installed',
cursor: 'Rule installed', cursor: 'Rule installed',
opencode: 'Command installed', opencode: 'Command installed',
universal: 'Skill installed', universal: 'Skill installed',
}; };
function mcpEntryLabel(entry: Extract<InstallEntry, { kind: 'json-key' }>): string {
return `MCP config installed — connects client agents to KTX MCP tools (${entry.jsonPath.join('.')})`;
}
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( export function formatInstallSummary(
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
entries: InstallEntry[], entries: InstallEntry[],
@ -483,11 +645,20 @@ export function formatInstallSummary(
entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)), entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)),
); );
} }
const mcpEntriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
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<string, string> = { const fileHints: Record<string, string> = {
skill: 'teaches your agent which KTX commands to run', skill: 'teaches admin agents which KTX CLI commands to run',
rule: 'tells your agent when to use KTX', rule: 'tells admin agents when to use KTX CLI',
'research-skill': 'teaches your agent the KTX MCP research workflow', '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[] = []; const lines: string[] = [];
@ -495,16 +666,30 @@ export function formatInstallSummary(
const targetEntries = entriesByTarget.get(install.target) ?? []; const targetEntries = entriesByTarget.get(install.target) ?? [];
lines.push(` ${targetDisplayNames[install.target]}`); lines.push(` ${targetDisplayNames[install.target]}`);
for (const entry of targetEntries) { for (const entry of targetEntries) {
const displayPath =
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
if (entry.kind === 'file') { if (entry.kind === 'file') {
const isRule = entry.role === 'rule' || fileEntryLabels[install.target] === 'Rule installed'; const displayPath =
const label = entry.role === 'research-skill' ? 'Research skill installed' : isRule ? 'Rule installed' : fileEntryLabels[install.target]; 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')] ?? ''; const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
lines.push(` + ${label}${hint}`); lines.push(` + ${label}${hint}`);
lines.push(` ${displayPath}`); lines.push(` ${displayPath}`);
} }
} }
for (const entry of mcpEntriesByTarget
.get(install.target)
?.filter((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key') ?? []) {
const displayPath = install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
lines.push(` + ${mcpEntryLabel(entry)}`);
lines.push(` ${displayPath}`);
}
} }
return lines.join('\n'); return lines.join('\n');
} }
@ -519,11 +704,20 @@ async function installTarget(input: {
const launcher = ktxCliLauncher(); const launcher = ktxCliLauncher();
for (const entry of entries) { for (const entry of entries) {
if (entry.kind !== 'file') continue; 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 = const content =
entry.role === 'rule' entry.role === 'rule'
? ruleInstructionContent({ projectDir: input.projectDir }) ? ruleInstructionContent({ projectDir: input.projectDir })
: entry.role === 'research-skill' : entry.role === 'analytics-skill'
? await readResearchSkillContent() ? await readAnalyticsSkillContent()
: cliInstructionContent({ projectDir: input.projectDir, launcher }); : cliInstructionContent({ projectDir: input.projectDir, launcher });
await mkdir(dirname(entry.path), { recursive: true }); await mkdir(dirname(entry.path), { recursive: true });
await writeFile(entry.path, content, 'utf-8'); await writeFile(entry.path, content, 'utf-8');
@ -555,14 +749,13 @@ export async function runKtxSetupAgentsStep(
args.inputMode === 'disabled' args.inputMode === 'disabled'
? args.mode ? args.mode
: ((await prompts.select({ : ((await prompts.select({
message: 'How should agents use this KTX project?', message: 'How should client agents connect to this KTX project?',
options: [ options: [
{ value: 'cli', label: 'CLI tools and skills' }, { value: 'mcp', label: 'MCP tools + analytics skill' },
{ value: 'skip', label: 'Skip' }, { 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 === 'back') return { status: 'skipped', projectDir: args.projectDir };
if (mode === 'skip') return { status: 'skipped', projectDir: args.projectDir };
const targets = const targets =
args.target !== undefined args.target !== undefined
@ -573,6 +766,7 @@ export async function runKtxSetupAgentsStep(
message: withMultiselectNavigation('Which agent targets should KTX install?'), message: withMultiselectNavigation('Which agent targets should KTX install?'),
options: [ options: [
{ value: 'claude-code', label: 'Claude Code' }, { value: 'claude-code', label: 'Claude Code' },
{ value: 'claude-desktop', label: 'Claude Desktop' },
{ value: 'codex', label: 'Codex' }, { value: 'codex', label: 'Codex' },
{ value: 'cursor', label: 'Cursor' }, { value: 'cursor', label: 'Cursor' },
{ value: 'opencode', label: 'OpenCode' }, { value: 'opencode', label: 'OpenCode' },
@ -586,19 +780,46 @@ export async function runKtxSetupAgentsStep(
return { status: 'missing-input', projectDir: args.projectDir }; 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 entries: InstallEntry[] = [];
const snippets: string[] = []; const snippets: string[] = [];
const notices = new Set<string>(); const notices = new Set<string>();
try { try {
for (const install of installs) { for (const install of installs) {
entries.push(...(await installTarget({ projectDir: args.projectDir, ...install }))); entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
const mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, target: install.target, scope: install.scope }); if (install.target === 'claude-desktop') {
entries.push(...mcpResult.entries); notices.add('Install the generated KTX plugin ZIP from Claude Desktop Plugins, then restart or reload Claude.');
for (const snippet of mcpResult.snippets) snippets.push(snippet); } else {
for (const notice of mcpResult.notices) notices.add(notice); 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); await markAgentsComplete(args.projectDir);
io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`); io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`);
for (const snippet of snippets) { for (const snippet of snippets) {

View file

@ -184,7 +184,7 @@ describe('runDemoTour', () => {
const mockAgents = vi.fn().mockResolvedValue({ const mockAgents = vi.fn().mockResolvedValue({
status: 'ready', status: 'ready',
projectDir: '/tmp/test', projectDir: '/tmp/test',
installs: [{ target: 'claude-code', scope: 'project', mode: 'cli' }], installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli' }],
} satisfies KtxSetupAgentsResult); } satisfies KtxSetupAgentsResult);
const navigation = vi.fn().mockResolvedValue('forward'); const navigation = vi.fn().mockResolvedValue('forward');

View file

@ -375,7 +375,7 @@ export async function runDemoTour(
yes: false, yes: false,
agents: true, agents: true,
scope: 'project', scope: 'project',
mode: 'cli', mode: 'mcp-cli',
skipAgents: false, skipAgents: false,
}, },
io, io,

View file

@ -232,7 +232,10 @@ describe('setup status', () => {
version: 1, version: 1,
projectDir: tempDir, projectDir: tempDir,
installedAt: '2026-05-07T00:00:00.000Z', 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: [], entries: [],
}, },
null, null,
@ -1466,7 +1469,7 @@ describe('setup status', () => {
return { return {
status: 'ready', status: 'ready',
projectDir: tempDir, 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 () => ({ agents: async () => ({
status: 'ready', status: 'ready',
projectDir: tempDir, 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 { return {
status: 'ready', status: 'ready',
projectDir: tempDir, 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 () => ({ const agents = vi.fn(async () => ({
status: 'ready' as const, status: 'ready' as const,
projectDir: tempDir, 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'); await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
@ -1687,7 +1690,7 @@ describe('setup status', () => {
version: 1, version: 1,
projectDir: tempDir, projectDir: tempDir,
installedAt: '2026-05-07T00:00:00.000Z', installedAt: '2026-05-07T00:00:00.000Z',
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }], installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
entries: [], entries: [],
}, },
null, null,
@ -1750,7 +1753,7 @@ describe('setup status', () => {
return { return {
status: 'ready', status: 'ready',
projectDir: tempDir, 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 { return {
status: 'ready', status: 'ready',
projectDir: tempDir, 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 () => ({ const agents = vi.fn(async () => ({
status: 'ready' as const, status: 'ready' as const,
projectDir: tempDir, 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( await expect(

View file

@ -309,12 +309,15 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
const databaseIds = project.config.setup?.database_connection_ids ?? Object.keys(project.config.connections); const databaseIds = project.config.setup?.database_connection_ids ?? Object.keys(project.config.connections);
const databasesComplete = completedSteps.includes('databases'); const databasesComplete = completedSteps.includes('databases');
const manifest = await readKtxAgentInstallManifest(resolvedProjectDir); const manifest = await readKtxAgentInstallManifest(resolvedProjectDir);
const agents = const agentMap = new Map<string, { target: string; scope: string; ready: boolean }>();
manifest?.installs.map((install) => ({ for (const install of manifest?.installs ?? []) {
agentMap.set(`${install.target}:${install.scope}`, {
target: install.target, target: install.target,
scope: install.scope, scope: install.scope,
ready: true, ready: true,
})) ?? []; });
}
const agents = [...agentMap.values()];
return { return {
project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir }, 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, agents: true,
...(args.target ? { target: args.target } : {}), ...(args.target ? { target: args.target } : {}),
scope: args.agentScope ?? 'project', scope: args.agentScope ?? 'project',
mode: 'cli', mode: 'mcp',
skipAgents: false, skipAgents: false,
}, },
io, io,

View file

@ -1,23 +1,25 @@
--- ---
name: ktx-research 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, 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. 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.
<workflow> <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: 2. **Inspect top hits in parallel** - for each promising ref:
- `kind: 'wiki'` -> `wiki_read` - `kind: 'wiki'` -> `wiki_read`
- `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source` - `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source`
- `kind: 'table'` or `kind: 'column'` -> `entity_details` - `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. 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. **Query** - 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. - Prefer `sl_query` when the semantic layer covers the question.
- Use `sql_execution` only for questions the semantic layer does not cover. - 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.
</workflow> </workflow>
<rules> <rules>
@ -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. - 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. - 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. - 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.
</rules> </rules>
<examples> <examples>

8
pnpm-lock.yaml generated
View file

@ -112,6 +112,9 @@ importers:
commander: commander:
specifier: 14.0.3 specifier: 14.0.3
version: 14.0.3 version: 14.0.3
fflate:
specifier: ^0.8.2
version: 0.8.2
ink: ink:
specifier: ^7.0.2 specifier: ^7.0.2
version: 7.0.2(@types/react@19.2.14)(react@19.2.6) version: 7.0.2(@types/react@19.2.14)(react@19.2.6)
@ -3191,6 +3194,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13} engines: {node: ^12.20 || >= 14.13}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-uri-to-path@1.0.0: file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@ -8355,6 +8361,8 @@ snapshots:
node-domexception: 1.0.0 node-domexception: 1.0.0
web-streams-polyfill: 3.3.3 web-streams-polyfill: 3.3.3
fflate@0.8.2: {}
file-uri-to-path@1.0.0: {} file-uri-to-path@1.0.0: {}
finalhandler@2.1.1: finalhandler@2.1.1: