diff --git a/packages/cli/src/commands/runtime-commands.ts b/packages/cli/src/commands/runtime-commands.ts index 53a60fb0..8f478658 100644 --- a/packages/cli/src/commands/runtime-commands.ts +++ b/packages/cli/src/commands/runtime-commands.ts @@ -4,9 +4,11 @@ import type { KtxRuntimeArgs } from '../runtime.js'; type RuntimeFeature = Extract['feature']; -const runtimeFeatureOption = new Option('--feature ', 'Runtime feature level') - .choices(['core', 'local-embeddings']) - .default('core'); +function createRuntimeFeatureOption() { + return 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; @@ -22,9 +24,10 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand runtime .command('install') .description('Install the bundled Python runtime wheel into the managed runtime') - .addOption(runtimeFeatureOption) + .addOption(createRuntimeFeatureOption()) + .option('--yes', 'Accept runtime installation without prompting', false) .option('--force', 'Reinstall even when the runtime already looks ready', false) - .action(async (options: { feature: RuntimeFeature; force?: boolean }) => { + .action(async (options: { feature: RuntimeFeature; yes?: boolean; force?: boolean }) => { await runRuntimeArgs(context, { command: 'install', cliVersion: context.packageInfo.version, @@ -33,6 +36,30 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand }); }); + runtime + .command('start') + .description('Start the KTX-managed Python HTTP daemon') + .addOption(createRuntimeFeatureOption()) + .option('--force', 'Restart even when a matching daemon is already running', false) + .action(async (options: { feature: RuntimeFeature; force?: boolean }) => { + await runRuntimeArgs(context, { + command: 'start', + cliVersion: context.packageInfo.version, + feature: options.feature, + force: options.force === true, + }); + }); + + runtime + .command('stop') + .description('Stop the KTX-managed Python HTTP daemon') + .action(async () => { + await runRuntimeArgs(context, { + command: 'stop', + cliVersion: context.packageInfo.version, + }); + }); + runtime .command('status') .description('Show managed Python runtime status') diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 7019f8d6..4f3a0a59 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -127,13 +127,21 @@ describe('runKtxCli', () => { it('routes runtime management commands with the CLI package version', async () => { const runtime = vi.fn(async () => 0); const installIo = makeIo(); + const startIo = makeIo(); + const stopIo = makeIo(); const statusIo = makeIo(); const doctorIo = makeIo(); const pruneIo = makeIo(); await expect( - runKtxCli(['runtime', 'install', '--feature', 'local-embeddings', '--force'], installIo.io, { runtime }), + runKtxCli(['runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, { + runtime, + }), ).resolves.toBe(0); + await expect( + runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }), + ).resolves.toBe(0); + await expect(runKtxCli(['runtime', 'stop'], stopIo.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); @@ -150,6 +158,24 @@ describe('runKtxCli', () => { ); expect(runtime).toHaveBeenNthCalledWith( 2, + { + command: 'start', + cliVersion: '0.0.0-private', + feature: 'local-embeddings', + force: true, + }, + startIo.io, + ); + expect(runtime).toHaveBeenNthCalledWith( + 3, + { + command: 'stop', + cliVersion: '0.0.0-private', + }, + stopIo.io, + ); + expect(runtime).toHaveBeenNthCalledWith( + 4, { command: 'status', cliVersion: '0.0.0-private', @@ -158,7 +184,7 @@ describe('runKtxCli', () => { statusIo.io, ); expect(runtime).toHaveBeenNthCalledWith( - 3, + 5, { command: 'doctor', cliVersion: '0.0.0-private', @@ -167,7 +193,7 @@ describe('runKtxCli', () => { doctorIo.io, ); expect(runtime).toHaveBeenNthCalledWith( - 4, + 6, { command: 'prune', cliVersion: '0.0.0-private', diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d9062f85..38277bb4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,6 +43,18 @@ export type { } from './setup-sources.js'; export { runKtxSetupSourcesStep } from './setup-sources.js'; export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runtime.js'; +export { + allocateDaemonPort, + readManagedPythonDaemonStatus, + startManagedPythonDaemon, + stopManagedPythonDaemon, +} from './managed-python-daemon.js'; +export type { + ManagedPythonDaemonStartResult, + ManagedPythonDaemonState, + ManagedPythonDaemonStatus, + ManagedPythonDaemonStopResult, +} from './managed-python-daemon.js'; export type { KtxMemoryFlowTuiIo, MemoryFlowTuiLiveSession } from './memory-flow-tui.js'; export { renderMemoryFlowTui, diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index 54de630c..e367d339 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import type { + ManagedPythonDaemonStartResult, + ManagedPythonDaemonStopResult, +} from './managed-python-daemon.js'; import type { ManagedPythonRuntimeDoctorCheck, ManagedPythonRuntimeInstallResult, @@ -106,6 +110,102 @@ describe('runKtxRuntime', () => { expect(io.stderr()).toBe(''); }); + it('starts the managed Python daemon and prints the base URL', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + startDaemon: vi.fn(async (): Promise => ({ + status: 'started', + baseUrl: 'http://127.0.0.1:61234', + layout: { + cliVersion: '0.2.0', + runtimeRoot: '/runtime', + versionDir: '/runtime/0.2.0', + venvDir: '/runtime/0.2.0/.venv', + manifestPath: '/runtime/0.2.0/manifest.json', + installLogPath: '/runtime/0.2.0/install.log', + assetDir: '/assets/python', + assetManifestPath: '/assets/python/manifest.json', + pythonPath: '/runtime/0.2.0/.venv/bin/python', + daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', + daemonStatePath: '/runtime/0.2.0/daemon.json', + daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', + daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', + }, + state: { + schemaVersion: 1, + pid: 4242, + host: '127.0.0.1', + port: 61234, + version: '0.2.0', + features: ['core', 'local-embeddings'], + startedAt: '2026-05-11T00:00:00.000Z', + stdoutLog: '/runtime/0.2.0/daemon.stdout.log', + stderrLog: '/runtime/0.2.0/daemon.stderr.log', + }, + })), + }; + + await expect( + runKtxRuntime( + { command: 'start', cliVersion: '0.2.0', feature: 'local-embeddings', force: true }, + io.io, + deps, + ), + ).resolves.toBe(0); + + expect(deps.startDaemon).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + features: ['local-embeddings'], + force: true, + }); + expect(io.stdout()).toContain('Started KTX Python daemon'); + expect(io.stdout()).toContain('url: http://127.0.0.1:61234'); + expect(io.stdout()).toContain('pid: 4242'); + expect(io.stdout()).toContain('features: core, local-embeddings'); + expect(io.stdout()).toContain('stderr: /runtime/0.2.0/daemon.stderr.log'); + }); + + it('stops the managed Python daemon', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + stopDaemon: vi.fn(async (): Promise => ({ + status: 'stopped', + layout: { + cliVersion: '0.2.0', + runtimeRoot: '/runtime', + versionDir: '/runtime/0.2.0', + venvDir: '/runtime/0.2.0/.venv', + manifestPath: '/runtime/0.2.0/manifest.json', + installLogPath: '/runtime/0.2.0/install.log', + assetDir: '/assets/python', + assetManifestPath: '/assets/python/manifest.json', + pythonPath: '/runtime/0.2.0/.venv/bin/python', + daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', + daemonStatePath: '/runtime/0.2.0/daemon.json', + daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', + daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', + }, + state: { + schemaVersion: 1, + pid: 4242, + host: '127.0.0.1', + port: 61234, + version: '0.2.0', + features: ['core'], + startedAt: '2026-05-11T00:00:00.000Z', + stdoutLog: '/runtime/0.2.0/daemon.stdout.log', + stderrLog: '/runtime/0.2.0/daemon.stderr.log', + }, + })), + }; + + await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0); + + expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' }); + expect(io.stdout()).toContain('Stopped KTX Python daemon'); + expect(io.stdout()).toContain('pid: 4242'); + }); + it('prints runtime status as JSON', async () => { const io = makeIo(); const deps: KtxRuntimeDeps = { diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 61ba203d..fe2b5f74 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -1,4 +1,10 @@ import type { KtxCliIo } from './cli-runtime.js'; +import { + startManagedPythonDaemon, + stopManagedPythonDaemon, + type ManagedPythonDaemonStartResult, + type ManagedPythonDaemonStopResult, +} from './managed-python-daemon.js'; import { doctorManagedPythonRuntime, installManagedPythonRuntime, @@ -15,12 +21,20 @@ import { export type KtxRuntimeArgs = | { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } + | { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } + | { command: 'stop'; cliVersion: string } | { command: 'status'; cliVersion: string; json: boolean } | { command: 'doctor'; cliVersion: string; json: boolean } | { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean }; export interface KtxRuntimeDeps { installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise; + startDaemon?: (options: { + cliVersion: string; + features: KtxRuntimeFeature[]; + force?: boolean; + }) => Promise; + stopDaemon?: (options: { cliVersion: string }) => Promise; readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; pruneRuntime?: (options: { @@ -45,6 +59,28 @@ function writeInstallResult(io: KtxCliIo, result: ManagedPythonRuntimeInstallRes io.stdout.write(`install log: ${result.layout.installLogPath}\n`); } +function writeDaemonStart(io: KtxCliIo, result: ManagedPythonDaemonStartResult): void { + const verb = result.status === 'reused' ? 'Using existing' : 'Started'; + io.stdout.write(`${verb} KTX Python daemon\n`); + io.stdout.write(`url: ${result.baseUrl}\n`); + io.stdout.write(`pid: ${result.state.pid}\n`); + io.stdout.write(`version: ${result.state.version}\n`); + io.stdout.write(`features: ${result.state.features.join(', ')}\n`); + io.stdout.write(`state: ${result.layout.daemonStatePath}\n`); + io.stdout.write(`stdout: ${result.state.stdoutLog}\n`); + io.stdout.write(`stderr: ${result.state.stderrLog}\n`); +} + +function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): void { + if (result.status === 'already-stopped') { + io.stdout.write('KTX Python daemon already stopped\n'); + return; + } + io.stdout.write('Stopped KTX Python daemon\n'); + io.stdout.write(`pid: ${result.state?.pid ?? 'unknown'}\n`); + io.stdout.write(`state: ${result.layout.daemonStatePath}\n`); +} + function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void { io.stdout.write('KTX Python runtime\n'); io.stdout.write(`status: ${status.kind}\n`); @@ -95,6 +131,22 @@ export async function runKtxRuntime( writeInstallResult(io, result); return 0; } + if (args.command === 'start') { + const startDaemon = deps.startDaemon ?? startManagedPythonDaemon; + const result = await startDaemon({ + cliVersion: args.cliVersion, + features: [args.feature], + force: args.force, + }); + writeDaemonStart(io, result); + return 0; + } + if (args.command === 'stop') { + const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon; + const result = await stopDaemon({ cliVersion: args.cliVersion }); + writeDaemonStop(io, result); + return 0; + } if (args.command === 'status') { const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus; const status = await readStatus({ cliVersion: args.cliVersion });