feat(cli): clean up command surface

This commit is contained in:
Andrey Avtomonov 2026-05-12 23:51:46 +02:00
parent 60457e9407
commit e15a4ebaec
61 changed files with 406 additions and 2076 deletions

View file

@ -4,8 +4,6 @@ import { registerAgentCommands } from './commands/agent-commands.js';
import { registerConnectionCommands } from './commands/connection-commands.js';
import { registerWikiCommands } from './commands/knowledge-commands.js';
import { registerPublicIngestCommands } from './commands/public-ingest-commands.js';
import { registerRuntimeCommands } from './commands/runtime-commands.js';
import { registerServeCommands } from './commands/serve-commands.js';
import { registerSetupCommands } from './commands/setup-commands.js';
import { registerSlCommands } from './commands/sl-commands.js';
import { registerStatusCommands } from './commands/status-commands.js';
@ -145,13 +143,23 @@ function isProjectAwareCommand(path: string[]): boolean {
const rootCommand = path[1];
if (rootCommand === 'dev') {
return path[2] !== undefined && path[2] !== 'completion';
return path[2] !== undefined && path[2] !== 'completion' && path[2] !== 'runtime';
}
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
}
function shouldSuppressProjectDirLine(path: string[], options: Record<string, unknown>): boolean {
if (path.join(' ') === 'ktx dev init') {
const commandPathKey = path.join(' ');
if (commandPathKey === 'ktx dev init') {
return true;
}
if (
commandPathKey === 'ktx status' &&
typeof options.projectDir !== 'string' &&
process.env.KTX_PROJECT_DIR === undefined &&
!findNearestKtxProjectDir(process.cwd())
) {
return true;
}
@ -159,7 +167,6 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
return true;
}
const commandPathKey = path.join(' ');
if (commandPathKey === 'ktx ingest watch') {
return options.json !== true;
}
@ -263,7 +270,6 @@ async function runBareInteractiveCommand(
mode: 'auto',
agents: false,
agentScope: 'project',
agentInstallMode: 'cli',
skipAgents: false,
inputMode: 'auto',
yes: false,
@ -324,12 +330,6 @@ export async function runCommanderKtxCli(
registerSlCommands(program, context);
profileMark('commander:register-sl');
registerRuntimeCommands(program, context);
profileMark('commander:register-runtime');
registerServeCommands(program, context);
profileMark('commander:register-serve');
registerStatusCommands(program, context);
profileMark('commander:register-status');

View file

@ -11,7 +11,6 @@ import type { KtxKnowledgeArgs } from './knowledge.js';
import type { KtxPublicIngestArgs } from './public-ingest.js';
import type { KtxRuntimeArgs } from './runtime.js';
import type { KtxScanArgs } from './scan.js';
import type { KtxServeArgs } from './serve.js';
import type { KtxSetupArgs } from './setup.js';
import type { KtxSlArgs } from './sl.js';
import { profileMark, profileSpan } from './startup-profile.js';
@ -32,7 +31,6 @@ export interface KtxCliIo {
}
export interface KtxCliDeps {
serveStdio?: (args: KtxServeArgs) => Promise<number>;
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
agent?: (args: KtxAgentArgs, io: KtxCliIo) => Promise<number>;
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;

View file

@ -1,52 +0,0 @@
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
import type { KtxServeArgs } from '../serve.js';
import { profileMark } from '../startup-profile.js';
profileMark('module:commands/serve-commands');
function parseMcp(value: string): 'stdio' {
if (value === 'stdio') {
return 'stdio';
}
throw new InvalidArgumentError('Only stdio is supported in this phase');
}
export function registerServeCommands(program: Command, context: KtxCliCommandContext): void {
program
.command('serve')
.description('Run standalone KTX services such as MCP stdio')
.requiredOption('--mcp <mode>', 'MCP transport mode', parseMcp)
.option('--user-id <id>', 'Local user id', 'local')
.option('--semantic-compute', 'Enable semantic-layer compute', false)
.option('--semantic-compute-url <url>', 'HTTP semantic-layer compute URL')
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
.option('--no-input', 'Disable interactive managed runtime installation')
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
.option('--execute-queries', 'Allow semantic-layer query execution', false)
.option('--memory-capture', 'Enable memory capture', false)
.option('--memory-model <model>', 'Memory capture model')
.showHelpAfterError()
.action(async (options, command): Promise<void> => {
const semanticCompute = options.semanticCompute === true || Boolean(options.semanticComputeUrl);
if (options.executeQueries === true && !semanticCompute) {
throw new Error('--execute-queries requires --semantic-compute');
}
const args: KtxServeArgs = {
mcp: options.mcp,
projectDir: resolveCommandProjectDir(command),
userId: options.userId,
semanticCompute,
semanticComputeUrl: options.semanticComputeUrl,
databaseIntrospectionUrl: options.databaseIntrospectionUrl,
executeQueries: options.executeQueries === true,
memoryCapture: options.memoryCapture === true,
memoryModel: options.memoryModel,
cliVersion: context.packageInfo.version,
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
};
const runner = context.deps.serveStdio ?? (await import('../serve.js')).runKtxServeStdio;
context.setExitCode(await runner(args));
});
}

View file

@ -64,13 +64,6 @@ function agentScope(value: string): 'project' | 'global' {
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function agentInstallMode(value: string): 'cli' | 'mcp' | 'both' {
if (value === 'cli' || value === 'mcp' || value === 'both') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function positiveNumber(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
@ -232,9 +225,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.addOption(new Option('--agent-scope <scope>', 'Agent install scope').argParser(agentScope).default('project'))
.option('--project', 'Install agent integration into the project scope', false)
.option('--global', 'Install agent integration into the global target scope', false)
.addOption(
new Option('--agent-install-mode <mode>', 'Agent install mode').argParser(agentInstallMode).default('cli'),
)
.option('--skip-agents', 'Leave agent integration incomplete for now', false)
.option('--yes', 'Accept safe defaults in non-interactive setup', false)
.option('--no-input', 'Disable interactive terminal input')
@ -371,7 +361,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
agents: options.agents === true,
...(options.target ? { target: options.target } : {}),
agentScope: resolvedAgentScope,
agentInstallMode: options.agentInstallMode,
skipAgents: options.skipAgents === true,
inputMode: options.input === false ? 'disabled' : 'auto',
yes: options.yes === true,

View file

@ -1,20 +1,46 @@
import type { Command } from '@commander-js/extra-typings';
import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir } from '../cli-program.js';
import { resolveCommandProjectDir, resolveCommandProjectDirOverride } from '../cli-program.js';
import { findNearestKtxProjectDir } from '../project-resolver.js';
function outputMode(options: { json?: boolean }): 'plain' | 'json' {
return options.json === true ? 'json' : 'plain';
}
function inputMode(options: { input?: boolean }): { inputMode?: 'disabled' } {
return options.input === false ? { inputMode: 'disabled' } : {};
}
export function registerStatusCommands(program: Command, context: KtxCliCommandContext): void {
program
.command('status')
.description('Show current KTX project setup status')
.description('Check current KTX setup and project readiness')
.option('--json', 'Print JSON output', false)
.action(async (options: { json?: boolean }, command) => {
const runner = context.deps.setup ?? (await import('../setup.js')).runKtxSetup;
.option('--no-input', 'Disable interactive terminal input')
.action(async (options: { json?: boolean; input?: boolean }, command) => {
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
if (!explicitOrEnvProjectDir && !nearestProjectDir) {
context.setExitCode(
await runner(
{
command: 'setup',
outputMode: outputMode(options),
...inputMode(options),
},
context.io,
),
);
return;
}
context.setExitCode(
await runner(
{
command: 'status',
command: 'project',
projectDir: resolveCommandProjectDir(command),
json: options.json === true,
outputMode: outputMode(options),
...inputMode(options),
},
context.io,
),

View file

@ -29,10 +29,11 @@ describe('dev Commander tree', () => {
await expect(runKtxCli(['dev', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
for (const command of ['init', 'doctor', 'scan', 'ingest', 'mapping']) {
for (const command of ['init', 'runtime', 'scan', 'ingest', 'mapping']) {
expect(testIo.stdout()).toContain(command);
}
for (const removed of [
'doctor',
'knowledge',
'model',
'replay',
@ -100,6 +101,7 @@ describe('dev Commander tree', () => {
it('rejects removed dev command groups', async () => {
for (const argv of [
['dev', 'doctor', 'setup'],
['dev', 'knowledge', 'list'],
['dev', 'model', 'list'],
['dev', 'artifacts'],
@ -114,8 +116,8 @@ describe('dev Commander tree', () => {
it.each([
{
argv: ['dev', 'doctor', '--help'],
expected: ['Usage: ktx dev doctor', '--json', '--no-input'],
argv: ['dev', 'runtime', '--help'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'doctor', 'prune'],
},
{
argv: ['dev', 'scan', '--help'],

View file

@ -3,8 +3,8 @@ import type { Command } from '@commander-js/extra-typings';
import { type CommandWithGlobalOptions, type KtxCliCommandContext, resolveCommandProjectDir } from './cli-program.js';
import { registerCompletionCommands } from './commands/completion-commands.js';
import { registerConnectionMappingCommands } from './commands/connection-commands.js';
import { registerDoctorCommands } from './commands/doctor-commands.js';
import { registerIngestCommands } from './commands/ingest-commands.js';
import { registerRuntimeCommands } from './commands/runtime-commands.js';
import { registerScanCommands } from './commands/scan-commands.js';
import { profileMark } from './startup-profile.js';
@ -50,7 +50,7 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
},
);
registerDoctorCommands(dev, context);
registerRuntimeCommands(dev, context);
registerScanCommands(dev, context);
registerIngestCommands(dev, context, {
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>

View file

@ -3,8 +3,6 @@ import { cp, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const execFileAsync = promisify(execFile);
@ -50,12 +48,6 @@ async function runBuiltCli(args: string[]): Promise<CliResult> {
}
}
function structuredContent<T extends object>(result: unknown): T {
const content = (result as { structuredContent?: unknown }).structuredContent;
expect(content).toBeDefined();
return content as T;
}
function parseJsonOutput<T>(stdout: string): T {
return JSON.parse(stdout) as T;
}
@ -132,121 +124,4 @@ describe('standalone local warehouse example', () => {
);
}, 30_000);
it('serves local wiki and semantic-layer MCP tools against the copied example project', async () => {
const projectDir = await copyExampleProject(tempDir);
const client = new Client({ name: 'ktx-example-client', version: '0.0.0' });
const transport = new StdioClientTransport({
command: process.execPath,
args: [CLI_BIN, 'serve', '--mcp', 'stdio', '--project-dir', projectDir, '--user-id', 'example-user'],
stderr: 'pipe',
});
try {
await client.connect(transport);
const knowledgeSearch = structuredContent<{
results: Array<{ key: string; summary: string; score: number }>;
totalFound: number;
}>(
await client.callTool({
name: 'knowledge_search',
arguments: { query: 'refund', limit: 5 },
}),
);
expect(knowledgeSearch.totalFound).toBe(1);
expect(knowledgeSearch.results[0]).toMatchObject({
key: 'revenue',
summary: 'Paid order value after refunds',
});
const knowledgeRead = structuredContent<{ key: string; summary: string; content: string; scope: string }>(
await client.callTool({ name: 'knowledge_read', arguments: { key: 'revenue' } }),
);
expect(knowledgeRead).toMatchObject({
key: 'revenue',
summary: 'Paid order value after refunds',
scope: 'GLOBAL',
});
expect(knowledgeRead.content).toContain('Revenue is paid order amount after refund adjustments.');
const knowledgeWrite = structuredContent<{ success: boolean; key: string; action: string }>(
await client.callTool({
name: 'knowledge_write',
arguments: {
key: 'gross_margin',
summary: 'Revenue after direct costs',
content: 'Gross margin subtracts direct order costs from revenue.',
tags: ['finance'],
sl_refs: ['warehouse.orders'],
},
}),
);
expect(knowledgeWrite).toEqual({ success: true, key: 'gross_margin', action: 'created' });
const slList = structuredContent<{
sources: Array<{
connectionId: string;
name: string;
description?: string;
columnCount: number;
measureCount: number;
joinCount: number;
}>;
totalSources: number;
}>(await client.callTool({ name: 'sl_list_sources', arguments: { connectionId: 'warehouse' } }));
expect(slList.totalSources).toBe(1);
expect(slList.sources[0]).toMatchObject({
connectionId: 'warehouse',
name: 'orders',
description: 'Orders placed through the storefront.',
columnCount: 3,
measureCount: 2,
joinCount: 0,
});
const slRead = structuredContent<{ sourceName: string; yaml: string }>(
await client.callTool({
name: 'sl_read_source',
arguments: { connectionId: 'warehouse', sourceName: 'orders' },
}),
);
expect(slRead.sourceName).toBe('orders');
expect(slRead.yaml).toContain('name: orders');
expect(slRead.yaml).toContain('total_revenue');
const slWrite = structuredContent<{ success: boolean; sourceName: string }>(
await client.callTool({
name: 'sl_write_source',
arguments: {
connectionId: 'warehouse',
sourceName: 'customers',
source: {
name: 'customers',
table: 'public.customers',
grain: ['id'],
columns: [{ name: 'id', type: 'number' }],
joins: [],
measures: [],
},
},
}),
);
expect(slWrite).toMatchObject({ success: true, sourceName: 'customers' });
const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>(
await client.callTool({
name: 'sl_validate',
arguments: { connectionId: 'warehouse', names: ['orders', 'customers'] },
}),
);
expect(slValidate.success).toBe(true);
expect(slValidate.errors).toEqual([]);
expect(slValidate.warnings).toContain(
'Local stdio validation checks YAML shape only; Python semantic validation is not configured.',
);
} finally {
await client.close();
}
}, 30_000);
});

View file

@ -143,7 +143,7 @@ describe('runPostgresHistoricSqlDoctorChecks', () => {
status: 'warn',
detail:
'pg_stat_statements ready (PostgreSQL 16.4) with warnings: pg_stat_statements.track is none; set it to top or all in the Postgres parameter group or config; info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
fix: 'Update the Postgres parameter group or config, then rerun `ktx dev doctor --project-dir /tmp/ktx-project`',
fix: 'Update the Postgres parameter group or config, then rerun `ktx status --project-dir /tmp/ktx-project`',
},
]);
});

View file

@ -63,7 +63,7 @@ function capabilityFailureFix(error: unknown, connectionId: string, projectDir:
if (error instanceof Error && error.name === 'HistoricSqlVersionUnsupportedError') {
return 'Use PostgreSQL 14 or newer, or disable historicSql for this connection';
}
return `Fix connections.${connectionId} Postgres settings, then rerun \`ktx dev doctor --project-dir ${projectDir}\``;
return `Fix connections.${connectionId} Postgres settings, then rerun \`ktx status --project-dir ${projectDir}\``;
}
function failureDetail(error: unknown): string {
@ -143,7 +143,7 @@ export async function runPostgresHistoricSqlDoctorChecks(
checkId(connectionId),
label,
readinessDetail(result),
`Update the Postgres parameter group or config, then rerun \`ktx dev doctor --project-dir ${project.projectDir}\``,
`Update the Postgres parameter group or config, then rerun \`ktx status --project-dir ${project.projectDir}\``,
),
);
} else {

View file

@ -117,16 +117,16 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe('');
});
it('prints the May 6 public command surface in root help', async () => {
it('prints the public command surface in root help', async () => {
const testIo = makeIo();
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'runtime', 'serve', 'status']) {
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']) {
expect(testIo.stdout()).toContain(`${command}`);
}
for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion']) {
for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion', 'runtime', 'serve']) {
expect(testIo.stdout()).not.toContain(`${removed} [`);
expect(testIo.stdout()).not.toContain(`${removed} `);
}
@ -150,18 +150,18 @@ describe('runKtxCli', () => {
const pruneIo = makeIo();
await expect(
runKtxCli(['runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, {
runKtxCli(['dev', 'runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, {
runtime,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
runKtxCli(['dev', 'runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
expect(runtime).toHaveBeenNthCalledWith(
1,
@ -229,6 +229,9 @@ describe('runKtxCli', () => {
},
pruneIo.io,
);
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, doctorIo, pruneIo]) {
expect(io.stderr()).toBe('');
}
});
it('prints the resolved project directory for ordinary project commands', async () => {
@ -266,7 +269,7 @@ describe('runKtxCli', () => {
it('documents runtime stop all in command help', async () => {
const testIo = makeIo();
await expect(runKtxCli(['runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('--all');
expect(testIo.stdout()).toContain('Stop all KTX daemon processes recorded or discoverable');
@ -407,7 +410,6 @@ describe('runKtxCli', () => {
mode: 'auto',
agents: false,
agentScope: 'project',
agentInstallMode: 'cli',
skipAgents: false,
inputMode: 'auto',
yes: false,
@ -653,28 +655,13 @@ describe('runKtxCli', () => {
expect(choiceIo.stderr()).toBe('');
});
it('dispatches serve stdio commands', async () => {
it('rejects removed serve commands', async () => {
const testIo = makeIo();
const serveStdio = vi.fn().mockResolvedValue(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'serve', '--mcp', 'stdio', '--user-id', 'agent'], testIo.io, {
serveStdio,
}),
).resolves.toBe(0);
await expect(runKtxCli(['--project-dir', tempDir, 'serve', '--mcp', 'stdio', '--user-id', 'agent'], testIo.io))
.resolves.toBe(1);
expect(serveStdio).toHaveBeenCalledWith({
mcp: 'stdio',
projectDir: tempDir,
userId: 'agent',
semanticCompute: false,
semanticComputeUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
});
expect(testIo.stderr()).toMatch(/unknown command|error:/);
});
it('routes public ingest through the public ingest parser', async () => {
@ -948,16 +935,14 @@ describe('runKtxCli', () => {
);
});
it('dispatches dev doctor and ingest parser cases through Commander', async () => {
it('rejects removed dev doctor while keeping ingest parser cases under dev', async () => {
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const doctorIo = makeIo();
const ingestRunIo = makeIo();
const ingestReplayHelpIo = makeIo();
await expect(runKtxCli(['dev', 'doctor', 'setup', '--json', '--no-input'], doctorIo.io, { doctor })).resolves.toBe(
0,
);
await expect(runKtxCli(['dev', 'doctor', 'setup', '--json', '--no-input'], doctorIo.io, { doctor })).resolves.toBe(1);
await expect(
runKtxCli(
[
@ -983,7 +968,7 @@ describe('runKtxCli', () => {
).resolves.toBe(0);
await expect(runKtxCli(['dev', 'ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
expect(doctor).toHaveBeenCalledWith({ command: 'setup', outputMode: 'json', inputMode: 'disabled' }, doctorIo.io);
expect(doctor).not.toHaveBeenCalled();
expect(ingest).toHaveBeenCalledWith(
{
command: 'run',
@ -1002,7 +987,7 @@ describe('runKtxCli', () => {
);
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx dev ingest replay [options] <runId>');
expect(ingestReplayHelpIo.stdout()).toContain('<runId>');
expect(doctorIo.stderr()).toBe('');
expect(doctorIo.stderr()).toMatch(/unknown command|error:/);
expect(ingestRunIo.stderr()).toBe('');
expect(ingestReplayHelpIo.stderr()).toBe('');
});
@ -1082,18 +1067,58 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
});
it('dispatches setup status and top-level status through the setup runner', async () => {
it('keeps setup status on the setup runner and routes top-level status through doctor', async () => {
const setup = vi.fn(async () => 0);
const doctor = vi.fn(async () => 0);
const setupIo = makeIo();
const statusIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'status', '--json'], setupIo.io, { setup }),
).resolves.toBe(0);
await expect(runKtxCli(['--project-dir', tempDir, 'status', '--json'], statusIo.io, { setup })).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'status', '--json', '--no-input'], statusIo.io, { setup, doctor }),
).resolves.toBe(0);
expect(setup).toHaveBeenNthCalledWith(1, { command: 'status', projectDir: tempDir, json: true }, setupIo.io);
expect(setup).toHaveBeenNthCalledWith(2, { command: 'status', projectDir: tempDir, json: true }, statusIo.io);
expect(setup).toHaveBeenCalledTimes(1);
expect(doctor).toHaveBeenCalledWith(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
statusIo.io,
);
expect(statusIo.stderr()).toBe('');
});
it('routes top-level status without a project to setup doctor checks', async () => {
const { mkdtemp, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const originalCwd = process.cwd();
const previousProjectDir = process.env.KTX_PROJECT_DIR;
const tempCwd = await mkdtemp(join(tmpdir(), 'ktx-status-no-project-'));
const doctor = vi.fn(async () => 0);
const statusIo = makeIo();
try {
delete process.env.KTX_PROJECT_DIR;
process.chdir(tempCwd);
await expect(runKtxCli(['status', '--json', '--no-input'], statusIo.io, { doctor })).resolves.toBe(0);
expect(doctor).toHaveBeenCalledWith(
{ command: 'setup', outputMode: 'json', inputMode: 'disabled' },
statusIo.io,
);
expect(statusIo.stderr()).toBe('');
} finally {
process.chdir(originalCwd);
if (previousProjectDir === undefined) {
delete process.env.KTX_PROJECT_DIR;
} else {
process.env.KTX_PROJECT_DIR = previousProjectDir;
}
await rm(tempCwd, { recursive: true, force: true });
}
});
it('dispatches setup context recovery commands through the setup runner', async () => {
@ -1356,8 +1381,6 @@ describe('runKtxCli', () => {
'--target',
'codex',
'--project',
'--agent-install-mode',
'both',
'--no-input',
'--yes',
],
@ -1376,7 +1399,6 @@ describe('runKtxCli', () => {
agents: true,
target: 'codex',
agentScope: 'project',
agentInstallMode: 'both',
inputMode: 'disabled',
yes: true,
}),
@ -2241,9 +2263,8 @@ describe('runKtxCli', () => {
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
});
it('dispatches serve public command options through Commander', async () => {
it('rejects removed public serve command options before dispatch', async () => {
const serveIo = makeIo();
const serveStdio = vi.fn(async () => 0);
await expect(
runKtxCli(
@ -2261,77 +2282,10 @@ describe('runKtxCli', () => {
'openai/gpt-5.2',
],
serveIo.io,
{ serveStdio },
),
).resolves.toBe(0);
expect(serveStdio).toHaveBeenCalledWith({
mcp: 'stdio',
projectDir: tempDir,
userId: 'local',
semanticCompute: true,
semanticComputeUrl: 'http://127.0.0.1:18080',
databaseIntrospectionUrl: undefined,
executeQueries: true,
memoryCapture: true,
memoryModel: 'openai/gpt-5.2',
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
});
expect(serveIo.stderr()).toBe('');
});
it('routes serve managed runtime install policies', async () => {
const autoIo = makeIo();
const neverIo = makeIo();
const conflictIo = makeIo();
const serveStdio = vi.fn(async () => 0);
await expect(
runKtxCli(['serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute', '--yes'], autoIo.io, {
serveStdio,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute', '--no-input'], neverIo.io, {
serveStdio,
}),
).resolves.toBe(0);
await expect(
runKtxCli(
['serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute', '--yes', '--no-input'],
conflictIo.io,
{ serveStdio },
),
).resolves.toBe(1);
expect(serveStdio).toHaveBeenNthCalledWith(1, {
mcp: 'stdio',
projectDir: tempDir,
userId: 'local',
semanticCompute: true,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'auto',
});
expect(serveStdio).toHaveBeenNthCalledWith(2, {
mcp: 'stdio',
projectDir: tempDir,
userId: 'local',
semanticCompute: true,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'never',
});
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
expect(serveIo.stderr()).toMatch(/unknown command|error:/);
});
it('prints dev help for bare dev commands', async () => {

View file

@ -118,7 +118,6 @@ describe('runKtxIngest', () => {
mode: 'new',
agents: false,
agentScope: 'project',
agentInstallMode: 'cli',
skipAgents: true,
inputMode: 'disabled',
yes: true,

View file

@ -108,9 +108,9 @@ function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonR
describe('managedRuntimeInstallCommand', () => {
it('prints the exact command for each managed runtime feature', () => {
expect(managedRuntimeInstallCommand('core')).toBe('ktx runtime install --yes');
expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes');
expect(managedRuntimeInstallCommand('local-embeddings')).toBe(
'ktx runtime install --feature local-embeddings --yes',
'ktx dev runtime install --feature local-embeddings --yes',
);
});
});
@ -166,7 +166,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
}),
).rejects.toThrow('KTX Python runtime is required for this command. Run: ktx runtime install --yes');
).rejects.toThrow('KTX Python runtime is required for this command. Run: ktx dev runtime install --yes');
expect(installRuntime).not.toHaveBeenCalled();
});

View file

@ -52,8 +52,8 @@ export interface ManagedPythonSemanticLayerComputeOptions extends ManagedPythonC
export function managedRuntimeInstallCommand(feature: KtxRuntimeFeature): string {
return feature === 'local-embeddings'
? 'ktx runtime install --feature local-embeddings --yes'
: 'ktx runtime install --yes';
? 'ktx dev runtime install --feature local-embeddings --yes'
: 'ktx dev runtime install --yes';
}
function installPrompt(feature: KtxRuntimeFeature): string {

View file

@ -446,7 +446,7 @@ describe('doctorManagedPythonRuntime', () => {
['asset', 'pass'],
['runtime', 'fail'],
]);
expect(checks[2]?.fix).toBe('Run: ktx runtime install --yes');
expect(checks[2]?.fix).toBe('Run: ktx dev runtime install --yes');
});
it('reports uv as a hard prerequisite when uv is missing', async () => {
@ -467,7 +467,7 @@ describe('doctorManagedPythonRuntime', () => {
label: 'uv',
status: 'fail',
detail: MISSING_UV_RUNTIME_INSTALL_MESSAGE,
fix: 'Install uv, make sure it is on PATH, and run: ktx runtime install --yes',
fix: 'Install uv, make sure it is on PATH, and run: ktx dev runtime install --yes',
});
});
});

View file

@ -115,7 +115,7 @@ export interface ManagedPythonRuntimePruneResult {
}
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx runtime install --yes';
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx dev runtime install --yes';
function defaultAssetDir(): string {
return fileURLToPath(new URL('../assets/python/', import.meta.url));
@ -411,7 +411,7 @@ export async function doctorManagedPythonRuntime(
id: 'uv',
label: 'uv',
detail: error instanceof Error ? error.message : String(error),
fix: 'Install uv, make sure it is on PATH, and run: ktx runtime install --yes',
fix: 'Install uv, make sure it is on PATH, and run: ktx dev runtime install --yes',
}),
);
}
@ -436,7 +436,7 @@ export async function doctorManagedPythonRuntime(
id: 'runtime',
label: 'Managed Python runtime',
detail: status.detail,
...(status.kind === 'ready' ? {} : { fix: 'Run: ktx runtime install --yes' }),
...(status.kind === 'ready' ? {} : { fix: 'Run: ktx dev runtime install --yes' }),
}),
);
return checks;

View file

@ -44,22 +44,15 @@ describe('KTX demo next steps', () => {
command: 'ktx wiki list',
description: 'Inspect generated wiki pages',
},
{
command: 'ktx serve --mcp stdio --user-id local',
description: 'Optional MCP server route for clients that require MCP',
},
]);
});
it('prefers the direct CLI route before MCP serving', () => {
it('uses only the direct CLI route for agent verification', () => {
const commands = KTX_NEXT_STEP_COMMANDS.map((step) => step.command);
expect(commands.indexOf('ktx agent context --json')).toBeLessThan(
commands.indexOf('ktx serve --mcp stdio --user-id local'),
);
expect(commands.indexOf('ktx agent tools --json')).toBeLessThan(
commands.indexOf('ktx serve --mcp stdio --user-id local'),
);
expect(commands).toContain('ktx agent context --json');
expect(commands).toContain('ktx agent tools --json');
expect(commands).not.toContain('ktx serve --mcp stdio --user-id local');
});
it('explains what the next-step commands are for', () => {

View file

@ -32,14 +32,7 @@ export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
},
] as const;
export const KTX_NEXT_STEP_MCP_COMMANDS = [
{
command: 'ktx serve --mcp stdio --user-id local',
description: 'Optional MCP server route for clients that require MCP',
},
] as const;
export const KTX_NEXT_STEP_COMMANDS = [...KTX_NEXT_STEP_DIRECT_COMMANDS, ...KTX_NEXT_STEP_MCP_COMMANDS] as const;
export const KTX_NEXT_STEP_COMMANDS = [...KTX_NEXT_STEP_DIRECT_COMMANDS] as const;
export const KTX_NEXT_STEP_COMMAND_WIDTH = Math.max(
...[...KTX_CONTEXT_BUILD_COMMANDS, ...KTX_NEXT_STEP_COMMANDS].map((step) => step.command.length),

View file

@ -36,72 +36,56 @@ describe('project directory defaults', () => {
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const scan = vi.fn(async () => 0);
const serveStdio = vi.fn(async () => 0);
const setup = vi.fn(async () => 0);
const agent = vi.fn(async () => 0);
const deps: KtxCliDeps = { agent, connection, demo, doctor, ingest, publicIngest, scan, serveStdio, setup };
const deps: KtxCliDeps = { agent, connection, demo, doctor, ingest, publicIngest, scan, setup };
const cases: Array<{
argv: string[];
spy: ReturnType<typeof vi.fn>;
expected: Record<string, unknown>;
runnerType: 'cli' | 'serve';
expectedStderr: string;
}> = [
{
argv: ['connection', 'list'],
spy: connection,
expected: { command: 'list', projectDir: '/tmp/ktx-env-project' },
runnerType: 'cli',
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['setup', 'demo', 'scan', '--no-input'],
spy: demo,
expected: { command: 'scan', projectDir: '/tmp/ktx-env-project' },
runnerType: 'cli',
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['dev', 'doctor', '--no-input'],
argv: ['status', '--no-input'],
spy: doctor,
expected: { command: 'project', projectDir: '/tmp/ktx-env-project' },
runnerType: 'cli',
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['ingest', 'status', 'run-1'],
spy: publicIngest,
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1' },
runnerType: 'cli',
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['setup', 'status'],
spy: setup,
expected: { command: 'status', projectDir: '/tmp/ktx-env-project' },
runnerType: 'cli',
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['dev', 'scan', 'warehouse'],
spy: scan,
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
runnerType: 'cli',
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['serve', '--mcp', 'stdio'],
spy: serveStdio,
expected: { mcp: 'stdio', projectDir: '/tmp/ktx-env-project' },
runnerType: 'serve',
expectedStderr: '',
},
{
argv: ['agent', 'tools', '--json'],
spy: agent,
expected: { command: 'tools', projectDir: '/tmp/ktx-env-project' },
runnerType: 'cli',
expectedStderr: '',
},
];
@ -109,11 +93,7 @@ describe('project directory defaults', () => {
for (const item of cases) {
const testIo = makeIo();
await expect(runKtxCli(item.argv, testIo.io, deps)).resolves.toBe(0);
if (item.runnerType === 'serve') {
expect(item.spy).toHaveBeenLastCalledWith(expect.objectContaining(item.expected));
} else {
expect(item.spy).toHaveBeenLastCalledWith(expect.objectContaining(item.expected), testIo.io);
}
expect(item.spy).toHaveBeenLastCalledWith(expect.objectContaining(item.expected), testIo.io);
expect(testIo.stderr()).toBe(item.expectedStderr);
}
});

View file

@ -300,7 +300,7 @@ describe('runKtxRuntime', () => {
label: 'Managed Python runtime',
status: 'fail',
detail: 'No runtime manifest',
fix: 'Run: ktx runtime install --yes',
fix: 'Run: ktx dev runtime install --yes',
},
]),
};
@ -309,7 +309,7 @@ describe('runKtxRuntime', () => {
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
expect(io.stdout()).toContain('FAIL Managed Python runtime: No runtime manifest');
expect(io.stdout()).toContain('Fix: Run: ktx runtime install --yes');
expect(io.stdout()).toContain('Fix: Run: ktx dev runtime install --yes');
});
it('requires --yes before pruning stale runtime directories', async () => {

View file

@ -229,7 +229,7 @@ export async function runKtxRuntime(
return checks.some((check) => check.status === 'fail') ? 1 : 0;
}
if (!args.dryRun && !args.yes) {
io.stderr.write('Refusing to prune without --yes. Preview with: ktx runtime prune --dry-run\n');
io.stderr.write('Refusing to prune without --yes. Preview with: ktx dev runtime prune --dry-run\n');
return 1;
}
const status = await (deps.readStatus ?? readManagedPythonRuntimeStatus)({ cliVersion: args.cliVersion });

View file

@ -1,551 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SourceAdapter } from '@ktx/context/ingest';
import { initKtxProject } from '@ktx/context/project';
import { describe, expect, it, vi } from 'vitest';
import { runKtxServeStdio } from './serve.js';
function makeManagedRuntimeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: { write: (chunk: string) => (stdout += chunk) },
stderr: { write: (chunk: string) => (stderr += chunk) },
},
stdout: () => stdout,
stderr: () => stderr,
};
}
describe('runKtxServeStdio', () => {
it('loads the project, creates local ports, and connects the server to stdio', async () => {
const connect = vi.fn().mockResolvedValue(undefined);
const project = {
projectDir: '/tmp/ktx-project',
config: {
connections: {},
llm: {
provider: { backend: 'gateway' },
models: { default: 'anthropic/claude-sonnet' },
},
},
} as never;
const loadProject = vi.fn().mockResolvedValue(project);
const contextTools = { connections: { list: vi.fn() } };
const createContextTools = vi.fn().mockReturnValue(contextTools);
const createServer = vi.fn().mockReturnValue({ connect });
const createTransport = vi.fn().mockReturnValue({ kind: 'stdio' });
let stderr = '';
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: false,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
},
{
loadProject,
createContextTools,
createServer,
createTransport,
stderr: { write: (chunk: string) => (stderr += chunk) },
},
),
).resolves.toBe(0);
expect(loadProject).toHaveBeenCalledWith({ projectDir: '/tmp/ktx-project' });
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
localIngest: expect.objectContaining({
adapters: expect.any(Array),
}),
localScan: expect.objectContaining({
adapters: expect.any(Array),
}),
}),
);
expect(createServer).toHaveBeenCalledWith({
name: 'ktx',
version: '0.0.0-private',
userContext: { userId: 'agent' },
contextTools,
memoryCapture: undefined,
});
expect(connect).toHaveBeenCalledWith({ kind: 'stdio' });
expect(stderr).toContain('ktx MCP server running on stdio for /tmp/ktx-project');
});
it('enables local ingest ports by default when serving stdio', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const connect = vi.fn().mockResolvedValue(undefined);
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: false,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
},
{
loadProject: async () => project,
createContextTools,
createServer: vi.fn(() => ({ connect }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
localIngest: expect.objectContaining({
adapters: expect.any(Array),
}),
localScan: expect.objectContaining({
adapters: expect.any(Array),
}),
}),
);
});
it('passes daemon database introspection URL to MCP local ingest adapters', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const connect = vi.fn().mockResolvedValue(undefined);
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
const createdAdapters: SourceAdapter[] = [];
const createIngestAdapters = vi.fn(() => createdAdapters);
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: false,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
},
{
loadProject: async () => project,
createContextTools,
createIngestAdapters,
createServer: vi.fn(() => ({ connect }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
localIngest: expect.objectContaining({
adapters: expect.any(Array),
pullConfigOptions: {
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
},
}),
localScan: expect.objectContaining({
adapters: createdAdapters,
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
}),
}),
);
expect(createIngestAdapters).toHaveBeenCalledWith(project, {
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
});
});
it('passes managed daemon options to MCP local ingest adapters and pull-config options', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const adapters: SourceAdapter[] = [
{ source: 'looker', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
];
const createIngestAdapters = vi.fn(() => adapters);
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
const managedRuntimeIo = makeManagedRuntimeIo();
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: false,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
},
{
loadProject: async () => project,
createContextTools,
createIngestAdapters,
managedRuntimeIo: managedRuntimeIo.io,
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
const expectedManagedDaemon = {
cliVersion: '0.2.0',
installPolicy: 'auto',
io: managedRuntimeIo.io,
};
expect(createIngestAdapters).toHaveBeenCalledWith(project, {
managedDaemon: expectedManagedDaemon,
});
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
localIngest: expect.objectContaining({
adapters,
pullConfigOptions: {
managedDaemon: expectedManagedDaemon,
},
}),
}),
);
});
it('uses CLI-native local ingest adapters for standalone scan tools', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const createContextTools = vi.fn(() => ({}) as never);
await runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'local',
semanticCompute: false,
executeQueries: false,
memoryCapture: false,
},
{
loadProject: vi.fn(async () => project),
createContextTools,
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
);
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
localIngest: expect.objectContaining({ adapters: expect.any(Array) }),
localScan: expect.objectContaining({ adapters: expect.any(Array) }),
}),
);
});
it('passes semantic compute to local project ports when enabled', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-serve-'));
try {
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: project.projectDir,
userId: 'local',
semanticCompute: true,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
},
{
loadProject: async () => project,
createContextTools,
createSemanticLayerCompute: () => semanticLayerCompute,
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
semanticLayerCompute,
localIngest: expect.objectContaining({
adapters: expect.any(Array),
semanticLayerCompute,
}),
localScan: expect.objectContaining({
adapters: expect.any(Array),
}),
}),
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it('uses managed semantic compute when MCP semantic compute has no explicit HTTP URL', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createManagedSemanticLayerCompute = vi.fn(async () => semanticLayerCompute);
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
const managedRuntimeIo = makeManagedRuntimeIo();
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: true,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
},
{
loadProject: async () => project,
createContextTools,
createManagedSemanticLayerCompute,
managedRuntimeIo: managedRuntimeIo.io,
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
cliVersion: '0.2.0',
installPolicy: 'auto',
io: managedRuntimeIo.io,
});
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
semanticLayerCompute,
}),
);
});
it('uses the HTTP semantic compute port when a daemon URL is provided', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createHttpSemanticLayerCompute = vi.fn(() => semanticLayerCompute);
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: true,
semanticComputeUrl: 'http://127.0.0.1:8765',
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: false,
memoryModel: undefined,
},
{
loadProject: async () => project,
createContextTools,
createHttpSemanticLayerCompute,
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createHttpSemanticLayerCompute).toHaveBeenCalledWith('http://127.0.0.1:8765');
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
semanticLayerCompute,
}),
);
});
it('passes a query executor to local project ports only when query execution is enabled', async () => {
const project = { projectDir: '/tmp/ktx-project', config: { connections: {} } } as never;
const connect = vi.fn().mockResolvedValue(undefined);
const createContextTools = vi.fn(() => ({ connections: { list: async () => [] } }));
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const queryExecutor = { execute: vi.fn() };
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: true,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: true,
memoryCapture: false,
memoryModel: undefined,
},
{
loadProject: async () => project,
createContextTools,
createSemanticLayerCompute: () => semanticLayerCompute,
createQueryExecutor: () => queryExecutor,
createServer: vi.fn(() => ({ connect }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createContextTools).toHaveBeenCalledWith(
project,
expect.objectContaining({
semanticLayerCompute,
queryExecutor,
localIngest: expect.objectContaining({
adapters: expect.any(Array),
semanticLayerCompute,
queryExecutor,
}),
localScan: expect.objectContaining({
adapters: expect.any(Array),
}),
}),
);
});
it('creates a local memory capture port when memory capture is enabled', async () => {
const project = {
projectDir: '/tmp/ktx-project',
config: {
connections: {},
llm: {
provider: { backend: 'gateway' },
models: { default: 'anthropic/claude-sonnet' },
},
},
} as never;
const connect = vi.fn().mockResolvedValue(undefined);
const contextTools = { connections: { list: vi.fn() } };
const memoryCapture = { capture: vi.fn(), status: vi.fn() };
const createContextTools = vi.fn().mockReturnValue(contextTools);
const createMemoryCapture = vi.fn().mockReturnValue(memoryCapture);
const createServer = vi.fn().mockReturnValue({ connect });
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: false,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: true,
memoryModel: 'anthropic/claude-sonnet',
},
{
loadProject: async () => project,
createContextTools,
createMemoryCapture,
createServer,
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createMemoryCapture).toHaveBeenCalledWith(project, {
llmProvider: expect.objectContaining({ getModel: expect.any(Function) }),
semanticLayerCompute: undefined,
});
expect(createServer).toHaveBeenCalledWith({
name: 'ktx',
version: '0.0.0-private',
userContext: { userId: 'agent' },
contextTools,
memoryCapture,
});
});
it('reuses semantic compute for local memory capture when enabled', async () => {
const project = {
projectDir: '/tmp/ktx-project',
config: {
connections: {},
llm: {
provider: { backend: 'gateway' },
models: { default: 'openai/gpt' },
},
},
} as never;
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createMemoryCapture = vi.fn().mockReturnValue({ capture: vi.fn(), status: vi.fn() });
await expect(
runKtxServeStdio(
{
mcp: 'stdio',
projectDir: '/tmp/ktx-project',
userId: 'agent',
semanticCompute: true,
semanticComputeUrl: undefined,
databaseIntrospectionUrl: undefined,
executeQueries: false,
memoryCapture: true,
memoryModel: 'openai/gpt',
},
{
loadProject: async () => project,
createContextTools: vi.fn(() => ({ connections: { list: async () => [] } })),
createSemanticLayerCompute: () => semanticLayerCompute,
createMemoryCapture,
createServer: vi.fn(() => ({ connect: vi.fn(async () => undefined) }) as never),
createTransport: vi.fn(() => ({}) as never),
stderr: { write: vi.fn() },
},
),
).resolves.toBe(0);
expect(createMemoryCapture).toHaveBeenCalledWith(project, {
llmProvider: expect.objectContaining({ getModel: expect.any(Function) }),
semanticLayerCompute,
});
});
});

View file

@ -1,171 +0,0 @@
import { createLocalKtxLlmProviderFromConfig } from '@ktx/context';
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
import {
createHttpSemanticLayerComputePort,
type KtxSemanticLayerComputePort,
} from '@ktx/context/daemon';
import { createDefaultLocalIngestAdapters, type LocalIngestMcpOptions } from '@ktx/context/ingest';
import {
createDefaultKtxMcpServer,
createLocalProjectMcpContextPorts,
type KtxMcpContextPorts,
} from '@ktx/context/mcp';
import { createLocalProjectMemoryCapture, type MemoryCaptureService } from '@ktx/context/memory';
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
import type { LocalScanMcpOptions } from '@ktx/context/scan';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import type { KtxCliIo } from './cli-runtime.js';
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import {
createManagedPythonSemanticLayerComputePort,
type KtxManagedPythonInstallPolicy,
} from './managed-python-command.js';
import type { ManagedPythonCoreDaemonOptions } from './managed-python-http.js';
import { profileMark } from './startup-profile.js';
profileMark('module:serve');
export interface KtxServeArgs {
mcp: 'stdio';
projectDir: string;
userId: string;
semanticCompute: boolean;
semanticComputeUrl?: string;
databaseIntrospectionUrl?: string;
executeQueries: boolean;
memoryCapture: boolean;
memoryModel?: string;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
}
interface KtxServeIo {
stderr: { write(chunk: string): void };
}
interface LocalProjectContextToolOptions {
semanticLayerCompute?: KtxSemanticLayerComputePort;
queryExecutor?: KtxSqlQueryExecutorPort;
localIngest?: LocalIngestMcpOptions;
localScan?: LocalScanMcpOptions;
}
interface KtxServeDeps {
loadProject?: typeof loadKtxProject;
createContextTools?: (project: KtxLocalProject, options?: LocalProjectContextToolOptions) => KtxMcpContextPorts;
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
createManagedSemanticLayerCompute?: typeof createManagedPythonSemanticLayerComputePort;
managedRuntimeIo?: KtxCliIo;
createHttpSemanticLayerCompute?: (baseUrl: string) => KtxSemanticLayerComputePort;
createIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
createMemoryCapture?: typeof createLocalProjectMemoryCapture;
createServer?: typeof createDefaultKtxMcpServer;
createTransport?: () => StdioServerTransport;
stderr?: KtxServeIo['stderr'];
}
function requiredManagedRuntimeCliVersion(args: KtxServeArgs): string {
if (!args.cliVersion) {
throw new Error('Managed Python semantic compute requires a CLI version.');
}
return args.cliVersion;
}
function managedDaemonOptionsForServe(
args: KtxServeArgs,
deps: KtxServeDeps,
): ManagedPythonCoreDaemonOptions | undefined {
if (args.databaseIntrospectionUrl || !args.cliVersion) {
return undefined;
}
return {
cliVersion: args.cliVersion,
installPolicy: args.runtimeInstallPolicy ?? 'prompt',
io: deps.managedRuntimeIo ?? process,
};
}
async function createServeSemanticLayerCompute(
args: KtxServeArgs,
deps: KtxServeDeps,
): Promise<KtxSemanticLayerComputePort | undefined> {
if (!args.semanticCompute) {
return undefined;
}
if (args.semanticComputeUrl) {
return (deps.createHttpSemanticLayerCompute ?? ((baseUrl) => createHttpSemanticLayerComputePort({ baseUrl })))(
args.semanticComputeUrl,
);
}
if (deps.createSemanticLayerCompute) {
return deps.createSemanticLayerCompute();
}
const createManagedSemanticLayerCompute =
deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort;
return createManagedSemanticLayerCompute({
cliVersion: requiredManagedRuntimeCliVersion(args),
installPolicy: args.runtimeInstallPolicy ?? 'prompt',
io: deps.managedRuntimeIo ?? process,
});
}
export async function runKtxServeStdio(args: KtxServeArgs, deps: KtxServeDeps = {}): Promise<number> {
const loadProjectFn = deps.loadProject ?? loadKtxProject;
const createContextToolsFn = deps.createContextTools ?? createLocalProjectMcpContextPorts;
const createServerFn = deps.createServer ?? createDefaultKtxMcpServer;
const createTransportFn = deps.createTransport ?? (() => new StdioServerTransport());
const stderr = deps.stderr ?? process.stderr;
const project = await loadProjectFn({ projectDir: args.projectDir });
const semanticLayerCompute = await createServeSemanticLayerCompute(args, deps);
const queryExecutor = args.executeQueries
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
: undefined;
const createIngestAdapters = deps.createIngestAdapters ?? createKtxCliLocalIngestAdapters;
const managedDaemon = managedDaemonOptionsForServe(args, deps);
const localAdapterOptions = {
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
...(managedDaemon ? { managedDaemon } : {}),
};
const localAdapters = createIngestAdapters(project, localAdapterOptions);
const llmProvider = args.memoryCapture
? (createLocalKtxLlmProviderFromConfig(project.config.llm) ?? undefined)
: undefined;
const memoryCapture: MemoryCaptureService | undefined = args.memoryCapture
? (deps.createMemoryCapture ?? createLocalProjectMemoryCapture)(project, {
llmProvider,
semanticLayerCompute,
})
: undefined;
const localIngest: LocalIngestMcpOptions = {
adapters: localAdapters,
pullConfigOptions: localAdapterOptions,
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
...(queryExecutor ? { queryExecutor } : {}),
};
const localScan: LocalScanMcpOptions = {
adapters: localAdapters,
databaseIntrospectionUrl: args.databaseIntrospectionUrl,
createConnector: (connectionId) => createKtxCliScanConnector(project, connectionId),
};
const contextToolOptions: LocalProjectContextToolOptions = {
localIngest,
localScan,
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
...(queryExecutor ? { queryExecutor } : {}),
};
const contextTools = createContextToolsFn(project, contextToolOptions);
const server = createServerFn({
name: 'ktx',
version: '0.0.0-private',
userContext: { userId: args.userId },
contextTools,
memoryCapture,
});
const transport = createTransportFn();
await server.connect(transport);
stderr.write(`ktx MCP server running on stdio for ${project.projectDir}\n`);
return 0;
}

View file

@ -36,25 +36,23 @@ describe('setup agents', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('plans project-scoped CLI and MCP files for every target', () => {
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'both' })).toEqual([
it('plans project-scoped CLI 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/rules/ktx.md'), role: 'rule' },
{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
]);
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, '.codex/instructions/ktx.md'), role: 'rule' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp' })).toEqual([
{ kind: 'json-key', path: join(tempDir, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx.md') },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'both' })).toEqual([
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
{ kind: 'json-key', path: join(tempDir, '.agents/mcp/ktx.json'), jsonPath: ['mcpServers', 'ktx'] },
]);
});
@ -70,7 +68,7 @@ describe('setup agents', () => {
agents: true,
target: 'universal',
scope: 'project',
mode: 'both',
mode: 'cli',
skipAgents: false,
},
io.io,
@ -78,11 +76,10 @@ describe('setup agents', () => {
).resolves.toEqual({
status: 'ready',
projectDir: tempDir,
installs: [{ target: 'universal', scope: 'project', mode: 'both' }],
installs: [{ target: 'universal', scope: 'project', mode: 'cli' }],
});
await expect(stat(join(tempDir, '.agents/skills/ktx/SKILL.md'))).resolves.toBeDefined();
await expect(stat(join(tempDir, '.agents/mcp/ktx.json'))).resolves.toBeDefined();
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
expect(skill).toContain(`--project-dir ${tempDir}`);
expect(skill).toContain('must not print secrets');
@ -90,13 +87,13 @@ describe('setup agents', () => {
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
version: 1,
projectDir: tempDir,
installs: [{ target: 'universal', scope: 'project', mode: 'both' }],
installs: [{ target: 'universal', scope: 'project', mode: 'cli' }],
});
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('agents');
expect(io.stderr()).toBe('');
});
it('writes PATH-independent launcher commands for skills and MCP configs', async () => {
it('writes PATH-independent launcher commands for skills', async () => {
const io = makeIo();
await expect(
@ -108,7 +105,7 @@ describe('setup agents', () => {
agents: true,
target: 'universal',
scope: 'project',
mode: 'both',
mode: 'cli',
skipAgents: false,
},
io.io,
@ -119,37 +116,21 @@ describe('setup agents', () => {
expect(skill).not.toContain('`ktx agent');
expect(skill).toContain('agent context --json');
expect(skill).toContain('agent sql execute');
const mcp = JSON.parse(await readFile(join(tempDir, '.agents/mcp/ktx.json'), 'utf-8')) as {
mcpServers?: { ktx?: { command?: string; args?: string[] } };
};
expect(mcp.mcpServers?.ktx?.command).toBe(process.execPath);
expect(mcp.mcpServers?.ktx?.args?.[0]).toMatch(/packages\/cli\/(src|dist)\/bin\.(ts|js)$/);
expect(mcp.mcpServers?.ktx?.args).toEqual([
expect.stringMatching(/packages\/cli\/(src|dist)\/bin\.(ts|js)$/),
'--project-dir',
tempDir,
'serve',
'--mcp',
'stdio',
'--semantic-compute',
'--execute-queries',
]);
});
it('removes only manifest-listed files and JSON keys', async () => {
it('removes only manifest-listed files', async () => {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'both',
skipAgents: false,
},
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'cli',
skipAgents: false,
},
io.io,
);
await writeFile(join(tempDir, '.claude/skills/ktx/keep.txt'), 'user file', 'utf-8');
@ -230,7 +211,7 @@ describe('setup agents', () => {
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'both',
mode: 'cli',
skipAgents: false,
},
io.io,
@ -243,25 +224,18 @@ describe('setup agents', () => {
expect(output).toContain('.claude/skills/ktx/SKILL.md');
expect(output).toContain('+ Rule installed — tells your agent when to use KTX');
expect(output).toContain('.claude/rules/ktx.md');
expect(output).toContain('+ MCP config added — lets your agent talk to KTX over MCP');
expect(output).toContain('.mcp.json');
});
it('formats summary with relative paths for project scope', () => {
const summary = formatInstallSummary(
[{ target: 'cursor', scope: 'project', mode: 'both' }],
[
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
{ kind: 'json-key', path: join(tempDir, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
],
[{ target: 'cursor', scope: 'project', mode: 'cli' }],
[{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') }],
tempDir,
);
expect(summary).toContain('Cursor');
expect(summary).toContain('+ Rule installed — tells your agent when to use KTX');
expect(summary).toContain('.cursor/rules/ktx.mdc');
expect(summary).toContain('+ MCP config added — lets your agent talk to KTX over MCP');
expect(summary).toContain('.cursor/mcp.json');
expect(summary).not.toContain(tempDir);
});
@ -269,12 +243,13 @@ describe('setup agents', () => {
const summary = formatInstallSummary(
[
{ target: 'claude-code', scope: 'project', mode: 'cli' },
{ target: 'codex', scope: 'project', mode: 'mcp' },
{ target: 'codex', scope: 'project', mode: 'cli' },
],
[
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' },
{ kind: 'json-key', path: join(tempDir, '.agents/mcp/ktx.json'), jsonPath: ['mcpServers', 'ktx'] },
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' },
],
tempDir,
);
@ -283,6 +258,6 @@ describe('setup agents', () => {
expect(summary).toContain('+ Skill installed — teaches your agent which KTX commands to run');
expect(summary).toContain('+ Rule installed — tells your agent when to use KTX');
expect(summary).toContain('Codex');
expect(summary).toContain('+ MCP config added — lets your agent talk to KTX over MCP');
expect(summary).toContain('.agents/skills/ktx/SKILL.md');
});
});

View file

@ -9,7 +9,7 @@ import { withSetupInterruptConfirmation } from './setup-interrupt.js';
export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal';
export type KtxAgentScope = 'project' | 'global';
export type KtxAgentInstallMode = 'cli' | 'mcp' | 'both';
export type KtxAgentInstallMode = 'cli';
export interface KtxSetupAgentsArgs {
projectDir: string;
@ -91,17 +91,9 @@ export function plannedKtxAgentFiles(input: {
'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' },
};
const mcpEntries: Record<KtxAgentTarget, InstallEntry> = {
'claude-code': { kind: 'json-key', path: join(root, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
codex: { kind: 'json-key', path: join(root, '.agents/mcp/ktx.json'), jsonPath: ['mcpServers', 'ktx'] },
cursor: { kind: 'json-key', path: join(root, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
opencode: { kind: 'json-key', path: join(root, '.opencode/mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
universal: { kind: 'json-key', path: join(root, '.agents/mcp/ktx.json'), jsonPath: ['mcpServers', 'ktx'] },
};
return [
...(input.mode === 'cli' || input.mode === 'both' ? [cliEntries[input.target], ruleEntries[input.target]] : []),
...(input.mode === 'mcp' || input.mode === 'both' ? [mcpEntries[input.target]] : []),
].filter((entry): entry is InstallEntry => entry !== undefined);
return [cliEntries[input.target], ruleEntries[input.target]].filter(
(entry): entry is InstallEntry => entry !== undefined,
);
}
function ktxCliLauncher(): KtxCliLauncher {
@ -187,32 +179,6 @@ function ruleInstructionContent(input: { projectDir: string }): string {
].join('\n');
}
function mcpConfig(projectDir: string, launcher: KtxCliLauncher): Record<string, unknown> {
return {
command: launcher.command,
args: [...launcher.args, '--project-dir', projectDir, 'serve', '--mcp', 'stdio', '--semantic-compute', '--execute-queries'],
env: {},
};
}
async function writeJsonKey(path: string, jsonPath: string[], value: Record<string, unknown>): Promise<void> {
let root: Record<string, unknown> = {};
try {
root = JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
} catch {
root = {};
}
let cursor = root;
for (const segment of jsonPath.slice(0, -1)) {
const next = cursor[segment];
if (!next || typeof next !== 'object' || Array.isArray(next)) cursor[segment] = {};
cursor = cursor[segment] as Record<string, unknown>;
}
cursor[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 removeJsonKey(path: string, jsonPath: string[]): Promise<void> {
const root = JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
let cursor: Record<string, unknown> = root;
@ -351,7 +317,6 @@ export function formatInstallSummary(
const fileHints: Record<string, string> = {
skill: 'teaches your agent which KTX commands to run',
rule: 'tells your agent when to use KTX',
mcp: 'lets your agent talk to KTX over MCP',
};
const lines: string[] = [];
@ -367,9 +332,6 @@ export function formatInstallSummary(
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
lines.push(` + ${label}${hint}`);
lines.push(` ${displayPath}`);
} else {
lines.push(` + MCP config added — ${fileHints.mcp}`);
lines.push(` ${displayPath}`);
}
}
}
@ -385,16 +347,13 @@ async function installTarget(input: {
const entries = plannedKtxAgentFiles(input);
const launcher = ktxCliLauncher();
for (const entry of entries) {
if (entry.kind === 'file') {
const content =
entry.role === 'rule'
? ruleInstructionContent({ projectDir: input.projectDir })
: cliInstructionContent({ projectDir: input.projectDir, launcher });
await mkdir(dirname(entry.path), { recursive: true });
await writeFile(entry.path, content, 'utf-8');
} else {
await writeJsonKey(entry.path, entry.jsonPath, mcpConfig(input.projectDir, launcher));
}
if (entry.kind !== 'file') continue;
const content =
entry.role === 'rule'
? ruleInstructionContent({ projectDir: input.projectDir })
: cliInstructionContent({ projectDir: input.projectDir, launcher });
await mkdir(dirname(entry.path), { recursive: true });
await writeFile(entry.path, content, 'utf-8');
}
return entries;
}
@ -425,8 +384,6 @@ export async function runKtxSetupAgentsStep(
message: 'How should agents use this KTX project?',
options: [
{ value: 'cli', label: 'CLI tools and skills' },
{ value: 'mcp', label: 'MCP server config' },
{ value: 'both', label: 'Both' },
{ value: 'skip', label: 'Skip' },
{ value: 'back', label: 'Back' },
],

View file

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

View file

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

View file

@ -245,7 +245,7 @@ describe('setup embeddings step', () => {
const io = makeIo();
const ensureLocalEmbeddings = vi.fn(async () => {
throw new Error(
'KTX Python runtime is required for this command. Run: ktx runtime install --feature local-embeddings --yes',
'KTX Python runtime is required for this command. Run: ktx dev runtime install --feature local-embeddings --yes',
);
});
@ -263,7 +263,7 @@ describe('setup embeddings step', () => {
expect(result.status).toBe('failed');
expect(io.stderr()).toContain(
'KTX Python runtime is required for this command. Run: ktx runtime install --feature local-embeddings --yes',
'KTX Python runtime is required for this command. Run: ktx dev runtime install --feature local-embeddings --yes',
);
});
@ -290,7 +290,7 @@ describe('setup embeddings step', () => {
expect(config.setup?.completed_steps ?? []).not.toContain('embeddings');
expect(config.ingest.embeddings.backend).toBe('deterministic');
expect(io.stderr()).toContain('Local embedding health check failed: 401 invalid api key [redacted]');
expect(io.stderr()).toContain('Prepare the runtime with: ktx runtime start --feature local-embeddings');
expect(io.stderr()).toContain('Prepare the runtime with: ktx dev runtime start --feature local-embeddings');
expect(io.stderr()).not.toContain('skip for now');
});

View file

@ -314,7 +314,7 @@ function localEmbeddingSetupMessage(message: string): string {
return [
`Local embedding health check failed: ${message}`,
'Local embeddings use the KTX-managed Python runtime.',
'Prepare the runtime with: ktx runtime start --feature local-embeddings',
'Prepare the runtime with: ktx dev runtime start --feature local-embeddings',
'Use --yes with setup to install and start the runtime without prompting.',
'The first run may download Python packages and the all-MiniLM-L6-v2 model.',
].join('\n');

View file

@ -1007,7 +1007,6 @@ describe('setup status', () => {
mode: 'new',
agents: false,
agentScope: 'project',
agentInstallMode: 'cli',
skipAgents: true,
inputMode: 'disabled',
yes: false,
@ -1525,7 +1524,6 @@ describe('setup status', () => {
agents: true,
target: 'codex',
agentScope: 'project',
agentInstallMode: 'cli',
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
@ -1579,7 +1577,6 @@ describe('setup status', () => {
agents: true,
target: 'codex',
agentScope: 'project',
agentInstallMode: 'cli',
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
@ -1996,7 +1993,7 @@ describe('setup status', () => {
const agents = vi.fn(async () => ({
status: 'ready' as const,
projectDir: tempDir,
installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'both' as const }],
installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'cli' as const }],
}));
await expect(
@ -2008,7 +2005,6 @@ describe('setup status', () => {
agents: true,
target: 'universal',
agentScope: 'project',
agentInstallMode: 'both',
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',

View file

@ -7,7 +7,6 @@ import type { KtxCliIo } from './cli-runtime.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
import {
type KtxAgentInstallMode,
type KtxAgentScope,
type KtxAgentTarget,
type KtxSetupAgentsDeps,
@ -60,7 +59,6 @@ export type KtxSetupArgs =
agents: boolean;
target?: KtxAgentTarget;
agentScope?: KtxAgentScope;
agentInstallMode?: KtxAgentInstallMode;
skipAgents?: boolean;
inputMode: 'auto' | 'disabled';
yes: boolean;
@ -736,7 +734,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
agents: true,
...(args.target ? { target: args.target } : {}),
scope: args.agentScope ?? 'project',
mode: args.agentInstallMode ?? 'cli',
mode: 'cli',
skipAgents: false,
},
io,

View file

@ -4,8 +4,6 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { parseKtxProjectConfig } from '@ktx/context/project';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -60,12 +58,6 @@ function getRunId(stdout: string): string {
return match[1];
}
function structuredContent<T extends object>(result: unknown): T {
const content = (result as { structuredContent?: unknown }).structuredContent;
expect(content).toBeDefined();
return content as T;
}
async function writeWarehouseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
@ -344,81 +336,13 @@ describe('standalone built ktx CLI smoke', () => {
expect(inspect.stdout).not.toContain('ktx serve --mcp stdio');
});
it('serves seeded demo wiki and semantic-layer context over stdio MCP', async () => {
const projectDir = join(tempDir, 'seeded-mcp-project');
const seeded = await runBuiltCli(
['setup', 'demo', '--mode', 'seeded', '--project-dir', projectDir, '--plain', '--no-input'],
{
env: { ...process.env, ANTHROPIC_API_KEY: '' },
},
);
expectProjectStderr(seeded, projectDir);
expect(seeded.stdout).toContain('Mode: seeded');
const client = new Client({ name: 'ktx-seeded-demo-smoke-client', version: '0.0.0' });
const transport = new StdioClientTransport({
command: process.execPath,
args: [CLI_BIN, 'serve', '--mcp', 'stdio', '--project-dir', projectDir, '--user-id', 'smoke-user'],
stderr: 'pipe',
});
try {
await client.connect(transport);
const toolNames = (await client.listTools()).tools.map((tool) => tool.name).sort();
expect(toolNames).toEqual(
expect.arrayContaining(['knowledge_read', 'knowledge_search', 'sl_read_source', 'sl_validate']),
);
const knowledgeSearch = structuredContent<{
results: Array<{ key: string; summary: string; score: number }>;
totalFound: number;
}>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract-first definition', limit: 10 } }));
expect(knowledgeSearch.totalFound).toBeGreaterThan(0);
expect(knowledgeSearch.results.map((result) => result.key)).toContain('orbit-arr-contract-first-definition');
const knowledgeRead = structuredContent<{
key: string;
summary: string;
content: string;
tags: string[];
slRefs: string[];
}>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'orbit-arr-contract-first-definition' } }));
expect(knowledgeRead.key).toBe('orbit-arr-contract-first-definition');
expect(knowledgeRead.summary).toContain('ARR');
expect(knowledgeRead.content).toContain('contract');
expect(knowledgeRead.slRefs).toContain('mart_arr_daily');
const slRead = structuredContent<{ sourceName: string; yaml: string }>(
await client.callTool({
name: 'sl_read_source',
arguments: { connectionId: 'dbt-main', sourceName: 'mart_arr_daily' },
}),
);
expect(slRead.sourceName).toBe('mart_arr_daily');
expect(slRead.yaml).toContain('name: mart_arr_daily');
expect(slRead.yaml).toContain('measures:');
const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>(
await client.callTool({
name: 'sl_validate',
arguments: { connectionId: 'dbt-main', names: ['mart_arr_daily', 'stg_contracts'] },
}),
);
expect(slValidate.success).toBe(true);
expect(slValidate.errors).toEqual([]);
} finally {
await client.close();
}
});
it('runs doctor setup through the built binary', async () => {
const result = await runBuiltCli(['dev', 'doctor', 'setup', '--no-input']);
const result = await runBuiltCli(['status', '--no-input']);
expect(result.stdout).toContain('KTX setup doctor');
expect(result.stdout).toContain('Node 22+');
expect(result.stdout).toContain('Workspace-local CLI');
expect(result.stderr).toBe(`Project: ${process.cwd()}\n`);
expect(result.stderr).toBe('');
expect([0, 1]).toContain(result.code);
});
@ -747,185 +671,4 @@ describe('standalone built ktx CLI smoke', () => {
});
});
it('serves local ingest MCP tools over stdio from the built binary', async () => {
const projectDir = join(tempDir, 'project');
const init = await runSetupNewProject(projectDir);
expectProjectStderr(init, projectDir);
await writeWarehouseConfig(projectDir);
const client = new Client({ name: 'ktx-smoke-client', version: '0.0.0' });
const transport = new StdioClientTransport({
command: process.execPath,
args: [CLI_BIN, 'serve', '--mcp', 'stdio', '--project-dir', projectDir, '--user-id', 'smoke-user'],
stderr: 'pipe',
});
try {
await client.connect(transport);
const tools = await client.listTools();
const toolNames = tools.tools.map((tool) => tool.name).sort();
expect(toolNames).toEqual(
expect.arrayContaining([
'connection_list',
'connection_test',
'ingest_report',
'ingest_replay',
'ingest_status',
'ingest_trigger',
'knowledge_read',
'knowledge_search',
'knowledge_write',
'scan_list_artifacts',
'scan_read_artifact',
'scan_report',
'scan_status',
'scan_trigger',
'sl_list_sources',
'sl_read_source',
'sl_validate',
'sl_write_source',
]),
);
const connections = structuredContent<{
connections: Array<{ id: string; name: string; connectionType: string }>;
}>(await client.callTool({ name: 'connection_list', arguments: {} }));
expect(connections).toEqual({
connections: [{ id: 'warehouse', name: 'warehouse', connectionType: 'POSTGRESQL' }],
});
await expect(client.callTool({ name: 'ingest_status', arguments: { runId: 'missing-run' } })).resolves.toEqual({
content: [{ type: 'text', text: 'Ingest run "missing-run" was not found.' }],
isError: true,
});
await expect(client.callTool({ name: 'ingest_report', arguments: { runId: 'missing-run' } })).resolves.toEqual({
content: [{ type: 'text', text: 'Ingest report "missing-run" was not found.' }],
isError: true,
});
await expect(client.callTool({ name: 'ingest_replay', arguments: { runId: 'missing-run' } })).resolves.toEqual({
content: [{ type: 'text', text: 'Ingest replay "missing-run" was not found.' }],
isError: true,
});
} finally {
await client.close();
}
});
it('serves scan execution and artifact inspection tools over stdio from the built binary', async () => {
const projectDir = join(tempDir, 'scan-mcp-project');
const init = await runSetupNewProject(projectDir);
expectProjectStderr(init, projectDir);
const dbPath = join(projectDir, 'warehouse.db');
createSqliteWarehouse(dbPath);
await writeSqliteScanConfig(projectDir, dbPath);
const client = new Client({ name: 'ktx-scan-smoke-client', version: '0.0.0' });
const transport = new StdioClientTransport({
command: process.execPath,
args: [CLI_BIN, 'serve', '--mcp', 'stdio', '--project-dir', projectDir, '--user-id', 'smoke-user'],
stderr: 'pipe',
});
try {
await client.connect(transport);
const connectionTest = structuredContent<{
id: string;
connectionType: string;
ok: boolean;
tableCount: number | null;
}>(await client.callTool({ name: 'connection_test', arguments: { connectionId: 'warehouse' } }));
expect(connectionTest).toMatchObject({
id: 'warehouse',
connectionType: 'SQLITE',
ok: true,
tableCount: 2,
});
const trigger = structuredContent<{
runId: string;
status: 'done';
done: true;
connectionId: string;
mode: string;
dryRun: boolean;
report: {
artifactPaths: { manifestShards: string[] };
manifestShardsWritten: number;
};
}>(
await client.callTool({
name: 'scan_trigger',
arguments: {
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
}),
);
expect(trigger).toMatchObject({
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
});
expect(trigger.report.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
expect(trigger.report.manifestShardsWritten).toBe(1);
const status = structuredContent<{
runId: string;
status: string;
done: boolean;
reportPath: string | null;
}>(await client.callTool({ name: 'scan_status', arguments: { runId: trigger.runId } }));
expect(status).toMatchObject({
runId: trigger.runId,
status: 'done',
done: true,
});
expect(status.reportPath).toContain('scan-report.json');
const artifacts = structuredContent<{
runId: string;
artifacts: Array<{ path: string; type: string }>;
}>(await client.callTool({ name: 'scan_list_artifacts', arguments: { runId: trigger.runId } }));
expect(artifacts.artifacts).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'semantic-layer/warehouse/_schema/public.yaml', type: 'manifest_shard' }),
expect.objectContaining({ type: 'report' }),
expect.objectContaining({ type: 'raw_source' }),
]),
);
const manifestArtifact = structuredContent<{
runId: string;
path: string;
type: string;
content: string;
}>(
await client.callTool({
name: 'scan_read_artifact',
arguments: {
runId: trigger.runId,
path: 'semantic-layer/warehouse/_schema/public.yaml',
},
}),
);
expect(manifestArtifact).toMatchObject({
runId: trigger.runId,
path: 'semantic-layer/warehouse/_schema/public.yaml',
type: 'manifest_shard',
});
expect(manifestArtifact.content).toContain('orders:');
expect(manifestArtifact.content).toContain('source: formal');
} finally {
await client.close();
}
});
});