mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +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>
|
<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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
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 { 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
8
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue