mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
feat(cli): clean up command surface
This commit is contained in:
parent
60457e9407
commit
e15a4ebaec
61 changed files with 406 additions and 2076 deletions
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ describe('runKtxIngest', () => {
|
|||
mode: 'new',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
agentInstallMode: 'cli',
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ export async function runDemoTour(
|
|||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'both',
|
||||
mode: 'cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue