mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-16 08:25:14 +02:00
Merge remote-tracking branch 'origin/main' into fix-wiki-sl-search-output
# Conflicts: # packages/cli/src/status-project.ts
This commit is contained in:
commit
de40d05c06
177 changed files with 17742 additions and 1412 deletions
|
|
@ -26,7 +26,8 @@
|
|||
],
|
||||
"scripts": {
|
||||
"assets:demo": "node scripts/build-demo-assets.mjs",
|
||||
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node ../../scripts/prepare-cli-bin.mjs",
|
||||
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.mjs && node ../../scripts/prepare-cli-bin.mjs",
|
||||
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('node_modules/.cache/tsc.tsbuildinfo', { force: true })\"",
|
||||
"docs:commands": "pnpm run build && node dist/print-command-tree.js",
|
||||
"smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000",
|
||||
"test": "vitest run --exclude src/standalone-smoke.test.ts --exclude src/example-smoke.test.ts --exclude src/setup-databases.test.ts --exclude src/scan.test.ts --exclude src/commands/connection-metabase-setup.test.ts --exclude src/setup-models.test.ts --exclude src/setup-sources.test.ts --exclude src/setup.test.ts --exclude src/connection.test.ts --exclude src/setup-embeddings.test.ts --exclude src/ingest.test.ts --exclude src/commands/connection-mapping.test.ts --exclude src/ingest-viz.test.ts --exclude src/demo.test.ts --exclude src/setup-project.test.ts --exclude src/sl.test.ts --exclude src/local-scan-connectors.test.ts --exclude src/commands/connection-notion.test.ts",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"@ktx/connector-sqlserver": "workspace:*",
|
||||
"@ktx/context": "workspace:*",
|
||||
"@ktx/llm": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"commander": "14.0.3",
|
||||
"ink": "^7.0.2",
|
||||
"react": "^19.2.6",
|
||||
|
|
|
|||
11
packages/cli/scripts/copy-runtime-assets.mjs
Normal file
11
packages/cli/scripts/copy-runtime-assets.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const skillsSource = join(packageRoot, 'src', 'skills');
|
||||
const skillsTarget = join(packageRoot, 'dist', 'skills');
|
||||
|
||||
await rm(skillsTarget, { recursive: true, force: true });
|
||||
await mkdir(dirname(skillsTarget), { recursive: true });
|
||||
await cp(skillsSource, skillsTarget, { recursive: true });
|
||||
|
|
@ -5,6 +5,7 @@ import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
|||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
import { registerMcpCommands } from './commands/mcp-commands.js';
|
||||
import { registerSetupCommands } from './commands/setup-commands.js';
|
||||
import { registerSlCommands } from './commands/sl-commands.js';
|
||||
import { registerStatusCommands } from './commands/status-commands.js';
|
||||
|
|
@ -55,9 +56,11 @@ type CommandPathNode = CommandWithGlobalOptions & {
|
|||
parent?: CommandPathNode | null;
|
||||
};
|
||||
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'mcp']);
|
||||
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
|
||||
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
|
||||
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
|
||||
const GLOBAL_OPTIONS_WITHOUT_VALUE = new Set(['--debug', '--help', '-h', '--version', '-v']);
|
||||
|
||||
class KtxProjectMissingAbortError extends Error {
|
||||
readonly isKtxProjectMissingAbort = true;
|
||||
|
|
@ -72,24 +75,6 @@ function isKtxProjectMissingAbortError(error: unknown): error is KtxProjectMissi
|
|||
(typeof error === 'object' && error !== null && (error as { isKtxProjectMissingAbort?: unknown }).isKtxProjectMissingAbort === true)
|
||||
);
|
||||
}
|
||||
const REMOVED_COMMAND_PATHS = new Set([
|
||||
'scan',
|
||||
'wiki read',
|
||||
'wiki write',
|
||||
]);
|
||||
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
|
||||
const OPTIONS_WITH_VALUE = new Set([
|
||||
'--project-dir',
|
||||
'--query-history-window-days',
|
||||
'--user-id',
|
||||
'--limit',
|
||||
'--format',
|
||||
'--connection-id',
|
||||
'--source-name',
|
||||
'--query-file',
|
||||
'--max-rows',
|
||||
]);
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
optsWithGlobals?: () => object;
|
||||
|
|
@ -336,43 +321,32 @@ function formatCliError(error: unknown): string {
|
|||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function commandPathFromArgv(argv: string[]): string[] {
|
||||
const path: string[] = [];
|
||||
for (let index = 0; index < argv.length && path.length < 2; index += 1) {
|
||||
function firstTopLevelCommandToken(argv: string[]): string | null {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (arg === '--') {
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
if ((path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE).has(arg)) {
|
||||
if (GLOBAL_OPTIONS_WITH_VALUE.has(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const optionsWithValue = path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE;
|
||||
if ([...optionsWithValue].some((option) => arg.startsWith(`${option}=`))) {
|
||||
if ([...GLOBAL_OPTIONS_WITH_VALUE].some((option) => arg.startsWith(`${option}=`))) {
|
||||
continue;
|
||||
}
|
||||
if (path.length === 0 && arg === '--debug') {
|
||||
if (GLOBAL_OPTIONS_WITHOUT_VALUE.has(arg) || arg.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
path.push(arg);
|
||||
return arg;
|
||||
}
|
||||
return path;
|
||||
return null;
|
||||
}
|
||||
|
||||
function removedCommandName(argv: string[]): string | null {
|
||||
const path = commandPathFromArgv(argv);
|
||||
if (path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathKey = path.join(' ');
|
||||
return REMOVED_COMMAND_PATHS.has(pathKey) ? path.at(-1) ?? null : null;
|
||||
function isKnownTopLevelCommand(program: Command, commandName: string): boolean {
|
||||
return program.commands.some((command) => command.name() === commandName || command.aliases().includes(commandName));
|
||||
}
|
||||
|
||||
async function runBareInteractiveCommand(
|
||||
|
|
@ -439,6 +413,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
|||
registerWikiCommands(program, context);
|
||||
registerSlCommands(program, context);
|
||||
registerStatusCommands(program, context);
|
||||
registerMcpCommands(program, context);
|
||||
registerDevCommands(program, context);
|
||||
|
||||
return program;
|
||||
|
|
@ -489,9 +464,9 @@ export async function runCommanderKtxCli(
|
|||
return 0;
|
||||
}
|
||||
|
||||
const removedCommand = removedCommandName(argv);
|
||||
if (removedCommand) {
|
||||
io.stderr.write(`error: unknown command '${removedCommand}'\n`);
|
||||
const topLevelCommand = firstTopLevelCommandToken(argv);
|
||||
if (topLevelCommand && !isKnownTopLevelCommand(program, topLevelCommand)) {
|
||||
io.stderr.write(`error: unknown command '${topLevelCommand}'\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ export interface KtxCliDeps {
|
|||
runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise<number>;
|
||||
knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise<number>;
|
||||
sl?: (args: KtxSlArgs, io: KtxCliIo) => Promise<number>;
|
||||
mcp?: {
|
||||
startDaemon?: typeof import('./managed-mcp-daemon.js').startKtxMcpDaemon;
|
||||
stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon;
|
||||
readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus;
|
||||
runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer;
|
||||
};
|
||||
}
|
||||
|
||||
export function getKtxCliPackageInfo(): KtxCliPackageInfo {
|
||||
|
|
|
|||
57
packages/cli/src/commands/mcp-commands.test.ts
Normal file
57
packages/cli/src/commands/mcp-commands.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Command } from '@commander-js/extra-typings';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import { registerMcpCommands } from './mcp-commands.js';
|
||||
|
||||
function makeContext(overrides: Partial<KtxCliCommandContext> = {}): KtxCliCommandContext {
|
||||
let exitCode = 0;
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
},
|
||||
deps: {},
|
||||
packageInfo: { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' },
|
||||
setExitCode: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
runInit: vi.fn(),
|
||||
writeDebug: vi.fn(),
|
||||
...overrides,
|
||||
get exitCode() {
|
||||
return exitCode;
|
||||
},
|
||||
} as KtxCliCommandContext;
|
||||
}
|
||||
|
||||
describe('registerMcpCommands', () => {
|
||||
it('registers the public mcp lifecycle commands', () => {
|
||||
const program = new Command().exitOverride();
|
||||
registerMcpCommands(program, makeContext());
|
||||
const mcp = program.commands.find((command) => command.name() === 'mcp');
|
||||
|
||||
expect(mcp?.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'logs',
|
||||
'serve-internal',
|
||||
'start',
|
||||
'status',
|
||||
'stop',
|
||||
]);
|
||||
expect(
|
||||
(mcp?.commands.find((command) => command.name() === 'serve-internal') as { _hidden?: boolean } | undefined)
|
||||
?._hidden,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-loopback start without token before spawning', async () => {
|
||||
const program = new Command().exitOverride();
|
||||
const startDaemon = vi.fn();
|
||||
const context = makeContext({ deps: { mcp: { startDaemon } } });
|
||||
registerMcpCommands(program, context);
|
||||
|
||||
await expect(program.parseAsync(['mcp', 'start', '--host', '0.0.0.0'], { from: 'user' })).rejects.toThrow(
|
||||
'Binding KTX MCP to 0.0.0.0 requires --token or KTX_MCP_TOKEN',
|
||||
);
|
||||
expect(startDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
136
packages/cli/src/commands/mcp-commands.ts
Normal file
136
packages/cli/src/commands/mcp-commands.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Command } from '@commander-js/extra-typings';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import {
|
||||
collectOption,
|
||||
parsePositiveIntegerOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import {
|
||||
mcpDaemonLayout,
|
||||
readKtxMcpDaemonStatus,
|
||||
startKtxMcpDaemon,
|
||||
stopKtxMcpDaemon,
|
||||
} from '../managed-mcp-daemon.js';
|
||||
import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js';
|
||||
|
||||
function tokenFromOption(value: string | undefined): string | undefined {
|
||||
return value ?? process.env.KTX_MCP_TOKEN;
|
||||
}
|
||||
|
||||
function binPath(): string {
|
||||
return fileURLToPath(new URL('../bin.js', import.meta.url));
|
||||
}
|
||||
|
||||
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
|
||||
|
||||
mcp
|
||||
.command('start')
|
||||
.description('Start the KTX MCP HTTP server')
|
||||
.option('--host <host>', 'Host to bind', '127.0.0.1')
|
||||
.option('--port <n>', 'Port to bind', parsePositiveIntegerOption, 7878)
|
||||
.option('--token <token>', 'Bearer token required for non-loopback binding')
|
||||
.option('--foreground', 'Run in the foreground', false)
|
||||
.option('--allowed-host <host>', 'Additional allowed Host header', collectOption, [])
|
||||
.option('--allowed-origin <origin>', 'Allowed browser Origin header', collectOption, [])
|
||||
.action(async (options, command) => {
|
||||
const projectDir = resolveCommandProjectDir(command);
|
||||
const token = tokenFromOption(options.token);
|
||||
buildMcpSecurityConfig({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
token,
|
||||
allowedHosts: options.allowedHost,
|
||||
allowedOrigins: options.allowedOrigin,
|
||||
});
|
||||
if (options.foreground) {
|
||||
await (context.deps.mcp?.runServer ?? runKtxMcpHttpServer)({
|
||||
projectDir,
|
||||
cliVersion: context.packageInfo.version,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
token,
|
||||
allowedHosts: options.allowedHost,
|
||||
allowedOrigins: options.allowedOrigin,
|
||||
io: context.io,
|
||||
});
|
||||
context.io.stdout.write(`KTX MCP server listening at http://${options.host}:${options.port}/mcp\n`);
|
||||
return;
|
||||
}
|
||||
const result = await (context.deps.mcp?.startDaemon ?? startKtxMcpDaemon)({
|
||||
projectDir,
|
||||
cliVersion: context.packageInfo.version,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
token,
|
||||
allowedHosts: options.allowedHost,
|
||||
allowedOrigins: options.allowedOrigin,
|
||||
binPath: binPath(),
|
||||
});
|
||||
context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`);
|
||||
});
|
||||
|
||||
mcp
|
||||
.command('stop')
|
||||
.description('Stop the KTX MCP daemon')
|
||||
.action(async (_options, command) => {
|
||||
const result = await (context.deps.mcp?.stopDaemon ?? stopKtxMcpDaemon)({
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
});
|
||||
context.io.stdout.write(result.status === 'stopped' ? 'KTX MCP daemon stopped.\n' : 'KTX MCP daemon is not running.\n');
|
||||
});
|
||||
|
||||
mcp
|
||||
.command('status')
|
||||
.description('Show KTX MCP daemon status')
|
||||
.action(async (_options, command) => {
|
||||
const status = await (context.deps.mcp?.readStatus ?? readKtxMcpDaemonStatus)({
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
});
|
||||
context.io.stdout.write(`${status.detail}\n`);
|
||||
if (status.kind === 'running') {
|
||||
context.io.stdout.write(`URL: ${status.url}\n`);
|
||||
context.io.stdout.write(`PID: ${status.state.pid}\n`);
|
||||
context.io.stdout.write(`Token auth: ${status.state.tokenAuth ? 'enabled' : 'disabled'}\n`);
|
||||
context.io.stdout.write(`Project: ${status.state.projectDir}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command('logs')
|
||||
.description('Print the KTX MCP daemon log')
|
||||
.option('--follow', 'Follow log output', false)
|
||||
.action(async (options, command) => {
|
||||
const logPath = mcpDaemonLayout(resolveCommandProjectDir(command)).logPath;
|
||||
if (options.follow) {
|
||||
const child = spawn('tail', ['-f', logPath], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout?.on('data', (chunk: Buffer) => context.io.stdout.write(chunk.toString('utf8')));
|
||||
child.stderr?.on('data', (chunk: Buffer) => context.io.stderr.write(chunk.toString('utf8')));
|
||||
await new Promise((resolve) => child.on('close', resolve));
|
||||
return;
|
||||
}
|
||||
context.io.stdout.write(await readFile(logPath, 'utf8'));
|
||||
});
|
||||
|
||||
mcp
|
||||
.command('serve-internal', { hidden: true })
|
||||
.option('--host <host>', 'Host to bind', '127.0.0.1')
|
||||
.requiredOption('--port <n>', 'Port to bind', parsePositiveIntegerOption)
|
||||
.option('--allowed-host <host>', 'Additional allowed Host header', collectOption, [])
|
||||
.option('--allowed-origin <origin>', 'Allowed browser Origin header', collectOption, [])
|
||||
.action(async (options, command) => {
|
||||
await (context.deps.mcp?.runServer ?? runKtxMcpHttpServer)({
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
cliVersion: context.packageInfo.version,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
token: process.env.KTX_MCP_TOKEN,
|
||||
allowedHosts: options.allowedHost,
|
||||
allowedOrigins: options.allowedOrigin,
|
||||
io: context.io,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ function shouldShowSetupEntryMenu(
|
|||
agents?: boolean;
|
||||
target?: string;
|
||||
global?: boolean;
|
||||
local?: boolean;
|
||||
skipAgents?: boolean;
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
|
|
@ -163,6 +164,7 @@ function shouldShowSetupEntryMenu(
|
|||
'agents',
|
||||
'target',
|
||||
'global',
|
||||
'local',
|
||||
'skipAgents',
|
||||
'yes',
|
||||
'input',
|
||||
|
|
@ -223,6 +225,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
]),
|
||||
)
|
||||
.option('--global', 'Install agent integration into the global target scope', false)
|
||||
.option('--local', 'Install Claude Code MCP config into the private per-project ~/.claude.json scope', false)
|
||||
.addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false))
|
||||
.option('--yes', 'Accept safe defaults in non-interactive setup', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
|
|
@ -392,9 +395,19 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.local && options.global) {
|
||||
context.io.stderr.write('Choose only one agent scope: --local or --global.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.local && options.target && options.target !== 'claude-code') {
|
||||
context.io.stderr.write('--local is only supported with --target claude-code.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
|
||||
const resolvedAgentScope = options.global ? 'global' : 'project';
|
||||
const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
|
||||
await runSetupArgs(context, {
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
|
|
|
|||
|
|
@ -489,15 +489,17 @@ describe('runKtxConnection', () => {
|
|||
it('rejects unknown drivers with a helpful error', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
mystery: { driver: 'duckdb' },
|
||||
});
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
'connections:\n mystery:\n driver: duckdb\n',
|
||||
'utf-8',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'mystery' }, io.io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.stderr()).toContain('uses driver "duckdb"');
|
||||
expect(io.stderr()).toContain('Supported:');
|
||||
expect(io.stderr()).toContain('connections.mystery.driver');
|
||||
expect(io.stderr()).toContain('postgres');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ describe('formatDoctorReport', () => {
|
|||
expect(output).toContain('Node 22+ · pnpm 10.20+');
|
||||
expect(output).not.toContain('v22.16.0');
|
||||
expect(output).toContain('Everything ready.');
|
||||
expect(output).toContain('ktx status --json');
|
||||
expect(output).toContain('ktx sl list');
|
||||
expect(output).toContain('ktx wiki list');
|
||||
expect(output).not.toContain('ktx scan');
|
||||
expect(output).not.toContain('ktx sl ask');
|
||||
});
|
||||
|
||||
it('shows the underlying detail for a single-check group on the group line', () => {
|
||||
|
|
@ -462,6 +467,7 @@ describe('runKtxDoctor', () => {
|
|||
it('includes Postgres query-history readiness in project doctor output', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
|
||||
process.env.OPENAI_API_KEY = 'test-key'; // pragma: allowlist secret
|
||||
process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse';
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
|
|
@ -516,8 +522,14 @@ describe('runKtxDoctor', () => {
|
|||
expect(out).toContain('pg_stat_statements ready (PostgreSQL 16.4)');
|
||||
expect(out).toContain('info: pg_stat_statements.max is 1000');
|
||||
expect(out).not.toContain('Update the Postgres parameter group or config');
|
||||
expect(out).toContain('ktx status --json');
|
||||
expect(out).toContain('ktx sl list');
|
||||
expect(out).toContain('ktx wiki list');
|
||||
expect(out).not.toContain('ktx scan');
|
||||
expect(out).not.toContain('ktx sl ask');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.WAREHOUSE_DATABASE_URL;
|
||||
});
|
||||
|
||||
it('returns blocked verdict when LLM is not configured', async () => {
|
||||
|
|
@ -543,6 +555,7 @@ describe('runKtxDoctor', () => {
|
|||
).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('no LLM configured');
|
||||
expect(testIo.stdout()).not.toContain('ktx ask');
|
||||
expect(testIo.stdout()).toContain('ktx setup');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import type { KtxConfigIssue } from '@ktx/context/project';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
import type { BuildProjectStatusOptions } from './status-project.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -287,7 +288,7 @@ interface RenderOptions {
|
|||
command?: 'setup' | 'project';
|
||||
}
|
||||
|
||||
const NEXT_STEPS_PROJECT = ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'];
|
||||
const NEXT_STEPS_PROJECT = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
|
||||
|
||||
export function formatDoctorReport(report: DoctorReport, options: Partial<RenderOptions> = {}): string {
|
||||
const opts: RenderOptions = {
|
||||
|
|
|
|||
|
|
@ -440,6 +440,7 @@ describe('runKtxCli', () => {
|
|||
expect(stdout).toContain('--agents');
|
||||
expect(stdout).toContain('--target <target>');
|
||||
expect(stdout).toContain('--global');
|
||||
expect(stdout).toContain('--local');
|
||||
expect(stdout).toContain('--yes');
|
||||
expect(stdout).toContain('--no-input');
|
||||
expect(stdout).toContain('Global Options:');
|
||||
|
|
@ -1286,6 +1287,38 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects --local with non-Claude targets', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'setup', '--agents', '--target', 'cursor', '--local', '--no-input'],
|
||||
setupIo.io,
|
||||
{ setup },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(setupIo.stderr()).toContain('--local is only supported with --target claude-code');
|
||||
expect(setup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --local and --global together', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code', '--local', '--global', '--no-input'],
|
||||
setupIo.io,
|
||||
{ setup },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(setupIo.stderr()).toContain('Choose only one agent scope: --local or --global.');
|
||||
expect(setup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects source-path with source-git-url', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const testIo = makeIo();
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export async function writeWarehouseConfig(projectDir: string): Promise<void> {
|
|||
'connections:',
|
||||
' prod-metabase:',
|
||||
' driver: metabase',
|
||||
' api_url: https://metabase.example.test',
|
||||
' warehouse_a:',
|
||||
' driver: postgres',
|
||||
'ingest:',
|
||||
|
|
|
|||
|
|
@ -43,13 +43,13 @@ export interface PrintListArgs<Row> {
|
|||
io: KtxCliIo;
|
||||
}
|
||||
|
||||
export interface KtxJsonResultEnvelope<T> {
|
||||
interface KtxJsonResultEnvelope<T> {
|
||||
kind: string;
|
||||
data: T;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
|
||||
function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
|
||||
io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject } from '@ktx/context/project';
|
||||
import { initKtxProject, loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxEmbeddingPort } from '@ktx/context';
|
||||
import { writeLocalKnowledgePage } from '@ktx/context/wiki';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runKtxKnowledge } from './knowledge.js';
|
||||
|
||||
|
|
@ -40,6 +41,28 @@ class FakeEmbeddingPort implements KtxEmbeddingPort {
|
|||
}
|
||||
}
|
||||
|
||||
interface WikiPageFixture {
|
||||
key?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
tags?: string[];
|
||||
slRefs?: string[];
|
||||
}
|
||||
|
||||
async function seedWikiPage(projectDir: string, fixture: WikiPageFixture = {}): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeLocalKnowledgePage(project, {
|
||||
key: fixture.key ?? 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: fixture.summary ?? 'Revenue',
|
||||
content: fixture.content ?? 'Revenue is paid order value.',
|
||||
tags: fixture.tags ?? ['finance'],
|
||||
refs: [],
|
||||
slRefs: fixture.slRefs ?? ['orders'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('runKtxKnowledge', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -51,36 +74,10 @@ describe('runKtxKnowledge', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes, reads, lists, and searches wiki pages', async () => {
|
||||
it('lists and searches wiki pages', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(writeIo.stdout()).toContain('Wrote wiki/global/metrics-revenue.md');
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(readIo.stdout()).toContain('# metrics-revenue');
|
||||
expect(readIo.stdout()).toContain('Revenue is paid order value.');
|
||||
await seedWikiPage(projectDir);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
|
||||
|
|
@ -93,27 +90,10 @@ describe('runKtxKnowledge', () => {
|
|||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
||||
});
|
||||
|
||||
it('prints wiki list, search, and read as public JSON envelopes', async () => {
|
||||
it('prints wiki list and search as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await seedWikiPage(projectDir);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
|
||||
|
|
@ -137,48 +117,6 @@ describe('runKtxKnowledge', () => {
|
|||
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
|
||||
meta: { command: 'wiki search' },
|
||||
});
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'wiki.page',
|
||||
data: {
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'orbit/company-overview',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Orbit',
|
||||
content: 'Orbit overview.',
|
||||
tags: [],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(writeIo.stderr()).toContain(
|
||||
'Invalid wiki key "orbit/company-overview". Wiki keys must be flat; use "orbit-company-overview".',
|
||||
);
|
||||
expect(writeIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('explains empty search results for a project without wiki pages', async () => {
|
||||
|
|
@ -198,24 +136,13 @@ describe('runKtxKnowledge', () => {
|
|||
it('uses configured embeddings for semantic wiki search', async () => {
|
||||
const projectDir = join(tempDir, 'semantic-project');
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await seedWikiPage(projectDir, {
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
slRefs: [],
|
||||
});
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -5,20 +5,16 @@ import {
|
|||
} from '@ktx/context';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
type LocalKnowledgeScope,
|
||||
type LocalKnowledgeSearchResult,
|
||||
type LocalKnowledgeSummary,
|
||||
listLocalKnowledgePages,
|
||||
readLocalKnowledgePage,
|
||||
searchLocalKnowledgePages,
|
||||
writeLocalKnowledgePage,
|
||||
} from '@ktx/context/wiki';
|
||||
import { resolveOutputMode } from './io/mode.js';
|
||||
import { printList, type PrintListColumn, writeJsonResult } from './io/print-list.js';
|
||||
import { printList, type PrintListColumn } from './io/print-list.js';
|
||||
|
||||
export type KtxKnowledgeArgs =
|
||||
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
|
||||
| {
|
||||
command: 'search';
|
||||
projectDir: string;
|
||||
|
|
@ -27,18 +23,6 @@ export type KtxKnowledgeArgs =
|
|||
output?: string;
|
||||
json?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
| {
|
||||
command: 'write';
|
||||
projectDir: string;
|
||||
key: string;
|
||||
scope: LocalKnowledgeScope;
|
||||
userId: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
refs: string[];
|
||||
slRefs: string[];
|
||||
};
|
||||
|
||||
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
||||
|
|
@ -104,25 +88,6 @@ export async function runKtxKnowledge(
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'read') {
|
||||
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
|
||||
if (!page) {
|
||||
throw new Error(`Wiki page "${args.key}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'wiki.page',
|
||||
data: page,
|
||||
meta: { command: 'wiki read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(`# ${page.key}\n\n`);
|
||||
io.stdout.write(`Scope: ${page.scope}\n`);
|
||||
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
||||
io.stdout.write(`${page.content}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'search') {
|
||||
const results = await searchLocalKnowledgePages(project, {
|
||||
query: args.query,
|
||||
|
|
@ -153,18 +118,6 @@ export async function runKtxKnowledge(
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const write = await writeLocalKnowledgePage(project, {
|
||||
key: args.key,
|
||||
scope: args.scope,
|
||||
userId: args.userId,
|
||||
summary: args.summary,
|
||||
content: args.content,
|
||||
tags: args.tags,
|
||||
refs: args.refs,
|
||||
slRefs: args.slRefs,
|
||||
});
|
||||
io.stdout.write(`Wrote ${write.path}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ function sqlAnalysisStub() {
|
|||
async analyzeBatch() {
|
||||
return new Map();
|
||||
},
|
||||
async validateReadOnly() {
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ describe('createKtxCliScanConnector', () => {
|
|||
expect(bigQueryMock.constructorInputs[0]).not.toHaveProperty('maxBytesBilled');
|
||||
});
|
||||
|
||||
it('throws for structural daemon-only fallback configs', async () => {
|
||||
it('rejects daemon-only fallback driver configs at config parse time', async () => {
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
|
|
@ -105,14 +105,13 @@ describe('createKtxCliScanConnector', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const project = await loadKtxProject({ projectDir: tempDir });
|
||||
|
||||
await expect(createKtxCliScanConnector(project, 'warehouse')).rejects.toThrow(
|
||||
'Connection "warehouse" uses driver "duckdb", which has no native standalone KTX scan connector',
|
||||
await expect(loadKtxProject({ projectDir: tempDir })).rejects.toThrow(
|
||||
/connections\.warehouse\.driver:.*Invalid discriminator value/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the connection block has no driver field', async () => {
|
||||
it('rejects connection blocks with no driver field at config parse time', async () => {
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
|
|
@ -125,10 +124,9 @@ describe('createKtxCliScanConnector', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const project = await loadKtxProject({ projectDir: tempDir });
|
||||
|
||||
await expect(createKtxCliScanConnector(project, 'warehouse')).rejects.toThrow(
|
||||
'Connection "warehouse" has no `driver` field in ktx.yaml',
|
||||
await expect(loadKtxProject({ projectDir: tempDir })).rejects.toThrow(
|
||||
/connections\.warehouse\.driver:.*Invalid discriminator value/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
133
packages/cli/src/managed-mcp-daemon.test.ts
Normal file
133
packages/cli/src/managed-mcp-daemon.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
mcpDaemonLayout,
|
||||
readKtxMcpDaemonStatus,
|
||||
startKtxMcpDaemon,
|
||||
stopKtxMcpDaemon,
|
||||
type KtxMcpDaemonChild,
|
||||
type KtxMcpDaemonState,
|
||||
} from './managed-mcp-daemon.js';
|
||||
|
||||
function child(pid = 4242): KtxMcpDaemonChild {
|
||||
return { pid, unref: vi.fn() };
|
||||
}
|
||||
|
||||
function state(projectDir: string, overrides: Partial<KtxMcpDaemonState> = {}): KtxMcpDaemonState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
tokenAuth: false,
|
||||
projectDir,
|
||||
startedAt: '2026-05-14T00:00:00.000Z',
|
||||
logPath: join(projectDir, '.ktx/logs/mcp.log'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('managed MCP daemon lifecycle', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-mcp-daemon-'));
|
||||
projectDir = join(tempDir, 'project');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the spec state and log paths', () => {
|
||||
expect(mcpDaemonLayout(projectDir)).toEqual({
|
||||
statePath: join(projectDir, '.ktx/mcp.json'),
|
||||
logPath: join(projectDir, '.ktx/logs/mcp.log'),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a detached child and writes state without the token value', async () => {
|
||||
const spawnDaemon = vi.fn(() => child(5555));
|
||||
await startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '0.0.0.0',
|
||||
port: 7879,
|
||||
token: 'secret-token',
|
||||
allowedHosts: ['mcp.example.test'],
|
||||
allowedOrigins: ['https://mcp.example.test'],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => false),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
now: () => new Date('2026-05-14T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(spawnDaemon).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[
|
||||
'/repo/packages/cli/dist/bin.js',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'mcp',
|
||||
'serve-internal',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'7879',
|
||||
'--allowed-host',
|
||||
'mcp.example.test',
|
||||
'--allowed-origin',
|
||||
'https://mcp.example.test',
|
||||
],
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
env: expect.objectContaining({ KTX_MCP_TOKEN: 'secret-token' }),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(JSON.parse(await readFile(join(projectDir, '.ktx/mcp.json'), 'utf8')))).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
|
||||
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`);
|
||||
|
||||
const status = await readKtxMcpDaemonStatus({
|
||||
projectDir,
|
||||
processAlive: vi.fn(() => true),
|
||||
fetchHealth: vi.fn(async () => ({ ok: true, body: { status: 'ok', projectDir, port: 7878 } })),
|
||||
});
|
||||
|
||||
expect(status.kind).toBe('running');
|
||||
if (status.kind !== 'running') {
|
||||
throw new Error(`Expected running status, received ${status.kind}`);
|
||||
}
|
||||
expect(status.url).toBe('http://127.0.0.1:7878/mcp');
|
||||
});
|
||||
|
||||
it('stops a recorded daemon and removes state', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
const alive = new Set([4242]);
|
||||
const killProcess = vi.fn((pid: number) => alive.delete(pid));
|
||||
|
||||
await expect(
|
||||
stopKtxMcpDaemon({
|
||||
projectDir,
|
||||
processAlive: vi.fn((pid) => alive.has(pid)),
|
||||
killProcess,
|
||||
stopGraceMs: 1,
|
||||
pollIntervalMs: 1,
|
||||
}),
|
||||
).resolves.toEqual({ status: 'stopped' });
|
||||
|
||||
expect(killProcess).toHaveBeenCalledWith(4242, 'SIGTERM');
|
||||
await expect(readFile(join(projectDir, '.ktx/mcp.json'), 'utf8')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
238
packages/cli/src/managed-mcp-daemon.ts
Normal file
238
packages/cli/src/managed-mcp-daemon.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface KtxMcpDaemonState {
|
||||
schemaVersion: 1;
|
||||
pid: number;
|
||||
host: string;
|
||||
port: number;
|
||||
tokenAuth: boolean;
|
||||
projectDir: string;
|
||||
startedAt: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface KtxMcpDaemonChild {
|
||||
pid?: number;
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
export type KtxMcpDaemonStatus =
|
||||
| { kind: 'stopped'; detail: string }
|
||||
| { kind: 'running'; detail: string; state: KtxMcpDaemonState; url: string }
|
||||
| { kind: 'stale'; detail: string; state?: KtxMcpDaemonState };
|
||||
|
||||
const stateSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
pid: z.number().int().positive(),
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
tokenAuth: z.boolean(),
|
||||
projectDir: z.string().min(1),
|
||||
startedAt: z.string().min(1),
|
||||
logPath: z.string().min(1),
|
||||
});
|
||||
|
||||
export function mcpDaemonLayout(projectDir: string): { statePath: string; logPath: string } {
|
||||
return {
|
||||
statePath: join(projectDir, '.ktx/mcp.json'),
|
||||
logPath: join(projectDir, '.ktx/logs/mcp.log'),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultKillProcess(pid: number, signal: NodeJS.Signals): void {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown }).code !== 'ESRCH') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readState(projectDir: string): Promise<KtxMcpDaemonState | undefined> {
|
||||
try {
|
||||
return stateSchema.parse(JSON.parse(await readFile(mcpDaemonLayout(projectDir).statePath, 'utf8')) as unknown);
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown }).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(projectDir: string, state: KtxMcpDaemonState): Promise<void> {
|
||||
const { statePath } = mcpDaemonLayout(projectDir);
|
||||
await mkdir(dirname(statePath), { recursive: true });
|
||||
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function defaultPortAvailable(host: string, port: number): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.listen(port, host, () => server.close(() => resolve(true)));
|
||||
});
|
||||
}
|
||||
|
||||
function defaultSpawnDaemon(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { detached: boolean; stdio: ['ignore', number, number]; env: NodeJS.ProcessEnv },
|
||||
): KtxMcpDaemonChild {
|
||||
return spawn(command, args, options);
|
||||
}
|
||||
|
||||
async function defaultFetchHealth(state: KtxMcpDaemonState): Promise<{ ok: boolean; body: unknown; detail?: string }> {
|
||||
try {
|
||||
const response = await fetch(`http://${state.host}:${state.port}/health`, {
|
||||
headers: { host: `${state.host}:${state.port}` },
|
||||
});
|
||||
const body = await response.json();
|
||||
return { ok: response.ok, body, detail: response.ok ? undefined : `HTTP ${response.status}` };
|
||||
} catch (error) {
|
||||
return { ok: false, body: null, detail: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startKtxMcpDaemon(options: {
|
||||
projectDir: string;
|
||||
cliVersion: string;
|
||||
host: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
allowedHosts: string[];
|
||||
allowedOrigins: string[];
|
||||
binPath: string;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
portAvailable?: (host: string, port: number) => Promise<boolean>;
|
||||
spawnDaemon?: typeof defaultSpawnDaemon;
|
||||
now?: () => Date;
|
||||
}): Promise<{ status: 'started'; 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 portAvailable = options.portAvailable ?? defaultPortAvailable;
|
||||
if (!(await portAvailable(options.host, options.port))) {
|
||||
throw new Error(`Port ${options.port} is already in use. Choose another port with --port <n>.`);
|
||||
}
|
||||
|
||||
const { logPath } = mcpDaemonLayout(options.projectDir);
|
||||
await mkdir(dirname(logPath), { recursive: true });
|
||||
const log = await open(logPath, 'a');
|
||||
try {
|
||||
const args = [
|
||||
options.binPath,
|
||||
'--project-dir',
|
||||
options.projectDir,
|
||||
'mcp',
|
||||
'serve-internal',
|
||||
'--host',
|
||||
options.host,
|
||||
'--port',
|
||||
String(options.port),
|
||||
...options.allowedHosts.flatMap((host) => ['--allowed-host', host]),
|
||||
...options.allowedOrigins.flatMap((origin) => ['--allowed-origin', origin]),
|
||||
];
|
||||
const child = (options.spawnDaemon ?? defaultSpawnDaemon)(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
env: {
|
||||
...process.env,
|
||||
KTX_CLI_VERSION: options.cliVersion,
|
||||
...(options.token ? { KTX_MCP_TOKEN: options.token } : {}),
|
||||
},
|
||||
});
|
||||
if (!child.pid) {
|
||||
throw new Error('Failed to start KTX MCP daemon: child process pid was not available.');
|
||||
}
|
||||
child.unref();
|
||||
const state: KtxMcpDaemonState = {
|
||||
schemaVersion: 1,
|
||||
pid: child.pid,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
tokenAuth: Boolean(options.token),
|
||||
projectDir: options.projectDir,
|
||||
startedAt: (options.now ?? (() => new Date()))().toISOString(),
|
||||
logPath,
|
||||
};
|
||||
await writeState(options.projectDir, state);
|
||||
return { status: 'started', state, url: `http://${state.host}:${state.port}/mcp` };
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readKtxMcpDaemonStatus(options: {
|
||||
projectDir: string;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
fetchHealth?: (state: KtxMcpDaemonState) => Promise<{ ok: boolean; body: unknown; detail?: string }>;
|
||||
}): Promise<KtxMcpDaemonStatus> {
|
||||
let state: KtxMcpDaemonState | undefined;
|
||||
try {
|
||||
state = await readState(options.projectDir);
|
||||
} catch (error) {
|
||||
return { kind: 'stale', detail: `MCP daemon state is invalid: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
if (!state) {
|
||||
return { kind: 'stopped', detail: `No MCP daemon state at ${mcpDaemonLayout(options.projectDir).statePath}` };
|
||||
}
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
if (!processAlive(state.pid)) {
|
||||
return { kind: 'stale', detail: `MCP daemon process ${state.pid} is not running`, state };
|
||||
}
|
||||
const health = await (options.fetchHealth ?? defaultFetchHealth)(state);
|
||||
if (!health.ok) {
|
||||
return { kind: 'stale', detail: health.detail ?? 'MCP daemon health check failed', state };
|
||||
}
|
||||
return {
|
||||
kind: 'running',
|
||||
detail: `KTX MCP daemon running at http://${state.host}:${state.port}/mcp`,
|
||||
state,
|
||||
url: `http://${state.host}:${state.port}/mcp`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopKtxMcpDaemon(options: {
|
||||
projectDir: string;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||
stopGraceMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ status: 'stopped' | 'already-stopped' }> {
|
||||
const state = await readState(options.projectDir);
|
||||
const { statePath } = mcpDaemonLayout(options.projectDir);
|
||||
if (!state) {
|
||||
return { status: 'already-stopped' };
|
||||
}
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
const killProcess = options.killProcess ?? defaultKillProcess;
|
||||
if (processAlive(state.pid)) {
|
||||
killProcess(state.pid, 'SIGTERM');
|
||||
const deadline = Date.now() + (options.stopGraceMs ?? 10_000);
|
||||
while (Date.now() <= deadline && processAlive(state.pid)) {
|
||||
await delay(options.pollIntervalMs ?? 100);
|
||||
}
|
||||
if (processAlive(state.pid)) {
|
||||
killProcess(state.pid, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
await rm(statePath, { force: true });
|
||||
return { status: 'stopped' };
|
||||
}
|
||||
274
packages/cli/src/mcp-http-server.test.ts
Normal file
274
packages/cli/src/mcp-http-server.test.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { request } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildMcpSecurityConfig,
|
||||
isMcpRequestAuthorized,
|
||||
normalizeHostHeader,
|
||||
runKtxMcpHttpServer,
|
||||
} from './mcp-http-server.js';
|
||||
|
||||
describe('normalizeHostHeader', () => {
|
||||
it('normalizes host headers before allow-list comparison', () => {
|
||||
expect(normalizeHostHeader('LOCALHOST:7878')).toBe('localhost');
|
||||
expect(normalizeHostHeader('127.0.0.1:7878')).toBe('127.0.0.1');
|
||||
expect(normalizeHostHeader('[::1]:7878')).toBe('::1');
|
||||
expect(normalizeHostHeader(' Example.COM ')).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMcpSecurityConfig', () => {
|
||||
it('allows loopback hosts without a token', () => {
|
||||
const config = buildMcpSecurityConfig({
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
token: undefined,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
});
|
||||
|
||||
expect(config.token).toBeUndefined();
|
||||
expect(config.allowedHosts).toEqual(['localhost', '127.0.0.1', '::1']);
|
||||
});
|
||||
|
||||
it('requires a token for non-loopback binding', () => {
|
||||
expect(() =>
|
||||
buildMcpSecurityConfig({
|
||||
host: '0.0.0.0',
|
||||
port: 7878,
|
||||
token: undefined,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
}),
|
||||
).toThrow('Binding KTX MCP to 0.0.0.0 requires --token or KTX_MCP_TOKEN');
|
||||
});
|
||||
|
||||
it('validates allowed origins as full origins', () => {
|
||||
expect(() =>
|
||||
buildMcpSecurityConfig({
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
token: undefined,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: ['localhost:7878'],
|
||||
}),
|
||||
).toThrow('Allowed origin must be a full origin URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMcpRequestAuthorized', () => {
|
||||
const config = buildMcpSecurityConfig({
|
||||
host: '0.0.0.0',
|
||||
port: 7878,
|
||||
token: 'secret-token',
|
||||
allowedHosts: ['mcp.example.test'],
|
||||
allowedOrigins: ['https://mcp.example.test'],
|
||||
});
|
||||
|
||||
it('accepts a valid host, origin, and bearer token', () => {
|
||||
expect(
|
||||
isMcpRequestAuthorized(
|
||||
{
|
||||
path: '/mcp',
|
||||
headers: {
|
||||
host: 'mcp.example.test:7878',
|
||||
origin: 'https://mcp.example.test',
|
||||
authorization: 'Bearer secret-token',
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects bad host headers before MCP handling', () => {
|
||||
expect(
|
||||
isMcpRequestAuthorized(
|
||||
{ path: '/health', headers: { host: 'evil.example.test' } },
|
||||
config,
|
||||
),
|
||||
).toEqual({ ok: false, status: 403, message: 'Host header is not allowed for KTX MCP.' });
|
||||
});
|
||||
|
||||
it('rejects browser origins unless explicitly allowed', () => {
|
||||
expect(
|
||||
isMcpRequestAuthorized(
|
||||
{
|
||||
path: '/health',
|
||||
headers: { host: 'mcp.example.test', origin: 'https://evil.example.test' },
|
||||
},
|
||||
config,
|
||||
),
|
||||
).toEqual({ ok: false, status: 403, message: 'Origin header is not allowed for KTX MCP.' });
|
||||
});
|
||||
|
||||
it('requires bearer auth on /mcp when token auth is enabled', () => {
|
||||
expect(
|
||||
isMcpRequestAuthorized(
|
||||
{ path: '/mcp', headers: { host: 'mcp.example.test', authorization: 'Bearer wrong' } },
|
||||
config,
|
||||
),
|
||||
).toEqual({ ok: false, status: 401, message: 'Missing or invalid KTX MCP bearer token.' });
|
||||
});
|
||||
|
||||
it('does not require bearer auth on /health', () => {
|
||||
expect(isMcpRequestAuthorized({ path: '/health', headers: { host: 'mcp.example.test' } }, config)).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function postJson(port: number, path: string, body: unknown, headers: Record<string, string> = {}) {
|
||||
return new Promise<{ status: number; headers: Record<string, string | string[] | undefined>; body: string }>(
|
||||
(resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
host: `127.0.0.1:${port}`,
|
||||
accept: 'application/json, text/event-stream',
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function get(port: number, path: string, headers: Record<string, string> = {}) {
|
||||
return new Promise<{ status: number; headers: Record<string, string | string[] | undefined>; body: string }>(
|
||||
(resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: { host: `127.0.0.1:${port}`, ...headers },
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createTestMcpServer() {
|
||||
return () => {
|
||||
const server = new McpServer({ name: 'ktx-test', version: '0.0.0-test' });
|
||||
server.registerTool('ping', { inputSchema: {} }, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }],
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKtxMcpHttpServer', () => {
|
||||
it('serves /health with project metadata', async () => {
|
||||
const handle = await runKtxMcpHttpServer({
|
||||
projectDir: '/tmp/ktx-project',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
createMcpServer: createTestMcpServer(),
|
||||
});
|
||||
try {
|
||||
const port = (handle.server.address() as AddressInfo).port;
|
||||
const response = await get(port, '/health');
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
status: 'ok',
|
||||
projectDir: '/tmp/ktx-project',
|
||||
port,
|
||||
});
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('allocates a stateful MCP session on initialize', async () => {
|
||||
const handle = await runKtxMcpHttpServer({
|
||||
projectDir: '/tmp/ktx-project',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
createMcpServer: createTestMcpServer(),
|
||||
});
|
||||
try {
|
||||
const port = (handle.server.address() as AddressInfo).port;
|
||||
const response = await postJson(port, '/mcp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'vitest', version: '0.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['mcp-session-id']).toBeTruthy();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown session ids with 404', async () => {
|
||||
const handle = await runKtxMcpHttpServer({
|
||||
projectDir: '/tmp/ktx-project',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
createMcpServer: createTestMcpServer(),
|
||||
});
|
||||
try {
|
||||
const port = (handle.server.address() as AddressInfo).port;
|
||||
const response = await postJson(
|
||||
port,
|
||||
'/mcp',
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
||||
{ 'mcp-session-id': 'missing-session' },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toContain('Unknown MCP session');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
340
packages/cli/src/mcp-http-server.ts
Normal file
340
packages/cli/src/mcp-http-server.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp';
|
||||
import { createLocalProjectMemoryCapture } from '@ktx/context/memory';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
|
||||
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
|
||||
|
||||
const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'] as const;
|
||||
|
||||
export interface McpSecurityConfigInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
allowedHosts: string[];
|
||||
allowedOrigins: string[];
|
||||
}
|
||||
|
||||
export interface McpSecurityConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
allowedHosts: string[];
|
||||
allowedOrigins: string[];
|
||||
}
|
||||
|
||||
export type McpAuthorizationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; status: 401 | 403; message: string };
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const normalized = normalizeHostHeader(host);
|
||||
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
|
||||
}
|
||||
|
||||
export function normalizeHostHeader(value: string): string {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (trimmed.startsWith('[')) {
|
||||
const close = trimmed.indexOf(']');
|
||||
return close >= 0 ? trimmed.slice(1, close) : trimmed.replace(/^\[/, '');
|
||||
}
|
||||
const colon = trimmed.lastIndexOf(':');
|
||||
if (colon > -1 && trimmed.indexOf(':') === colon) {
|
||||
return trimmed.slice(0, colon);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function fullOrigin(value: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`Allowed origin must be a full origin URL: ${value}`);
|
||||
}
|
||||
if (!parsed.protocol || !parsed.host || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||
throw new Error(`Allowed origin must be a full origin URL: ${value}`);
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
export function buildMcpSecurityConfig(input: McpSecurityConfigInput): McpSecurityConfig {
|
||||
if (!isLoopbackHost(input.host) && !input.token) {
|
||||
throw new Error(`Binding KTX MCP to ${input.host} requires --token or KTX_MCP_TOKEN`);
|
||||
}
|
||||
const allowedHostSet = new Set<string>(DEFAULT_ALLOWED_HOSTS);
|
||||
if (!isLoopbackHost(input.host)) {
|
||||
allowedHostSet.add(normalizeHostHeader(input.host));
|
||||
}
|
||||
for (const host of input.allowedHosts) {
|
||||
allowedHostSet.add(normalizeHostHeader(host));
|
||||
}
|
||||
return {
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
...(input.token ? { token: input.token } : {}),
|
||||
allowedHosts: [...allowedHostSet],
|
||||
allowedOrigins: input.allowedOrigins.map(fullOrigin),
|
||||
};
|
||||
}
|
||||
|
||||
function headerValue(headers: IncomingHttpHeaders | Record<string, string | undefined>, name: string): string | undefined {
|
||||
const value = headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export function isMcpRequestAuthorized(
|
||||
request: { path: string; headers: IncomingHttpHeaders | Record<string, string | undefined> },
|
||||
config: McpSecurityConfig,
|
||||
): McpAuthorizationResult {
|
||||
const host = headerValue(request.headers, 'host');
|
||||
if (!host || !config.allowedHosts.includes(normalizeHostHeader(host))) {
|
||||
return { ok: false, status: 403, message: 'Host header is not allowed for KTX MCP.' };
|
||||
}
|
||||
const origin = headerValue(request.headers, 'origin');
|
||||
if (origin && !config.allowedOrigins.includes(origin)) {
|
||||
return { ok: false, status: 403, message: 'Origin header is not allowed for KTX MCP.' };
|
||||
}
|
||||
if (request.path === '/mcp' && config.token) {
|
||||
const auth = headerValue(request.headers, 'authorization');
|
||||
if (auth !== `Bearer ${config.token}`) {
|
||||
return { ok: false, status: 401, message: 'Missing or invalid KTX MCP bearer token.' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface KtxMcpHttpServerHandle {
|
||||
server: Server;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunKtxMcpHttpServerOptions extends McpSecurityConfigInput {
|
||||
projectDir: string;
|
||||
cliVersion?: string;
|
||||
io?: KtxCliIo;
|
||||
createMcpServer?: () => McpServer;
|
||||
loadProject?: typeof loadKtxProject;
|
||||
}
|
||||
|
||||
function noopIo(): KtxCliIo {
|
||||
return {
|
||||
stdout: { write() {} },
|
||||
stderr: { write() {} },
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: object): void {
|
||||
const payload = `${JSON.stringify(body)}\n`;
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function writeText(res: ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function requestPath(req: IncomingMessage): string {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
return url.pathname;
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
return raw.trim().length === 0 ? undefined : (JSON.parse(raw) as unknown);
|
||||
}
|
||||
|
||||
async function defaultMcpServerFactory(input: {
|
||||
project: KtxLocalProject;
|
||||
projectDir: string;
|
||||
cliVersion: string;
|
||||
io?: KtxCliIo;
|
||||
}): Promise<() => McpServer> {
|
||||
const io = input.io ?? noopIo();
|
||||
const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
|
||||
const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
|
||||
cliVersion: input.cliVersion,
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
const sqlAnalysis = createManagedDaemonSqlAnalysisPort({
|
||||
cliVersion: input.cliVersion,
|
||||
projectDir: input.projectDir,
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
const contextTools = createLocalProjectMcpContextPorts(input.project, {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
sqlAnalysis,
|
||||
localScan: {
|
||||
createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId),
|
||||
},
|
||||
localIngest: {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
},
|
||||
});
|
||||
|
||||
let memoryCapture: ReturnType<typeof createLocalProjectMemoryCapture> | undefined;
|
||||
try {
|
||||
memoryCapture = createLocalProjectMemoryCapture(input.project, { semanticLayerCompute, queryExecutor });
|
||||
} catch (error) {
|
||||
input.io?.stderr.write(`KTX MCP memory_capture disabled: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
|
||||
return () =>
|
||||
createDefaultKtxMcpServer({
|
||||
name: 'ktx',
|
||||
version: input.cliVersion,
|
||||
userContext: { userId: 'local' },
|
||||
contextTools,
|
||||
memoryCapture,
|
||||
});
|
||||
}
|
||||
|
||||
function listenerPort(server: Server, fallback: number): number {
|
||||
const address = server.address();
|
||||
return typeof address === 'object' && address ? address.port : fallback;
|
||||
}
|
||||
|
||||
function transportAllowedHosts(config: McpSecurityConfig, server: Server): string[] {
|
||||
const port = listenerPort(server, config.port);
|
||||
const hosts = new Set<string>(config.allowedHosts);
|
||||
for (const host of config.allowedHosts) {
|
||||
hosts.add(`${host}:${port}`);
|
||||
if (config.port !== 0 && config.port !== port) {
|
||||
hosts.add(`${host}:${config.port}`);
|
||||
}
|
||||
}
|
||||
return [...hosts];
|
||||
}
|
||||
|
||||
export async function runKtxMcpHttpServer(options: RunKtxMcpHttpServerOptions): Promise<KtxMcpHttpServerHandle> {
|
||||
const config = buildMcpSecurityConfig(options);
|
||||
const project =
|
||||
options.createMcpServer === undefined
|
||||
? await (options.loadProject ?? loadKtxProject)({ projectDir: options.projectDir })
|
||||
: undefined;
|
||||
const createMcpServer =
|
||||
options.createMcpServer ??
|
||||
(await defaultMcpServerFactory({
|
||||
project: project!,
|
||||
projectDir: options.projectDir,
|
||||
cliVersion: options.cliVersion ?? '0.0.0-private',
|
||||
io: options.io,
|
||||
}));
|
||||
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
|
||||
async function newTransport(): Promise<StreamableHTTPServerTransport> {
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
sessions.set(sessionId, transport);
|
||||
},
|
||||
onsessionclosed: (sessionId) => {
|
||||
sessions.delete(sessionId);
|
||||
},
|
||||
allowedHosts: transportAllowedHosts(config, server),
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
sessions.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
await createMcpServer().connect(transport);
|
||||
return transport;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = requestPath(req);
|
||||
const auth = isMcpRequestAuthorized({ path, headers: req.headers }, config);
|
||||
if (!auth.ok) {
|
||||
writeText(res, auth.status, auth.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === '/health' && req.method === 'GET') {
|
||||
const port = listenerPort(server, config.port);
|
||||
writeJson(res, 200, { status: 'ok', projectDir: options.projectDir, port });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path !== '/mcp' || !['POST', 'GET', 'DELETE'].includes(req.method ?? '')) {
|
||||
writeText(res, 404, 'Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
const normalizedSessionId = Array.isArray(sessionId) ? sessionId[0] : sessionId;
|
||||
|
||||
if (req.method === 'POST') {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch (error) {
|
||||
writeText(res, 400, `Invalid JSON body: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
const existing = normalizedSessionId ? sessions.get(normalizedSessionId) : undefined;
|
||||
if (existing) {
|
||||
await existing.handleRequest(req, res, body);
|
||||
return;
|
||||
}
|
||||
if (normalizedSessionId) {
|
||||
writeText(res, 404, `Unknown MCP session: ${normalizedSessionId}`);
|
||||
return;
|
||||
}
|
||||
if (!isInitializeRequest(body)) {
|
||||
writeText(res, 400, 'MCP initialize request is required before session traffic.');
|
||||
return;
|
||||
}
|
||||
await (await newTransport()).handleRequest(req, res, body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedSessionId || !sessions.has(normalizedSessionId)) {
|
||||
writeText(res, 404, normalizedSessionId ? `Unknown MCP session: ${normalizedSessionId}` : 'Missing MCP session id.');
|
||||
return;
|
||||
}
|
||||
await sessions.get(normalizedSessionId)!.handleRequest(req, res);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(config.port, config.host, () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
async close() {
|
||||
for (const transport of sessions.values()) {
|
||||
await transport.close();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -12,11 +12,12 @@ describe('renderKtxCommandTree', () => {
|
|||
.filter((line) => /^ {2}[├└]── \S/.test(line))
|
||||
.map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]);
|
||||
|
||||
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) {
|
||||
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'dev']) {
|
||||
expect(topLevel).toContain(expected);
|
||||
}
|
||||
|
||||
expect(output).toContain('│ └── test [connectionId]');
|
||||
expect(output).toContain('│ ├── status Show KTX MCP daemon status');
|
||||
expect(output).not.toContain('│ ├── add');
|
||||
expect(output).not.toContain('│ ├── remove');
|
||||
expect(output).not.toContain('│ ├── map');
|
||||
|
|
@ -24,7 +25,6 @@ describe('renderKtxCommandTree', () => {
|
|||
expect(output).not.toContain('│ ├── metabase');
|
||||
expect(output).not.toContain('│ ├── notion');
|
||||
expect(output).not.toContain('scan <connectionId>');
|
||||
expect(output).not.toContain('│ ├── status');
|
||||
expect(output).not.toContain('│ ├── replay');
|
||||
expect(output).not.toContain('│ └── replay');
|
||||
expect(output).not.toContain('│ ├── run');
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
it('plans warehouse connections as scan targets and source connections as source ingest targets', () => {
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
prod_metabase: { driver: 'metabase' },
|
||||
prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' },
|
||||
docs: { driver: 'notion' },
|
||||
});
|
||||
|
||||
|
|
@ -745,7 +745,7 @@ describe('runKtxPublicIngest', () => {
|
|||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
prod_metabase: { driver: 'metabase' },
|
||||
prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' },
|
||||
});
|
||||
const runScan = vi.fn(async () => 1);
|
||||
const runIngest = vi.fn(async () => 0);
|
||||
|
|
|
|||
|
|
@ -133,6 +133,50 @@ function warningLine(warning: KtxScanWarning): string {
|
|||
return `${warning.code}: ${location}${warning.message}`;
|
||||
}
|
||||
|
||||
function groupWarningsByCode(warnings: readonly KtxScanWarning[]): Map<string, KtxScanWarning[]> {
|
||||
const groups = new Map<string, KtxScanWarning[]>();
|
||||
for (const warning of warnings) {
|
||||
const list = groups.get(warning.code);
|
||||
if (list) {
|
||||
list.push(warning);
|
||||
} else {
|
||||
groups.set(warning.code, [warning]);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function describeWarningGroup(code: string, count: number): string {
|
||||
switch (code) {
|
||||
case 'sampling_failed':
|
||||
return `${count} ${plural(count, 'table')} could not be sampled (retries exhausted); descriptions used metadata-only fallback or were skipped.`;
|
||||
case 'description_fallback_used':
|
||||
return `${count} ${plural(count, 'table')} got an AI description from column metadata only (no sample rows available).`;
|
||||
case 'enrichment_failed':
|
||||
return `${count} ${plural(count, 'table/column')} could not be enriched.`;
|
||||
case 'connector_capability_missing':
|
||||
return `${count} ${plural(count, 'table')} affected by missing connector capability.`;
|
||||
case 'statistics_failed':
|
||||
return `${count} statistics ${plural(count, 'lookup')} failed.`;
|
||||
case 'llm_unavailable':
|
||||
return 'LLM provider unavailable; AI enrichment was skipped.';
|
||||
case 'embedding_unavailable':
|
||||
return 'Embedding provider unavailable; embeddings were skipped.';
|
||||
case 'relationship_validation_failed':
|
||||
return `${count} relationship ${plural(count, 'validation')} could not run.`;
|
||||
case 'relationship_llm_invalid_reference':
|
||||
return `${count} LLM-proposed ${plural(count, 'relationship')} referenced unknown columns.`;
|
||||
case 'relationship_llm_proposal_failed':
|
||||
return `${count} LLM relationship ${plural(count, 'proposal')} failed.`;
|
||||
case 'scan_enrichment_backend_not_configured':
|
||||
return 'Scan enrichment backend is not configured; AI stages were skipped.';
|
||||
case 'credential_redacted':
|
||||
return `${count} ${plural(count, 'credential')} were redacted from scan output.`;
|
||||
default:
|
||||
return `${count} ${plural(count, 'warning')} (${code})`;
|
||||
}
|
||||
}
|
||||
|
||||
function managedDaemonOptionsForScanRun(args: Extract<KtxScanArgs, { command: 'run' }>, io: KtxCliIo) {
|
||||
if (args.databaseIntrospectionUrl || !args.cliVersion || !args.runtimeInstallPolicy) {
|
||||
return undefined;
|
||||
|
|
@ -153,11 +197,26 @@ function writeNeedsAttention(report: KtxScanReport, io: KtxCliIo): void {
|
|||
}
|
||||
if (report.warnings.length > 0) {
|
||||
io.stdout.write(` ${report.warnings.length} ${plural(report.warnings.length, 'warning')}\n`);
|
||||
for (const warning of report.warnings.slice(0, 5)) {
|
||||
io.stdout.write(` - ${warningLine(warning)}\n`);
|
||||
}
|
||||
if (report.warnings.length > 5) {
|
||||
io.stdout.write(` - ${report.warnings.length - 5} more warnings in the JSON report\n`);
|
||||
const groups = groupWarningsByCode(report.warnings);
|
||||
for (const [code, warnings] of groups) {
|
||||
io.stdout.write(` - ${describeWarningGroup(code, warnings.length)}\n`);
|
||||
const first = warnings[0];
|
||||
if (first) {
|
||||
io.stdout.write(` ${warningLine(first)}\n`);
|
||||
}
|
||||
if (warnings.length > 1) {
|
||||
const moreTables = warnings
|
||||
.slice(1)
|
||||
.map((warning) =>
|
||||
warning.table ? (warning.column ? `${warning.table}.${warning.column}` : warning.table) : null,
|
||||
)
|
||||
.filter((value): value is string => value !== null)
|
||||
.slice(0, 3);
|
||||
if (moreTables.length > 0) {
|
||||
const suffix = warnings.length - 1 > moreTables.length ? `, …` : '';
|
||||
io.stdout.write(` also: ${moreTables.join(', ')}${suffix}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (report.capabilityGaps.length > 0) {
|
||||
|
|
|
|||
|
|
@ -37,23 +37,28 @@ describe('setup agents', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('plans project-scoped CLI files for every target', () => {
|
||||
it('plans project-scoped CLI and research files for every target', () => {
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'cli' })).toEqual([
|
||||
{ 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([
|
||||
{ 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([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -97,6 +102,31 @@ describe('setup agents', () => {
|
|||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('installs the research skill from the runtime asset', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'universal',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'ready' });
|
||||
|
||||
const researchSkill = await readFile(join(tempDir, '.agents/skills/ktx-research/SKILL.md'), 'utf-8');
|
||||
expect(researchSkill).toContain('name: ktx-research');
|
||||
expect(researchSkill).toContain('Always run `discover_data` before writing SQL.');
|
||||
expect(researchSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.');
|
||||
});
|
||||
|
||||
it('writes PATH-independent launcher commands for skills', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
|
|
@ -123,6 +153,178 @@ describe('setup agents', () => {
|
|||
expect(skill).not.toContain('sql execute');
|
||||
});
|
||||
|
||||
it('writes Claude Code project MCP config and tracks the json key', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'ready' });
|
||||
|
||||
const mcpJson = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
|
||||
mcpServers: { ktx: { type: string; url: string; headers?: Record<string, string> } };
|
||||
};
|
||||
expect(mcpJson.mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' });
|
||||
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
|
||||
entries: expect.arrayContaining([{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }]),
|
||||
});
|
||||
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
});
|
||||
|
||||
it('writes Cursor project MCP config', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'cursor',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
||||
const cursorJson = JSON.parse(await readFile(join(tempDir, '.cursor/mcp.json'), 'utf-8')) as {
|
||||
mcpServers: { ktx: { url: string; headers?: Record<string, string> } };
|
||||
};
|
||||
expect(cursorJson.mcpServers.ktx).toEqual({ url: 'http://localhost:7878/mcp' });
|
||||
});
|
||||
|
||||
it('prints Codex and opencode snippets without mutating printed-only config files', async () => {
|
||||
const codexIo = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'codex',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
codexIo.io,
|
||||
);
|
||||
expect(codexIo.stdout()).toContain('[mcp_servers.ktx]');
|
||||
expect(codexIo.stdout()).toContain('url = "http://localhost:7878/mcp"');
|
||||
|
||||
const opencodeIo = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'opencode',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
opencodeIo.io,
|
||||
);
|
||||
expect(opencodeIo.stdout()).toContain('"mcp"');
|
||||
expect(opencodeIo.stdout()).toContain('"type": "remote"');
|
||||
await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => {
|
||||
await mkdir(join(tempDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, '.ktx/mcp.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
pid: 999999,
|
||||
host: '127.0.0.1',
|
||||
port: 8787,
|
||||
tokenAuth: true,
|
||||
projectDir: tempDir,
|
||||
startedAt: '2026-05-14T00:00:00.000Z',
|
||||
logPath: join(tempDir, '.ktx/logs/mcp.log'),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
const io = makeIo();
|
||||
const previousToken = process.env.KTX_MCP_TOKEN;
|
||||
process.env.KTX_MCP_TOKEN = 'secret-token';
|
||||
|
||||
try {
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
||||
const rendered = JSON.stringify(JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')));
|
||||
expect(rendered).toContain('http://127.0.0.1:8787/mcp');
|
||||
expect(rendered).toContain('Bearer ${KTX_MCP_TOKEN}');
|
||||
expect(rendered).not.toContain('secret-token');
|
||||
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.KTX_MCP_TOKEN;
|
||||
} else {
|
||||
process.env.KTX_MCP_TOKEN = previousToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('writes Claude Code local MCP config under the project key in ~/.claude.json', 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 runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'local',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
||||
const config = JSON.parse(await readFile(join(home, '.claude.json'), 'utf-8')) as {
|
||||
projects: Record<string, { mcpServers: { ktx: { type: string; url: string } } }>;
|
||||
};
|
||||
expect(config.projects[tempDir].mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' });
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes only manifest-listed files', async () => {
|
||||
const io = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -12,9 +13,10 @@ import {
|
|||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
|
||||
|
||||
export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal';
|
||||
export type KtxAgentScope = 'project' | 'global';
|
||||
export type KtxAgentScope = 'project' | 'global' | 'local';
|
||||
export type KtxAgentInstallMode = 'cli';
|
||||
|
||||
export interface KtxSetupAgentsArgs {
|
||||
|
|
@ -45,18 +47,179 @@ export interface KtxAgentInstallManifest {
|
|||
installedAt: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
entries: Array<
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' }
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'research-skill' }
|
||||
| { kind: 'json-key'; path: string; jsonPath: string[] }
|
||||
>;
|
||||
}
|
||||
|
||||
type InstallEntry = KtxAgentInstallManifest['entries'][number];
|
||||
|
||||
interface KtxMcpEndpointInfo {
|
||||
url: string;
|
||||
tokenAuth: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface KtxMcpClientInstallResult {
|
||||
entries: InstallEntry[];
|
||||
snippets: string[];
|
||||
notices: string[];
|
||||
}
|
||||
|
||||
interface KtxCliLauncher {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
async function readJsonObject(path: string): Promise<Record<string, unknown>> {
|
||||
if (!existsSync(path)) return {};
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Expected JSON object in ${path}`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function objectAtPath(root: Record<string, unknown>, jsonPath: string[]): Record<string, unknown> {
|
||||
let cursor = root;
|
||||
for (const segment of jsonPath) {
|
||||
const current = cursor[segment];
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Record<string, unknown>;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
async function writeJsonKey(path: string, jsonPath: string[], value: unknown): Promise<void> {
|
||||
const root = await readJsonObject(path);
|
||||
const parent = objectAtPath(root, jsonPath.slice(0, -1));
|
||||
parent[jsonPath.at(-1) as string] = value;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(root, null, 2)}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function resolveMcpEndpoint(projectDir: string): Promise<KtxMcpEndpointInfo> {
|
||||
const status = await readKtxMcpDaemonStatus({ projectDir }).catch(() => null);
|
||||
if (status?.kind === 'running') {
|
||||
return {
|
||||
url: status.url,
|
||||
tokenAuth: status.state.tokenAuth,
|
||||
running: true,
|
||||
};
|
||||
}
|
||||
if (status?.kind === 'stale' && status.state) {
|
||||
return {
|
||||
url: `http://${status.state.host}:${status.state.port}/mcp`,
|
||||
tokenAuth: status.state.tokenAuth || Boolean(process.env.KTX_MCP_TOKEN),
|
||||
running: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: 'http://localhost:7878/mcp',
|
||||
tokenAuth: Boolean(process.env.KTX_MCP_TOKEN),
|
||||
running: false,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenHeaders(endpoint: KtxMcpEndpointInfo): Record<string, string> | undefined {
|
||||
return endpoint.tokenAuth ? { Authorization: 'Bearer ${KTX_MCP_TOKEN}' } : undefined;
|
||||
}
|
||||
|
||||
function claudeMcpEntry(endpoint: KtxMcpEndpointInfo): Record<string, unknown> {
|
||||
return {
|
||||
type: 'http',
|
||||
url: endpoint.url,
|
||||
...(tokenHeaders(endpoint) ? { headers: tokenHeaders(endpoint) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function cursorMcpEntry(endpoint: KtxMcpEndpointInfo): Record<string, unknown> {
|
||||
return {
|
||||
url: endpoint.url,
|
||||
...(tokenHeaders(endpoint) ? { headers: tokenHeaders(endpoint) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function codexSnippet(endpoint: KtxMcpEndpointInfo): string {
|
||||
if (endpoint.tokenAuth) {
|
||||
return [
|
||||
'Codex MCP config does not currently document HTTP headers.',
|
||||
'Run KTX on loopback without token auth for Codex, or configure headers after Codex documents support.',
|
||||
].join('\n');
|
||||
}
|
||||
return [`[mcp_servers.ktx]`, `url = "${endpoint.url}"`].join('\n');
|
||||
}
|
||||
|
||||
function opencodeSnippet(endpoint: KtxMcpEndpointInfo): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcp: {
|
||||
ktx: {
|
||||
type: 'remote',
|
||||
url: endpoint.url,
|
||||
enabled: true,
|
||||
...(tokenHeaders(endpoint) ? { headers: tokenHeaders(endpoint) } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } {
|
||||
const home = process.env.HOME ?? '';
|
||||
if (scope === 'global') {
|
||||
return { path: join(home, '.claude.json'), jsonPath: ['mcpServers', 'ktx'] };
|
||||
}
|
||||
if (scope === 'local') {
|
||||
return { path: join(home, '.claude.json'), jsonPath: ['projects', resolve(projectDir), 'mcpServers', 'ktx'] };
|
||||
}
|
||||
return { path: join(resolve(projectDir), '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] };
|
||||
}
|
||||
|
||||
function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } {
|
||||
const home = process.env.HOME ?? '';
|
||||
return {
|
||||
path: scope === 'global' ? join(home, '.cursor/mcp.json') : join(resolve(projectDir), '.cursor/mcp.json'),
|
||||
jsonPath: ['mcpServers', 'ktx'],
|
||||
};
|
||||
}
|
||||
|
||||
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 (!endpoint.running) {
|
||||
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
}
|
||||
|
||||
if (input.target === 'claude-code') {
|
||||
const config = claudeConfigPath(input.projectDir, input.scope);
|
||||
await writeJsonKey(config.path, config.jsonPath, claudeMcpEntry(endpoint));
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
} else if (input.target === 'cursor') {
|
||||
const config = cursorConfigPath(input.projectDir, input.scope);
|
||||
await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint));
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
} else if (input.target === 'codex') {
|
||||
snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
|
||||
} else if (input.target === 'opencode') {
|
||||
const path = input.scope === 'global' ? '~/.config/opencode/opencode.json' : relative(input.projectDir, join(input.projectDir, 'opencode.json'));
|
||||
snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`);
|
||||
}
|
||||
|
||||
return { entries, snippets, notices };
|
||||
}
|
||||
|
||||
export function agentInstallManifestPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/install-manifest.json');
|
||||
}
|
||||
|
|
@ -72,6 +235,7 @@ export function plannedKtxAgentFiles(input: {
|
|||
const home = process.env.HOME ?? '';
|
||||
return [
|
||||
{ kind: 'file', path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const },
|
||||
{ kind: 'file', path: join(home, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' as const },
|
||||
{ kind: 'file', path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const },
|
||||
];
|
||||
}
|
||||
|
|
@ -79,25 +243,44 @@ export function plannedKtxAgentFiles(input: {
|
|||
const codexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex');
|
||||
return [
|
||||
{ kind: 'file', path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const },
|
||||
{ kind: 'file', path: join(codexHome, 'skills/ktx-research/SKILL.md'), role: 'research-skill' as const },
|
||||
{ kind: 'file', path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const },
|
||||
];
|
||||
}
|
||||
if (input.target === 'cursor' || input.target === 'opencode') {
|
||||
return [];
|
||||
}
|
||||
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
|
||||
}
|
||||
|
||||
const root = resolve(input.projectDir);
|
||||
const cliEntries: Partial<Record<KtxAgentTarget, InstallEntry>> = {
|
||||
'claude-code': { kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
codex: { kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
cursor: { kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') },
|
||||
opencode: { kind: 'file', path: join(root, '.opencode/commands/ktx.md') },
|
||||
universal: { kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') },
|
||||
const cliEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
|
||||
'claude-code': [
|
||||
{ kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(root, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
],
|
||||
codex: [
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
],
|
||||
cursor: [
|
||||
{ kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') },
|
||||
{ kind: 'file', path: join(root, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' },
|
||||
],
|
||||
opencode: [
|
||||
{ kind: 'file', path: join(root, '.opencode/commands/ktx.md') },
|
||||
{ kind: 'file', path: join(root, '.opencode/commands/ktx-research.md'), role: 'research-skill' },
|
||||
],
|
||||
universal: [
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') },
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
],
|
||||
};
|
||||
const ruleEntries: Partial<Record<KtxAgentTarget, InstallEntry>> = {
|
||||
'claude-code': { kind: 'file', path: join(root, '.claude/rules/ktx.md'), role: 'rule' },
|
||||
codex: { kind: 'file', path: join(root, '.codex/instructions/ktx.md'), role: 'rule' },
|
||||
};
|
||||
return [cliEntries[input.target], ruleEntries[input.target]].filter(
|
||||
return [...(cliEntries[input.target] ?? []), ruleEntries[input.target]].filter(
|
||||
(entry): entry is InstallEntry => entry !== undefined,
|
||||
);
|
||||
}
|
||||
|
|
@ -109,6 +292,12 @@ function ktxCliLauncher(): KtxCliLauncher {
|
|||
};
|
||||
}
|
||||
|
||||
async function readResearchSkillContent(): Promise<string> {
|
||||
const path = fileURLToPath(new URL('./skills/research/SKILL.md', import.meta.url));
|
||||
const content = await readFile(path, 'utf-8');
|
||||
return content.endsWith('\n') ? content : `${content}\n`;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
|
||||
return value;
|
||||
|
|
@ -283,16 +472,22 @@ export function formatInstallSummary(
|
|||
projectDir: string,
|
||||
): string {
|
||||
const entriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
|
||||
let idx = 0;
|
||||
for (const install of installs) {
|
||||
const planned = plannedKtxAgentFiles({ projectDir, ...install });
|
||||
entriesByTarget.set(install.target, entries.slice(idx, idx + planned.length));
|
||||
idx += planned.length;
|
||||
const plannedFilePaths = new Set(
|
||||
plannedKtxAgentFiles({ projectDir, ...install })
|
||||
.filter((entry) => entry.kind === 'file')
|
||||
.map((entry) => entry.path),
|
||||
);
|
||||
entriesByTarget.set(
|
||||
install.target,
|
||||
entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)),
|
||||
);
|
||||
}
|
||||
|
||||
const fileHints: Record<string, string> = {
|
||||
skill: 'teaches your agent which KTX commands to run',
|
||||
rule: 'tells your agent when to use KTX',
|
||||
'research-skill': 'teaches your agent the KTX MCP research workflow',
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
|
|
@ -304,7 +499,7 @@ export function formatInstallSummary(
|
|||
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
if (entry.kind === 'file') {
|
||||
const isRule = entry.role === 'rule' || fileEntryLabels[install.target] === 'Rule installed';
|
||||
const label = isRule ? 'Rule installed' : fileEntryLabels[install.target];
|
||||
const label = entry.role === 'research-skill' ? 'Research skill installed' : isRule ? 'Rule installed' : fileEntryLabels[install.target];
|
||||
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
|
||||
lines.push(` + ${label} — ${hint}`);
|
||||
lines.push(` ${displayPath}`);
|
||||
|
|
@ -327,6 +522,8 @@ async function installTarget(input: {
|
|||
const content =
|
||||
entry.role === 'rule'
|
||||
? ruleInstructionContent({ projectDir: input.projectDir })
|
||||
: entry.role === 'research-skill'
|
||||
? await readResearchSkillContent()
|
||||
: cliInstructionContent({ projectDir: input.projectDir, launcher });
|
||||
await mkdir(dirname(entry.path), { recursive: true });
|
||||
await writeFile(entry.path, content, 'utf-8');
|
||||
|
|
@ -391,11 +588,25 @@ export async function runKtxSetupAgentsStep(
|
|||
|
||||
const installs = targets.map((target) => ({ target, scope: args.scope, mode }));
|
||||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices = new Set<string>();
|
||||
try {
|
||||
for (const install of installs) entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
|
||||
for (const install of installs) {
|
||||
entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
|
||||
const mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, target: install.target, scope: install.scope });
|
||||
entries.push(...mcpResult.entries);
|
||||
for (const snippet of mcpResult.snippets) snippets.push(snippet);
|
||||
for (const notice of mcpResult.notices) notices.add(notice);
|
||||
}
|
||||
await writeManifest(args.projectDir, mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries));
|
||||
await markAgentsComplete(args.projectDir);
|
||||
io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`);
|
||||
for (const snippet of snippets) {
|
||||
io.stdout.write(`\n${snippet}\n`);
|
||||
}
|
||||
for (const notice of notices) {
|
||||
io.stdout.write(`\n${notice}\n`);
|
||||
}
|
||||
return { status: 'ready', projectDir: args.projectDir, installs };
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ describe('setup context build state', () => {
|
|||
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-abc123',
|
||||
status: 'running',
|
||||
status: 'stale',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: '2026-05-09T10:00:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
|
|
@ -207,6 +207,7 @@ describe('setup context build state', () => {
|
|||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
|
||||
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
|
||||
sourceProgress: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
|
|
@ -623,34 +624,13 @@ describe('setup context build state', () => {
|
|||
expect(io.stderr()).toContain('No databases or context sources are configured for a KTX context build.');
|
||||
});
|
||||
|
||||
it('normalizes legacy detached and paused setup context states to stale', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-old',
|
||||
status: 'detached' as never,
|
||||
startedAt: '2026-05-09T09:00:00.000Z',
|
||||
updatedAt: '2026-05-09T09:00:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: [],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-old'),
|
||||
});
|
||||
|
||||
await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({
|
||||
status: 'stale',
|
||||
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a fresh foreground build when a stale running state is found', async () => {
|
||||
it('starts a fresh foreground build when stale state is found', async () => {
|
||||
await writeReadyProject(tempDir, {
|
||||
connections: { warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } } },
|
||||
});
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-running',
|
||||
status: 'running',
|
||||
runId: 'setup-context-local-stale',
|
||||
status: 'stale',
|
||||
startedAt: '2026-05-09T09:00:00.000Z',
|
||||
updatedAt: '2026-05-09T09:00:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
|
|
@ -658,7 +638,8 @@ describe('setup context build state', () => {
|
|||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-running'),
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-stale'),
|
||||
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
|
||||
});
|
||||
const io = makeIo();
|
||||
const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 }));
|
||||
|
|
|
|||
|
|
@ -27,10 +27,8 @@ import {
|
|||
|
||||
export type KtxSetupContextBuildStatus =
|
||||
| 'not_started'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
| 'stale';
|
||||
|
||||
export interface KtxSetupContextCommands {
|
||||
|
|
@ -84,7 +82,6 @@ export interface KtxSetupContextStepArgs {
|
|||
forcePrompt?: boolean;
|
||||
allowEmpty?: boolean;
|
||||
prompt?: boolean;
|
||||
autoWatch?: boolean;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
|
|
@ -154,14 +151,8 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat
|
|||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const rawStatus = typeof record.status === 'string' ? record.status : 'not_started';
|
||||
const legacyActive = rawStatus === 'detached' || rawStatus === 'paused' || rawStatus === 'running';
|
||||
const status: KtxSetupContextBuildStatus = legacyActive
|
||||
? 'stale'
|
||||
: rawStatus === 'completed' ||
|
||||
rawStatus === 'failed' ||
|
||||
rawStatus === 'interrupted' ||
|
||||
rawStatus === 'not_started' ||
|
||||
rawStatus === 'stale'
|
||||
const status: KtxSetupContextBuildStatus =
|
||||
rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'not_started' || rawStatus === 'stale'
|
||||
? rawStatus
|
||||
: 'not_started';
|
||||
const runId = typeof record.runId === 'string' && record.runId.length > 0 ? record.runId : undefined;
|
||||
|
|
@ -187,11 +178,7 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat
|
|||
? record.retryableFailedTargets.filter((item): item is string => typeof item === 'string')
|
||||
: [],
|
||||
commands: contextBuildCommands(projectDir, runId),
|
||||
...(typeof record.failureReason === 'string'
|
||||
? { failureReason: record.failureReason }
|
||||
: legacyActive
|
||||
? { failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.' }
|
||||
: {}),
|
||||
...(typeof record.failureReason === 'string' ? { failureReason: record.failureReason } : {}),
|
||||
...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -552,9 +539,9 @@ async function runBuild(
|
|||
const now = deps.now ?? (() => new Date());
|
||||
const runId = deps.runIdFactory?.() ?? runIdFactory();
|
||||
const startedAt = now().toISOString();
|
||||
const runningState: KtxSetupContextState = {
|
||||
const incompleteState: KtxSetupContextState = {
|
||||
runId,
|
||||
status: 'running',
|
||||
status: 'stale',
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
primarySourceConnectionIds: targets.primarySourceConnectionIds,
|
||||
|
|
@ -563,8 +550,9 @@ async function runBuild(
|
|||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(args.projectDir, runId),
|
||||
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
|
||||
};
|
||||
await writeKtxSetupContextState(args.projectDir, runningState);
|
||||
await writeKtxSetupContextState(args.projectDir, incompleteState);
|
||||
|
||||
let lastSourceProgress: ContextBuildSourceProgressUpdate[] | undefined;
|
||||
const contextBuild = deps.runContextBuild ?? runContextBuild;
|
||||
|
|
@ -584,7 +572,7 @@ async function runBuild(
|
|||
const resolvedDir = resolve(args.projectDir);
|
||||
mkdirSync(join(resolvedDir, '.ktx', 'setup'), { recursive: true });
|
||||
const progressState = normalizeState(resolvedDir, {
|
||||
...runningState,
|
||||
...incompleteState,
|
||||
sourceProgress: sources,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
|
@ -600,7 +588,7 @@ async function runBuild(
|
|||
if (buildResult.exitCode !== 0) {
|
||||
const updatedAt = now().toISOString();
|
||||
await writeKtxSetupContextState(args.projectDir, {
|
||||
...runningState,
|
||||
...incompleteState,
|
||||
status: 'failed',
|
||||
updatedAt,
|
||||
reportIds: completedReportIds,
|
||||
|
|
@ -616,7 +604,7 @@ async function runBuild(
|
|||
if (!readiness.ready) {
|
||||
const updatedAt = now().toISOString();
|
||||
await writeKtxSetupContextState(args.projectDir, {
|
||||
...runningState,
|
||||
...incompleteState,
|
||||
status: 'failed',
|
||||
updatedAt,
|
||||
reportIds: completedReportIds,
|
||||
|
|
@ -635,13 +623,14 @@ async function runBuild(
|
|||
await markContextComplete(project.projectDir);
|
||||
const completedAt = now().toISOString();
|
||||
await writeKtxSetupContextState(args.projectDir, {
|
||||
...runningState,
|
||||
...incompleteState,
|
||||
status: 'completed',
|
||||
updatedAt: completedAt,
|
||||
completedAt,
|
||||
reportIds: completedReportIds,
|
||||
artifactPaths: completedArtifactPaths,
|
||||
retryableFailedTargets: [],
|
||||
failureReason: undefined,
|
||||
...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}),
|
||||
});
|
||||
writeSuccess(project, readiness, targets, io);
|
||||
|
|
|
|||
|
|
@ -1024,6 +1024,8 @@ describe('setup sources step', () => {
|
|||
databaseMappings: { '1': 'warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [], items: [] },
|
||||
defaultTagNames: [],
|
||||
},
|
||||
},
|
||||
deps: {
|
||||
|
|
@ -1181,6 +1183,8 @@ describe('setup sources step', () => {
|
|||
databaseMappings: { '1': 'warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [], items: [] },
|
||||
defaultTagNames: [],
|
||||
},
|
||||
});
|
||||
const testPrompts = prompts({
|
||||
|
|
|
|||
|
|
@ -451,6 +451,8 @@ function buildMetabaseConnection(args: KtxSetupSourcesArgs): KtxProjectConnectio
|
|||
databaseMappings: { [String(args.metabaseDatabaseId)]: args.sourceWarehouseConnectionId },
|
||||
syncEnabled: { [String(args.metabaseDatabaseId)]: true },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [], items: [] },
|
||||
defaultTagNames: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { writeKtxSetupState } from '@ktx/context/project';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
|
||||
import { contextBuildCommands, readKtxSetupContextState, writeKtxSetupContextState } from './setup-context.js';
|
||||
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
|
||||
import { runDemoTour } from './setup-demo-tour.js';
|
||||
import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
|
||||
|
||||
|
|
@ -276,7 +276,7 @@ describe('setup status', () => {
|
|||
});
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-abc123',
|
||||
status: 'running',
|
||||
status: 'stale',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: '2026-05-09T10:01:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
|
|
@ -285,6 +285,7 @@ describe('setup status', () => {
|
|||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
|
||||
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
|
||||
});
|
||||
|
||||
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
|
||||
|
|
@ -311,7 +312,7 @@ describe('setup status', () => {
|
|||
' url: env:DATABASE_URL',
|
||||
' metabase:',
|
||||
' driver: metabase',
|
||||
' url: env:METABASE_URL',
|
||||
' api_url: https://metabase.example.test',
|
||||
' api_key_ref: env:METABASE_API_KEY',
|
||||
' warehouse_connection_id: warehouse',
|
||||
'llm:',
|
||||
|
|
@ -1619,40 +1620,6 @@ describe('setup status', () => {
|
|||
expect(io.stderr()).toContain('KTX context is not ready for agents.');
|
||||
});
|
||||
|
||||
it('does not offer background watch choices from setup status', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
' url: env:DATABASE_URL',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-stale',
|
||||
status: 'running',
|
||||
startedAt: '2026-05-09T09:00:00.000Z',
|
||||
updatedAt: '2026-05-09T09:00:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: [],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-stale'),
|
||||
});
|
||||
|
||||
const status = await readKtxSetupStatus(tempDir);
|
||||
expect(status.context.status).toBe('stale');
|
||||
const state = await readKtxSetupContextState(tempDir);
|
||||
expect(state.status).toBe('stale');
|
||||
});
|
||||
|
||||
it('routes a ready project menu selection to agent setup', async () => {
|
||||
const calls: string[] = [];
|
||||
const io = makeIo();
|
||||
|
|
|
|||
|
|
@ -163,10 +163,7 @@ type KtxSetupFlowStatus =
|
|||
| 'skipped'
|
||||
| 'back'
|
||||
| 'missing-input'
|
||||
| 'failed'
|
||||
| 'detached'
|
||||
| 'paused'
|
||||
| 'interrupted';
|
||||
| 'failed';
|
||||
|
||||
export interface KtxSetupEntryMenuPromptAdapter {
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
|
|
@ -408,10 +405,6 @@ function setupContextReady(status: KtxSetupStatus): boolean {
|
|||
return status.context.ready;
|
||||
}
|
||||
|
||||
function setupContextActive(status: KtxSetupStatus): boolean {
|
||||
return status.context.status === 'running';
|
||||
}
|
||||
|
||||
function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
|
||||
io.stderr.write('KTX context is not ready for agents.\n\n');
|
||||
io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
|
||||
|
|
@ -451,27 +444,22 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
args.inputMode !== 'disabled' &&
|
||||
!args.agents &&
|
||||
(io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined);
|
||||
let autoWatchActiveBuild = false;
|
||||
|
||||
setupLoop: while (true) {
|
||||
entryAction = undefined;
|
||||
if (canShowEntryMenu) {
|
||||
const status = await readKtxSetupStatus(args.projectDir);
|
||||
if (setupContextActive(status)) {
|
||||
autoWatchActiveBuild = true;
|
||||
} else {
|
||||
entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action;
|
||||
if (entryAction === 'exit') {
|
||||
(deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.');
|
||||
return 0;
|
||||
}
|
||||
if (entryAction === 'status') {
|
||||
io.stdout.write(formatKtxSetupStatus(status));
|
||||
return 0;
|
||||
}
|
||||
if (entryAction === 'demo') {
|
||||
return await runKtxSetupDemoFromEntryMenu(args, io, deps);
|
||||
}
|
||||
entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action;
|
||||
if (entryAction === 'exit') {
|
||||
(deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.');
|
||||
return 0;
|
||||
}
|
||||
if (entryAction === 'status') {
|
||||
io.stdout.write(formatKtxSetupStatus(status));
|
||||
return 0;
|
||||
}
|
||||
if (entryAction === 'demo') {
|
||||
return await runKtxSetupDemoFromEntryMenu(args, io, deps);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -500,30 +488,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
const currentStatus = await readKtxSetupStatus(projectResult.projectDir);
|
||||
let readyAction: string | undefined;
|
||||
|
||||
if (args.inputMode !== 'disabled' && !agentsRequested && setupContextActive(currentStatus)) {
|
||||
const contextRunner =
|
||||
deps.context ?? ((contextArgs, contextIo) => runKtxSetupContextStep(contextArgs, contextIo, deps.contextDeps));
|
||||
const contextResult = await contextRunner(
|
||||
{
|
||||
projectDir: projectResult.projectDir,
|
||||
inputMode: args.inputMode,
|
||||
allowEmpty: true,
|
||||
...(autoWatchActiveBuild ? { autoWatch: true } : {}),
|
||||
},
|
||||
io,
|
||||
);
|
||||
autoWatchActiveBuild = false;
|
||||
if (contextResult.status === 'back') {
|
||||
continue;
|
||||
}
|
||||
if (contextResult.status === 'failed' || contextResult.status === 'missing-input') {
|
||||
return 1;
|
||||
}
|
||||
if (contextResult.status !== 'ready') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.inputMode !== 'disabled' && !agentsRequested) {
|
||||
if (isKtxSetupReady(currentStatus)) {
|
||||
readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action;
|
||||
|
|
|
|||
49
packages/cli/src/skills/research/SKILL.md
Normal file
49
packages/cli/src/skills/research/SKILL.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
name: ktx-research
|
||||
description: Use when answering a question that needs data from a KTX-connected database - investigating, analyzing, "how many", "show me", "what's the breakdown of", finding records by value, exploring tables, comparing periods, or any data-investigation request. Triggers even when the user does not say "research"; if the answer requires querying a configured KTX connection, this skill applies.
|
||||
---
|
||||
|
||||
# KTX Research Workflow
|
||||
|
||||
You have access to KTX MCP tools for investigating data. Follow this workflow.
|
||||
|
||||
<workflow>
|
||||
1. **Discover** - call `discover_data` first to see what exists across wiki, semantic-layer sources, and raw tables. Returns refs only.
|
||||
2. **Inspect top hits in parallel** - for each promising ref:
|
||||
- `kind: 'wiki'` -> `wiki_read`
|
||||
- `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source`
|
||||
- `kind: 'table'` or `kind: 'column'` -> `entity_details`
|
||||
3. **Resolve literals** - if the user named a value such as "Acme Corp" or "status=shipped", call `dictionary_search` to find which column holds it.
|
||||
4. **Query** -
|
||||
- Prefer `sl_query` when the semantic layer covers the question.
|
||||
- Use `sql_execution` only for questions the semantic layer does not cover.
|
||||
5. **Capture learnings** - at the end of the turn, call `memory_capture` so future turns benefit. Skip when the answer carries no durable knowledge.
|
||||
</workflow>
|
||||
|
||||
<rules>
|
||||
- Always run `discover_data` before writing SQL. Do not guess table names.
|
||||
- Prefer the semantic layer over raw SQL when both can answer the question; measures are the source of truth.
|
||||
- Read entity details before writing SQL against an unfamiliar table. Do not assume column names.
|
||||
- Treat `sql_execution` as read-only. Writes are rejected by the server.
|
||||
- Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent.
|
||||
</rules>
|
||||
|
||||
<examples>
|
||||
**Input:** "How many orders did Acme Corp place last month?"
|
||||
|
||||
**Workflow:**
|
||||
1. `dictionary_search({ values: ["Acme Corp"] })` finds `customers.name`.
|
||||
2. `discover_data({ query: "orders customer monthly" })` finds an orders semantic-layer source.
|
||||
3. `sl_read_source({ connectionId: "warehouse", sourceName: "orders_facts" })` confirms the source grain, measures, and dimensions.
|
||||
4. `sl_query({ connectionId: "warehouse", measures: ["order_count"], filters: ["customer_name = 'Acme Corp'"] })` answers through the semantic layer.
|
||||
5. `memory_capture({ userMessage, assistantMessage })` captures the durable finding.
|
||||
|
||||
---
|
||||
|
||||
**Input:** "What columns does the events table have?"
|
||||
|
||||
**Workflow:**
|
||||
1. `discover_data({ query: "events table" })` returns a `table` ref.
|
||||
2. `entity_details({ connectionId: "warehouse", entities: [{ table: "analytics.events" }] })` returns columns, types, and foreign keys.
|
||||
3. Answer directly. No query is needed.
|
||||
</examples>
|
||||
|
|
@ -213,7 +213,11 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
if (!source) {
|
||||
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
|
||||
}
|
||||
const result = await validateLocalSlSource(source.yaml, { project, connectionId: args.connectionId });
|
||||
const result = await validateLocalSlSource(source.yaml, {
|
||||
project,
|
||||
connectionId: args.connectionId,
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
if (!result.valid) {
|
||||
for (const error of result.errors) {
|
||||
io.stderr.write(`${error}\n`);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
red,
|
||||
yellow,
|
||||
} from './io/symbols.js';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
|
||||
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
|
||||
type ProjectVerdict = 'ready' | 'partial' | 'blocked';
|
||||
|
|
@ -76,6 +77,8 @@ interface WarningItem {
|
|||
fix?: string;
|
||||
}
|
||||
|
||||
const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -139,7 +142,7 @@ function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): Ll
|
|||
backend,
|
||||
model,
|
||||
status: 'fail',
|
||||
detail: 'no LLM configured — ktx ask will not work',
|
||||
detail: 'no LLM configured; research agent will not run',
|
||||
fix: 'Run: ktx setup (choose an LLM provider)',
|
||||
};
|
||||
}
|
||||
|
|
@ -578,7 +581,7 @@ function buildVerdict(
|
|||
if (llm.status === 'fail') {
|
||||
return {
|
||||
verdict: 'blocked',
|
||||
reason: 'LLM not configured — `ktx ask` will not work.',
|
||||
reason: 'LLM not configured; research agent will not run.',
|
||||
nextActions: ['ktx setup'],
|
||||
};
|
||||
}
|
||||
|
|
@ -612,7 +615,7 @@ function buildVerdict(
|
|||
return {
|
||||
verdict: 'ready',
|
||||
reason: 'Ready.',
|
||||
nextActions: ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'],
|
||||
nextActions: [...PROJECT_READY_COMMANDS],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"tsBuildInfoFile": "./node_modules/.cache/tsc.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue