mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-13 11:22:11 +02:00
Merge origin/main into remove-ingest-fallbacks
This commit is contained in:
commit
7101424a12
215 changed files with 6272 additions and 82916 deletions
|
|
@ -79,7 +79,43 @@ describe('registerMcpCommands', () => {
|
|||
|
||||
expect(startDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(context.io.stdout.write).toHaveBeenCalledWith(
|
||||
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n',
|
||||
[
|
||||
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp',
|
||||
'',
|
||||
'KTX is ready for configured agents.',
|
||||
'Open your agent for this KTX project and ask a data question, for example:',
|
||||
' "Use KTX to show me the available tables and metrics."',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('prints a friendly next step after starting the daemon', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const startDaemon = vi.fn().mockResolvedValue({
|
||||
status: 'started',
|
||||
url: 'http://127.0.0.1:7878/mcp',
|
||||
state: {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
tokenAuth: false,
|
||||
projectDir: '/tmp/ktx-started',
|
||||
startedAt: '2026-05-14T00:00:00.000Z',
|
||||
logPath: '/tmp/ktx-started/.ktx/logs/mcp.log',
|
||||
},
|
||||
});
|
||||
const context = makeContext({ deps: { mcp: { startDaemon } } });
|
||||
registerMcpCommands(program, context);
|
||||
|
||||
await program.parseAsync(['--project-dir', '/tmp/ktx-started', 'mcp', 'start'], { from: 'user' });
|
||||
|
||||
expect(context.io.stdout.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('KTX MCP daemon started: http://127.0.0.1:7878/mcp\n\nKTX is ready for configured agents.'),
|
||||
);
|
||||
expect(context.io.stdout.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Use KTX to show me the available tables and metrics."'),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ function binPath(): string {
|
|||
return fileURLToPath(new URL('../bin.js', import.meta.url));
|
||||
}
|
||||
|
||||
function formatMcpStartResultMessage(input: { status: 'started' | 'already-running'; url: string }): string {
|
||||
return [
|
||||
input.status === 'started' ? `KTX MCP daemon started: ${input.url}` : `KTX MCP daemon already running: ${input.url}`,
|
||||
'',
|
||||
'KTX is ready for configured agents.',
|
||||
'Open your agent for this KTX project and ask a data question, for example:',
|
||||
' "Use KTX to show me the available tables and metrics."',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
|
||||
|
||||
|
|
@ -82,11 +93,7 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
|
|||
allowedOrigins: options.allowedOrigin,
|
||||
binPath: binPath(),
|
||||
});
|
||||
context.io.stdout.write(
|
||||
result.status === 'started'
|
||||
? `KTX MCP daemon started: ${result.url}\n`
|
||||
: `KTX MCP daemon already running: ${result.url}\n`,
|
||||
);
|
||||
context.io.stdout.write(formatMcpStartResultMessage({ status: result.status, url: result.url }));
|
||||
});
|
||||
|
||||
mcp
|
||||
|
|
|
|||
|
|
@ -85,8 +85,6 @@ function optionWasSpecified(command: Command, optionName: string): boolean {
|
|||
|
||||
function shouldShowSetupEntryMenu(
|
||||
options: {
|
||||
new?: boolean;
|
||||
existing?: boolean;
|
||||
agents?: boolean;
|
||||
target?: string;
|
||||
global?: boolean;
|
||||
|
|
@ -98,7 +96,6 @@ function shouldShowSetupEntryMenu(
|
|||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
llmModel?: string;
|
||||
anthropicModel?: string;
|
||||
vertexProject?: string;
|
||||
vertexLocation?: string;
|
||||
skipLlm?: boolean;
|
||||
|
|
@ -108,7 +105,6 @@ function shouldShowSetupEntryMenu(
|
|||
skipEmbeddings?: boolean;
|
||||
database?: KtxSetupDatabaseDriver[];
|
||||
databaseConnectionId?: string[];
|
||||
newDatabaseConnectionId?: string;
|
||||
databaseUrl?: string;
|
||||
databaseSchema?: string[];
|
||||
enableQueryHistory?: boolean;
|
||||
|
|
@ -160,8 +156,6 @@ function shouldShowSetupEntryMenu(
|
|||
}
|
||||
|
||||
return ![
|
||||
'new',
|
||||
'existing',
|
||||
'agents',
|
||||
'target',
|
||||
'global',
|
||||
|
|
@ -173,7 +167,6 @@ function shouldShowSetupEntryMenu(
|
|||
'anthropicApiKeyEnv',
|
||||
'anthropicApiKeyFile',
|
||||
'llmModel',
|
||||
'anthropicModel',
|
||||
'vertexProject',
|
||||
'vertexLocation',
|
||||
'skipLlm',
|
||||
|
|
@ -181,7 +174,6 @@ function shouldShowSetupEntryMenu(
|
|||
'embeddingApiKeyEnv',
|
||||
'embeddingApiKeyFile',
|
||||
'skipEmbeddings',
|
||||
'newDatabaseConnectionId',
|
||||
'databaseUrl',
|
||||
'enableQueryHistory',
|
||||
'disableQueryHistory',
|
||||
|
|
@ -214,8 +206,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
.command('setup')
|
||||
.description('Set up or resume a local KTX project')
|
||||
.addOption(new Option('--project-dir <path>', 'KTX project directory').hideHelp())
|
||||
.addOption(new Option('--new', 'Create a new KTX project before setup').hideHelp().default(false))
|
||||
.addOption(new Option('--existing', 'Use an existing KTX project').hideHelp().default(false))
|
||||
.option('--agents', 'Install agent integration only', false)
|
||||
.addOption(
|
||||
new Option('--target <target>', 'Agent target').choices([
|
||||
|
|
@ -230,7 +220,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
.option('--global', 'Install agent integration into the global target scope', false)
|
||||
.option('--local', 'Install Claude Code MCP config into the private per-project ~/.claude.json scope', false)
|
||||
.addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false))
|
||||
.option('--yes', 'Accept safe defaults in non-interactive setup', false)
|
||||
.option('--yes', 'Accept project creation and runtime install defaults where setup confirms', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend).hideHelp())
|
||||
.addOption(
|
||||
|
|
@ -240,7 +230,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
new Option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key').hideHelp(),
|
||||
)
|
||||
.addOption(new Option('--llm-model <model>', 'LLM model ID or backend model alias').hideHelp())
|
||||
.addOption(new Option('--anthropic-model <model>', 'Anthropic model ID to validate and save').hideHelp())
|
||||
.addOption(new Option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
|
||||
.addOption(new Option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
|
||||
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
|
||||
|
|
@ -269,16 +258,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
.default([] as string[])
|
||||
.hideHelp(),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--new-database-connection-id <id>', 'Connection id for one new database connection')
|
||||
.argParser((value) => {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.hideHelp(),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection').hideHelp(),
|
||||
)
|
||||
|
|
@ -365,11 +344,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.llmModel && options.anthropicModel) {
|
||||
context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options.llmBackend &&
|
||||
options.llmBackend !== 'anthropic' &&
|
||||
|
|
@ -419,12 +393,18 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
return;
|
||||
}
|
||||
|
||||
const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
|
||||
const creatingDatabaseConnection = options.database.length > 0 || options.databaseUrl !== undefined;
|
||||
if (creatingDatabaseConnection && options.databaseConnectionId.length > 1) {
|
||||
context.io.stderr.write('Choose only one new database connection id when configuring a database.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
|
||||
await runSetupArgs(context, {
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
mode,
|
||||
mode: 'auto',
|
||||
agents: options.agents === true,
|
||||
...(options.target ? { target: options.target } : {}),
|
||||
agentScope: resolvedAgentScope,
|
||||
|
|
@ -436,7 +416,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
|
||||
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
|
||||
...(options.llmModel ? { llmModel: options.llmModel } : {}),
|
||||
...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
|
||||
...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
|
||||
...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
|
||||
skipLlm: options.skipLlm === true,
|
||||
|
|
@ -445,8 +424,12 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
...(options.embeddingApiKeyFile ? { embeddingApiKeyFile: options.embeddingApiKeyFile } : {}),
|
||||
skipEmbeddings: options.skipEmbeddings === true,
|
||||
...(options.database.length > 0 ? { databaseDrivers: options.database } : {}),
|
||||
...(options.databaseConnectionId.length > 0 ? { databaseConnectionIds: options.databaseConnectionId } : {}),
|
||||
...(options.newDatabaseConnectionId ? { databaseConnectionId: options.newDatabaseConnectionId } : {}),
|
||||
...(options.databaseConnectionId.length > 0 && creatingDatabaseConnection
|
||||
? { databaseConnectionId: options.databaseConnectionId[0] }
|
||||
: {}),
|
||||
...(options.databaseConnectionId.length > 0 && !creatingDatabaseConnection
|
||||
? { databaseConnectionIds: options.databaseConnectionId }
|
||||
: {}),
|
||||
...(options.databaseUrl ? { databaseUrl: options.databaseUrl } : {}),
|
||||
databaseSchemas: options.databaseSchema,
|
||||
...(options.enableQueryHistory ? { enableQueryHistory: true } : {}),
|
||||
|
|
|
|||
|
|
@ -676,8 +676,7 @@ describe('runKtxDoctor', () => {
|
|||
' adapters:',
|
||||
' - live-database',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' model: deterministic',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
|
@ -694,8 +693,8 @@ describe('runKtxDoctor', () => {
|
|||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Embeddings');
|
||||
expect(testIo.stdout()).toContain('deterministic');
|
||||
expect(testIo.stdout()).toContain('semantic search degraded');
|
||||
expect(testIo.stdout()).toContain('none');
|
||||
expect(testIo.stdout()).toContain('semantic search will be skipped');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -470,8 +470,6 @@ describe('runKtxCli', () => {
|
|||
expect(stdout).not.toContain('setup context');
|
||||
|
||||
for (const hiddenFlag of [
|
||||
'--new',
|
||||
'--existing',
|
||||
'--agent-scope',
|
||||
'--skip-agents',
|
||||
'--llm-backend',
|
||||
|
|
@ -480,7 +478,6 @@ describe('runKtxCli', () => {
|
|||
'--embedding-backend',
|
||||
'--database ',
|
||||
'--database-connection-id',
|
||||
'--new-database-connection-id',
|
||||
'--enable-historic-sql',
|
||||
'--historic-sql-min-executions',
|
||||
'--enable-query-history',
|
||||
|
|
@ -842,8 +839,12 @@ describe('runKtxCli', () => {
|
|||
it('rejects removed setup options', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const cases = [
|
||||
['setup', '--new'],
|
||||
['setup', '--existing'],
|
||||
['setup', '--project'],
|
||||
['setup', '--agent-scope', 'global'],
|
||||
['setup', '--anthropic-model', 'claude-sonnet-4-6'],
|
||||
['setup', '--new-database-connection-id', 'warehouse'],
|
||||
['setup', '--skip-initial-source-ingest'],
|
||||
];
|
||||
|
||||
|
|
@ -1065,7 +1066,7 @@ describe('runKtxCli', () => {
|
|||
'--no-input',
|
||||
'--anthropic-api-key-env',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'--anthropic-model',
|
||||
'--llm-model',
|
||||
'claude-sonnet-4-6',
|
||||
],
|
||||
setupIo.io,
|
||||
|
|
@ -1080,7 +1081,7 @@ describe('runKtxCli', () => {
|
|||
inputMode: 'disabled',
|
||||
cliVersion: '0.1.0-rc.1',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
}),
|
||||
setupIo.io,
|
||||
|
|
@ -1104,7 +1105,7 @@ describe('runKtxCli', () => {
|
|||
'local-gcp-project',
|
||||
'--vertex-location',
|
||||
'us-east5',
|
||||
'--anthropic-model',
|
||||
'--llm-model',
|
||||
'claude-sonnet-4-6',
|
||||
],
|
||||
setupIo.io,
|
||||
|
|
@ -1121,7 +1122,7 @@ describe('runKtxCli', () => {
|
|||
llmBackend: 'vertex',
|
||||
vertexProject: 'local-gcp-project',
|
||||
vertexLocation: 'us-east5',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
}),
|
||||
setupIo.io,
|
||||
|
|
@ -1239,7 +1240,7 @@ describe('runKtxCli', () => {
|
|||
'--skip-embeddings',
|
||||
'--database',
|
||||
'postgres',
|
||||
'--new-database-connection-id',
|
||||
'--database-connection-id',
|
||||
'warehouse',
|
||||
'--database-url',
|
||||
'env:DATABASE_URL',
|
||||
|
|
@ -1283,18 +1284,41 @@ describe('runKtxCli', () => {
|
|||
const setup = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['setup', '--new-database-connection-id', 'status', '--no-input'], testIo.io, { setup }),
|
||||
runKtxCli(['setup', '--database-connection-id', 'status', '--no-input'], testIo.io, { setup }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
databaseConnectionId: 'status',
|
||||
databaseConnectionIds: ['status'],
|
||||
}),
|
||||
testIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches non-TTY agents setup with target without requiring --no-input or --yes', async () => {
|
||||
const testIo = makeIo({ stdoutIsTty: false });
|
||||
const setup = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code'], testIo.io, { setup }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
agentScope: 'project',
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
}),
|
||||
testIo.io,
|
||||
);
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('dispatches setup source flags', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const testIo = makeIo();
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ export function deepReadinessGaps(config: KtxProjectConfig): string[] {
|
|||
if (
|
||||
!embeddings ||
|
||||
embeddings.backend === 'none' ||
|
||||
embeddings.backend === 'deterministic' ||
|
||||
!embeddings.model ||
|
||||
embeddings.dimensions <= 0
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export async function writeMetabaseConfig(projectDir: string): Promise<void> {
|
|||
' adapters:',
|
||||
' - metabase',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' backend: none',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
|
|
@ -502,7 +502,7 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync
|
|||
' adapters:',
|
||||
' - metabase',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' backend: none',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ describe('runKtxIngest', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
skipAgents: true,
|
||||
|
|
@ -322,7 +322,7 @@ describe('runKtxIngest', () => {
|
|||
expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:');
|
||||
expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`);
|
||||
expect(runIo.stderr()).toContain(
|
||||
`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
|
||||
`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -777,7 +777,7 @@ describe('runKtxIngest', () => {
|
|||
' adapters:',
|
||||
' - metabase',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' backend: none',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
|
|
@ -1869,7 +1869,7 @@ describe('runKtxIngest', () => {
|
|||
' adapters:',
|
||||
' - looker',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' backend: none',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => {
|
|||
}),
|
||||
).resolves.toEqual({
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log',
|
||||
stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log',
|
||||
env: {
|
||||
[MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: 'http://127.0.0.1:61234',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { startManagedPythonDaemon, type ManagedPythonDaemonStartResult } from '.
|
|||
|
||||
export interface ManagedLocalEmbeddingsDaemon {
|
||||
baseUrl: string;
|
||||
stdoutLog: string;
|
||||
stderrLog: string;
|
||||
env: Record<typeof MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, string>;
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +93,8 @@ export async function ensureManagedLocalEmbeddingsDaemon(
|
|||
|
||||
return {
|
||||
baseUrl: daemon.baseUrl,
|
||||
stdoutLog: daemon.state.stdoutLog,
|
||||
stderrLog: daemon.state.stderrLog,
|
||||
env: {
|
||||
[MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: daemon.baseUrl,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
type KtxMcpDaemonState,
|
||||
} from './managed-mcp-daemon.js';
|
||||
|
||||
type KtxMcpDaemonStartOptions = Parameters<typeof startKtxMcpDaemon>[0];
|
||||
|
||||
function child(pid = 4242): KtxMcpDaemonChild {
|
||||
return { pid, unref: vi.fn() };
|
||||
}
|
||||
|
|
@ -40,6 +42,7 @@ describe('managed MCP daemon lifecycle', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -94,6 +97,33 @@ describe('managed MCP daemon lifecycle', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('sanitizes IPv6 CIDR entries from child NO_PROXY env', async () => {
|
||||
vi.stubEnv('NO_PROXY', 'localhost,fd07:b51a:cc66:f0::/64');
|
||||
vi.stubEnv('no_proxy', '::1,fd00::/8,*.orb.local');
|
||||
const spawnDaemon = vi.fn<NonNullable<KtxMcpDaemonStartOptions['spawnDaemon']>>(() => child(5555));
|
||||
|
||||
await startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 7879,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => false),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
now: () => new Date('2026-05-14T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const env = spawnDaemon.mock.calls[0]?.[2].env;
|
||||
if (!env) {
|
||||
throw new Error('Expected MCP daemon spawn env');
|
||||
}
|
||||
expect(env.NO_PROXY).toBe('localhost,::1,*.orb.local');
|
||||
expect(env.no_proxy).toBe(env.NO_PROXY);
|
||||
});
|
||||
|
||||
it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createServer } from 'node:net';
|
|||
import { dirname, join } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
import { sanitizeChildProxyEnv } from './proxy-env.js';
|
||||
|
||||
export interface KtxMcpDaemonState {
|
||||
schemaVersion: 1;
|
||||
|
|
@ -166,11 +167,11 @@ export async function startKtxMcpDaemon(options: {
|
|||
const child = (options.spawnDaemon ?? defaultSpawnDaemon)(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
env: {
|
||||
env: sanitizeChildProxyEnv({
|
||||
...process.env,
|
||||
KTX_CLI_VERSION: options.cliVersion,
|
||||
...(options.token ? { KTX_MCP_TOKEN: options.token } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!child.pid) {
|
||||
throw new Error('Failed to start KTX MCP daemon: child process pid was not available.');
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonR
|
|||
asset: {
|
||||
manifest: installedManifest.asset,
|
||||
wheelPath: '/assets/python/kaelio_ktx-0.2.0-py3-none-any.whl',
|
||||
requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
|
||||
},
|
||||
manifest: installedManifest,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { join } from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
readManagedPythonDaemonStatus,
|
||||
sanitizeProxyEnv,
|
||||
startManagedPythonDaemon,
|
||||
stopAllManagedPythonDaemons,
|
||||
stopManagedPythonDaemon,
|
||||
|
|
@ -80,6 +79,7 @@ function installResult(root: string, features: Array<'core' | 'local-embeddings'
|
|||
asset: {
|
||||
manifest: manifest(root, features).asset,
|
||||
wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'),
|
||||
requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
|
||||
},
|
||||
manifest: manifest(root, features),
|
||||
};
|
||||
|
|
@ -133,6 +133,7 @@ describe('managed Python daemon lifecycle', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -188,6 +189,27 @@ describe('managed Python daemon lifecycle', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('sanitizes IPv6 CIDR entries from child NO_PROXY env', async () => {
|
||||
vi.stubEnv('NO_PROXY', 'localhost,fd07:b51a:cc66:f0::/64,127.0.0.0/8');
|
||||
vi.stubEnv('no_proxy', '::1,fd00::/8,*.orb.local');
|
||||
const spawnDaemon = makeSpawn(5555);
|
||||
|
||||
await startManagedPythonDaemon({
|
||||
...daemonOptionsBase(tempDir),
|
||||
features: ['local-embeddings'],
|
||||
installRuntime: vi.fn(async () => installResult(tempDir, ['core', 'local-embeddings'])),
|
||||
spawnDaemon,
|
||||
fetch: makeFetch(),
|
||||
allocatePort: vi.fn(async () => 61234),
|
||||
now: () => new Date('2026-05-11T00:00:00.000Z'),
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
const env = vi.mocked(spawnDaemon).mock.calls[0]?.[2].env;
|
||||
expect(env?.NO_PROXY).toBe('localhost,127.0.0.0/8,::1,*.orb.local');
|
||||
expect(env?.no_proxy).toBe(env?.NO_PROXY);
|
||||
});
|
||||
|
||||
it('makes a final health probe before reporting startup failure', async () => {
|
||||
const spawnDaemon = makeSpawn(5556);
|
||||
const installRuntime = vi.fn(async () => installResult(tempDir));
|
||||
|
|
@ -405,38 +427,3 @@ describe('managed Python daemon lifecycle', () => {
|
|||
expect(await readFile(layout(tempDir).daemonStatePath, 'utf8')).toContain('"pid": 4242');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeProxyEnv', () => {
|
||||
it('removes IPv6 CIDR entries from NO_PROXY that crash httpx', () => {
|
||||
const cleaned = sanitizeProxyEnv({
|
||||
NO_PROXY: 'localhost,127.0.0.1,fd07:b51a:cc66:f0::/64,*.orb.internal',
|
||||
no_proxy: 'localhost,127.0.0.1,fd07:b51a:cc66:f0::/64,*.orb.internal',
|
||||
});
|
||||
expect(cleaned.NO_PROXY).toBe('localhost,127.0.0.1,*.orb.internal');
|
||||
expect(cleaned.no_proxy).toBe('localhost,127.0.0.1,*.orb.internal');
|
||||
});
|
||||
|
||||
it('deletes NO_PROXY entirely when every entry is unparseable', () => {
|
||||
const cleaned = sanitizeProxyEnv({ NO_PROXY: 'fd07::/64,::1' });
|
||||
expect(cleaned.NO_PROXY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves IPv4 addresses, IPv4 CIDRs, hostnames, and wildcards', () => {
|
||||
const cleaned = sanitizeProxyEnv({
|
||||
NO_PROXY: '127.0.0.0/8,10.0.0.1,localhost,*.example.com',
|
||||
});
|
||||
expect(cleaned.NO_PROXY).toBe('127.0.0.0/8,10.0.0.1,localhost,*.example.com');
|
||||
});
|
||||
|
||||
it('leaves other env vars untouched', () => {
|
||||
const cleaned = sanitizeProxyEnv({ PATH: '/usr/bin', NO_PROXY: '::1', FOO: 'bar' });
|
||||
expect(cleaned.PATH).toBe('/usr/bin');
|
||||
expect(cleaned.FOO).toBe('bar');
|
||||
expect(cleaned.NO_PROXY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when NO_PROXY is not set', () => {
|
||||
const cleaned = sanitizeProxyEnv({ PATH: '/usr/bin' });
|
||||
expect(cleaned).toEqual({ PATH: '/usr/bin' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
} from './managed-python-runtime.js';
|
||||
import { sanitizeChildProxyEnv } from './proxy-env.js';
|
||||
|
||||
export interface ManagedPythonDaemonState {
|
||||
schemaVersion: 1;
|
||||
|
|
@ -696,10 +697,10 @@ export async function startManagedPythonDaemon(
|
|||
{
|
||||
detached: true,
|
||||
stdio: ['ignore', stdout.fd, stderr.fd],
|
||||
env: {
|
||||
...sanitizeProxyEnv(process.env),
|
||||
env: sanitizeChildProxyEnv({
|
||||
...process.env,
|
||||
KTX_DAEMON_VERSION: options.cliVersion,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
|
|
@ -807,32 +808,3 @@ export async function stopAllManagedPythonDaemons(
|
|||
scanErrors: discovery.scanErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter NO_PROXY/no_proxy values to remove entries httpx cannot parse.
|
||||
*
|
||||
* httpx (used by the Python daemon via huggingface_hub / sentence-transformers)
|
||||
* treats each comma-separated NO_PROXY entry as a URL pattern. Raw IPv6 CIDR
|
||||
* blocks like `fd07:b51a:cc66:f0::/64` raise `InvalidURL` and crash the daemon.
|
||||
* OrbStack and similar Docker setups inject such entries by default.
|
||||
*
|
||||
* We drop any entry containing `::` (the unambiguous IPv6 marker) but keep
|
||||
* IPv4 addresses, IPv4 CIDRs, hostnames, and wildcard hosts intact.
|
||||
*/
|
||||
export function sanitizeProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const result: NodeJS.ProcessEnv = { ...env };
|
||||
for (const key of ['NO_PROXY', 'no_proxy']) {
|
||||
const value = result[key];
|
||||
if (typeof value !== 'string' || value.length === 0) continue;
|
||||
const kept = value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0 && !entry.includes('::'));
|
||||
if (kept.length === 0) {
|
||||
delete result[key];
|
||||
} else {
|
||||
result[key] = kept.join(',');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
||||
|
|
@ -14,10 +15,33 @@ import {
|
|||
type ManagedPythonRuntimeExec,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
async function writeAsset(root: string, contents = 'wheel-bytes') {
|
||||
function runtimeWheelContents(input: { label?: string; requiresPython?: string | null } = {}): Buffer {
|
||||
const label = input.label ?? 'runtime-wheel';
|
||||
const requiresPython = input.requiresPython === null ? [] : [`Requires-Python: ${input.requiresPython ?? '>=3.13'}`];
|
||||
return Buffer.from(
|
||||
zipSync({
|
||||
'kaelio_ktx-0.1.0.dist-info/METADATA': strToU8(
|
||||
[
|
||||
'Metadata-Version: 2.4',
|
||||
'Name: kaelio-ktx',
|
||||
'Version: 0.1.0',
|
||||
...requiresPython,
|
||||
`Summary: ${label}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeAsset(
|
||||
root: string,
|
||||
options: { label?: string; requiresPython?: string | null; contents?: Buffer } = {},
|
||||
) {
|
||||
const assetDir = join(root, 'assets', 'python');
|
||||
await mkdir(assetDir, { recursive: true });
|
||||
const wheelPath = join(assetDir, 'kaelio_ktx-0.1.0-py3-none-any.whl');
|
||||
const contents = options.contents ?? runtimeWheelContents(options);
|
||||
await writeFile(wheelPath, contents);
|
||||
await writeFile(
|
||||
join(assetDir, 'manifest.json'),
|
||||
|
|
@ -30,7 +54,7 @@ async function writeAsset(root: string, contents = 'wheel-bytes') {
|
|||
wheel: {
|
||||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
bytes: Buffer.byteLength(contents),
|
||||
bytes: contents.byteLength,
|
||||
},
|
||||
},
|
||||
null,
|
||||
|
|
@ -145,17 +169,18 @@ describe('verifyRuntimeAsset', () => {
|
|||
});
|
||||
|
||||
it('reads the manifest and verifies the wheel checksum', async () => {
|
||||
const { assetDir, wheelPath } = await writeAsset(tempDir, 'valid-wheel');
|
||||
const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'valid-wheel' });
|
||||
|
||||
const asset = await verifyRuntimeAsset({ assetDir });
|
||||
|
||||
expect(asset.manifest.distributionName).toBe('kaelio-ktx');
|
||||
expect(asset.manifest.normalizedName).toBe('kaelio_ktx');
|
||||
expect(asset.wheelPath).toBe(wheelPath);
|
||||
expect(asset.requiresPython).toEqual({ specifier: '>=3.13', minimumVersion: '3.13' });
|
||||
});
|
||||
|
||||
it('rejects a wheel whose checksum does not match the manifest', async () => {
|
||||
const { assetDir, wheelPath } = await writeAsset(tempDir, 'original');
|
||||
const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'original' });
|
||||
await writeFile(wheelPath, 'tampered');
|
||||
|
||||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||||
|
|
@ -164,7 +189,7 @@ describe('verifyRuntimeAsset', () => {
|
|||
});
|
||||
|
||||
it('rejects an unsafe wheel filename in the manifest', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'valid-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'valid-wheel' });
|
||||
await writeFile(
|
||||
join(assetDir, 'manifest.json'),
|
||||
`${JSON.stringify({
|
||||
|
|
@ -190,6 +215,22 @@ describe('verifyRuntimeAsset', () => {
|
|||
/Missing bundled Python runtime manifest.*pnpm run artifacts:build/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a bundled wheel without Requires-Python metadata', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { requiresPython: null });
|
||||
|
||||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||||
/Bundled Python runtime wheel metadata is missing Requires-Python/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a bundled wheel without a supported minimum Python version', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { requiresPython: '<4' });
|
||||
|
||||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||||
/Unsupported bundled Python runtime Requires-Python: <4/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installManagedPythonRuntime', () => {
|
||||
|
|
@ -204,7 +245,7 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
it('creates a venv, installs the core wheel, and writes a manifest', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
|
|
@ -222,7 +263,8 @@ describe('installManagedPythonRuntime', () => {
|
|||
expect(result.status).toBe('installed');
|
||||
expect(commands).toEqual([
|
||||
{ command: 'uv', args: ['--version'] },
|
||||
{ command: 'uv', args: ['venv', result.layout.venvDir, '--python', '3.13'] },
|
||||
{ command: 'uv', args: ['python', 'install', '3.13'] },
|
||||
{ command: 'uv', args: ['venv', '--python', '3.13', result.layout.venvDir] },
|
||||
{
|
||||
command: 'uv',
|
||||
args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
|
||||
|
|
@ -240,7 +282,7 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
it('disables repo uv config for managed runtime uv commands', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const commands: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => {
|
||||
commands.push({ command, args, env: options?.env });
|
||||
|
|
@ -258,13 +300,14 @@ describe('installManagedPythonRuntime', () => {
|
|||
|
||||
expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
|
||||
['uv', '--version', '1', '/opt/homebrew/bin'],
|
||||
['uv', 'python', '1', '/opt/homebrew/bin'],
|
||||
['uv', 'venv', '1', '/opt/homebrew/bin'],
|
||||
['uv', 'pip', '1', '/opt/homebrew/bin'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs the local-embeddings extra when requested', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'embedding-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' });
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
|
|
@ -288,7 +331,7 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
it('fails with the hard-prerequisite message when uv is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
|
|
@ -309,7 +352,7 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
it('reuses an existing compatible runtime when force is false', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
|
|
@ -335,14 +378,17 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
expect(second.status).toBe('ready');
|
||||
expect(exec).toHaveBeenCalledTimes(3);
|
||||
expect(exec).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('keeps failed install logs in the versioned runtime directory', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
if (command === 'uv' && args[0] === 'venv') {
|
||||
throw Object.assign(new Error('uv venv failed'), { stdout: 'creating\n', stderr: 'bad python\n' });
|
||||
throw Object.assign(new Error('uv venv failed'), {
|
||||
stdout: 'creating\n',
|
||||
stderr: '× No solution found\n╰─▶ current Python version (3.12.3) does not satisfy Python>=3.13\n',
|
||||
});
|
||||
}
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
|
@ -355,11 +401,11 @@ describe('installManagedPythonRuntime', () => {
|
|||
features: ['core'],
|
||||
exec,
|
||||
}),
|
||||
).rejects.toThrow(/Python runtime install failed/);
|
||||
).rejects.toThrow(/current Python version \(3\.12\.3\) does not satisfy Python>=3\.13/);
|
||||
|
||||
const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8');
|
||||
expect(log).toContain('$ uv venv');
|
||||
expect(log).toContain('bad python');
|
||||
expect(log).toContain('$ uv venv --python 3.13');
|
||||
expect(log).toContain('current Python version (3.12.3) does not satisfy Python>=3.13');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -386,7 +432,7 @@ describe('readManagedPythonRuntimeStatus', () => {
|
|||
});
|
||||
|
||||
it('reports ready when manifest and executables exist', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
|
|
@ -413,7 +459,7 @@ describe('readManagedPythonRuntimeStatus', () => {
|
|||
});
|
||||
|
||||
it('reports broken when an executable is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
|
|
@ -449,7 +495,7 @@ describe('doctorManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
it('checks uv, bundled assets, and installed runtime status', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
|
|
@ -471,7 +517,7 @@ describe('doctorManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
it('reports uv as a hard prerequisite when uv is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => {
|
||||
throw new Error('spawn uv ENOENT');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { homedir } from 'node:os';
|
|||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { z } from 'zod';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -12,16 +13,6 @@ const execFileAsync = promisify(execFile);
|
|||
export const runtimeFeatureSchema = z.enum(['core', 'local-embeddings']);
|
||||
export type KtxRuntimeFeature = z.infer<typeof runtimeFeatureSchema>;
|
||||
|
||||
/**
|
||||
* Python version the managed runtime venv must be built with. KTX's bundled
|
||||
* wheels declare `requires-python = ">=3.13"`; without an explicit `--python`
|
||||
* flag, `uv venv` may pick a too-old system Python (Ubuntu 24.04 ships 3.12)
|
||||
* and the subsequent `uv pip install` fails late with a confusing "package
|
||||
* requires Python >=3.13" error. Pinning here pushes uv to auto-download the
|
||||
* right interpreter via its python-management feature.
|
||||
*/
|
||||
const MANAGED_RUNTIME_PYTHON_VERSION = '3.13';
|
||||
|
||||
const runtimeAssetManifestSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
distributionName: z.literal('kaelio-ktx'),
|
||||
|
|
@ -88,6 +79,10 @@ export interface ManagedPythonDaemonLayout extends ManagedPythonRuntimeLayout {
|
|||
export interface ManagedRuntimeAsset {
|
||||
manifest: KtxRuntimeAssetManifest;
|
||||
wheelPath: string;
|
||||
requiresPython: {
|
||||
specifier: string;
|
||||
minimumVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ManagedPythonRuntimeExec = (
|
||||
|
|
@ -206,6 +201,40 @@ function isErrnoException(error: unknown, code: string): boolean {
|
|||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function parseRequiresPythonFromWheel(input: { wheelPath: string; contents: Buffer }): ManagedRuntimeAsset['requiresPython'] {
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(new Uint8Array(input.contents));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to read bundled Python runtime wheel metadata: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const metadataEntry = Object.entries(files).find(([path]) => path.endsWith('.dist-info/METADATA'));
|
||||
if (!metadataEntry) {
|
||||
throw new Error(`Bundled Python runtime wheel metadata is missing: ${input.wheelPath}`);
|
||||
}
|
||||
|
||||
const metadata = strFromU8(metadataEntry[1]);
|
||||
const requiresPython = metadata
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^Requires-Python:\s*(.+)\s*$/i)?.[1]?.trim())
|
||||
.find((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
if (!requiresPython) {
|
||||
throw new Error('Bundled Python runtime wheel metadata is missing Requires-Python');
|
||||
}
|
||||
|
||||
const minimumMatch = requiresPython.match(/(?:^|[,\s])>=\s*([0-9]+)\.([0-9]+)(?:\.[0-9]+)?\b/);
|
||||
if (!minimumMatch) {
|
||||
throw new Error(`Unsupported bundled Python runtime Requires-Python: ${requiresPython}`);
|
||||
}
|
||||
|
||||
return {
|
||||
specifier: requiresPython,
|
||||
minimumVersion: `${minimumMatch[1]}.${minimumMatch[2]}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<ManagedRuntimeAsset> {
|
||||
const manifestPath = join(input.assetDir, 'manifest.json');
|
||||
let manifestData: unknown;
|
||||
|
|
@ -231,7 +260,7 @@ export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<M
|
|||
if (sha256 !== manifest.wheel.sha256 || wheel.byteLength !== manifest.wheel.bytes) {
|
||||
throw new Error(`Bundled Python runtime wheel checksum mismatch: ${wheelPath}`);
|
||||
}
|
||||
return { manifest, wheelPath };
|
||||
return { manifest, wheelPath, requiresPython: parseRequiresPythonFromWheel({ wheelPath, contents: wheel }) };
|
||||
}
|
||||
|
||||
function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] {
|
||||
|
|
@ -272,6 +301,14 @@ function errorOutput(error: unknown): { stdout: string; stderr: string } {
|
|||
};
|
||||
}
|
||||
|
||||
function installFailureMessage(input: { logPath: string; stdout: string; stderr: string }): string {
|
||||
const output = [input.stderr.trim(), input.stdout.trim()].filter((part) => part.length > 0).join('\n');
|
||||
if (!output) {
|
||||
return `Python runtime install failed. Install log: ${input.logPath}`;
|
||||
}
|
||||
return `Python runtime install failed.\n${output}\nInstall log: ${input.logPath}`;
|
||||
}
|
||||
|
||||
async function runLogged(input: {
|
||||
exec: ManagedPythonRuntimeExec;
|
||||
logPath: string;
|
||||
|
|
@ -298,7 +335,7 @@ async function runLogged(input: {
|
|||
if (output.stderr) {
|
||||
await appendFile(input.logPath, output.stderr.endsWith('\n') ? output.stderr : `${output.stderr}\n`);
|
||||
}
|
||||
throw new Error(`Python runtime install failed. Install log: ${input.logPath}`);
|
||||
throw new Error(installFailureMessage({ logPath: input.logPath, stdout: output.stdout, stderr: output.stderr }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -344,7 +381,14 @@ export async function installManagedPythonRuntime(
|
|||
exec,
|
||||
logPath: layout.installLogPath,
|
||||
command: 'uv',
|
||||
args: ['venv', layout.venvDir, '--python', MANAGED_RUNTIME_PYTHON_VERSION],
|
||||
args: ['python', 'install', asset.requiresPython.minimumVersion],
|
||||
env: uvEnv,
|
||||
});
|
||||
await runLogged({
|
||||
exec,
|
||||
logPath: layout.installLogPath,
|
||||
command: 'uv',
|
||||
args: ['venv', '--python', asset.requiresPython.minimumVersion, layout.venvDir],
|
||||
env: uvEnv,
|
||||
});
|
||||
const wheelSpec = features.includes('local-embeddings') ? `${asset.wheelPath}[local-embeddings]` : asset.wheelPath;
|
||||
|
|
|
|||
|
|
@ -49,8 +49,10 @@ describe('KTX demo next steps', () => {
|
|||
const rendered = formatNextStepLines().join('\n');
|
||||
|
||||
expect(rendered).toContain('KTX context is ready for agents.');
|
||||
expect(rendered).toContain('KTX project directory');
|
||||
expect(rendered).toContain('ask a data question');
|
||||
expect(rendered).toContain('Verify with:');
|
||||
expect(rendered).not.toContain('this directory');
|
||||
expect(rendered).not.toContain('Preferred route');
|
||||
expect(rendered).not.toContain('Optional MCP:');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function commandLines(commands: ReadonlyArray<{ command: string; description: st
|
|||
|
||||
export function formatNextStepLines(indent = ' '): string[] {
|
||||
return [
|
||||
`${indent}KTX context is ready for agents. Open your coding agent in this directory and ask a data question.`,
|
||||
`${indent}KTX context is ready for agents. Open your coding agent from the KTX project directory and ask a data question.`,
|
||||
`${indent}Verify with:`,
|
||||
...commandLines(KTX_NEXT_STEP_DIRECT_COMMANDS, indent),
|
||||
];
|
||||
|
|
|
|||
21
packages/cli/src/proxy-env.test.ts
Normal file
21
packages/cli/src/proxy-env.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeChildProxyEnv } from './proxy-env.js';
|
||||
|
||||
describe('sanitizeChildProxyEnv', () => {
|
||||
it('drops IPv6 CIDR no-proxy entries and normalizes both env keys', () => {
|
||||
const env = sanitizeChildProxyEnv({
|
||||
NO_PROXY: 'localhost,127.0.0.1,127.0.0.0/8,fd07:b51a:cc66:f0::/64,*.orb.local',
|
||||
no_proxy: '::1,0.250.250.0/24,fd00::/8,*.orb.internal',
|
||||
});
|
||||
|
||||
expect(env.NO_PROXY).toBe('localhost,127.0.0.1,127.0.0.0/8,*.orb.local,::1,0.250.250.0/24,*.orb.internal');
|
||||
expect(env.no_proxy).toBe(env.NO_PROXY);
|
||||
});
|
||||
|
||||
it('preserves the input object and leaves missing proxy env unset', () => {
|
||||
const input = { PATH: '/usr/bin' };
|
||||
|
||||
expect(sanitizeChildProxyEnv(input)).toEqual({ PATH: '/usr/bin' });
|
||||
expect(input).toEqual({ PATH: '/usr/bin' });
|
||||
});
|
||||
});
|
||||
27
packages/cli/src/proxy-env.ts
Normal file
27
packages/cli/src/proxy-env.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'] as const;
|
||||
|
||||
function isIpv6CidrNoProxyEntry(entry: string): boolean {
|
||||
return entry.includes('/') && entry.includes(':');
|
||||
}
|
||||
|
||||
function cleanedNoProxyValue(env: NodeJS.ProcessEnv): string | undefined {
|
||||
const entries = NO_PROXY_KEYS.flatMap((key) => (env[key] ?? '').split(','))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0 && !isIpv6CidrNoProxyEntry(entry));
|
||||
|
||||
if (!NO_PROXY_KEYS.some((key) => env[key] !== undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
return [...new Set(entries)].join(',');
|
||||
}
|
||||
|
||||
export function sanitizeChildProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const sanitized = { ...env };
|
||||
const noProxy = cleanedNoProxyValue(env);
|
||||
if (noProxy === undefined) {
|
||||
return sanitized;
|
||||
}
|
||||
sanitized.NO_PROXY = noProxy;
|
||||
sanitized.no_proxy = noProxy;
|
||||
return sanitized;
|
||||
}
|
||||
|
|
@ -7,10 +7,10 @@ import {
|
|||
} from './runtime-requirements.js';
|
||||
|
||||
describe('runtime requirement detection', () => {
|
||||
it('requires core for agent/MCP setup', () => {
|
||||
it('does not require runtime for agent/MCP setup alone', () => {
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
|
||||
expect(resolveProjectRuntimeRequirements(config, { agents: true }).features).toEqual(['core']);
|
||||
expect(resolveProjectRuntimeRequirements(config).features).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires core for Looker source ingest unless an external daemon is configured', () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { KtxRuntimeFeature } from './managed-python-runtime.js';
|
|||
import type { KtxPublicIngestPlan } from './public-ingest.js';
|
||||
|
||||
type KtxRuntimeRequirementReason =
|
||||
| 'agent-mcp'
|
||||
| 'query-history'
|
||||
| 'looker-source'
|
||||
| 'database-introspection'
|
||||
|
|
@ -26,7 +25,6 @@ export interface KtxRuntimeRequirements {
|
|||
}
|
||||
|
||||
export interface KtxProjectRuntimeRequirementOptions {
|
||||
agents?: boolean;
|
||||
databaseIntrospectionFallback?: boolean;
|
||||
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||
}
|
||||
|
|
@ -92,14 +90,6 @@ export function resolveProjectRuntimeRequirements(
|
|||
const env = options.env ?? process.env;
|
||||
const requirements: KtxRuntimeRequirement[] = [];
|
||||
|
||||
if (options.agents === true) {
|
||||
requirements.push({
|
||||
feature: 'core',
|
||||
reason: 'agent-mcp',
|
||||
detail: 'Agent MCP setup uses semantic-layer query tools and SQL validation.',
|
||||
});
|
||||
}
|
||||
|
||||
if (options.databaseIntrospectionFallback === true && !hasDaemonOverride(env)) {
|
||||
requirements.push({
|
||||
feature: 'core',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe('runKtxRuntime', () => {
|
|||
},
|
||||
asset: {
|
||||
wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { readKtxSetupState } from '@ktx/context/project';
|
|||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
formatInstallSummary,
|
||||
createAgentNextActionsLineFormatter,
|
||||
formatInstallSummaryLines,
|
||||
plannedKtxAgentFiles,
|
||||
readKtxAgentInstallManifest,
|
||||
removeKtxAgentInstall,
|
||||
|
|
@ -82,7 +83,11 @@ describe('setup agents', () => {
|
|||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
|
||||
{
|
||||
kind: 'file',
|
||||
path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'),
|
||||
role: 'claude-desktop-skill-bundle',
|
||||
},
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
|
|
@ -123,7 +128,16 @@ describe('setup agents', () => {
|
|||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
|
||||
{
|
||||
kind: 'file',
|
||||
path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'),
|
||||
role: 'claude-desktop-skill-bundle',
|
||||
},
|
||||
{
|
||||
kind: 'file',
|
||||
path: join(tempDir, '.ktx/agents/claude/ktx.zip'),
|
||||
role: 'claude-desktop-skill-bundle',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -144,7 +158,7 @@ describe('setup agents', () => {
|
|||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }],
|
||||
|
|
@ -170,6 +184,118 @@ describe('setup agents', () => {
|
|||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('installs a specified target in non-interactive mode without --yes', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined();
|
||||
const mcpConfig = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(mcpConfig.mcpServers).toHaveProperty('ktx');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints concrete target guidance when non-interactive agent setup has no target', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toEqual({ status: 'missing-input', projectDir: tempDir });
|
||||
|
||||
expect(io.stderr()).toBe('Run in a TTY, or pass --target <target>.\n');
|
||||
});
|
||||
|
||||
it('prints standalone agent next actions after successful installation', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
const result = await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'ready',
|
||||
nextActions: expect.stringContaining('Run this command before using Claude Code:'),
|
||||
});
|
||||
expect(io.stdout()).toContain('Required before using agents');
|
||||
expect(io.stdout()).toContain('Run this command before using Claude Code:');
|
||||
expect(io.stdout()).toContain('RUN:');
|
||||
expect(io.stdout()).toContain(`ktx mcp start --project-dir ${tempDir}`);
|
||||
expect(io.stdout()).toContain('If you need to stop MCP later:');
|
||||
expect(io.stdout()).toContain(`ktx mcp stop --project-dir ${tempDir}`);
|
||||
expect(io.stdout()).toContain('All set.');
|
||||
expect(io.stdout()).not.toContain('Finish agent setup');
|
||||
expect(io.stdout()).not.toContain('Next actions');
|
||||
});
|
||||
|
||||
it('can return agent next actions without printing them', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
const result = await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
showNextActions: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'ready',
|
||||
nextActions: expect.stringContaining(`ktx mcp start --project-dir ${tempDir}`),
|
||||
});
|
||||
expect(io.stdout()).toContain('Claude Code · Project scope');
|
||||
expect(io.stdout()).not.toContain('Agent integration complete');
|
||||
expect(io.stdout()).not.toContain('Required before using agents');
|
||||
expect(io.stdout()).not.toContain('All set.');
|
||||
});
|
||||
|
||||
it('installs the analytics skill from the runtime asset', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
|
|
@ -252,7 +378,6 @@ describe('setup agents', () => {
|
|||
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
|
||||
entries: expect.arrayContaining([{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }]),
|
||||
});
|
||||
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
});
|
||||
|
||||
it('prompts for MCP-first client agent connection mode in interactive setup', async () => {
|
||||
|
|
@ -283,10 +408,18 @@ describe('setup agents', () => {
|
|||
});
|
||||
|
||||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: 'How should client agents connect to this KTX project?',
|
||||
message: 'What should agents be allowed to do with this KTX project?',
|
||||
options: [
|
||||
{ value: 'mcp', label: 'MCP tools + analytics skill' },
|
||||
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
|
||||
{
|
||||
value: 'mcp',
|
||||
label: 'Ask data questions with KTX MCP',
|
||||
hint: 'Installs the MCP connection and analytics workflow skill. Best for normal use.',
|
||||
},
|
||||
{
|
||||
value: 'mcp-cli',
|
||||
label: 'Ask data questions + manage KTX with CLI commands',
|
||||
hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(prompts.multiselect).toHaveBeenCalledWith(
|
||||
|
|
@ -330,10 +463,18 @@ describe('setup agents', () => {
|
|||
});
|
||||
|
||||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: 'Where should KTX install supported agent config?',
|
||||
message: `Where should KTX install supported agent config?\n\nKTX project: ${tempDir}`,
|
||||
options: [
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'global', label: 'Global' },
|
||||
{
|
||||
value: 'project',
|
||||
label: 'Project scope (KTX project directory)',
|
||||
hint: 'Only agents opened from this KTX project path load the project-scoped config.',
|
||||
},
|
||||
{
|
||||
value: 'global',
|
||||
label: 'Global scope (user config)',
|
||||
hint: 'Agents can load this KTX project from any working directory.',
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -342,7 +483,7 @@ describe('setup agents', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => {
|
||||
it('registers Claude Desktop MCP and ships an uploadable analytics skill zip', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
|
||||
|
|
@ -372,9 +513,11 @@ describe('setup agents', () => {
|
|||
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip');
|
||||
const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip');
|
||||
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
await expect(stat(pluginPath)).resolves.toBeDefined();
|
||||
await expect(stat(analyticsSkillPath)).resolves.toBeDefined();
|
||||
await expect(stat(adminSkillPath)).rejects.toThrow();
|
||||
const launcherStat = await stat(launcherPath);
|
||||
expect(launcherStat.mode & 0o111).not.toBe(0);
|
||||
const launcher = await readFile(launcherPath, 'utf-8');
|
||||
|
|
@ -390,19 +533,24 @@ describe('setup agents', () => {
|
|||
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
|
||||
});
|
||||
|
||||
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
|
||||
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
|
||||
const setupMd = await readZipText(pluginPath, 'SETUP.md');
|
||||
expect(setupMd).not.toContain('ktx mcp start');
|
||||
expect(setupMd).toContain('claude_desktop_config.json');
|
||||
await expect(readZipText(pluginPath, 'skills/ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
|
||||
expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
|
||||
await expect(readZipText(analyticsSkillPath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
|
||||
await expect(readZipText(analyticsSkillPath, '.claude-plugin/plugin.json')).rejects.toThrow('Missing zip entry');
|
||||
await expect(readZipText(analyticsSkillPath, 'skills/ktx-analytics/SKILL.md')).rejects.toThrow(
|
||||
'Missing zip entry',
|
||||
);
|
||||
|
||||
expect(io.stdout()).toContain('Claude plugin generated');
|
||||
expect(io.stdout()).toContain('.ktx/agents/claude/ktx-plugin.zip');
|
||||
expect(io.stdout()).toContain('KTX MCP server registered');
|
||||
expect(io.stdout()).toContain('Claude Desktop');
|
||||
expect(io.stdout()).toContain(analyticsSkillPath);
|
||||
expect(io.stdout()).not.toContain(adminSkillPath);
|
||||
expect(io.stdout()).toContain('claude_desktop_config.json');
|
||||
expect(io.stdout()).toContain('Restart Claude Desktop');
|
||||
expect(io.stdout()).toContain('Required before using agents');
|
||||
expect(io.stdout()).toContain('1. Restart Claude Desktop');
|
||||
expect(io.stdout()).toContain('Claude Desktop loads KTX MCP after restart.');
|
||||
expect(io.stdout()).toContain('2. Upload Claude Desktop skills');
|
||||
expect(io.stdout()).toContain('Customize > Skills > + > Create skill > Upload a skill');
|
||||
expect(io.stdout()).toContain('Upload this file:');
|
||||
expect(io.stdout()).toContain('Toggle the uploaded KTX skills on.');
|
||||
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
|
|
@ -459,7 +607,7 @@ describe('setup agents', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
|
||||
it('includes an uploadable admin CLI skill zip for Claude Desktop when requested', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
|
|
@ -485,12 +633,18 @@ describe('setup agents', () => {
|
|||
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
|
||||
});
|
||||
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md');
|
||||
const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip');
|
||||
const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip');
|
||||
expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
|
||||
await expect(readZipText(analyticsSkillPath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
|
||||
const adminSkill = await readZipText(adminSkillPath, 'ktx/SKILL.md');
|
||||
expect(adminSkill).toContain(`--project-dir ${tempDir}`);
|
||||
expect(adminSkill).toContain('status --json');
|
||||
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
|
||||
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
await expect(readZipText(adminSkillPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
await expect(readZipText(adminSkillPath, 'ktx-analytics/SKILL.md')).rejects.toThrow('Missing zip entry');
|
||||
expect(io.stdout()).toContain(analyticsSkillPath);
|
||||
expect(io.stdout()).toContain(adminSkillPath);
|
||||
expect(io.stdout()).toContain('Upload each file separately:');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
|
|
@ -568,6 +722,9 @@ describe('setup agents', () => {
|
|||
);
|
||||
expect(codexIo.stdout()).toContain('[mcp_servers.ktx]');
|
||||
expect(codexIo.stdout()).toContain('url = "http://localhost:7878/mcp"');
|
||||
expect(codexIo.stdout()).toContain('1. Configure Codex');
|
||||
expect(codexIo.stdout()).toContain('Open ~/.codex/config.toml, then paste this block:');
|
||||
expect(codexIo.stdout()).toContain('PASTE:');
|
||||
|
||||
const opencodeIo = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
|
|
@ -585,6 +742,8 @@ describe('setup agents', () => {
|
|||
);
|
||||
expect(opencodeIo.stdout()).toContain('"mcp"');
|
||||
expect(opencodeIo.stdout()).toContain('"type": "remote"');
|
||||
expect(opencodeIo.stdout()).toContain('1. Configure OpenCode');
|
||||
expect(opencodeIo.stdout()).toContain('Open opencode.json, then paste this block:');
|
||||
await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow();
|
||||
|
||||
const universalIo = makeIo();
|
||||
|
|
@ -603,6 +762,8 @@ describe('setup agents', () => {
|
|||
);
|
||||
expect(universalIo.stdout()).toContain('Universal MCP endpoint:');
|
||||
expect(universalIo.stdout()).toContain('http://localhost:7878/mcp');
|
||||
expect(universalIo.stdout()).toContain('1. Configure unsupported MCP clients');
|
||||
expect(universalIo.stdout()).toContain('Use this endpoint when setting up unsupported MCP clients:');
|
||||
});
|
||||
|
||||
it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => {
|
||||
|
|
@ -648,7 +809,9 @@ describe('setup agents', () => {
|
|||
expect(rendered).toContain('http://127.0.0.1:8787/mcp');
|
||||
expect(rendered).toContain('Bearer ${KTX_MCP_TOKEN}');
|
||||
expect(rendered).not.toContain('secret-token');
|
||||
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
expect(io.stdout()).toContain('Run this command before using Claude Code:');
|
||||
expect(io.stdout()).toContain('RUN:');
|
||||
expect(io.stdout()).toContain(`ktx mcp start --project-dir ${tempDir}`);
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.KTX_MCP_TOKEN;
|
||||
|
|
@ -713,7 +876,7 @@ describe('setup agents', () => {
|
|||
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
|
||||
});
|
||||
|
||||
it('removes generated Claude Desktop plugin from the manifest', async () => {
|
||||
it('removes generated Claude Desktop skill zips from the manifest', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
|
|
@ -732,10 +895,12 @@ describe('setup agents', () => {
|
|||
},
|
||||
io.io,
|
||||
);
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip');
|
||||
const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip');
|
||||
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
await expect(stat(pluginPath)).resolves.toBeDefined();
|
||||
await expect(stat(analyticsSkillPath)).resolves.toBeDefined();
|
||||
await expect(stat(adminSkillPath)).resolves.toBeDefined();
|
||||
await expect(stat(launcherPath)).resolves.toBeDefined();
|
||||
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
|
|
@ -744,7 +909,8 @@ describe('setup agents', () => {
|
|||
|
||||
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
|
||||
|
||||
await expect(stat(pluginPath)).rejects.toThrow();
|
||||
await expect(stat(analyticsSkillPath)).rejects.toThrow();
|
||||
await expect(stat(adminSkillPath)).rejects.toThrow();
|
||||
await expect(stat(launcherPath)).rejects.toThrow();
|
||||
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
|
|
@ -782,7 +948,7 @@ describe('setup agents', () => {
|
|||
).resolves.toEqual({ status: 'skipped', projectDir: tempDir });
|
||||
});
|
||||
|
||||
it('explains how to select multiple agent targets in interactive mode', async () => {
|
||||
it('prints one navigation hint before interactive agent target prompts', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async () => 'mcp-cli'),
|
||||
|
|
@ -806,10 +972,11 @@ describe('setup agents', () => {
|
|||
),
|
||||
).resolves.toEqual({ status: 'back', projectDir: tempDir });
|
||||
|
||||
expect(io.stdout()).toContain('Space to select, Enter to confirm, Esc to go back.');
|
||||
expect(io.stdout().match(/Space to select/g)).toHaveLength(1);
|
||||
expect(prompts.multiselect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
'Which agent targets should KTX install?\nUse Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
|
||||
message: 'Which agent targets should KTX install?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -832,36 +999,44 @@ describe('setup agents', () => {
|
|||
);
|
||||
|
||||
const output = io.stdout();
|
||||
expect(output).toContain('Agent integration complete');
|
||||
expect(output).toContain('Claude Code');
|
||||
expect(output).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
|
||||
expect(output).toContain('.claude/skills/ktx-analytics/SKILL.md');
|
||||
expect(output).toContain('+ Skill installed — teaches admin agents which KTX CLI commands to run');
|
||||
expect(output).toContain('.claude/skills/ktx/SKILL.md');
|
||||
expect(output).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
|
||||
expect(output).toContain('.claude/rules/ktx.md');
|
||||
expect(output).toContain('Claude Code · Project scope');
|
||||
expect(output).toContain(join(tempDir, '.mcp.json'));
|
||||
expect(output).toContain('Requires MCP to be started.');
|
||||
expect(output).toContain('Analytics skill installed.');
|
||||
expect(output).toContain('Admin CLI skill installed.');
|
||||
expect(output).not.toContain('Agent integration complete');
|
||||
expect(output).not.toContain(`KTX project\n ${tempDir}`);
|
||||
expect(output).not.toContain('Installed agents');
|
||||
expect(output).not.toContain('.claude/skills/ktx-analytics/SKILL.md');
|
||||
expect(output).not.toContain('.claude/skills/ktx/SKILL.md');
|
||||
expect(output).not.toContain('.claude/rules/ktx.md');
|
||||
});
|
||||
|
||||
it('formats summary with relative paths for project scope', () => {
|
||||
const summary = formatInstallSummary(
|
||||
it('formats summary with explicit project-scoped config paths', () => {
|
||||
const summary = formatInstallSummaryLines(
|
||||
[{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }],
|
||||
[
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
|
||||
{ kind: 'json-key', path: join(tempDir, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
|
||||
],
|
||||
tempDir,
|
||||
);
|
||||
|
||||
expect(summary).toContain('Cursor');
|
||||
expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
|
||||
expect(summary).toContain('.cursor/rules/ktx-analytics.mdc');
|
||||
expect(summary).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
|
||||
expect(summary).toContain('.cursor/rules/ktx.mdc');
|
||||
expect(summary).not.toContain(tempDir);
|
||||
expect(summary).toEqual([
|
||||
{
|
||||
title: 'Cursor · Project scope',
|
||||
lines: [
|
||||
join(tempDir, '.cursor/mcp.json'),
|
||||
'Requires MCP to be started.',
|
||||
'Cursor rules installed.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats summary with multiple agent targets', () => {
|
||||
const summary = formatInstallSummary(
|
||||
const summary = formatInstallSummaryLines(
|
||||
[
|
||||
{ target: 'claude-code', scope: 'project', mode: 'mcp-cli' },
|
||||
{ target: 'codex', scope: 'project', mode: 'mcp-cli' },
|
||||
|
|
@ -870,6 +1045,7 @@ describe('setup agents', () => {
|
|||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' },
|
||||
{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' },
|
||||
|
|
@ -877,12 +1053,232 @@ describe('setup agents', () => {
|
|||
tempDir,
|
||||
);
|
||||
|
||||
expect(summary).toContain('Claude Code');
|
||||
expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
|
||||
expect(summary).toContain('+ Skill installed — teaches admin agents which KTX CLI commands to run');
|
||||
expect(summary).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
|
||||
expect(summary).toContain('Codex');
|
||||
expect(summary).toContain('.agents/skills/ktx-analytics/SKILL.md');
|
||||
expect(summary).toContain('.agents/skills/ktx/SKILL.md');
|
||||
expect(summary).toEqual([
|
||||
{
|
||||
title: 'Claude Code · Project scope',
|
||||
lines: [
|
||||
join(tempDir, '.mcp.json'),
|
||||
'Requires MCP to be started.',
|
||||
'Analytics skill installed.',
|
||||
'Admin CLI skill installed.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Codex · Project scope',
|
||||
lines: [
|
||||
'Add the snippet shown below to ~/.codex/config.toml.',
|
||||
'Requires MCP to be started.',
|
||||
'Codex guidance installed.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prints one target-aware next actions block for mixed agent targets', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async ({ message }: { message: string }) =>
|
||||
message.startsWith('Where should') ? 'project' : 'mcp',
|
||||
),
|
||||
multiselect: vi.fn(async () => ['claude-code', 'claude-desktop']),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [
|
||||
{ target: 'claude-code', scope: 'project', mode: 'mcp' },
|
||||
{ target: 'claude-desktop', scope: 'global', mode: 'mcp' },
|
||||
],
|
||||
});
|
||||
|
||||
const output = io.stdout();
|
||||
expect(output).toContain('Required before using agents');
|
||||
expect(output).not.toContain('Next actions');
|
||||
expect(output).toContain('1. Start MCP');
|
||||
expect(output).toContain('Run this command before using Claude Code:');
|
||||
expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`);
|
||||
expect(output).toContain(`ktx mcp stop --project-dir ${tempDir}\n\n2. Open Claude Code`);
|
||||
expect(output).toContain('Open Claude Code from the KTX project directory');
|
||||
expect(output).toContain('RUN:');
|
||||
expect(output).toContain(`cd '${tempDir}'`);
|
||||
expect(output).toContain('3. Restart Claude Desktop');
|
||||
expect(output).toContain('Claude Desktop loads KTX MCP after restart.');
|
||||
expect(output).toContain('4. Upload Claude Desktop skills');
|
||||
expect(output).toContain('Customize > Skills > + > Create skill > Upload a skill');
|
||||
expect(output).toContain(join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'));
|
||||
expect(output).not.toContain(join(tempDir, '.ktx/agents/claude/ktx.zip'));
|
||||
expect(output).toContain('Upload this file:');
|
||||
expect(output).toContain('All set.');
|
||||
expect(output).not.toContain('Finish Claude Desktop setup');
|
||||
expect(output).not.toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not tell global Claude Code installs to open from the project directory', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'global',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [{ target: 'claude-code', scope: 'global', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
const output = io.stdout();
|
||||
expect(output).toContain('2. Open Claude Code');
|
||||
expect(output).toContain('RUN:');
|
||||
expect(output).toContain('claude');
|
||||
expect(output).not.toContain('Open Claude Code from the KTX project directory');
|
||||
expect(output).not.toContain(`cd '${tempDir}'`);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('explains next actions for Codex, Cursor, OpenCode, and universal MCP targets', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async () => 'mcp-cli'),
|
||||
multiselect: vi.fn(async () => ['codex', 'cursor', 'opencode', 'universal']),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [
|
||||
{ target: 'codex', scope: 'project', mode: 'mcp-cli' },
|
||||
{ target: 'cursor', scope: 'project', mode: 'mcp-cli' },
|
||||
{ target: 'opencode', scope: 'project', mode: 'mcp-cli' },
|
||||
{ target: 'universal', scope: 'project', mode: 'mcp-cli' },
|
||||
],
|
||||
});
|
||||
|
||||
const output = io.stdout();
|
||||
expect(output).toContain('Required before using agents');
|
||||
expect(output).toContain('1. Configure Codex');
|
||||
expect(output).toContain('2. Configure OpenCode');
|
||||
expect(output).toContain('3. Configure unsupported MCP clients');
|
||||
expect(output).toContain('4. Start MCP');
|
||||
expect(output).toContain('Run this command before using Codex, Cursor, OpenCode, and Universal .agents:');
|
||||
expect(output).toContain('Open Cursor from the KTX project directory');
|
||||
expect(output).toContain('Open ~/.codex/config.toml, then paste this block:\n\n PASTE:\n [mcp_servers.ktx]');
|
||||
expect(output).toContain('Open opencode.json, then paste this block:');
|
||||
expect(output).toContain('Use this endpoint when setting up unsupported MCP clients:');
|
||||
expect(output).toContain('Codex guidance installed');
|
||||
expect(output).toContain('Cursor rules installed');
|
||||
expect(output).toContain('OpenCode commands installed');
|
||||
expect(output).toContain('.agents guidance installed');
|
||||
});
|
||||
|
||||
describe('createAgentNextActionsLineFormatter', () => {
|
||||
function makeColorStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } {
|
||||
return { write: () => true, hasColors: () => true };
|
||||
}
|
||||
|
||||
function makePlainStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } {
|
||||
return { write: () => true, hasColors: () => false };
|
||||
}
|
||||
|
||||
const ESC = String.fromCharCode(27);
|
||||
|
||||
it('returns the line untouched when the stream cannot render colors', () => {
|
||||
const format = createAgentNextActionsLineFormatter(makePlainStdout());
|
||||
expect(format('2. Upload Claude Desktop skills')).toBe('2. Upload Claude Desktop skills');
|
||||
expect(format(' /tmp/ktx/.ktx/agents/claude/ktx.zip')).toBe(' /tmp/ktx/.ktx/agents/claude/ktx.zip');
|
||||
});
|
||||
|
||||
it('styles step headings and aligns sub-prose under the title', () => {
|
||||
const format = createAgentNextActionsLineFormatter(makeColorStdout());
|
||||
const heading = format('2. Upload Claude Desktop skills');
|
||||
expect(heading).toContain(ESC);
|
||||
expect(heading).toContain('2');
|
||||
expect(heading).toContain('Upload Claude Desktop skills');
|
||||
expect(heading).not.toMatch(/^2\. /);
|
||||
|
||||
const sub = format(' Toggle the uploaded KTX skills on.');
|
||||
expect(sub).toMatch(/^ {3}/);
|
||||
expect(sub).toContain('Toggle the uploaded KTX skills on.');
|
||||
});
|
||||
|
||||
it('renders skill bundle .zip paths as bullets and shortens HOME to ~', () => {
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = '/tmp/test-home';
|
||||
try {
|
||||
const format = createAgentNextActionsLineFormatter(makeColorStdout());
|
||||
const line = format(' /tmp/test-home/.ktx/agents/claude/ktx-analytics.zip');
|
||||
expect(line).toContain('•');
|
||||
expect(line).toContain('~/.ktx/agents/claude/ktx-analytics.zip');
|
||||
expect(line).not.toContain('/tmp/test-home/');
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces breadcrumb separators with a typographic chevron', () => {
|
||||
const format = createAgentNextActionsLineFormatter(makeColorStdout());
|
||||
const line = format(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.');
|
||||
expect(line).toContain('›');
|
||||
expect(line).not.toContain(' > ');
|
||||
});
|
||||
|
||||
it('leaves already-styled lines untouched to avoid double-wrapping', () => {
|
||||
const format = createAgentNextActionsLineFormatter(makeColorStdout());
|
||||
const preStyled = `${ESC}[1m2. Already styled${ESC}[22m`;
|
||||
expect(format(preStyled)).toBe(preStyled);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import type { Writable } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { styleText } from 'node:util';
|
||||
import { log, outro } from '@clack/prompts';
|
||||
import {
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
|
|
@ -9,8 +12,6 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { bold, dim, green } from './io/symbols.js';
|
||||
import { withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
createKtxSetupUiAdapter,
|
||||
|
|
@ -31,6 +32,7 @@ export interface KtxSetupAgentsArgs {
|
|||
scope: KtxAgentScope;
|
||||
mode: KtxAgentInstallMode;
|
||||
skipAgents: boolean;
|
||||
showNextActions?: boolean;
|
||||
}
|
||||
|
||||
export type KtxSetupAgentsResult =
|
||||
|
|
@ -38,6 +40,7 @@ export type KtxSetupAgentsResult =
|
|||
status: 'ready';
|
||||
projectDir: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
nextActions?: string;
|
||||
}
|
||||
| { status: 'skipped'; projectDir: string }
|
||||
| { status: 'back'; projectDir: string }
|
||||
|
|
@ -50,7 +53,11 @@ export interface KtxAgentInstallManifest {
|
|||
installedAt: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
entries: Array<
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
|
||||
| {
|
||||
kind: 'file';
|
||||
path: string;
|
||||
role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle' | 'launcher';
|
||||
}
|
||||
| { kind: 'json-key'; path: string; jsonPath: string[] }
|
||||
>;
|
||||
}
|
||||
|
|
@ -69,11 +76,95 @@ interface KtxMcpClientInstallResult {
|
|||
notices: string[];
|
||||
}
|
||||
|
||||
const MCP_DAEMON_REQUIRED_NOTICE = 'mcp-daemon-required';
|
||||
|
||||
interface KtxCliLauncher {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
|
||||
return (
|
||||
output.isTTY === true &&
|
||||
typeof (output as { on?: unknown }).on === 'function' &&
|
||||
typeof (output as { columns?: unknown }).columns !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function writeSetupInfo(io: KtxCliIo, message: string): void {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
log.info(message, { output: io.stdout });
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function writeSetupStep(io: KtxCliIo, message: string): void {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
log.step(message, { output: io.stdout });
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`\n${message}\n`);
|
||||
}
|
||||
|
||||
function writeSetupOutro(io: KtxCliIo, message: string): void {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
outro(message, { output: io.stdout });
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`\n${message}\n`);
|
||||
}
|
||||
|
||||
const STEP_HEADING_RE = /^(\d+)\. (.+)$/;
|
||||
const ACTION_MARKER_RE = /^(RUN|PASTE|USE|OPEN):$/;
|
||||
|
||||
export function createAgentNextActionsLineFormatter(
|
||||
stdout: KtxCliIo['stdout'],
|
||||
): (line: string) => string {
|
||||
const maybeHasColors = (stdout as { hasColors?: unknown }).hasColors;
|
||||
const supportsColor = typeof maybeHasColors === 'function' && Boolean(maybeHasColors.call(stdout));
|
||||
if (!supportsColor) return (line) => line;
|
||||
|
||||
const homeDir = process.env.HOME ? resolve(process.env.HOME) : '';
|
||||
const styleOptions = { validateStream: false } as const;
|
||||
const dim = (s: string) => styleText('dim', s, styleOptions);
|
||||
const bold = (s: string) => styleText('bold', s, styleOptions);
|
||||
const cyanBold = (s: string) => styleText(['cyan', 'bold'], s, styleOptions);
|
||||
const dimCyan = (s: string) => styleText(['dim', 'cyan'], s, styleOptions);
|
||||
const shortenPath = (path: string): string => {
|
||||
if (!homeDir) return path;
|
||||
if (path === homeDir) return '~';
|
||||
if (path.startsWith(`${homeDir}/`)) return `~/${path.slice(homeDir.length + 1)}`;
|
||||
return path;
|
||||
};
|
||||
|
||||
return (rawLine: string): string => {
|
||||
if (rawLine.length === 0 || rawLine.includes('[')) return rawLine;
|
||||
|
||||
const heading = rawLine.match(STEP_HEADING_RE);
|
||||
if (heading) {
|
||||
return `${cyanBold(heading[1])} ${bold(heading[2])}`;
|
||||
}
|
||||
|
||||
if (!rawLine.startsWith(' ')) return rawLine;
|
||||
const body = rawLine.slice(2);
|
||||
|
||||
if (ACTION_MARKER_RE.test(body)) {
|
||||
return ` ${dim(body)}`;
|
||||
}
|
||||
|
||||
if (body.endsWith('.zip') && (body.startsWith('/') || body.startsWith('~'))) {
|
||||
return ` ${dimCyan('•')} ${shortenPath(body)}`;
|
||||
}
|
||||
|
||||
if (body.includes(' > ')) {
|
||||
return ` ${body.replaceAll(' > ', ` ${dim('›')} `)}`;
|
||||
}
|
||||
|
||||
return ` ${dim(body)}`;
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonObject(path: string): Promise<Record<string, unknown>> {
|
||||
if (!existsSync(path)) return {};
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
|
|
@ -257,7 +348,7 @@ async function installMcpClientConfig(input: {
|
|||
|
||||
const endpoint = await resolveMcpEndpoint(input.projectDir);
|
||||
if (!endpoint.running) {
|
||||
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
notices.push(MCP_DAEMON_REQUIRED_NOTICE);
|
||||
}
|
||||
|
||||
if (input.target === 'claude-code') {
|
||||
|
|
@ -269,15 +360,15 @@ async function installMcpClientConfig(input: {
|
|||
await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint));
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
} else if (input.target === 'codex') {
|
||||
snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
|
||||
snippets.push(`Add this Codex MCP snippet to ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
|
||||
} else if (input.target === 'opencode') {
|
||||
const path =
|
||||
input.scope === 'global'
|
||||
? '~/.config/opencode/opencode.json'
|
||||
: relative(input.projectDir, join(input.projectDir, 'opencode.json'));
|
||||
snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`);
|
||||
snippets.push(`Add this OpenCode MCP snippet to ${path}:\n${opencodeSnippet(endpoint)}`);
|
||||
} else if (input.target === 'universal') {
|
||||
snippets.push(universalMcpSnippet(endpoint));
|
||||
snippets.push(`Use this universal MCP endpoint with unsupported MCP clients:\n${universalMcpSnippet(endpoint)}`);
|
||||
}
|
||||
|
||||
return { entries, snippets, notices };
|
||||
|
|
@ -307,8 +398,12 @@ export function agentInstallManifestPath(projectDir: string): string {
|
|||
return join(resolve(projectDir), '.ktx/agents/install-manifest.json');
|
||||
}
|
||||
|
||||
function claudeDesktopPluginPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip');
|
||||
function claudeDesktopAnalyticsSkillBundlePath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/claude/ktx-analytics.zip');
|
||||
}
|
||||
|
||||
function claudeDesktopAdminSkillBundlePath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip');
|
||||
}
|
||||
|
||||
function claudeDesktopLauncherPath(projectDir: string): string {
|
||||
|
|
@ -354,7 +449,20 @@ export function plannedKtxAgentFiles(input: {
|
|||
if (input.target === 'claude-desktop') {
|
||||
return [
|
||||
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
|
||||
{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const },
|
||||
{
|
||||
kind: 'file',
|
||||
path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir),
|
||||
role: 'claude-desktop-skill-bundle' as const,
|
||||
},
|
||||
...(withAdminCli
|
||||
? [
|
||||
{
|
||||
kind: 'file' as const,
|
||||
path: claudeDesktopAdminSkillBundlePath(input.projectDir),
|
||||
role: 'claude-desktop-skill-bundle' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
|
||||
|
|
@ -484,43 +592,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
function claudePluginJsonContent(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: 'ktx',
|
||||
version: '0.0.0-local',
|
||||
description: 'KTX analytics workflow guidance and local MCP tools.',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function claudePluginVersionContent(): string {
|
||||
return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string {
|
||||
return [
|
||||
'# KTX Claude Plugin',
|
||||
'',
|
||||
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
|
||||
'',
|
||||
`KTX project: \`${input.projectDir}\``,
|
||||
'',
|
||||
'Included:',
|
||||
'',
|
||||
'- `ktx-analytics` skill for the MCP analytics workflow',
|
||||
...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []),
|
||||
'',
|
||||
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
|
||||
'',
|
||||
'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string {
|
||||
function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): string {
|
||||
const binPath = input.launcher.args[0];
|
||||
if (!binPath) {
|
||||
throw new Error('Expected KTX CLI launcher to include a bin path.');
|
||||
|
|
@ -569,40 +641,45 @@ function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): strin
|
|||
' run_with_node "$(command -v node)" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2',
|
||||
'echo "KTX Claude Desktop launcher could not find Node.js. Set KTX_NODE to a Node executable and rerun ktx setup --agents." >&2',
|
||||
'exit 127',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function writeClaudeDesktopPlugin(input: {
|
||||
async function writeClaudeDesktopSkillBundle(input: {
|
||||
projectDir: string;
|
||||
path: string;
|
||||
mode: KtxAgentInstallMode;
|
||||
skillName: 'ktx-analytics' | 'ktx';
|
||||
launcher: KtxCliLauncher;
|
||||
}): Promise<void> {
|
||||
const withAdminCli = input.mode === 'mcp-cli';
|
||||
const content =
|
||||
input.skillName === 'ktx-analytics'
|
||||
? await readAnalyticsSkillContent()
|
||||
: cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher });
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()),
|
||||
'version.json': strToU8(claudePluginVersionContent()),
|
||||
'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
|
||||
'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })),
|
||||
[`${input.skillName}/SKILL.md`]: strToU8(content),
|
||||
};
|
||||
if (withAdminCli) {
|
||||
files['skills/ktx/SKILL.md'] = strToU8(
|
||||
cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }),
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(input.path), { recursive: true });
|
||||
await writeFile(input.path, Buffer.from(zipSync(files)));
|
||||
}
|
||||
|
||||
function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx' {
|
||||
if (path.endsWith('/ktx-analytics.zip')) {
|
||||
return 'ktx-analytics';
|
||||
}
|
||||
if (path.endsWith('/ktx.zip')) {
|
||||
return 'ktx';
|
||||
}
|
||||
throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`);
|
||||
}
|
||||
|
||||
async function writeClaudeDesktopLauncher(input: {
|
||||
path: string;
|
||||
launcher: KtxCliLauncher;
|
||||
}): Promise<void> {
|
||||
await mkdir(dirname(input.path), { recursive: true });
|
||||
await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8');
|
||||
await writeFile(input.path, claudeDesktopLauncherContent({ launcher: input.launcher }), 'utf-8');
|
||||
await chmod(input.path, 0o755);
|
||||
}
|
||||
|
||||
|
|
@ -719,17 +796,8 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
|
|||
universal: 'Universal .agents',
|
||||
};
|
||||
|
||||
const fileEntryLabels: Record<KtxAgentTarget, string> = {
|
||||
'claude-code': 'Skill installed',
|
||||
'claude-desktop': 'Skill installed',
|
||||
codex: 'Skill installed',
|
||||
cursor: 'Rule installed',
|
||||
opencode: 'Command installed',
|
||||
universal: 'Skill installed',
|
||||
};
|
||||
|
||||
function mcpEntryLabel(entry: Extract<InstallEntry, { kind: 'json-key' }>): string {
|
||||
return `MCP config installed — connects client agents to KTX MCP tools (${entry.jsonPath.join('.')})`;
|
||||
export function targetDisplayName(target: string): string {
|
||||
return Object.hasOwn(targetDisplayNames, target) ? targetDisplayNames[target as KtxAgentTarget] : target;
|
||||
}
|
||||
|
||||
function targetSupportsGlobalScope(target: KtxAgentTarget): boolean {
|
||||
|
|
@ -740,11 +808,72 @@ function effectiveInstallScope(target: KtxAgentTarget, requestedScope: KtxAgentS
|
|||
return target === 'claude-desktop' ? 'global' : requestedScope;
|
||||
}
|
||||
|
||||
export function formatInstallSummary(
|
||||
function scopeDisplayName(scope: KtxAgentScope): string {
|
||||
if (scope === 'project') return 'Project scope';
|
||||
if (scope === 'global') return 'Global scope';
|
||||
return 'Local scope';
|
||||
}
|
||||
|
||||
function targetUsesHttpMcpDaemon(target: KtxAgentTarget): boolean {
|
||||
return target !== 'claude-desktop';
|
||||
}
|
||||
|
||||
function manualMcpConfigInstruction(target: KtxAgentTarget, scope: KtxAgentScope): string {
|
||||
if (target === 'codex') {
|
||||
return 'Add the snippet shown below to ~/.codex/config.toml.';
|
||||
}
|
||||
if (target === 'opencode') {
|
||||
return scope === 'global'
|
||||
? 'Add the snippet shown below to ~/.config/opencode/opencode.json.'
|
||||
: 'Add the snippet shown below to opencode.json.';
|
||||
}
|
||||
if (target === 'universal') {
|
||||
return 'Use the printed endpoint with unsupported MCP clients.';
|
||||
}
|
||||
return 'Add the printed snippet manually.';
|
||||
}
|
||||
|
||||
function guidanceInstallLine(target: KtxAgentTarget): string {
|
||||
if (target === 'codex') return 'Codex guidance installed';
|
||||
if (target === 'cursor') return 'Cursor rules installed';
|
||||
if (target === 'opencode') return 'OpenCode commands installed';
|
||||
if (target === 'universal') return '.agents guidance installed';
|
||||
return 'Agent guidance installed';
|
||||
}
|
||||
|
||||
function hasEntryRole(entries: InstallEntry[], role: Extract<InstallEntry, { kind: 'file' }>['role']): boolean {
|
||||
return entries.some((entry) => entry.kind === 'file' && entry.role === role);
|
||||
}
|
||||
|
||||
function hasAdminCliEntries(entries: InstallEntry[]): boolean {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.kind === 'file' &&
|
||||
(entry.role === 'skill' || entry.role === 'rule' || entry.role === undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export interface InstallSummaryEntry {
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function formatInlinePath(path: string): string {
|
||||
const home = process.env.HOME;
|
||||
if (!home) return path;
|
||||
const resolvedHome = resolve(home);
|
||||
if (path === resolvedHome) return '~';
|
||||
if (path.startsWith(`${resolvedHome}/`)) {
|
||||
return `~/${relative(resolvedHome, path)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function formatInstallSummaryLines(
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
|
||||
entries: InstallEntry[],
|
||||
projectDir: string,
|
||||
): string {
|
||||
): InstallSummaryEntry[] {
|
||||
const entriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
|
||||
for (const install of installs) {
|
||||
const plannedFilePaths = new Set(
|
||||
|
|
@ -766,48 +895,233 @@ export function formatInstallSummary(
|
|||
);
|
||||
}
|
||||
|
||||
const fileHints: Record<string, string> = {
|
||||
skill: 'teaches admin agents which KTX CLI commands to run',
|
||||
rule: 'tells admin agents when to use KTX CLI',
|
||||
'analytics-skill': 'teaches your agent the KTX MCP analytics workflow',
|
||||
'claude-plugin': 'bundles KTX skills for Claude Desktop (MCP server is registered in claude_desktop_config.json)',
|
||||
launcher: 'runs the local KTX CLI with an available Node.js for Claude Desktop',
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const install of installs) {
|
||||
return installs.map((install) => {
|
||||
const targetEntries = entriesByTarget.get(install.target) ?? [];
|
||||
lines.push(` ${targetDisplayNames[install.target]}`);
|
||||
for (const entry of targetEntries) {
|
||||
if (entry.kind === 'file') {
|
||||
const isRule = entry.role === 'rule' || (!entry.role && fileEntryLabels[install.target] === 'Rule installed');
|
||||
const label =
|
||||
entry.role === 'analytics-skill'
|
||||
? 'Analytics skill installed'
|
||||
: entry.role === 'claude-plugin'
|
||||
? 'Claude plugin generated'
|
||||
: entry.role === 'launcher'
|
||||
? 'Launcher installed'
|
||||
: isRule
|
||||
? 'Rule installed'
|
||||
: fileEntryLabels[install.target];
|
||||
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
|
||||
lines.push(` + ${label} — ${hint}`);
|
||||
if (entry.role !== 'claude-plugin') {
|
||||
const displayPath =
|
||||
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` ${displayPath}`);
|
||||
const mcpEntry = mcpEntriesByTarget
|
||||
.get(install.target)
|
||||
?.find((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key');
|
||||
const lines: string[] = [];
|
||||
|
||||
if (mcpEntry) {
|
||||
lines.push(formatInlinePath(mcpEntry.path));
|
||||
} else if (install.target !== 'claude-desktop') {
|
||||
lines.push(manualMcpConfigInstruction(install.target, install.scope));
|
||||
}
|
||||
|
||||
if (targetUsesHttpMcpDaemon(install.target)) {
|
||||
lines.push('Requires MCP to be started.');
|
||||
}
|
||||
|
||||
const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill');
|
||||
const hasAdmin = hasAdminCliEntries(targetEntries);
|
||||
const claudeDesktopSkillBundles = targetEntries.filter(
|
||||
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
|
||||
entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle',
|
||||
);
|
||||
|
||||
if (install.target === 'claude-code') {
|
||||
if (hasAnalytics) {
|
||||
lines.push('Analytics skill installed.');
|
||||
}
|
||||
if (hasAdmin) {
|
||||
lines.push('Admin CLI skill installed.');
|
||||
}
|
||||
} else if (install.target === 'claude-desktop') {
|
||||
if (claudeDesktopSkillBundles.length > 0) {
|
||||
lines.push('Skill bundles:');
|
||||
for (const bundle of claudeDesktopSkillBundles) {
|
||||
lines.push(` ${bundle.path}`);
|
||||
}
|
||||
}
|
||||
} else if (hasAnalytics || hasAdmin) {
|
||||
lines.push(`${guidanceInstallLine(install.target)}.`);
|
||||
}
|
||||
for (const entry of mcpEntriesByTarget
|
||||
.get(install.target)
|
||||
?.filter((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key') ?? []) {
|
||||
const displayPath = install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` + ${mcpEntryLabel(entry)}`);
|
||||
lines.push(` ${displayPath}`);
|
||||
|
||||
if (hasEntryRole(targetEntries, 'launcher')) {
|
||||
lines.push('Starts KTX over stdio from Claude Desktop.');
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`,
|
||||
lines,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function claudeDesktopSkillBundlePathsForInstalls(
|
||||
projectDir: string,
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
|
||||
): string[] {
|
||||
return installs
|
||||
.filter((install) => install.target === 'claude-desktop')
|
||||
.flatMap((install) => plannedKtxAgentFiles({ projectDir, ...install }))
|
||||
.filter(
|
||||
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
|
||||
entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle',
|
||||
)
|
||||
.map((entry) => entry.path);
|
||||
}
|
||||
|
||||
function humanList(values: string[]): string {
|
||||
if (values.length <= 2) {
|
||||
return values.join(' and ');
|
||||
}
|
||||
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
|
||||
}
|
||||
|
||||
function pushBlankLine(lines: string[]): void {
|
||||
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingBlankLines(lines: string[]): void {
|
||||
while (lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function manualActionFromSnippet(snippet: string): {
|
||||
title: string;
|
||||
instruction: string;
|
||||
marker: 'PASTE' | 'USE';
|
||||
body: string[];
|
||||
} {
|
||||
const [label = '', ...body] = snippet.split('\n');
|
||||
const codexPrefix = 'Add this Codex MCP snippet to ~/.codex/config.toml:';
|
||||
if (label === codexPrefix) {
|
||||
return {
|
||||
title: 'Configure Codex',
|
||||
instruction: 'Open ~/.codex/config.toml, then paste this block:',
|
||||
marker: 'PASTE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const opencodeMatch = label.match(/^Add this OpenCode MCP snippet to (.+):$/);
|
||||
if (opencodeMatch) {
|
||||
return {
|
||||
title: 'Configure OpenCode',
|
||||
instruction: `Open ${opencodeMatch[1]}, then paste this block:`,
|
||||
marker: 'PASTE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
if (label === 'Use this universal MCP endpoint with unsupported MCP clients:') {
|
||||
return {
|
||||
title: 'Configure unsupported MCP clients',
|
||||
instruction: 'Use this endpoint when setting up unsupported MCP clients:',
|
||||
marker: 'USE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Configure MCP client',
|
||||
instruction: label,
|
||||
marker: 'PASTE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function formatAgentNextActions(input: {
|
||||
projectDir: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
notices: string[];
|
||||
snippets: string[];
|
||||
}): string {
|
||||
const projectDir = resolve(input.projectDir);
|
||||
const lines: string[] = [];
|
||||
let step = 1;
|
||||
|
||||
for (const snippet of input.snippets) {
|
||||
const action = manualActionFromSnippet(snippet);
|
||||
lines.push(`${step}. ${action.title}`);
|
||||
lines.push(` ${action.instruction}`);
|
||||
if (action.body.length > 0) {
|
||||
lines.push('', ` ${action.marker}:`);
|
||||
}
|
||||
for (const line of action.body) {
|
||||
lines.push(` ${line}`);
|
||||
}
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
const httpTargets = input.installs
|
||||
.filter((install) => targetUsesHttpMcpDaemon(install.target))
|
||||
.map((install) => targetDisplayName(install.target));
|
||||
if (input.notices.length > 0 && httpTargets.length > 0) {
|
||||
lines.push(`${step}. Start MCP`);
|
||||
lines.push(` Run this command before using ${humanList(httpTargets)}:`);
|
||||
lines.push('');
|
||||
lines.push(' RUN:');
|
||||
lines.push(` ktx mcp start --project-dir ${projectDir}`);
|
||||
lines.push('');
|
||||
lines.push(' If you need to stop MCP later:');
|
||||
lines.push(` ktx mcp stop --project-dir ${projectDir}`);
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
const claudeCodeInstall = input.installs.find((install) => install.target === 'claude-code');
|
||||
if (claudeCodeInstall) {
|
||||
lines.push(`${step}. Open Claude Code`);
|
||||
if (claudeCodeInstall.scope === 'project') {
|
||||
lines.push(' Open Claude Code from the KTX project directory:');
|
||||
lines.push('');
|
||||
lines.push(' RUN:');
|
||||
lines.push(` cd ${shellScriptQuote(projectDir)}`);
|
||||
lines.push(' claude');
|
||||
} else {
|
||||
lines.push(' RUN:');
|
||||
lines.push(' claude');
|
||||
}
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
const cursorInstall = input.installs.find((install) => install.target === 'cursor');
|
||||
if (cursorInstall) {
|
||||
lines.push(`${step}. Open Cursor`);
|
||||
if (cursorInstall.scope === 'project') {
|
||||
lines.push(' Open Cursor from the KTX project directory:');
|
||||
lines.push('');
|
||||
lines.push(' OPEN:');
|
||||
lines.push(` ${projectDir}`);
|
||||
} else {
|
||||
lines.push(' Open Cursor.');
|
||||
}
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
if (input.installs.some((install) => install.target === 'claude-desktop')) {
|
||||
lines.push(`${step}. Restart Claude Desktop`);
|
||||
lines.push(' Claude Desktop loads KTX MCP after restart.');
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
|
||||
const skillBundlePaths = claudeDesktopSkillBundlePathsForInstalls(projectDir, input.installs);
|
||||
if (skillBundlePaths.length > 0) {
|
||||
lines.push(`${step}. Upload Claude Desktop skills`);
|
||||
lines.push(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.');
|
||||
lines.push(skillBundlePaths.length === 1 ? ' Upload this file:' : ' Upload each file separately:');
|
||||
for (const path of skillBundlePaths) {
|
||||
lines.push(` ${path}`);
|
||||
}
|
||||
lines.push(' Toggle the uploaded KTX skills on.');
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push('Open your configured agent and ask a data question.');
|
||||
}
|
||||
|
||||
trimTrailingBlankLines(lines);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -825,11 +1139,11 @@ async function installTarget(input: {
|
|||
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
|
||||
continue;
|
||||
}
|
||||
if (entry.role === 'claude-plugin') {
|
||||
await writeClaudeDesktopPlugin({
|
||||
if (entry.role === 'claude-desktop-skill-bundle') {
|
||||
await writeClaudeDesktopSkillBundle({
|
||||
projectDir: input.projectDir,
|
||||
path: entry.path,
|
||||
mode: input.mode,
|
||||
skillName: claudeDesktopSkillNameForBundle(entry.path),
|
||||
launcher,
|
||||
});
|
||||
continue;
|
||||
|
|
@ -866,14 +1180,25 @@ export async function runKtxSetupAgentsStep(
|
|||
}
|
||||
|
||||
const prompts = deps.prompts ?? createPromptAdapter();
|
||||
if (args.inputMode === 'auto' && args.target === undefined) {
|
||||
writeSetupInfo(io, 'Space to select, Enter to confirm, Esc to go back.');
|
||||
}
|
||||
const mode =
|
||||
args.inputMode === 'disabled'
|
||||
? args.mode
|
||||
: ((await prompts.select({
|
||||
message: 'How should client agents connect to this KTX project?',
|
||||
message: 'What should agents be allowed to do with this KTX project?',
|
||||
options: [
|
||||
{ value: 'mcp', label: 'MCP tools + analytics skill' },
|
||||
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
|
||||
{
|
||||
value: 'mcp',
|
||||
label: 'Ask data questions with KTX MCP',
|
||||
hint: 'Installs the MCP connection and analytics workflow skill. Best for normal use.',
|
||||
},
|
||||
{
|
||||
value: 'mcp-cli',
|
||||
label: 'Ask data questions + manage KTX with CLI commands',
|
||||
hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.',
|
||||
},
|
||||
],
|
||||
})) as KtxAgentInstallMode | 'back');
|
||||
if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir };
|
||||
|
|
@ -884,7 +1209,7 @@ export async function runKtxSetupAgentsStep(
|
|||
: args.inputMode === 'disabled'
|
||||
? []
|
||||
: ((await prompts.multiselect({
|
||||
message: withMultiselectNavigation('Which agent targets should KTX install?'),
|
||||
message: 'Which agent targets should KTX install?',
|
||||
options: [
|
||||
{ value: 'claude-code', label: 'Claude Code' },
|
||||
{ value: 'claude-desktop', label: 'Claude Desktop' },
|
||||
|
|
@ -897,7 +1222,11 @@ export async function runKtxSetupAgentsStep(
|
|||
})) as KtxAgentTarget[]);
|
||||
if (targets.includes('back' as KtxAgentTarget)) return { status: 'back', projectDir: args.projectDir };
|
||||
if (targets.length === 0) {
|
||||
io.stderr.write('Missing agent target: pass --target or use interactive setup.\n');
|
||||
io.stderr.write(
|
||||
args.inputMode === 'disabled'
|
||||
? 'Run in a TTY, or pass --target <target>.\n'
|
||||
: 'Missing agent target: pass --target or use interactive setup.\n',
|
||||
);
|
||||
return { status: 'missing-input', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -908,10 +1237,18 @@ export async function runKtxSetupAgentsStep(
|
|||
scopeTargets.length > 0 &&
|
||||
scopeTargets.every(targetSupportsGlobalScope)
|
||||
? ((await prompts.select({
|
||||
message: 'Where should KTX install supported agent config?',
|
||||
message: `Where should KTX install supported agent config?\n\nKTX project: ${resolve(args.projectDir)}`,
|
||||
options: [
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'global', label: 'Global' },
|
||||
{
|
||||
value: 'project',
|
||||
label: 'Project scope (KTX project directory)',
|
||||
hint: 'Only agents opened from this KTX project path load the project-scoped config.',
|
||||
},
|
||||
{
|
||||
value: 'global',
|
||||
label: 'Global scope (user config)',
|
||||
hint: 'Agents can load this KTX project from any working directory.',
|
||||
},
|
||||
],
|
||||
})) as KtxAgentScope | 'back')
|
||||
: args.scope;
|
||||
|
|
@ -921,7 +1258,6 @@ export async function runKtxSetupAgentsStep(
|
|||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices = new Set<string>();
|
||||
let claudeDesktopTutorial: string | undefined;
|
||||
try {
|
||||
for (const install of installs) {
|
||||
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
|
||||
|
|
@ -934,25 +1270,6 @@ export async function runKtxSetupAgentsStep(
|
|||
entries.push(...mcpResult.entries);
|
||||
for (const snippet of mcpResult.snippets) snippets.push(snippet);
|
||||
for (const notice of mcpResult.notices) notices.add(notice);
|
||||
if (install.target === 'claude-desktop') {
|
||||
const pluginEntry = targetEntries.find(
|
||||
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
|
||||
entry.kind === 'file' && entry.role === 'claude-plugin',
|
||||
);
|
||||
const pluginPath = pluginEntry?.path ?? '';
|
||||
const configPath = claudeDesktopConfigPath().path;
|
||||
claudeDesktopTutorial = [
|
||||
`${green('✓')} ${bold('KTX MCP server registered')}`,
|
||||
` ${dim(configPath)}`,
|
||||
'',
|
||||
bold('1. Restart Claude Desktop'),
|
||||
' Quit and reopen so it picks up the new MCP server.',
|
||||
'',
|
||||
bold('2. Install the KTX plugin'),
|
||||
' Open Claude Desktop → Settings → Plugins and install from file:',
|
||||
` 📦 ${dim(pluginPath)}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
await writeManifest(
|
||||
args.projectDir,
|
||||
|
|
@ -960,23 +1277,25 @@ export async function runKtxSetupAgentsStep(
|
|||
);
|
||||
await markAgentsComplete(args.projectDir);
|
||||
const setupUi = createKtxSetupUiAdapter();
|
||||
setupUi.note(
|
||||
formatInstallSummary(installs, entries, args.projectDir),
|
||||
'Agent integration complete',
|
||||
io,
|
||||
);
|
||||
if (claudeDesktopTutorial) {
|
||||
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io, {
|
||||
format: (line) => line,
|
||||
for (const summary of formatInstallSummaryLines(installs, entries, args.projectDir)) {
|
||||
writeSetupStep(
|
||||
io,
|
||||
summary.lines.length > 0 ? `${summary.title}\n${summary.lines.join('\n')}` : summary.title,
|
||||
);
|
||||
}
|
||||
const nextActions = formatAgentNextActions({
|
||||
projectDir: args.projectDir,
|
||||
installs,
|
||||
notices: [...notices],
|
||||
snippets,
|
||||
});
|
||||
if (args.showNextActions !== false) {
|
||||
setupUi.note(nextActions, 'Required before using agents', io, {
|
||||
format: createAgentNextActionsLineFormatter(io.stdout),
|
||||
});
|
||||
writeSetupOutro(io, 'All set.');
|
||||
}
|
||||
const nextStepBlocks: string[] = [];
|
||||
for (const notice of notices) nextStepBlocks.push(notice);
|
||||
for (const snippet of snippets) nextStepBlocks.push(snippet);
|
||||
if (nextStepBlocks.length > 0) {
|
||||
setupUi.note(nextStepBlocks.join('\n\n'), 'Next steps', io, { format: bold });
|
||||
}
|
||||
return { status: 'ready', projectDir: args.projectDir, installs };
|
||||
return { status: 'ready', projectDir: args.projectDir, installs, nextActions };
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
|
|
|
|||
|
|
@ -46,9 +46,14 @@ function makePromptAdapter(options: {
|
|||
};
|
||||
}
|
||||
|
||||
function managedDaemon(baseUrl = 'http://127.0.0.1:61234') {
|
||||
function managedDaemon(
|
||||
baseUrl = 'http://127.0.0.1:61234',
|
||||
logs: { stdoutLog?: string; stderrLog?: string } = {},
|
||||
) {
|
||||
return {
|
||||
baseUrl,
|
||||
stdoutLog: logs.stdoutLog ?? '/tmp/ktx-daemon.stdout.log',
|
||||
stderrLog: logs.stderrLog ?? '/tmp/ktx-daemon.stderr.log',
|
||||
env: {
|
||||
KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: baseUrl,
|
||||
},
|
||||
|
|
@ -324,12 +329,71 @@ describe('setup embeddings step', () => {
|
|||
expect(result.status).toBe('failed');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(config.ingest.embeddings.backend).toBe('deterministic');
|
||||
expect(config.ingest.embeddings.backend).toBe('none');
|
||||
expect(io.stderr()).toContain('Local embedding health check failed: 401 invalid api key [redacted]');
|
||||
expect(io.stderr()).toContain('Prepare the runtime with: ktx dev runtime start --feature local-embeddings');
|
||||
expect(io.stderr()).not.toContain('skip for now');
|
||||
});
|
||||
|
||||
it('prints the recent daemon stderr tail when local embedding health check fails', async () => {
|
||||
const io = makeIo();
|
||||
const stderrLog = join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log');
|
||||
await mkdir(join(tempDir, '.ktx', 'runtime'), { recursive: true });
|
||||
await writeFile(
|
||||
stderrLog,
|
||||
Array.from({ length: 45 }, (_value, index) => `daemon traceback line ${index + 1}`).join('\n'),
|
||||
);
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: {},
|
||||
ensureLocalEmbeddings: vi.fn(async () => managedDaemon('http://127.0.0.1:61234', { stderrLog })),
|
||||
healthCheck: vi.fn(async () => ({ ok: false as const, message: 'HTTP 500' })),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(io.stderr()).toContain('Recent local embeddings daemon stderr:');
|
||||
expect(io.stderr()).toContain('daemon traceback line 6');
|
||||
expect(io.stderr()).toContain('daemon traceback line 45');
|
||||
expect(io.stderr()).not.toContain('daemon traceback line 5');
|
||||
});
|
||||
|
||||
it('does not print daemon stderr diagnostics when the log is unavailable or empty', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: {},
|
||||
ensureLocalEmbeddings: vi.fn(async () =>
|
||||
managedDaemon('http://127.0.0.1:61234', {
|
||||
stderrLog: join(tempDir, '.ktx', 'runtime', 'missing.stderr.log'),
|
||||
}),
|
||||
),
|
||||
healthCheck: vi.fn(async () => ({ ok: false as const, message: 'HTTP 500' })),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(io.stderr()).not.toContain('Recent local embeddings daemon stderr:');
|
||||
});
|
||||
|
||||
it('uses fixed OpenAI defaults and only asks for credentials when OpenAI is selected', async () => {
|
||||
const io = makeIo();
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
|
|
@ -436,7 +500,7 @@ describe('setup embeddings step', () => {
|
|||
expect(result.status).toBe('skipped');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(config.ingest.embeddings.backend).toBe('deterministic');
|
||||
expect(config.ingest.embeddings.backend).toBe('none');
|
||||
});
|
||||
|
||||
it('returns back without writing config when the local health check fails and Back is selected', async () => {
|
||||
|
|
@ -460,7 +524,7 @@ describe('setup embeddings step', () => {
|
|||
|
||||
expect(result.status).toBe('back');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.ingest.embeddings.backend).toBe('deterministic');
|
||||
expect(config.ingest.embeddings.backend).toBe('none');
|
||||
});
|
||||
|
||||
it('preserves already completed embeddings setup when no embedding args request changes', async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { writeFile } from 'node:fs/promises';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
type KtxProjectConfig,
|
||||
|
|
@ -59,6 +59,7 @@ export interface KtxSetupEmbeddingsDeps {
|
|||
healthCheck?: (config: KtxEmbeddingConfig) => Promise<KtxEmbeddingHealthCheckResult>;
|
||||
ensureLocalEmbeddings?: (options: {
|
||||
cliVersion: string;
|
||||
projectDir: string;
|
||||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
}) => Promise<ManagedLocalEmbeddingsDaemon>;
|
||||
|
|
@ -85,6 +86,7 @@ const EMBEDDING_OPTION_PROMPT_CONTEXT =
|
|||
'KTX uses embeddings for semantic search over semantic-layer sources, wiki context, schema metadata, ' +
|
||||
'and relationship evidence.';
|
||||
const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000;
|
||||
const LOCAL_EMBEDDING_STDERR_TAIL_LINES = 40;
|
||||
|
||||
function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
|
||||
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
|
|
@ -94,7 +96,6 @@ async function hasCompletedEmbeddings(projectDir: string, config: KtxProjectConf
|
|||
return (
|
||||
(await readKtxSetupState(projectDir)).completed_steps.includes('embeddings') &&
|
||||
config.ingest.embeddings.backend !== 'none' &&
|
||||
config.ingest.embeddings.backend !== 'deterministic' &&
|
||||
typeof config.ingest.embeddings.model === 'string' &&
|
||||
config.ingest.embeddings.model.length > 0 &&
|
||||
config.ingest.embeddings.dimensions > 0
|
||||
|
|
@ -287,14 +288,33 @@ async function chooseEmbeddingBackend(
|
|||
return 'back';
|
||||
}
|
||||
|
||||
function localEmbeddingSetupMessage(message: string): string {
|
||||
return [
|
||||
async function readLocalEmbeddingDaemonStderrTail(stderrLog: string | undefined): Promise<string[]> {
|
||||
if (!stderrLog) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const lines = (await readFile(stderrLog, 'utf8'))
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0);
|
||||
return lines.slice(-LOCAL_EMBEDDING_STDERR_TAIL_LINES);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function localEmbeddingSetupMessage(message: string, stderrTail: string[] = []): string {
|
||||
const lines = [
|
||||
`Local embedding health check failed: ${message}`,
|
||||
'Local embeddings use the KTX-managed Python runtime.',
|
||||
'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');
|
||||
];
|
||||
if (stderrTail.length > 0) {
|
||||
lines.push('Recent local embeddings daemon stderr:', ...stderrTail);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function promptAfterLocalEmbeddingFailure(
|
||||
|
|
@ -448,9 +468,13 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
}
|
||||
|
||||
progress.fail('Embedding test failed');
|
||||
const stderrTail =
|
||||
selectedBackend === 'sentence-transformers'
|
||||
? await readLocalEmbeddingDaemonStderrTail(managedLocalEmbeddings?.stderrLog)
|
||||
: [];
|
||||
io.stderr.write(
|
||||
selectedBackend === 'sentence-transformers'
|
||||
? `${localEmbeddingSetupMessage(health.message)}\n`
|
||||
? `${localEmbeddingSetupMessage(health.message, stderrTail)}\n`
|
||||
: `Embedding health check failed: ${health.message}\n`,
|
||||
);
|
||||
if (args.inputMode === 'disabled') {
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ describe('setup Anthropic model step', () => {
|
|||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -351,7 +351,7 @@ describe('setup Anthropic model step', () => {
|
|||
llmBackend: 'vertex',
|
||||
vertexProject: 'local-gcp-project',
|
||||
vertexLocation: 'us-east5',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -658,7 +658,7 @@ describe('setup Anthropic model step', () => {
|
|||
llmBackend: 'vertex',
|
||||
vertexProject: 'kaelio-orbit-looker-20260430',
|
||||
vertexLocation: 'us-east5',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -686,7 +686,7 @@ describe('setup Anthropic model step', () => {
|
|||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
anthropicApiKeyFile: secretPath,
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -723,7 +723,7 @@ describe('setup Anthropic model step', () => {
|
|||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
anthropicApiKeyFile: missingSecretPath,
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -1014,7 +1014,7 @@ describe('setup Anthropic model step', () => {
|
|||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -1166,8 +1166,7 @@ describe('setup Anthropic model step', () => {
|
|||
' default: claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' model: deterministic',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
|
|
@ -1209,8 +1208,7 @@ describe('setup Anthropic model step', () => {
|
|||
` default: ${fixture.model}`,
|
||||
'ingest:',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' model: deterministic',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export interface KtxSetupModelArgs {
|
|||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
llmModel?: string;
|
||||
anthropicModel?: string;
|
||||
vertexProject?: string;
|
||||
vertexLocation?: string;
|
||||
forcePrompt?: boolean;
|
||||
|
|
@ -478,14 +477,14 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin
|
|||
if (args.vertexProject || args.vertexLocation) {
|
||||
return 'vertex';
|
||||
}
|
||||
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel || args.anthropicModel) {
|
||||
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel) {
|
||||
return 'anthropic';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function requestedModel(args: KtxSetupModelArgs): string | undefined {
|
||||
return args.llmModel ?? args.anthropicModel;
|
||||
return args.llmModel;
|
||||
}
|
||||
|
||||
async function chooseBackend(
|
||||
|
|
@ -929,7 +928,6 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
!args.anthropicApiKeyEnv &&
|
||||
!args.anthropicApiKeyFile &&
|
||||
!args.llmModel &&
|
||||
!args.anthropicModel &&
|
||||
!args.vertexProject &&
|
||||
!args.vertexLocation
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -55,12 +55,12 @@ describe('setup project step', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a new project with --new and marks the project step complete', async () => {
|
||||
it('creates a new project in non-interactive auto mode with --yes and marks the project step complete', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
const testIo = makeIo();
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir, mode: 'new', inputMode: 'disabled', yes: false },
|
||||
{ projectDir, mode: 'auto', inputMode: 'disabled', yes: true },
|
||||
testIo.io,
|
||||
);
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ describe('setup project step', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('loads an existing project with --existing and drops config setup progress', async () => {
|
||||
it('loads an existing project in auto mode and drops config setup progress', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
|
|
@ -91,7 +91,7 @@ describe('setup project step', () => {
|
|||
);
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir, mode: 'existing', inputMode: 'disabled', yes: false },
|
||||
{ projectDir, mode: 'auto', inputMode: 'disabled', yes: false },
|
||||
makeIo().io,
|
||||
);
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ describe('setup project step', () => {
|
|||
await expect(
|
||||
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: false }, rejectedIo.io),
|
||||
).resolves.toMatchObject({ status: 'missing-input' });
|
||||
expect(rejectedIo.stderr()).toContain('Missing setup choice: pass --new or --yes');
|
||||
expect(rejectedIo.stderr()).toContain('Missing setup choice: pass --yes');
|
||||
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
|
|
@ -121,15 +121,15 @@ describe('setup project step', () => {
|
|||
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('fails --existing clearly when ktx.yaml is missing', async () => {
|
||||
it('fails clearly in no-input auto mode when ktx.yaml is missing and --yes is absent', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupProjectStep({ projectDir, mode: 'existing', inputMode: 'disabled', yes: false }, testIo.io),
|
||||
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: false }, testIo.io),
|
||||
).resolves.toMatchObject({ status: 'missing-input' });
|
||||
|
||||
expect(testIo.stderr()).toContain(`No existing KTX project found at ${projectDir}`);
|
||||
expect(testIo.stderr()).toContain('Missing setup choice: pass --yes');
|
||||
});
|
||||
|
||||
it('prompts to use the current directory and creates a project in interactive auto mode', async () => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export type KtxSetupProjectMode = 'auto' | 'new' | 'existing' | 'prompt-new';
|
||||
export type KtxSetupProjectMode = 'auto' | 'prompt-new';
|
||||
export type KtxSetupInputMode = 'auto' | 'disabled';
|
||||
|
||||
export interface KtxSetupProjectArgs {
|
||||
|
|
@ -29,8 +29,18 @@ export interface KtxSetupProjectArgs {
|
|||
allowBack?: boolean;
|
||||
}
|
||||
|
||||
export type KtxSetupCreatedProjectCleanup =
|
||||
| { kind: 'remove-project-dir'; projectDir: string }
|
||||
| { kind: 'remove-ktx-scaffold'; projectDir: string };
|
||||
|
||||
export type KtxSetupProjectResult =
|
||||
| { status: 'ready'; projectDir: string; project: KtxLocalProject; confirmedCreation?: boolean }
|
||||
| {
|
||||
status: 'ready';
|
||||
projectDir: string;
|
||||
project: KtxLocalProject;
|
||||
confirmedCreation?: boolean;
|
||||
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
|
||||
}
|
||||
| { status: 'back'; projectDir: string }
|
||||
| { status: 'cancelled'; projectDir: string }
|
||||
| { status: 'missing-input'; projectDir: string };
|
||||
|
|
@ -49,7 +59,12 @@ export interface KtxSetupProjectDeps {
|
|||
}
|
||||
|
||||
type PromptProjectDirResult =
|
||||
| { status: 'selected'; projectDir: string; confirmedCreation: boolean }
|
||||
| {
|
||||
status: 'selected';
|
||||
projectDir: string;
|
||||
confirmedCreation: boolean;
|
||||
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
|
||||
}
|
||||
| { status: 'cancelled'; projectDir: string }
|
||||
| { status: 'missing-input'; projectDir: string }
|
||||
| { status: 'back'; projectDir: string };
|
||||
|
|
@ -92,12 +107,29 @@ async function existingFolderState(
|
|||
}
|
||||
|
||||
type ConfirmProjectDirResult =
|
||||
| { status: 'confirmed'; confirmedCreation: boolean }
|
||||
| {
|
||||
status: 'confirmed';
|
||||
confirmedCreation: boolean;
|
||||
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
|
||||
}
|
||||
| { status: 'choose-another' }
|
||||
| { status: 'back' }
|
||||
| { status: 'cancelled' }
|
||||
| { status: 'not-directory' };
|
||||
|
||||
function cleanupForFolderState(
|
||||
projectDir: string,
|
||||
state: Awaited<ReturnType<typeof existingFolderState>>,
|
||||
): KtxSetupCreatedProjectCleanup | undefined {
|
||||
if (state === 'missing') {
|
||||
return { kind: 'remove-project-dir', projectDir };
|
||||
}
|
||||
if (state === 'empty-directory') {
|
||||
return { kind: 'remove-ktx-scaffold', projectDir };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function confirmProjectDir(
|
||||
selectedDir: string,
|
||||
io: KtxCliIo,
|
||||
|
|
@ -137,7 +169,7 @@ async function confirmProjectDir(
|
|||
if (action === 'choose-another') return { status: 'choose-another' };
|
||||
if (action === 'back') return { status: 'back' };
|
||||
if (action !== 'create') return { status: 'cancelled' };
|
||||
return { status: 'confirmed', confirmedCreation: true };
|
||||
return { status: 'confirmed', confirmedCreation: true, createdProjectCleanup: cleanupForFolderState(selectedDir, state) };
|
||||
}
|
||||
|
||||
async function normalizeSetupGitignore(projectDir: string): Promise<void> {
|
||||
|
|
@ -220,10 +252,28 @@ async function promptForNewProjectDir(
|
|||
if (confirmed.status === 'choose-another') continue;
|
||||
if (confirmed.status === 'back') return { status: 'back', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation };
|
||||
return {
|
||||
status: 'selected',
|
||||
projectDir: selectedDir,
|
||||
confirmedCreation: confirmed.confirmedCreation,
|
||||
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function createProjectWithCleanup(
|
||||
projectDir: string,
|
||||
deps: KtxSetupProjectDeps,
|
||||
): Promise<{ project: KtxLocalProject; createdProjectCleanup?: KtxSetupCreatedProjectCleanup }> {
|
||||
const state = await existingFolderState(projectDir);
|
||||
const project = await createProject(projectDir, deps);
|
||||
const createdProjectCleanup = cleanupForFolderState(projectDir, state);
|
||||
return {
|
||||
project,
|
||||
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runKtxSetupProjectStep(
|
||||
args: KtxSetupProjectArgs,
|
||||
io: KtxCliIo,
|
||||
|
|
@ -233,30 +283,14 @@ export async function runKtxSetupProjectStep(
|
|||
const homeDir = deps.homeDir ?? homedir();
|
||||
const exists = hasProjectConfig(projectDir);
|
||||
|
||||
if (args.mode === 'existing') {
|
||||
if (!exists) {
|
||||
io.stderr.write(`No existing KTX project found at ${projectDir}. Pass --new to create it.\n`);
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
const project = await loadExistingProject(projectDir, deps);
|
||||
printProjectSummary(io, projectDir);
|
||||
return { status: 'ready', projectDir, project };
|
||||
}
|
||||
|
||||
if (args.mode === 'new') {
|
||||
const project = await createProject(projectDir, deps);
|
||||
printProjectSummary(io, projectDir);
|
||||
return { status: 'ready', projectDir, project };
|
||||
}
|
||||
|
||||
if (args.mode === 'prompt-new') {
|
||||
if (args.inputMode === 'disabled') {
|
||||
io.stderr.write('Missing new project folder: pass --new --project-dir to create a project without prompts.\n');
|
||||
io.stderr.write('Missing new project folder: pass --project-dir and --yes to create a project without prompts.\n');
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
if (!io.stdout.isTTY && !deps.prompts) {
|
||||
io.stderr.write(
|
||||
'Missing new project folder: pass --new --project-dir to create a project outside an interactive terminal.\n',
|
||||
'Missing new project folder: pass --project-dir and --yes to create a project outside an interactive terminal.\n',
|
||||
);
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
|
|
@ -277,6 +311,7 @@ export async function runKtxSetupProjectStep(
|
|||
projectDir: selected.projectDir,
|
||||
project,
|
||||
confirmedCreation: selected.confirmedCreation,
|
||||
...(selected.createdProjectCleanup ? { createdProjectCleanup: selected.createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -288,16 +323,21 @@ export async function runKtxSetupProjectStep(
|
|||
|
||||
if (args.inputMode === 'disabled') {
|
||||
if (!args.yes) {
|
||||
io.stderr.write('Missing setup choice: pass --new or --yes to create a project in non-interactive setup.\n');
|
||||
io.stderr.write('Missing setup choice: pass --yes to create a project in non-interactive setup.\n');
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
const project = await createProject(projectDir, deps);
|
||||
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
|
||||
printProjectSummary(io, projectDir);
|
||||
return { status: 'ready', projectDir, project };
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir,
|
||||
project,
|
||||
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (!io.stdout.isTTY && !deps.prompts) {
|
||||
io.stderr.write('Missing setup choice: pass --new or --yes to create a project outside an interactive terminal.\n');
|
||||
io.stderr.write('Missing setup choice: pass --yes to create a project outside an interactive terminal.\n');
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -332,9 +372,14 @@ export async function runKtxSetupProjectStep(
|
|||
}
|
||||
|
||||
if (choice === 'current') {
|
||||
const project = await createProject(projectDir, deps);
|
||||
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
|
||||
printProjectSummary(io, projectDir);
|
||||
return { status: 'ready', projectDir, project };
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir,
|
||||
project,
|
||||
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (choice === 'new-default') {
|
||||
|
|
@ -349,6 +394,7 @@ export async function runKtxSetupProjectStep(
|
|||
projectDir: defaultProjectDir,
|
||||
project,
|
||||
confirmedCreation: confirmed.confirmedCreation,
|
||||
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -372,7 +418,13 @@ export async function runKtxSetupProjectStep(
|
|||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
const project = await createProject(customDir, deps);
|
||||
printProjectSummary(io, customDir);
|
||||
return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation };
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir: customDir,
|
||||
project,
|
||||
confirmedCreation: confirmed.confirmedCreation,
|
||||
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
prompts.cancel('Setup cancelled.');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { join } from 'node:path';
|
|||
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
|
||||
import { buildDefaultKtxProjectConfig, readKtxSetupState, type KtxProjectConfig } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ManagedPythonCommandRuntime } from './managed-python-command.js';
|
||||
import { runKtxSetupRuntimeStep } from './setup-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
|
|
@ -43,9 +42,9 @@ describe('runKtxSetupRuntimeStep', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ensures core runtime for agent setup and records the runtime step', async () => {
|
||||
it('skips runtime setup when the project has no direct runtime requirements', async () => {
|
||||
const io = makeIo();
|
||||
const ensureRuntime = vi.fn(async (): Promise<ManagedPythonCommandRuntime> => ({} as ManagedPythonCommandRuntime));
|
||||
const ensureRuntime = vi.fn();
|
||||
|
||||
await expect(
|
||||
runKtxSetupRuntimeStep(
|
||||
|
|
@ -54,7 +53,6 @@ describe('runKtxSetupRuntimeStep', () => {
|
|||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
agents: true,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
|
|
@ -63,17 +61,11 @@ describe('runKtxSetupRuntimeStep', () => {
|
|||
env: {},
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'ready' });
|
||||
).resolves.toMatchObject({ status: 'skipped' });
|
||||
|
||||
expect(ensureRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'prompt',
|
||||
feature: 'core',
|
||||
}),
|
||||
);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('runtime');
|
||||
expect(io.stdout()).toContain('Runtime ready: yes (core)');
|
||||
expect(ensureRuntime).not.toHaveBeenCalled();
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).not.toContain('runtime');
|
||||
expect(io.stdout()).toContain('Runtime setup skipped.');
|
||||
});
|
||||
|
||||
it('fails fast when required runtime features cannot be installed in no-input mode', async () => {
|
||||
|
|
@ -89,7 +81,7 @@ describe('runKtxSetupRuntimeStep', () => {
|
|||
inputMode: 'disabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'never',
|
||||
agents: true,
|
||||
databaseIntrospectionFallback: true,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
|
|
@ -109,6 +101,8 @@ describe('runKtxSetupRuntimeStep', () => {
|
|||
const io = makeIo();
|
||||
const ensureLocalEmbeddings = vi.fn(async () => ({
|
||||
baseUrl: 'http://127.0.0.1:61234',
|
||||
stdoutLog: join(tempDir, '.ktx', 'runtime', 'daemon.stdout.log'),
|
||||
stderrLog: join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log'),
|
||||
env: { KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: 'http://127.0.0.1:61234' },
|
||||
}));
|
||||
const config: KtxProjectConfig = {
|
||||
|
|
@ -131,7 +125,6 @@ describe('runKtxSetupRuntimeStep', () => {
|
|||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
agents: false,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ export interface KtxSetupRuntimeArgs {
|
|||
inputMode: 'auto' | 'disabled';
|
||||
cliVersion: string;
|
||||
runtimeInstallPolicy: KtxManagedPythonInstallPolicy;
|
||||
agents: boolean;
|
||||
databaseIntrospectionFallback?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +61,6 @@ export async function runKtxSetupRuntimeStep(
|
|||
const loadProjectForRuntime = deps.loadProject ?? loadKtxProject;
|
||||
const project = await loadProjectForRuntime({ projectDir: args.projectDir });
|
||||
const requirements = resolveProjectRuntimeRequirements(project.config, {
|
||||
agents: args.agents,
|
||||
databaseIntrospectionFallback: args.databaseIntrospectionFallback,
|
||||
env: deps.env ?? process.env,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
|
@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
|
||||
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
|
||||
import { runDemoTour } from './setup-demo-tour.js';
|
||||
import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
|
||||
import { formatKtxSetupCompletionSummary, formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
|
||||
|
||||
vi.mock('./setup-demo-tour.js', () => ({
|
||||
runDemoTour: vi.fn(async () => 0),
|
||||
|
|
@ -108,7 +108,7 @@ describe('setup status', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reports deterministic default embeddings as not setup-ready', async () => {
|
||||
it('reports disabled default embeddings as not setup-ready', async () => {
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
|
|
@ -122,8 +122,7 @@ describe('setup status', () => {
|
|||
' default: claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' model: deterministic',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
'connections: {}',
|
||||
].join('\n'),
|
||||
|
|
@ -133,7 +132,7 @@ describe('setup status', () => {
|
|||
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
|
||||
project: { path: tempDir, ready: true },
|
||||
llm: { backend: 'anthropic', ready: true, model: 'claude-sonnet-4-6' },
|
||||
embeddings: { backend: 'deterministic', ready: false, model: 'deterministic', dimensions: 8 },
|
||||
embeddings: { backend: 'none', ready: false, dimensions: 8 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -373,8 +372,7 @@ describe('setup status', () => {
|
|||
' default: claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' model: deterministic',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
|
@ -424,6 +422,112 @@ describe('setup status', () => {
|
|||
expect(rendered).not.toContain('No KTX project found.');
|
||||
});
|
||||
|
||||
it('formats a concise ready summary for completed agent setup', () => {
|
||||
const rendered = formatKtxSetupCompletionSummary(
|
||||
{
|
||||
project: { path: tempDir, ready: true },
|
||||
llm: { ready: true, model: 'sonnet' },
|
||||
embeddings: { ready: true, model: 'text-embedding-3-small' },
|
||||
databases: [{ connectionId: 'postgres-warehouse', ready: true }],
|
||||
sources: [{ connectionId: 'dbt-main', type: 'dbt', ready: true }],
|
||||
runtime: { required: true, ready: true, features: ['core'] },
|
||||
context: { ready: true, status: 'completed' },
|
||||
agents: [
|
||||
{ target: 'claude-code', scope: 'project', ready: true },
|
||||
{ target: 'claude-desktop', scope: 'global', ready: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentNextActions: [
|
||||
'1. Start MCP',
|
||||
' Run this command before using Claude Code:',
|
||||
'',
|
||||
' RUN:',
|
||||
` ktx mcp start --project-dir ${tempDir}`,
|
||||
'',
|
||||
' If you need to stop MCP later:',
|
||||
` ktx mcp stop --project-dir ${tempDir}`,
|
||||
'',
|
||||
'2. Open Claude Code',
|
||||
' Open Claude Code from the KTX project directory:',
|
||||
'',
|
||||
' RUN:',
|
||||
` cd '${tempDir}'`,
|
||||
' claude',
|
||||
].join('\n'),
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered).toContain(`Project\n ${tempDir}`);
|
||||
expect(rendered).toContain('Context\n built');
|
||||
expect(rendered).toContain('Agents configured\n Claude Code, Claude Desktop');
|
||||
expect(rendered).toContain('REQUIRED BEFORE USING AGENTS\n\n 1. Start MCP');
|
||||
expect(rendered).toContain(' Run this command before using Claude Code:');
|
||||
expect(rendered).toContain(' RUN:');
|
||||
expect(rendered).toContain(' If you need to stop MCP later:');
|
||||
expect(rendered).toContain(`ktx mcp stop --project-dir ${tempDir}`);
|
||||
expect(rendered).toContain('After that, try\n Ask your agent: "Use KTX to show me the available tables."');
|
||||
expect(rendered).not.toContain('Verify');
|
||||
expect(rendered).not.toContain('Project ready: yes');
|
||||
expect(rendered).not.toContain('What you can do next');
|
||||
});
|
||||
|
||||
it('prints agent next actions inside the final ready summary during full setup', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
target: 'claude-code',
|
||||
skipAgents: false,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
databaseSchemas: [],
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
runtime: async () => runtimeReady(tempDir),
|
||||
context: async () => {
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-test',
|
||||
status: 'completed',
|
||||
primarySourceConnectionIds: [],
|
||||
contextSourceConnectionIds: [],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-test'),
|
||||
});
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'context'] });
|
||||
return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const output = testIo.stdout();
|
||||
expect(output).toContain('Claude Code · Project scope');
|
||||
expect(output).toContain(join(tempDir, '.mcp.json'));
|
||||
expect(output).toContain('Requires MCP to be started.');
|
||||
expect(output).toContain('Analytics skill installed.');
|
||||
expect(output).not.toContain('Agent integration complete');
|
||||
expect(output).toContain('Finish KTX agent setup');
|
||||
expect(output).not.toContain('KTX project ready');
|
||||
expect(output).toContain('REQUIRED BEFORE USING AGENTS');
|
||||
expect(output).toContain('Run this command before using Claude Code:');
|
||||
expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`);
|
||||
expect(output).not.toContain('Finish agent setup');
|
||||
});
|
||||
|
||||
it('prints the setup shell intro for auto-created run mode', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
|
|
@ -459,6 +563,112 @@ describe('setup status', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('removes a newly created missing project directory when a later runtime step fails', async () => {
|
||||
const projectDir = join(tempDir, 'missing-project');
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
model: async () => ({ status: 'skipped', projectDir }),
|
||||
embeddings: async () => ({ status: 'skipped', projectDir }),
|
||||
databases: async () => ({ status: 'skipped', projectDir }),
|
||||
sources: async () => ({ status: 'skipped', projectDir }),
|
||||
runtime: async () => ({ status: 'failed', projectDir, requirements: { features: ['core'], requirements: [] } }),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
await expect(stat(projectDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('removes KTX scaffold files from an initially empty project directory when runtime setup fails', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
model: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
databases: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
sources: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
runtime: async () => ({ status: 'failed', projectDir: tempDir, requirements: { features: ['core'], requirements: [] } }),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
await expect(stat(tempDir)).resolves.toBeDefined();
|
||||
expect(await readdir(tempDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves a pre-existing non-empty project directory when runtime setup fails', async () => {
|
||||
await writeFile(join(tempDir, 'notes.txt'), 'keep me\n', 'utf-8');
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
model: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
databases: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
sources: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
runtime: async () => ({ status: 'failed', projectDir: tempDir, requirements: { features: ['core'], requirements: [] } }),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
await expect(readFile(join(tempDir, 'notes.txt'), 'utf-8')).resolves.toBe('keep me\n');
|
||||
await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('shows demo near the bottom of the first setup intent menu before project creation', async () => {
|
||||
const testIo = makeIo();
|
||||
const select = vi.fn(async (options: { options: Array<{ value: string; label: string }> }) => {
|
||||
|
|
@ -843,7 +1053,7 @@ describe('setup status', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('creates a project through run mode when --new is selected', async () => {
|
||||
it('creates a project through run mode when --yes is selected', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
|
|
@ -851,11 +1061,11 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
|
|
@ -943,14 +1153,14 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -967,7 +1177,7 @@ describe('setup status', () => {
|
|||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
}),
|
||||
testIo.io,
|
||||
|
|
@ -983,16 +1193,16 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
llmBackend: 'vertex',
|
||||
vertexProject: 'local-gcp-project',
|
||||
vertexLocation: 'us-east5',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -1011,7 +1221,7 @@ describe('setup status', () => {
|
|||
llmBackend: 'vertex',
|
||||
vertexProject: 'local-gcp-project',
|
||||
vertexLocation: 'us-east5',
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
}),
|
||||
testIo.io,
|
||||
|
|
@ -1028,14 +1238,14 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
embeddingBackend: 'openai',
|
||||
embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
|
||||
|
|
@ -1066,13 +1276,14 @@ describe('setup status', () => {
|
|||
it('passes no-input runtime policy to the embeddings step', async () => {
|
||||
const io = makeIo();
|
||||
const embeddings = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
skipAgents: true,
|
||||
|
|
@ -1103,13 +1314,14 @@ describe('setup status', () => {
|
|||
const io = makeIo();
|
||||
const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));
|
||||
const context = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
skipAgents: true,
|
||||
|
|
@ -1148,6 +1360,7 @@ describe('setup status', () => {
|
|||
|
||||
it('lets Back from embedding setup return to the model step instead of exiting', async () => {
|
||||
const testIo = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
const modelResults = [
|
||||
{ status: 'ready' as const, projectDir: tempDir },
|
||||
{ status: 'back' as const, projectDir: tempDir },
|
||||
|
|
@ -1160,7 +1373,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
|
|
@ -1184,6 +1397,7 @@ describe('setup status', () => {
|
|||
|
||||
it('lets Back from database selection return to embedding setup', async () => {
|
||||
const testIo = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
const modelResults = [
|
||||
{ status: 'ready' as const, projectDir: tempDir },
|
||||
{ status: 'back' as const, projectDir: tempDir },
|
||||
|
|
@ -1207,11 +1421,11 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
|
|
@ -1254,7 +1468,7 @@ describe('setup status', () => {
|
|||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: true,
|
||||
|
|
@ -1291,14 +1505,14 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
embeddingBackend: 'openai',
|
||||
embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
|
||||
|
|
@ -1349,7 +1563,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
|
|
@ -1425,7 +1639,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
|
|
@ -1475,7 +1689,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
|
|
@ -1523,7 +1737,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
|
|
@ -1584,7 +1798,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
|
|
@ -1631,7 +1845,7 @@ describe('setup status', () => {
|
|||
expect(committedConfig.stdout).toContain('warehouse:');
|
||||
});
|
||||
|
||||
it('runs agent setup after context succeeds in --agents mode', async () => {
|
||||
it('runs agent setup without runtime or context in --agents mode', async () => {
|
||||
const calls: string[] = [];
|
||||
const io = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
|
@ -1641,7 +1855,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: true,
|
||||
target: 'codex',
|
||||
agentScope: 'project',
|
||||
|
|
@ -1663,11 +1877,11 @@ describe('setup status', () => {
|
|||
sources: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
runtime: async () => {
|
||||
calls.push('runtime');
|
||||
return runtimeReady(tempDir);
|
||||
throw new Error('runtime should not run');
|
||||
},
|
||||
context: async () => {
|
||||
calls.push('context');
|
||||
return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
|
||||
throw new Error('context should not run');
|
||||
},
|
||||
agents: async () => {
|
||||
calls.push('agents');
|
||||
|
|
@ -1681,11 +1895,13 @@ describe('setup status', () => {
|
|||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(calls).toEqual(['runtime', 'context', 'agents']);
|
||||
expect(calls).toEqual(['agents']);
|
||||
});
|
||||
|
||||
it('does not install agents when non-interactive --agents finds context incomplete', async () => {
|
||||
it('installs agents when non-interactive --agents finds context incomplete', async () => {
|
||||
const io = makeIo();
|
||||
const runtime = vi.fn(async () => runtimeReady(tempDir));
|
||||
const context = vi.fn(async () => ({ status: 'skipped' as const, projectDir: tempDir }));
|
||||
const agents = vi.fn(async () => ({
|
||||
status: 'ready' as const,
|
||||
projectDir: tempDir,
|
||||
|
|
@ -1698,7 +1914,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: true,
|
||||
target: 'codex',
|
||||
agentScope: 'project',
|
||||
|
|
@ -1714,15 +1930,64 @@ describe('setup status', () => {
|
|||
},
|
||||
io.io,
|
||||
{
|
||||
runtime: async () => runtimeReady(tempDir),
|
||||
context: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
runtime,
|
||||
context,
|
||||
agents,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(agents).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('KTX context is not ready for agents.');
|
||||
expect(runtime).not.toHaveBeenCalled();
|
||||
expect(context).not.toHaveBeenCalled();
|
||||
expect(agents).toHaveBeenCalledTimes(1);
|
||||
expect(io.stderr()).not.toContain('KTX context is not ready for agents.');
|
||||
});
|
||||
|
||||
it('runs non-TTY --agents with a target without requiring --no-input or --yes', async () => {
|
||||
const io = makeIo();
|
||||
const agents = vi.fn(async () => ({
|
||||
status: 'ready' as const,
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'claude-code' as const, scope: 'project' as const, mode: 'mcp' as const }],
|
||||
}));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
agentScope: 'project',
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
skipDatabases: false,
|
||||
skipSources: false,
|
||||
skipAgents: false,
|
||||
databaseSchemas: [],
|
||||
},
|
||||
io.io,
|
||||
{ agents },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(agents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
}),
|
||||
io.io,
|
||||
);
|
||||
expect(io.stderr()).not.toContain('Interactive setup requires a terminal');
|
||||
});
|
||||
|
||||
it('routes a ready project menu selection to agent setup', async () => {
|
||||
|
|
@ -1789,7 +2054,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
|
|
@ -1843,7 +2108,7 @@ describe('setup status', () => {
|
|||
}
|
||||
}
|
||||
|
||||
expect(calls).toEqual(['runtime', 'agents']);
|
||||
expect(calls).toEqual(['agents']);
|
||||
});
|
||||
|
||||
it('skips to agent setup when context is ready but agents are not configured', async () => {
|
||||
|
|
@ -1892,7 +2157,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'existing',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
|
|
@ -1940,10 +2205,10 @@ describe('setup status', () => {
|
|||
).resolves.toBe(0);
|
||||
|
||||
expect(readyMenuSelect).not.toHaveBeenCalled();
|
||||
expect(calls).toEqual(['runtime', 'agents']);
|
||||
expect(calls).toEqual(['agents']);
|
||||
});
|
||||
|
||||
it('runs only project resolution, runtime, context gate, and agent setup in --agents mode', async () => {
|
||||
it('runs only project resolution and agent setup in --agents mode', async () => {
|
||||
const io = makeIo();
|
||||
const runtime = vi.fn(async () => runtimeReady(tempDir));
|
||||
const context = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, runId: 'setup-context-local-test' }));
|
||||
|
|
@ -1958,7 +2223,7 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: true,
|
||||
target: 'universal',
|
||||
agentScope: 'project',
|
||||
|
|
@ -1984,8 +2249,8 @@ describe('setup status', () => {
|
|||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runtime).toHaveBeenCalledTimes(1);
|
||||
expect(context).toHaveBeenCalledTimes(1);
|
||||
expect(runtime).not.toHaveBeenCalled();
|
||||
expect(context).not.toHaveBeenCalled();
|
||||
expect(agents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -1999,14 +2264,14 @@ describe('setup status', () => {
|
|||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: false,
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
|
||||
anthropicModel: 'claude-sonnet-4-6',
|
||||
llmModel: 'claude-sonnet-4-6',
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
|
|
@ -2017,6 +2282,7 @@ describe('setup status', () => {
|
|||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(model).toHaveBeenCalledTimes(1);
|
||||
expect(embeddings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
|
||||
import {
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
type KtxSetupAgentsDeps,
|
||||
readKtxAgentInstallManifest,
|
||||
runKtxSetupAgentsStep,
|
||||
targetDisplayName,
|
||||
} from './setup-agents.js';
|
||||
import {
|
||||
type KtxSetupDatabaseDriver,
|
||||
|
|
@ -32,7 +34,11 @@ import {
|
|||
isKtxSetupLlmConfigReady,
|
||||
runKtxSetupAnthropicModelStep,
|
||||
} from './setup-models.js';
|
||||
import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
|
||||
import {
|
||||
type KtxSetupCreatedProjectCleanup,
|
||||
type KtxSetupProjectDeps,
|
||||
runKtxSetupProjectStep,
|
||||
} from './setup-project.js';
|
||||
import {
|
||||
isKtxPreAgentSetupReady,
|
||||
isKtxSetupReady,
|
||||
|
|
@ -75,7 +81,7 @@ export type KtxSetupArgs =
|
|||
| {
|
||||
command: 'run';
|
||||
projectDir: string;
|
||||
mode: 'auto' | 'new' | 'existing';
|
||||
mode: 'auto';
|
||||
agents: boolean;
|
||||
target?: KtxAgentTarget;
|
||||
agentScope?: KtxAgentScope;
|
||||
|
|
@ -87,7 +93,6 @@ export type KtxSetupArgs =
|
|||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
llmModel?: string;
|
||||
anthropicModel?: string;
|
||||
vertexProject?: string;
|
||||
vertexLocation?: string;
|
||||
skipLlm: boolean;
|
||||
|
|
@ -237,7 +242,6 @@ function embeddingsReady(status: KtxSetupStatus['embeddings']): boolean {
|
|||
return (
|
||||
status.backend !== undefined &&
|
||||
status.backend !== 'none' &&
|
||||
status.backend !== 'deterministic' &&
|
||||
typeof status.model === 'string' &&
|
||||
status.model.length > 0 &&
|
||||
typeof status.dimensions === 'number' &&
|
||||
|
|
@ -338,7 +342,6 @@ export async function readKtxSetupStatus(
|
|||
}
|
||||
const agents = [...agentMap.values()];
|
||||
const runtimeRequirements = resolveProjectRuntimeRequirements(project.config, {
|
||||
agents: agents.length > 0,
|
||||
env: options.env ?? process.env,
|
||||
});
|
||||
let runtimeReady = runtimeRequirements.features.length === 0 || completedSteps.includes('runtime');
|
||||
|
|
@ -435,6 +438,35 @@ export function formatKtxSetupStatus(status: KtxSetupStatus): string {
|
|||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
export function formatKtxSetupCompletionSummary(
|
||||
status: KtxSetupStatus,
|
||||
options: { agentNextActions?: string } = {},
|
||||
): string {
|
||||
const readyAgents = status.agents.filter((agent) => agent.ready).map((agent) => targetDisplayName(agent.target));
|
||||
const lines = [
|
||||
'Project',
|
||||
` ${status.project.path}`,
|
||||
'',
|
||||
'Context',
|
||||
` ${status.context.ready ? 'built' : formatContextBuilt(status.context)}`,
|
||||
'',
|
||||
'Agents configured',
|
||||
` ${readyAgents.length > 0 ? readyAgents.join(', ') : 'not installed'}`,
|
||||
];
|
||||
const agentNextActions = options.agentNextActions?.trim();
|
||||
if (agentNextActions) {
|
||||
lines.push(
|
||||
'',
|
||||
'REQUIRED BEFORE USING AGENTS',
|
||||
'',
|
||||
...agentNextActions.split('\n').map((line) => (line ? ` ${line}` : '')),
|
||||
);
|
||||
}
|
||||
lines.push('', agentNextActions ? 'After that, try' : 'Try it');
|
||||
lines.push(' Ask your agent: "Use KTX to show me the available tables."');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function setupStatusReady(status: KtxSetupStatus): boolean {
|
||||
if (!status.project.ready) {
|
||||
return false;
|
||||
|
|
@ -459,10 +491,8 @@ function setupContextReady(status: KtxSetupStatus): boolean {
|
|||
return status.context.ready;
|
||||
}
|
||||
|
||||
function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
|
||||
io.stderr.write('KTX context is not ready for agents.\n\n');
|
||||
io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
|
||||
io.stderr.write(`Then install agent integration:\n ktx setup --agents --project-dir ${resolve(projectDir)}\n`);
|
||||
function shouldPrintConciseReadySummary(status: KtxSetupStatus): boolean {
|
||||
return setupStatusReady(status) && setupContextReady(status) && status.agents.some((agent) => agent.ready);
|
||||
}
|
||||
|
||||
function setupRuntimeInstallPolicy(args: Extract<KtxSetupArgs, { command: 'run' }>): 'prompt' | 'auto' | 'never' {
|
||||
|
|
@ -477,6 +507,23 @@ async function commitSetupConfigChanges(projectDir: string): Promise<void> {
|
|||
await project.git.commitFile('ktx.yaml', 'setup: update KTX project config', 'ktx setup', 'setup@ktx.local');
|
||||
}
|
||||
|
||||
const KTX_SETUP_SCAFFOLD_PATHS = ['ktx.yaml', '.ktx', 'wiki', 'semantic-layer', 'raw-sources', '.git'];
|
||||
|
||||
async function cleanupCreatedProjectScaffold(cleanup: KtxSetupCreatedProjectCleanup | undefined): Promise<void> {
|
||||
if (!cleanup) {
|
||||
return;
|
||||
}
|
||||
if (cleanup.kind === 'remove-project-dir') {
|
||||
await rm(cleanup.projectDir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
KTX_SETUP_SCAFFOLD_PATHS.map((relativePath) =>
|
||||
rm(join(cleanup.projectDir, relativePath), { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
|
||||
try {
|
||||
return await runKtxSetupInner(args, io, deps);
|
||||
|
|
@ -493,6 +540,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
setupUi.intro('KTX setup', io);
|
||||
let entryAction: KtxSetupEntryAction | undefined;
|
||||
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;
|
||||
let agentNextActions: string | undefined;
|
||||
const canShowEntryMenu =
|
||||
args.showEntryMenu === true &&
|
||||
args.inputMode !== 'disabled' &&
|
||||
|
|
@ -552,18 +600,19 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
}
|
||||
|
||||
const runOnly = readyAction;
|
||||
const agentOnlySetup = agentsRequested || runOnly === 'agents';
|
||||
const shouldRunModels = !runOnly || runOnly === 'models';
|
||||
const shouldRunEmbeddings = !runOnly || runOnly === 'embeddings';
|
||||
const shouldRunDatabases = !runOnly || runOnly === 'databases';
|
||||
const shouldRunSources = !runOnly || runOnly === 'sources';
|
||||
const shouldRunRuntime =
|
||||
agentsRequested || !runOnly || runOnly === 'runtime' || runOnly === 'context' || runOnly === 'agents';
|
||||
const shouldRunContext = agentsRequested || !runOnly || runOnly === 'context';
|
||||
!agentOnlySetup && (!runOnly || runOnly === 'runtime' || runOnly === 'context');
|
||||
const shouldRunContext = !agentOnlySetup && (!runOnly || runOnly === 'context');
|
||||
const shouldRunAgents = agentsRequested || !runOnly || runOnly === 'agents';
|
||||
const showPromptInstructions = projectResult.confirmedCreation !== true;
|
||||
|
||||
const setupSteps: KtxSetupFlowStep[] = agentsRequested
|
||||
? ['runtime', 'context']
|
||||
const setupSteps: KtxSetupFlowStep[] = agentOnlySetup
|
||||
? []
|
||||
: ['models', 'embeddings', 'databases', 'sources', 'runtime', 'context'];
|
||||
if (shouldRunAgents && args.skipAgents !== true) {
|
||||
setupSteps.push('agents');
|
||||
|
|
@ -605,7 +654,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}),
|
||||
...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}),
|
||||
...(args.llmModel ? { llmModel: args.llmModel } : {}),
|
||||
...(args.anthropicModel ? { anthropicModel: args.anthropicModel } : {}),
|
||||
...(args.vertexProject ? { vertexProject: args.vertexProject } : {}),
|
||||
...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}),
|
||||
forcePrompt: forcePromptSteps.has('models') || runOnly === 'models',
|
||||
|
|
@ -702,7 +750,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
inputMode: args.inputMode,
|
||||
cliVersion: args.cliVersion,
|
||||
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
|
||||
agents: shouldRunAgents && args.skipAgents !== true,
|
||||
},
|
||||
io,
|
||||
);
|
||||
|
|
@ -724,22 +771,34 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
} else {
|
||||
const agentsRunner =
|
||||
deps.agents ?? ((agentArgs, agentIo) => runKtxSetupAgentsStep(agentArgs, agentIo, deps.agentsDeps));
|
||||
stepResult = await agentsRunner(
|
||||
const agentResult = await agentsRunner(
|
||||
{
|
||||
projectDir: projectResult.projectDir,
|
||||
inputMode: args.inputMode,
|
||||
inputMode:
|
||||
args.inputMode === 'auto' && io.stdout.isTTY !== true && deps.agentsDeps?.prompts === undefined
|
||||
? 'disabled'
|
||||
: args.inputMode,
|
||||
yes: args.yes,
|
||||
agents: true,
|
||||
...(args.target ? { target: args.target } : {}),
|
||||
scope: args.agentScope ?? 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
showNextActions: agentsRequested,
|
||||
},
|
||||
io,
|
||||
);
|
||||
stepResult = agentResult;
|
||||
if (agentResult.status === 'ready') {
|
||||
agentNextActions = agentResult.nextActions;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepResult.status === 'failed' || stepResult.status === 'missing-input') {
|
||||
if (stepResult.status === 'failed') {
|
||||
await cleanupCreatedProjectScaffold(projectResult.createdProjectCleanup);
|
||||
return 1;
|
||||
}
|
||||
if (stepResult.status === 'missing-input') {
|
||||
return 1;
|
||||
}
|
||||
if (stepResult.status === 'back') {
|
||||
|
|
@ -759,10 +818,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
}
|
||||
if (step === 'context' && stepResult.status !== 'ready') {
|
||||
if (shouldRunAgents && args.skipAgents !== true) {
|
||||
if (agentsRequested) {
|
||||
writeContextNotReadyForAgents(projectResult.projectDir, io);
|
||||
return args.inputMode === 'disabled' ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -779,19 +834,30 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
const status = await readKtxSetupStatus(projectResult.projectDir, { cliVersion: args.cliVersion });
|
||||
const focusedOnAgents = args.agents || entryAction === 'agents';
|
||||
if (!focusedOnAgents) {
|
||||
setupUi.note(formatKtxSetupStatus(status).trimEnd(), 'Project status', io, {
|
||||
format: (line) => line,
|
||||
});
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
if (shouldPrintConciseReadySummary(status)) {
|
||||
setupUi.note(
|
||||
formatKtxSetupCompletionSummary(status, { agentNextActions }),
|
||||
agentNextActions ? 'Finish KTX agent setup' : 'KTX project ready',
|
||||
io,
|
||||
{
|
||||
format: (line) => line,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setupUi.note(formatKtxSetupStatus(status).trimEnd(), 'Project status', io, {
|
||||
format: (line) => line,
|
||||
});
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ describe('runKtxSl', () => {
|
|||
yaml: [
|
||||
'name: orders',
|
||||
'table: public.orders',
|
||||
'description: Paid order facts',
|
||||
'descriptions:',
|
||||
' user: Paid order facts',
|
||||
'grain: [order_id]',
|
||||
'columns:',
|
||||
' - name: order_id',
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ async function writeSqliteScanConfig(projectDir: string, dbPath: string, enrich
|
|||
' enrichment:',
|
||||
' mode: deterministic',
|
||||
' embeddings:',
|
||||
' backend: deterministic',
|
||||
' backend: none',
|
||||
' dimensions: 6',
|
||||
]
|
||||
: []),
|
||||
|
|
@ -116,7 +116,6 @@ async function runSetupNewProject(projectDir: string): Promise<CliResult> {
|
|||
'setup',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--new',
|
||||
'--no-input',
|
||||
'--yes',
|
||||
'--skip-llm',
|
||||
|
|
@ -166,7 +165,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
});
|
||||
|
||||
it('runs status setup checks through the built binary', async () => {
|
||||
const result = await runBuiltCli(['status', '--verbose', '--no-input']);
|
||||
const result = await runBuiltCli(['status', '--verbose', '--no-input'], { cwd: tempDir });
|
||||
|
||||
expect(result.stdout).toMatch(/KTX status/);
|
||||
if (result.stdout.includes('No project here yet.')) {
|
||||
|
|
|
|||
|
|
@ -242,15 +242,6 @@ function buildEmbeddingsStatus(config: KtxProjectEmbeddingConfig, env: NodeJS.Pr
|
|||
detail: 'disabled — semantic search will be skipped',
|
||||
};
|
||||
}
|
||||
if (backend === 'deterministic') {
|
||||
return {
|
||||
backend,
|
||||
model,
|
||||
dimensions,
|
||||
status: 'warn',
|
||||
detail: 'deterministic — semantic search degraded (lexical/dictionary lanes still work)',
|
||||
};
|
||||
}
|
||||
if (backend === 'openai') {
|
||||
const ref = config.openai?.api_key;
|
||||
const resolved = resolveRef(ref, env);
|
||||
|
|
@ -645,7 +636,7 @@ function buildVerdict(
|
|||
const reasons: string[] = [];
|
||||
if (llm.status === 'warn') reasons.push('LLM credentials missing');
|
||||
if (embeddings.status === 'warn') {
|
||||
if (embeddings.backend === 'deterministic' || embeddings.backend === 'none') {
|
||||
if (embeddings.backend === 'none') {
|
||||
reasons.push('semantic search disabled');
|
||||
} else {
|
||||
reasons.push('embedding credentials missing');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue