mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Add MCP agent client setup support
This commit is contained in:
parent
658024dcf3
commit
0361449c8a
19 changed files with 745 additions and 146 deletions
|
|
@ -80,7 +80,7 @@ skill**.
|
|||
| Target | MCP tools + analytics skill | Adds with admin CLI skill |
|
||||
|--------|------------------------------|---------------------------|
|
||||
| Claude Code | `.mcp.json`, `.claude/skills/ktx-analytics/SKILL.md` | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` |
|
||||
| Claude Desktop | `.ktx/agents/claude/ktx-plugin.zip` with analytics skill and local stdio MCP config | Adds `skills/ktx/SKILL.md` inside the plugin ZIP |
|
||||
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` stdio entry + `.ktx/agents/claude/ktx-plugin.zip` with analytics skill | 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` |
|
||||
|
|
@ -167,20 +167,32 @@ same markdown command definitions.
|
|||
|
||||
## 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:
|
||||
During setup, select **Claude Desktop** from the agent targets. KTX writes the
|
||||
MCP server entry directly into Claude Desktop's config and generates a separate
|
||||
plugin ZIP for the analytics skill:
|
||||
|
||||
```bash
|
||||
ktx --project-dir /path/to/project mcp stdio
|
||||
```
|
||||
- `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
|
||||
`%AppData%/Claude/claude_desktop_config.json` (Windows) gets an
|
||||
`mcpServers.ktx` entry that runs the KTX MCP server over stdio via a local
|
||||
launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates
|
||||
a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn
|
||||
the server without needing `node` in PATH.
|
||||
- `.ktx/agents/claude/ktx-plugin.zip` contains the `ktx-analytics` skill (and
|
||||
the admin `ktx` skill if you choose **MCP tools + analytics skill + admin
|
||||
CLI skill**). Install the ZIP from Claude Desktop's plugin UI to load the
|
||||
skill.
|
||||
|
||||
If you choose **MCP tools + analytics skill + admin CLI skill**, the plugin ZIP
|
||||
also includes the admin `ktx` skill.
|
||||
After `ktx setup`, restart Claude Desktop so it picks up the new MCP server
|
||||
entry, then install the plugin ZIP. No daemon needs to be running — Claude
|
||||
Desktop spawns the MCP server itself per session.
|
||||
|
||||
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.
|
||||
Claude Desktop does not introspect local stdio MCP servers, so the per-tool
|
||||
"Connector"-style UI is not rendered for KTX. The tools are still callable
|
||||
from any Claude Desktop chat.
|
||||
|
||||
If you move the KTX checkout or project directory, rerun `ktx setup --agents`
|
||||
to refresh the absolute paths in `claude_desktop_config.json` and the launcher
|
||||
shim, then reinstall the regenerated plugin ZIP.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -267,7 +279,7 @@ Admin CLI skills call the same KTX CLI commands:
|
|||
|
||||
| | Claude Code | Claude Desktop | Cursor | Codex | OpenCode |
|
||||
|---|---|---|---|---|---|
|
||||
| MCP tools | Yes | Local stdio via plugin | Yes | Snippet | Snippet |
|
||||
| MCP tools | Yes | Local stdio via `claude_desktop_config.json` | Yes | Snippet | Snippet |
|
||||
| Analytics skill | `.claude/skills/ktx-analytics/SKILL.md` | Included in plugin ZIP | `.cursor/rules/ktx-analytics.mdc` | `.agents/skills/ktx-analytics/SKILL.md` | `.opencode/commands/ktx-analytics.md` |
|
||||
| Admin CLI skills | Optional | Optional in plugin ZIP | Optional (.mdc) | Optional | Optional |
|
||||
| Global install | Yes | Project-local ZIP | No | Yes | No |
|
||||
|
|
|
|||
|
|
@ -903,14 +903,16 @@ Add this test after the `dictionary_search` registration test:
|
|||
limit: 5,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
structuredContent: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 'public.orders',
|
||||
connectionId: 'warehouse',
|
||||
tableRef: { catalog: null, db: 'public', name: 'orders' },
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
refs: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 'public.orders',
|
||||
connectionId: 'warehouse',
|
||||
tableRef: { catalog: null, db: 'public', name: 'orders' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(discover.search).toHaveBeenCalledWith({
|
||||
query: 'orders',
|
||||
|
|
|
|||
|
|
@ -143,7 +143,9 @@ or `entity_details` tools.
|
|||
}
|
||||
```
|
||||
|
||||
**Output:** array of refs, each:
|
||||
**Output:** `{ refs: Ref[] }` — the MCP protocol requires `structuredContent`
|
||||
to be a JSON object, so the array of matches is wrapped under `refs`. Each
|
||||
ref is shaped:
|
||||
|
||||
```typescript
|
||||
{
|
||||
|
|
|
|||
|
|
@ -186,6 +186,10 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
|
|||
return true;
|
||||
}
|
||||
|
||||
if (commandPathKey === 'ktx mcp stdio') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
commandPathKey === 'ktx status' &&
|
||||
typeof options.projectDir !== 'string' &&
|
||||
|
|
|
|||
|
|
@ -56,6 +56,33 @@ describe('registerMcpCommands', () => {
|
|||
expect(startDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints "already running" when startDaemon reports already-running', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const startDaemon = vi.fn().mockResolvedValue({
|
||||
status: 'already-running',
|
||||
url: 'http://127.0.0.1:7878/mcp',
|
||||
state: {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
tokenAuth: false,
|
||||
projectDir: '/tmp/ktx-already',
|
||||
startedAt: '2026-05-14T00:00:00.000Z',
|
||||
logPath: '/tmp/ktx-already/.ktx/logs/mcp.log',
|
||||
},
|
||||
});
|
||||
const context = makeContext({ deps: { mcp: { startDaemon } } });
|
||||
registerMcpCommands(program, context);
|
||||
|
||||
await program.parseAsync(['--project-dir', '/tmp/ktx-already', 'mcp', 'start'], { from: 'user' });
|
||||
|
||||
expect(startDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(context.io.stdout.write).toHaveBeenCalledWith(
|
||||
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n',
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -82,7 +82,11 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
|
|||
allowedOrigins: options.allowedOrigin,
|
||||
binPath: binPath(),
|
||||
});
|
||||
context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`);
|
||||
context.io.stdout.write(
|
||||
result.status === 'started'
|
||||
? `KTX MCP daemon started: ${result.url}\n`
|
||||
: `KTX MCP daemon already running: ${result.url}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
mcp
|
||||
|
|
|
|||
|
|
@ -94,6 +94,78 @@ describe('managed MCP daemon lifecycle', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
const spawnDaemon = vi.fn(() => child(9999));
|
||||
|
||||
const result = await startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => true),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('already-running');
|
||||
expect(result.url).toBe('http://127.0.0.1:7878/mcp');
|
||||
expect(result.state.pid).toBe(4242);
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the recorded daemon uses a different host or port', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
const spawnDaemon = vi.fn(() => child(9999));
|
||||
|
||||
await expect(
|
||||
startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 9000,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => true),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
}),
|
||||
).rejects.toThrow(/different configuration[\s\S]*ktx mcp stop/);
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when token-auth presence differs from the recorded daemon', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
join(projectDir, '.ktx/mcp.json'),
|
||||
`${JSON.stringify(state(projectDir, { tokenAuth: false }), null, 2)}\n`,
|
||||
);
|
||||
const spawnDaemon = vi.fn(() => child(9999));
|
||||
|
||||
await expect(
|
||||
startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
token: 'secret-token',
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => true),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
}),
|
||||
).rejects.toThrow(/different configuration/);
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports running when the process is alive and health passes', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
|
|
|
|||
|
|
@ -121,11 +121,25 @@ export async function startKtxMcpDaemon(options: {
|
|||
portAvailable?: (host: string, port: number) => Promise<boolean>;
|
||||
spawnDaemon?: typeof defaultSpawnDaemon;
|
||||
now?: () => Date;
|
||||
}): Promise<{ status: 'started'; state: KtxMcpDaemonState; url: string }> {
|
||||
}): Promise<{ status: 'started' | 'already-running'; state: KtxMcpDaemonState; url: string }> {
|
||||
const existing = await readState(options.projectDir).catch(() => undefined);
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
if (existing && processAlive(existing.pid)) {
|
||||
throw new Error(`KTX MCP daemon is already recorded at http://${existing.host}:${existing.port}/mcp`);
|
||||
const sameConfig =
|
||||
existing.host === options.host &&
|
||||
existing.port === options.port &&
|
||||
existing.tokenAuth === Boolean(options.token);
|
||||
if (sameConfig) {
|
||||
return {
|
||||
status: 'already-running',
|
||||
state: existing,
|
||||
url: `http://${existing.host}:${existing.port}/mcp`,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`KTX MCP daemon is already running at http://${existing.host}:${existing.port}/mcp ` +
|
||||
'with a different configuration. Run `ktx mcp stop` first, then start again.',
|
||||
);
|
||||
}
|
||||
const portAvailable = options.portAvailable ?? defaultPortAvailable;
|
||||
if (!(await portAvailable(options.host, options.port))) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import process from 'node:process';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
|
@ -32,14 +33,32 @@ export async function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions)
|
|||
cliVersion: options.cliVersion ?? '0.0.0-private',
|
||||
io: protocolIo,
|
||||
}));
|
||||
const transport = new StdioServerTransport(options.stdin, options.stdout);
|
||||
const stdin = options.stdin ?? process.stdin;
|
||||
const transport = new StdioServerTransport(stdin, options.stdout);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transport.onclose = resolve;
|
||||
let settled = false;
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stdin.off('end', closeTransport);
|
||||
stdin.off('close', closeTransport);
|
||||
callback();
|
||||
};
|
||||
const closeTransport = () => {
|
||||
transport.close().catch((error: unknown) => {
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
});
|
||||
};
|
||||
transport.onclose = () => settle(resolve);
|
||||
transport.onerror = (error) => {
|
||||
options.io?.stderr.write(`KTX MCP stdio transport error: ${error.message}\n`);
|
||||
reject(error);
|
||||
settle(() => reject(error));
|
||||
};
|
||||
createMcpServer().connect(transport).catch(reject);
|
||||
stdin.once('end', closeTransport);
|
||||
stdin.once('close', closeTransport);
|
||||
createMcpServer().connect(transport).catch((error: unknown) => {
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,37 @@ async function readZipText(path: string, entry: string): Promise<string> {
|
|||
return strFromU8(content);
|
||||
}
|
||||
|
||||
function captureEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): Record<string, string | undefined> {
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of keys) snapshot[key] = env[key];
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function clearEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): void {
|
||||
for (const key of keys) delete env[key];
|
||||
}
|
||||
|
||||
function captureKtxEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('KTX_')) snapshot[key] = env[key];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function clearKtxEnv(env: NodeJS.ProcessEnv): void {
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('KTX_')) delete env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnvKeys(env: NodeJS.ProcessEnv, snapshot: Record<string, string | undefined>): void {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) delete env[key];
|
||||
else env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('setup agents', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -50,6 +81,7 @@ describe('setup agents', () => {
|
|||
{ 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-runner.sh'), role: 'launcher' },
|
||||
{ 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([
|
||||
|
|
@ -90,6 +122,7 @@ describe('setup agents', () => {
|
|||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
|
||||
]);
|
||||
});
|
||||
|
|
@ -301,10 +334,14 @@ describe('setup agents', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('generates a Claude Desktop plugin zip with analytics skill and stdio MCP config', async () => {
|
||||
it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
|
||||
const ktxEnvSnapshot = captureKtxEnv(process.env);
|
||||
process.env.HOME = home;
|
||||
clearEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
|
||||
clearKtxEnv(process.env);
|
||||
try {
|
||||
const io = makeIo();
|
||||
|
||||
|
|
@ -328,31 +365,62 @@ describe('setup agents', () => {
|
|||
});
|
||||
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
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[] } };
|
||||
const launcherStat = await stat(launcherPath);
|
||||
expect(launcherStat.mode & 0o111).not.toBe(0);
|
||||
const launcher = await readFile(launcherPath, 'utf-8');
|
||||
expect(launcher).toContain('KTX_CLI_BIN=');
|
||||
expect(launcher).toContain('.nvm/versions/node');
|
||||
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, 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(config.mcpServers.ktx).toEqual({
|
||||
command: launcherPath,
|
||||
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
|
||||
});
|
||||
|
||||
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
|
||||
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
|
||||
const setupMd = await readZipText(pluginPath, 'SETUP.md');
|
||||
expect(setupMd).not.toContain('ktx mcp start');
|
||||
expect(setupMd).toContain('claude_desktop_config.json');
|
||||
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');
|
||||
expect(io.stdout()).toContain('KTX MCP server registered');
|
||||
expect(io.stdout()).toContain('claude_desktop_config.json');
|
||||
expect(io.stdout()).toContain('Restart Claude Desktop');
|
||||
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
restoreEnvKeys(process.env, envSnapshot);
|
||||
restoreEnvKeys(process.env, ktxEnvSnapshot);
|
||||
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(
|
||||
it('captures KTX_*, OPENAI_API_KEY, and ANTHROPIC_API_KEY into the Claude Desktop MCP env block', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
const envSnapshot = captureEnvKeys(process.env, [
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'KTX_LOG_LEVEL',
|
||||
]);
|
||||
const ktxEnvSnapshot = captureKtxEnv(process.env);
|
||||
process.env.HOME = home;
|
||||
clearKtxEnv(process.env);
|
||||
process.env.OPENAI_API_KEY = 'sk-test-openai'; // pragma: allowlist secret
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; // pragma: allowlist secret
|
||||
process.env.KTX_LOG_LEVEL = 'debug';
|
||||
try {
|
||||
const io = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
|
|
@ -360,19 +428,65 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'claude-desktop',
|
||||
scope: 'project',
|
||||
mode: 'mcp-cli',
|
||||
mode: 'mcp',
|
||||
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');
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: { ktx: { env?: Record<string, string> } };
|
||||
};
|
||||
expect(config.mcpServers.ktx.env).toEqual({
|
||||
OPENAI_API_KEY: 'sk-test-openai', // pragma: allowlist secret
|
||||
ANTHROPIC_API_KEY: 'sk-ant-test', // pragma: allowlist secret
|
||||
KTX_LOG_LEVEL: 'debug',
|
||||
});
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
restoreEnvKeys(process.env, envSnapshot);
|
||||
restoreEnvKeys(process.env, ktxEnvSnapshot);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', 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-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');
|
||||
const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md');
|
||||
expect(adminSkill).toContain(`--project-dir ${tempDir}`);
|
||||
expect(adminSkill).toContain('status --json');
|
||||
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
|
||||
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs MCP client config and analytics skill without admin CLI files', async () => {
|
||||
|
|
@ -592,27 +706,47 @@ describe('setup agents', () => {
|
|||
});
|
||||
|
||||
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();
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
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');
|
||||
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
await expect(stat(pluginPath)).resolves.toBeDefined();
|
||||
await expect(stat(launcherPath)).resolves.toBeDefined();
|
||||
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(beforeConfig.mcpServers.ktx).toBeDefined();
|
||||
|
||||
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
|
||||
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
|
||||
|
||||
await expect(stat(pluginPath)).rejects.toThrow();
|
||||
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
|
||||
await expect(stat(pluginPath)).rejects.toThrow();
|
||||
await expect(stat(launcherPath)).rejects.toThrow();
|
||||
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(afterConfig.mcpServers.ktx).toBeUndefined();
|
||||
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('treats cancel as skip in interactive mode', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
|
|
@ -9,9 +9,11 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { bold, dim, green } from './io/symbols.js';
|
||||
import { withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
createKtxSetupUiAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
|
||||
|
|
@ -48,7 +50,7 @@ export interface KtxAgentInstallManifest {
|
|||
installedAt: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
entries: Array<
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' }
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
|
||||
| { kind: 'json-key'; path: string; jsonPath: string[] }
|
||||
>;
|
||||
}
|
||||
|
|
@ -197,16 +199,63 @@ function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: str
|
|||
};
|
||||
}
|
||||
|
||||
function claudeDesktopConfigPath(): { path: string; jsonPath: string[] } {
|
||||
const home = process.env.HOME ?? '';
|
||||
const path =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.APPDATA ?? join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json')
|
||||
: join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
return { path, jsonPath: ['mcpServers', 'ktx'] };
|
||||
}
|
||||
|
||||
const CLAUDE_DESKTOP_FORWARDED_ENV_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const;
|
||||
|
||||
export function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const captured: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined || value === '') continue;
|
||||
if (key.startsWith('KTX_') || (CLAUDE_DESKTOP_FORWARDED_ENV_KEYS as readonly string[]).includes(key)) {
|
||||
captured[key] = value;
|
||||
}
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
function claudeDesktopMcpEntry(input: {
|
||||
launcherPath: string;
|
||||
projectDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Record<string, unknown> {
|
||||
const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env);
|
||||
return {
|
||||
command: input.launcherPath,
|
||||
args: ['--project-dir', input.projectDir, 'mcp', 'stdio'],
|
||||
...(Object.keys(captured).length > 0 ? { env: captured } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function installMcpClientConfig(input: {
|
||||
projectDir: string;
|
||||
target: KtxAgentTarget;
|
||||
scope: KtxAgentScope;
|
||||
}): Promise<KtxMcpClientInstallResult> {
|
||||
const endpoint = await resolveMcpEndpoint(input.projectDir);
|
||||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices: string[] = [];
|
||||
|
||||
if (input.target === 'claude-desktop') {
|
||||
const config = claudeDesktopConfigPath();
|
||||
const launcherPath = claudeDesktopLauncherPath(input.projectDir);
|
||||
await writeJsonKey(
|
||||
config.path,
|
||||
config.jsonPath,
|
||||
claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }),
|
||||
);
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
return { entries, snippets, notices };
|
||||
}
|
||||
|
||||
const endpoint = await resolveMcpEndpoint(input.projectDir);
|
||||
if (!endpoint.running) {
|
||||
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
}
|
||||
|
|
@ -244,7 +293,8 @@ function plannedMcpJsonEntries(input: {
|
|||
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
|
||||
}
|
||||
if (input.target === 'claude-desktop') {
|
||||
return [];
|
||||
const config = claudeDesktopConfigPath();
|
||||
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
|
||||
}
|
||||
if (input.target === 'cursor') {
|
||||
const config = cursorConfigPath(input.projectDir, input.scope);
|
||||
|
|
@ -261,6 +311,10 @@ function claudeDesktopPluginPath(projectDir: string): string {
|
|||
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip');
|
||||
}
|
||||
|
||||
function claudeDesktopLauncherPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
}
|
||||
|
||||
export function plannedKtxAgentFiles(input: {
|
||||
projectDir: string;
|
||||
target: KtxAgentTarget;
|
||||
|
|
@ -298,7 +352,10 @@ export function plannedKtxAgentFiles(input: {
|
|||
return [];
|
||||
}
|
||||
if (input.target === 'claude-desktop') {
|
||||
return [{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const }];
|
||||
return [
|
||||
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
|
||||
{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const },
|
||||
];
|
||||
}
|
||||
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
|
||||
}
|
||||
|
|
@ -373,6 +430,10 @@ function shellQuote(value: string): string {
|
|||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function shellScriptQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function ktxCommandLine(launcher: KtxCliLauncher, args: string[]): string {
|
||||
return [launcher.command, ...launcher.args, ...args].map(shellQuote).join(' ');
|
||||
}
|
||||
|
|
@ -436,27 +497,11 @@ 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.',
|
||||
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
|
||||
'',
|
||||
`KTX project: \`${input.projectDir}\``,
|
||||
'',
|
||||
|
|
@ -464,13 +509,69 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
|
|||
'',
|
||||
'- `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',
|
||||
'',
|
||||
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
|
||||
'',
|
||||
'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string {
|
||||
const binPath = input.launcher.args[0];
|
||||
if (!binPath) {
|
||||
throw new Error('Expected KTX CLI launcher to include a bin path.');
|
||||
}
|
||||
const candidates = [
|
||||
input.launcher.command,
|
||||
'/opt/homebrew/bin/node',
|
||||
'/usr/local/bin/node',
|
||||
'/usr/bin/node',
|
||||
];
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
'set -eu',
|
||||
'',
|
||||
`KTX_CLI_BIN=${shellScriptQuote(binPath)}`,
|
||||
'',
|
||||
'run_with_node() {',
|
||||
' node_bin=$1',
|
||||
' shift',
|
||||
' exec "$node_bin" "$KTX_CLI_BIN" "$@"',
|
||||
'}',
|
||||
'',
|
||||
'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then',
|
||||
' run_with_node "$KTX_NODE" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
'if [ -x "$HOME/.volta/bin/node" ]; then',
|
||||
' run_with_node "$HOME/.volta/bin/node" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
...candidates.map((candidate) =>
|
||||
[
|
||||
`if [ -x ${shellScriptQuote(candidate)} ]; then`,
|
||||
` run_with_node ${shellScriptQuote(candidate)} "$@"`,
|
||||
'fi',
|
||||
].join('\n'),
|
||||
),
|
||||
'',
|
||||
'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do',
|
||||
' if [ -x "$candidate" ]; then',
|
||||
' run_with_node "$candidate" "$@"',
|
||||
' fi',
|
||||
'done',
|
||||
'',
|
||||
'if command -v node >/dev/null 2>&1; then',
|
||||
' run_with_node "$(command -v node)" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2',
|
||||
'exit 127',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function writeClaudeDesktopPlugin(input: {
|
||||
projectDir: string;
|
||||
path: string;
|
||||
|
|
@ -481,7 +582,6 @@ async function writeClaudeDesktopPlugin(input: {
|
|||
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 })),
|
||||
};
|
||||
|
|
@ -494,6 +594,15 @@ async function writeClaudeDesktopPlugin(input: {
|
|||
await writeFile(input.path, Buffer.from(zipSync(files)));
|
||||
}
|
||||
|
||||
async function writeClaudeDesktopLauncher(input: {
|
||||
path: string;
|
||||
launcher: KtxCliLauncher;
|
||||
}): Promise<void> {
|
||||
await mkdir(dirname(input.path), { recursive: true });
|
||||
await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8');
|
||||
await chmod(input.path, 0o755);
|
||||
}
|
||||
|
||||
function ruleInstructionContent(input: { projectDir: string }): string {
|
||||
return [
|
||||
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` +
|
||||
|
|
@ -658,7 +767,8 @@ export function formatInstallSummary(
|
|||
skill: 'teaches admin agents which KTX CLI commands to run',
|
||||
rule: 'tells admin agents when to use KTX CLI',
|
||||
'analytics-skill': 'teaches your agent the KTX MCP analytics workflow',
|
||||
'claude-plugin': 'bundles KTX skills and local stdio MCP config for Claude Desktop',
|
||||
'claude-plugin': 'bundles KTX skills for Claude Desktop (MCP server is registered in claude_desktop_config.json)',
|
||||
launcher: 'runs the local KTX CLI with an available Node.js for Claude Desktop',
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
|
|
@ -667,20 +777,24 @@ export function formatInstallSummary(
|
|||
lines.push(` ${targetDisplayNames[install.target]}`);
|
||||
for (const entry of targetEntries) {
|
||||
if (entry.kind === 'file') {
|
||||
const displayPath =
|
||||
install.scope === 'global' && entry.role !== 'claude-plugin' ? entry.path : relative(projectDir, entry.path);
|
||||
const isRule = entry.role === 'rule' || (!entry.role && fileEntryLabels[install.target] === 'Rule installed');
|
||||
const label =
|
||||
entry.role === 'analytics-skill'
|
||||
? 'Analytics skill installed'
|
||||
: entry.role === 'claude-plugin'
|
||||
? 'Claude plugin generated'
|
||||
: entry.role === 'launcher'
|
||||
? 'Launcher installed'
|
||||
: isRule
|
||||
? 'Rule installed'
|
||||
: fileEntryLabels[install.target];
|
||||
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
|
||||
lines.push(` + ${label} — ${hint}`);
|
||||
lines.push(` ${displayPath}`);
|
||||
if (entry.role !== 'claude-plugin') {
|
||||
const displayPath =
|
||||
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` ${displayPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of mcpEntriesByTarget
|
||||
|
|
@ -704,6 +818,10 @@ async function installTarget(input: {
|
|||
const launcher = ktxCliLauncher();
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'file') continue;
|
||||
if (entry.role === 'launcher') {
|
||||
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
|
||||
continue;
|
||||
}
|
||||
if (entry.role === 'claude-plugin') {
|
||||
await writeClaudeDesktopPlugin({
|
||||
projectDir: input.projectDir,
|
||||
|
|
@ -800,20 +918,37 @@ export async function runKtxSetupAgentsStep(
|
|||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices = new Set<string>();
|
||||
let claudeDesktopTutorial: string | undefined;
|
||||
try {
|
||||
for (const install of installs) {
|
||||
entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
|
||||
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
|
||||
entries.push(...targetEntries);
|
||||
const mcpResult = await installMcpClientConfig({
|
||||
projectDir: args.projectDir,
|
||||
target: install.target,
|
||||
scope: install.scope,
|
||||
});
|
||||
entries.push(...mcpResult.entries);
|
||||
for (const snippet of mcpResult.snippets) snippets.push(snippet);
|
||||
for (const notice of mcpResult.notices) notices.add(notice);
|
||||
if (install.target === 'claude-desktop') {
|
||||
notices.add('Install the generated KTX plugin ZIP from Claude Desktop Plugins, then restart or reload Claude.');
|
||||
} else {
|
||||
const mcpResult = await installMcpClientConfig({
|
||||
projectDir: args.projectDir,
|
||||
target: install.target,
|
||||
scope: install.scope,
|
||||
});
|
||||
entries.push(...mcpResult.entries);
|
||||
for (const snippet of mcpResult.snippets) snippets.push(snippet);
|
||||
for (const notice of mcpResult.notices) notices.add(notice);
|
||||
const pluginEntry = targetEntries.find(
|
||||
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
|
||||
entry.kind === 'file' && entry.role === 'claude-plugin',
|
||||
);
|
||||
const pluginPath = pluginEntry?.path ?? '';
|
||||
const configPath = claudeDesktopConfigPath().path;
|
||||
claudeDesktopTutorial = [
|
||||
`${green('✓')} ${bold('KTX MCP server registered')}`,
|
||||
` ${dim(configPath)}`,
|
||||
'',
|
||||
bold('1. Restart Claude Desktop'),
|
||||
' Quit and reopen so it picks up the new MCP server.',
|
||||
'',
|
||||
bold('2. Install the KTX plugin'),
|
||||
' Open Claude Desktop → Settings → Plugins and install from file:',
|
||||
` ${dim(pluginPath)}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
await writeManifest(
|
||||
|
|
@ -821,12 +956,20 @@ export async function runKtxSetupAgentsStep(
|
|||
mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries),
|
||||
);
|
||||
await markAgentsComplete(args.projectDir);
|
||||
io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`);
|
||||
for (const snippet of snippets) {
|
||||
io.stdout.write(`\n${snippet}\n`);
|
||||
const setupUi = createKtxSetupUiAdapter();
|
||||
setupUi.note(
|
||||
formatInstallSummary(installs, entries, args.projectDir),
|
||||
'Agent integration complete',
|
||||
io,
|
||||
);
|
||||
if (claudeDesktopTutorial) {
|
||||
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io);
|
||||
}
|
||||
for (const notice of notices) {
|
||||
io.stdout.write(`\n${notice}\n`);
|
||||
const nextStepBlocks: string[] = [];
|
||||
for (const notice of notices) nextStepBlocks.push(notice);
|
||||
for (const snippet of snippets) nextStepBlocks.push(snippet);
|
||||
if (nextStepBlocks.length > 0) {
|
||||
setupUi.note(nextStepBlocks.join('\n\n'), 'Next steps', io, { format: bold });
|
||||
}
|
||||
return { status: 'ready', projectDir: args.projectDir, installs };
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -138,9 +138,13 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
|
|||
};
|
||||
}
|
||||
|
||||
interface KtxSetupNoteOptions {
|
||||
format?: (line: string) => string;
|
||||
}
|
||||
|
||||
export interface KtxSetupUiAdapter {
|
||||
intro(title: string, io: KtxCliIo): void;
|
||||
note(message: string, title: string, io: KtxCliIo): void;
|
||||
note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void;
|
||||
}
|
||||
|
||||
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
|
||||
|
|
@ -160,9 +164,12 @@ export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
|
|||
}
|
||||
io.stdout.write(`${title}\n`);
|
||||
},
|
||||
note(message, title, io) {
|
||||
note(message, title, io, options) {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
note(message, title, { output: io.stdout });
|
||||
note(message, title, {
|
||||
output: io.stdout,
|
||||
...(options?.format ? { format: options.format } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`\n${title}:\n`);
|
||||
|
|
|
|||
|
|
@ -709,15 +709,18 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
|
||||
const status = await readKtxSetupStatus(projectResult.projectDir);
|
||||
io.stdout.write(formatKtxSetupStatus(status));
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
const focusedOnAgents = args.agents || entryAction === 'agents';
|
||||
if (!focusedOnAgents) {
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,36 @@ describe('local KTX embedding config', () => {
|
|||
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when backend is openai but no apiKey is resolvable from env', () => {
|
||||
const config: KtxProjectEmbeddingConfig = {
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
|
||||
};
|
||||
|
||||
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves openai embedding config from env', () => {
|
||||
const config: KtxProjectEmbeddingConfig = {
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLocalKtxEmbeddingConfig(config, { OPENAI_API_KEY: 'sk-test' }), // pragma: allowlist secret
|
||||
).toEqual({
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { apiKey: 'sk-test' }, // pragma: allowlist secret
|
||||
batchSize: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('constructs deterministic embeddings from the default project config', () => {
|
||||
const createKtxEmbeddingProvider = vi.fn(() => ({}) as never);
|
||||
const provider = createLocalKtxEmbeddingProviderFromConfig(
|
||||
|
|
|
|||
|
|
@ -145,11 +145,23 @@ export function resolveLocalKtxEmbeddingConfig(
|
|||
batchSize: config.batchSize,
|
||||
};
|
||||
}
|
||||
if (config.backend === 'openai') {
|
||||
const openai = resolvedProviderConfig(config.openai, env);
|
||||
if (!openai?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
backend: config.backend,
|
||||
model: config.model ?? 'deterministic',
|
||||
dimensions: config.dimensions,
|
||||
openai,
|
||||
batchSize: config.batchSize,
|
||||
};
|
||||
}
|
||||
return {
|
||||
backend: config.backend,
|
||||
model: config.model ?? 'deterministic',
|
||||
dimensions: config.dimensions,
|
||||
...(resolvedProviderConfig(config.openai, env) ? { openai: resolvedProviderConfig(config.openai, env) } : {}),
|
||||
...(config.sentenceTransformers
|
||||
? {
|
||||
sentenceTransformers: {
|
||||
|
|
|
|||
|
|
@ -89,13 +89,36 @@ const slQueryDimensionSchema = z.union([
|
|||
}),
|
||||
]);
|
||||
|
||||
const slQueryOrderBySchema = z.union([
|
||||
z.string(),
|
||||
const slQueryOrderBySchema = z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value === 'string') {
|
||||
return { field: value };
|
||||
}
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const obj = { ...(value as Record<string, unknown>) };
|
||||
if (!('field' in obj) && typeof obj.id === 'string') {
|
||||
obj.field = obj.id;
|
||||
}
|
||||
if (!('direction' in obj) && 'desc' in obj) {
|
||||
obj.direction = obj.desc === true ? 'desc' : 'asc';
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
z.object({
|
||||
field: z.string().min(1),
|
||||
direction: z.enum(['asc', 'desc']).default('asc'),
|
||||
field: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'Field/measure/dimension id to order by, e.g. "orders.created_at", a dimension key like "mart_nrr_quarterly.quarter_label", or a measure alias.',
|
||||
),
|
||||
direction: z
|
||||
.enum(['asc', 'desc'])
|
||||
.default('asc')
|
||||
.describe('Sort direction: "asc" or "desc". Defaults to "asc".'),
|
||||
}),
|
||||
]);
|
||||
);
|
||||
|
||||
const slQuerySchema = z.object({
|
||||
connectionId: connectionIdSchema.optional(),
|
||||
|
|
@ -378,7 +401,10 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
|
|||
'sl_query',
|
||||
{
|
||||
title: 'Semantic Layer Query',
|
||||
description: 'Execute a semantic-layer query and return rows, headers, SQL, and the query plan.',
|
||||
description:
|
||||
'Execute a semantic-layer query and return rows, headers, SQL, and the query plan. ' +
|
||||
'order_by items use the shape {"field": "orders.created_at", "direction": "asc"|"desc"}; ' +
|
||||
'a bare string is treated as field with direction "asc".',
|
||||
inputSchema: slQuerySchema.shape,
|
||||
},
|
||||
slQuerySchema,
|
||||
|
|
@ -443,7 +469,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
|
|||
inputSchema: discoverDataSchema.shape,
|
||||
},
|
||||
discoverDataSchema,
|
||||
async (input) => jsonToolResult(await discover.search(input)),
|
||||
async (input) => jsonToolResult({ refs: await discover.search(input) }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -256,6 +256,51 @@ describe('createKtxMcpServer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('sl_query normalizes order_by from cube-style {id, desc} and bare strings to {field, direction}', async () => {
|
||||
const fake = makeFakeServer();
|
||||
const semanticLayer: KtxSemanticLayerMcpPort = {
|
||||
listSources: vi.fn(),
|
||||
readSource: vi.fn(),
|
||||
writeSource: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
|
||||
sql: '',
|
||||
headers: [],
|
||||
rows: [],
|
||||
totalRows: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
createKtxMcpServer({
|
||||
server: fake.server,
|
||||
userContext: { userId: 'local-user' },
|
||||
contextTools: { semanticLayer },
|
||||
});
|
||||
|
||||
await getTool(fake.tools, 'sl_query').handler({
|
||||
connectionId: 'warehouse',
|
||||
measures: ['orders.count'],
|
||||
order_by: [
|
||||
{ field: 'orders.total', direction: 'desc' },
|
||||
{ id: 'orders.quarter_label', desc: false },
|
||||
{ id: 'orders.created_at', desc: true },
|
||||
'orders.segment',
|
||||
],
|
||||
});
|
||||
|
||||
expect(semanticLayer.query).toHaveBeenCalledWith({
|
||||
connectionId: 'warehouse',
|
||||
query: expect.objectContaining({
|
||||
order_by: [
|
||||
{ field: 'orders.total', direction: 'desc' },
|
||||
{ field: 'orders.quarter_label', direction: 'asc' },
|
||||
{ field: 'orders.created_at', direction: 'desc' },
|
||||
{ field: 'orders.segment', direction: 'asc' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('registers discover_data when the host provides a discover port', async () => {
|
||||
const fake = makeFakeServer();
|
||||
const discover: KtxDiscoverDataMcpPort = {
|
||||
|
|
@ -288,14 +333,16 @@ describe('createKtxMcpServer', () => {
|
|||
limit: 5,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
structuredContent: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 'public.orders',
|
||||
connectionId: 'warehouse',
|
||||
tableRef: { catalog: null, db: 'public', name: 'orders' },
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
refs: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 'public.orders',
|
||||
connectionId: 'warehouse',
|
||||
tableRef: { catalog: null, db: 'public', name: 'orders' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(discover.search).toHaveBeenCalledWith({
|
||||
query: 'orders',
|
||||
|
|
|
|||
|
|
@ -182,6 +182,46 @@ grain: []
|
|||
});
|
||||
});
|
||||
|
||||
it('strips authoring-only fields (usage, inherits_columns_from) before sending sources to the daemon', async () => {
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/_schema/public.yaml',
|
||||
`tables:
|
||||
invoices:
|
||||
table: public.invoices
|
||||
columns:
|
||||
- name: invoice_id
|
||||
type: number
|
||||
pk: true
|
||||
- name: amount
|
||||
type: number
|
||||
usage:
|
||||
narrative: Activation policy windows table for invoice analytics.
|
||||
frequencyTier: mid
|
||||
commonFilters:
|
||||
- amount
|
||||
commonGroupBys: []
|
||||
commonJoins: []
|
||||
staleSince: null
|
||||
`,
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'Add manifest shard with usage',
|
||||
);
|
||||
|
||||
await compileLocalSlQuery(project, {
|
||||
connectionId: 'warehouse',
|
||||
query: { measures: ['sum(invoices.amount)'], dimensions: [] },
|
||||
compute,
|
||||
});
|
||||
|
||||
const lastCall = (compute.query as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
|
||||
const invoices = lastCall?.sources.find((s: Record<string, unknown>) => s.name === 'invoices');
|
||||
expect(invoices).toBeDefined();
|
||||
expect(invoices).not.toHaveProperty('usage');
|
||||
expect(invoices).not.toHaveProperty('inherits_columns_from');
|
||||
expect(invoices).not.toHaveProperty('source_type');
|
||||
});
|
||||
|
||||
it('resolves the only configured connection when connectionId is omitted', async () => {
|
||||
await compileLocalSlQuery(project, {
|
||||
query: { measures: ['orders.order_count'], dimensions: [] },
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { KtxSqlQueryExecutorPort } from '../connections/index.js';
|
|||
import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
|
||||
import type { KtxLocalProject } from '../project/index.js';
|
||||
import { loadLocalSlSourceRecords } from './local-sl.js';
|
||||
import { toResolvedWire } from './semantic-layer.service.js';
|
||||
import type { SemanticLayerQueryExecutionResult, SemanticLayerQueryInput } from './types.js';
|
||||
|
||||
const COMPILE_ONLY_REASON =
|
||||
|
|
@ -77,8 +78,8 @@ async function loadComputableSources(
|
|||
connectionId: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return (await loadLocalSlSourceRecords(project, { connectionId: assertSafeConnectionId(connectionId) }))
|
||||
.map((record) => ({ ...record.source }))
|
||||
.filter((source) => source.table || source.sql);
|
||||
.filter((record) => record.source.table || record.source.sql)
|
||||
.map((record) => toResolvedWire(record.source) as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function headersFromColumns(columns: Array<Record<string, unknown>>): string[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue