diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index f96e6f1c..8b517a3b 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -4,6 +4,7 @@ import { registerAgentCommands } from './commands/agent-commands.js'; import { registerConnectionCommands } from './commands/connection-commands.js'; import { registerWikiCommands } from './commands/knowledge-commands.js'; import { registerPublicIngestCommands } from './commands/public-ingest-commands.js'; +import { registerRuntimeCommands } from './commands/runtime-commands.js'; import { registerServeCommands } from './commands/serve-commands.js'; import { registerSetupCommands } from './commands/setup-commands.js'; import { registerSlCommands } from './commands/sl-commands.js'; @@ -17,6 +18,7 @@ profileMark('module:cli-program'); export interface KtxCliCommandContext { io: KtxCliIo; deps: KtxCliDeps; + packageInfo: KtxCliPackageInfo; setExitCode: (code: number) => void; runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void; @@ -205,6 +207,7 @@ export async function runCommanderKtxCli( const context: KtxCliCommandContext = { io, deps, + packageInfo: info, setExitCode: (code: number) => { exitCode = code; }, @@ -229,6 +232,9 @@ export async function runCommanderKtxCli( registerSlCommands(program, context); profileMark('commander:register-sl'); + registerRuntimeCommands(program, context); + profileMark('commander:register-runtime'); + registerServeCommands(program, context); profileMark('commander:register-serve'); diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index 60129922..d933ac97 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -7,6 +7,7 @@ import type { KtxDoctorArgs } from './doctor.js'; import type { KtxIngestArgs } from './ingest.js'; import type { KtxKnowledgeArgs } from './knowledge.js'; import type { KtxPublicIngestArgs } from './public-ingest.js'; +import type { KtxRuntimeArgs } from './runtime.js'; import type { KtxScanArgs } from './scan.js'; import type { KtxServeArgs } from './serve.js'; import type { KtxSetupArgs } from './setup.js'; @@ -37,6 +38,7 @@ export interface KtxCliDeps { doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise; ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise; publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise; + runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise; scan?: (args: KtxScanArgs, io: KtxCliIo) => Promise; knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise; sl?: (args: KtxSlArgs, io: KtxCliIo) => Promise; diff --git a/packages/cli/src/commands/runtime-commands.ts b/packages/cli/src/commands/runtime-commands.ts new file mode 100644 index 00000000..53a60fb0 --- /dev/null +++ b/packages/cli/src/commands/runtime-commands.ts @@ -0,0 +1,73 @@ +import { type Command, Option } from '@commander-js/extra-typings'; +import type { KtxCliCommandContext } from '../cli-program.js'; +import type { KtxRuntimeArgs } from '../runtime.js'; + +type RuntimeFeature = Extract['feature']; + +const runtimeFeatureOption = new Option('--feature ', 'Runtime feature level') + .choices(['core', 'local-embeddings']) + .default('core'); + +async function runRuntimeArgs(context: KtxCliCommandContext, args: KtxRuntimeArgs): Promise { + const runner = context.deps.runtime ?? (await import('../runtime.js')).runKtxRuntime; + context.setExitCode(await runner(args, context.io)); +} + +export function registerRuntimeCommands(program: Command, context: KtxCliCommandContext): void { + const runtime = program + .command('runtime') + .description('Install, inspect, and prune the KTX-managed Python runtime') + .showHelpAfterError(); + + runtime + .command('install') + .description('Install the bundled Python runtime wheel into the managed runtime') + .addOption(runtimeFeatureOption) + .option('--force', 'Reinstall even when the runtime already looks ready', false) + .action(async (options: { feature: RuntimeFeature; force?: boolean }) => { + await runRuntimeArgs(context, { + command: 'install', + cliVersion: context.packageInfo.version, + feature: options.feature, + force: options.force === true, + }); + }); + + runtime + .command('status') + .description('Show managed Python runtime status') + .option('--json', 'Print JSON output', false) + .action(async (options: { json?: boolean }) => { + await runRuntimeArgs(context, { + command: 'status', + cliVersion: context.packageInfo.version, + json: options.json === true, + }); + }); + + runtime + .command('doctor') + .description('Check managed Python runtime prerequisites and installation') + .option('--json', 'Print JSON output', false) + .action(async (options: { json?: boolean }) => { + await runRuntimeArgs(context, { + command: 'doctor', + cliVersion: context.packageInfo.version, + json: options.json === true, + }); + }); + + runtime + .command('prune') + .description('Remove stale managed Python runtimes for older CLI versions') + .option('--dry-run', 'List stale runtimes without deleting them', false) + .option('--yes', 'Confirm deletion of stale runtime directories', false) + .action(async (options: { dryRun?: boolean; yes?: boolean }) => { + await runRuntimeArgs(context, { + command: 'prune', + cliVersion: context.packageInfo.version, + dryRun: options.dryRun === true, + yes: options.yes === true, + }); + }); +} diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index aa751925..2e23216a 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -108,7 +108,7 @@ describe('runKtxCli', () => { await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx [options] [command]'); - for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'serve', 'status']) { + for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'runtime', 'serve', 'status']) { expect(testIo.stdout()).toContain(`${command}`); } for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion']) { @@ -124,6 +124,60 @@ describe('runKtxCli', () => { expect(testIo.stderr()).toBe(''); }); + it('routes runtime management commands with the CLI package version', async () => { + const runtime = vi.fn(async () => 0); + const installIo = makeIo(); + const statusIo = makeIo(); + const doctorIo = makeIo(); + const pruneIo = makeIo(); + + await expect( + runKtxCli(['runtime', 'install', '--feature', 'local-embeddings', '--force'], installIo.io, { runtime }), + ).resolves.toBe(0); + await expect(runKtxCli(['runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0); + await expect(runKtxCli(['runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0); + await expect(runKtxCli(['runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0); + + expect(runtime).toHaveBeenNthCalledWith( + 1, + { + command: 'install', + cliVersion: '0.0.0-private', + feature: 'local-embeddings', + force: true, + }, + installIo.io, + ); + expect(runtime).toHaveBeenNthCalledWith( + 2, + { + command: 'status', + cliVersion: '0.0.0-private', + json: true, + }, + statusIo.io, + ); + expect(runtime).toHaveBeenNthCalledWith( + 3, + { + command: 'doctor', + cliVersion: '0.0.0-private', + json: false, + }, + doctorIo.io, + ); + expect(runtime).toHaveBeenNthCalledWith( + 4, + { + command: 'prune', + cliVersion: '0.0.0-private', + dryRun: true, + yes: false, + }, + pruneIo.io, + ); + }); + it('exposes demo under setup help instead of root help', async () => { const testIo = makeIo(); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6018c28c..d9062f85 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -42,6 +42,7 @@ export type { KtxSetupSourceType, } from './setup-sources.js'; export { runKtxSetupSourcesStep } from './setup-sources.js'; +export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runtime.js'; export type { KtxMemoryFlowTuiIo, MemoryFlowTuiLiveSession } from './memory-flow-tui.js'; export { renderMemoryFlowTui,