mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
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:
parent
2de4dd2c1b
commit
960e23b1c3
18 changed files with 909 additions and 211 deletions
29
README.md
29
README.md
|
|
@ -6,6 +6,8 @@
|
|||
<strong>The context layer for analytics agents</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">by Kaelio</p>
|
||||
|
||||
<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://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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 '<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`
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
.addOption(
|
||||
new Option('--target <target>', 'Agent target').choices([
|
||||
'claude-code',
|
||||
'claude-desktop',
|
||||
'codex',
|
||||
'cursor',
|
||||
'opencode',
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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',
|
||||
|
|
|
|||
65
packages/cli/src/mcp-server-factory.ts
Normal file
65
packages/cli/src/mcp-server-factory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
45
packages/cli/src/mcp-stdio-server.ts
Normal file
45
packages/cli/src/mcp-stdio-server.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<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', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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[]>> = {
|
||||
'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<Record<KtxAgentTarget, InstallEntry>> = {
|
||||
'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<string> {
|
||||
const path = fileURLToPath(new URL('./skills/research/SKILL.md', import.meta.url));
|
||||
async function readAnalyticsSkillContent(): Promise<string> {
|
||||
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<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 {
|
||||
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<KtxAgentTarget, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'claude-desktop': 'Claude Desktop',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
|
|
@ -460,12 +609,25 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
|
|||
|
||||
const fileEntryLabels: Record<KtxAgentTarget, string> = {
|
||||
'claude-code': 'Skill installed',
|
||||
'claude-desktop': 'Skill installed',
|
||||
codex: 'Skill installed',
|
||||
cursor: 'Rule installed',
|
||||
opencode: 'Command 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(
|
||||
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<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> = {
|
||||
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<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');
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ export async function runDemoTour(
|
|||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 databasesComplete = completedSteps.includes('databases');
|
||||
const manifest = await readKtxAgentInstallManifest(resolvedProjectDir);
|
||||
const agents =
|
||||
manifest?.installs.map((install) => ({
|
||||
const agentMap = new Map<string, { target: string; scope: string; ready: boolean }>();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
<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.
|
||||
</workflow>
|
||||
|
||||
<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.
|
||||
- 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.
|
||||
</rules>
|
||||
|
||||
<examples>
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue