From 2a8a7c0f02980c9ed4f96e8ed194a9e4af622f22 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 11 May 2026 10:12:02 +0200 Subject: [PATCH] docs: add plan for managed python runtime installer --- ...-05-11-managed-python-runtime-installer.md | 1750 +++++++++++++++++ 1 file changed, 1750 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md diff --git a/docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md b/docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md new file mode 100644 index 00000000..c6271cb1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md @@ -0,0 +1,1750 @@ +# Managed Python Runtime Installer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Install and inspect the bundled `kaelio-ktx` Python wheel in a +versioned KTX-managed runtime directory. + +**Architecture:** Add a CLI-owned managed-runtime module that knows where the +bundled wheel asset lives, verifies its checksum, creates a versioned virtual +environment with `uv`, installs the requested feature set, and writes an +installed-runtime manifest. Add `ktx runtime install`, `status`, `doctor`, and +`prune` commands that expose this behavior without changing normal +Python-backed commands yet. + +**Tech Stack:** TypeScript, Node 22 ESM, Commander, Vitest, `zod`, `uv`, npm +package assets. + +--- + +## Existing status + +This plan is based on +`docs/superpowers/specs/2026-05-11-npm-managed-python-runtime-design.md`. + +Plan 1, `docs/superpowers/plans/2026-05-11-bundled-python-runtime-wheel.md`, +is implemented in this worktree. The implemented source includes +`scripts/build-python-runtime-wheel.mjs`, +`scripts/build-python-runtime-wheel.test.mjs`, runtime-wheel handling in +`scripts/package-artifacts.mjs`, test coverage in +`scripts/package-artifacts.test.mjs`, and the `kaelio-ktx` release-policy +entry. The targeted verification command passes: + +```bash +node --test scripts/build-python-runtime-wheel.test.mjs scripts/package-artifacts.test.mjs scripts/release-readiness.test.mjs +``` + +Expected current result: + +```text +# pass 38 +# fail 0 +``` + +No other plan files currently reference the npm-managed Python runtime spec. + +This plan implements the next prerequisite: + +- Platform-specific managed runtime roots. +- Versioned runtime directories keyed by the CLI package version. +- Runtime asset manifest reading and wheel checksum verification. +- `uv` virtual environment creation. +- Core and `local-embeddings` feature installation levels. +- Installed-runtime manifest writing. +- `ktx runtime install`, `ktx runtime status`, `ktx runtime doctor`, and + `ktx runtime prune`. + +This plan intentionally leaves the following spec requirements for later +plans: + +- Lazy install from normal commands such as `ktx sl query`. +- `ktx runtime start` and `ktx runtime stop`. +- Daemon state, health checks, reuse, and stale-daemon repair. +- Public npm package renaming from `@ktx/cli` to `@kaelio/ktx`. + +## File structure + +- Create `packages/cli/src/managed-python-runtime.ts`: pure managed-runtime + library for path calculation, asset verification, install/status/doctor, and + pruning. +- Create `packages/cli/src/managed-python-runtime.test.ts`: unit tests for + runtime roots, manifest validation, install command shape, status checks, and + prune safety. +- Create `packages/cli/src/runtime.ts`: command runner that formats + `install`, `status`, `doctor`, and `prune` output. +- Create `packages/cli/src/runtime.test.ts`: command-runner tests with injected + managed-runtime dependencies. +- Create `packages/cli/src/commands/runtime-commands.ts`: Commander + registration for `ktx runtime ...`. +- Modify `packages/cli/src/cli-runtime.ts`: add the runtime command runner to + CLI dependency injection. +- Modify `packages/cli/src/cli-program.ts`: pass package info into command + registration and register the runtime command group. +- Modify `packages/cli/src/index.ts`: export runtime command types and the + runner for tests and programmatic use. +- Modify `packages/cli/src/index.test.ts`: assert root help exposes + `runtime` and Commander routes runtime subcommands correctly. + +### Task 1: Add failing managed-runtime library tests + +**Files:** + +- Create: `packages/cli/src/managed-python-runtime.test.ts` +- Test: `packages/cli/src/managed-python-runtime.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `packages/cli/src/managed-python-runtime.test.ts` with this content: + +```typescript +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + doctorManagedPythonRuntime, + installManagedPythonRuntime, + managedPythonRuntimeLayout, + pruneManagedPythonRuntimes, + readManagedPythonRuntimeStatus, + verifyRuntimeAsset, + type ManagedPythonRuntimeExec, +} from './managed-python-runtime.js'; + +async function writeAsset(root: string, contents = 'wheel-bytes') { + const assetDir = join(root, 'assets', 'python'); + await mkdir(assetDir, { recursive: true }); + const wheelPath = join(assetDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'); + await writeFile(wheelPath, contents); + await writeFile( + join(assetDir, 'manifest.json'), + `${JSON.stringify( + { + schemaVersion: 1, + distributionName: 'kaelio-ktx', + normalizedName: 'kaelio_ktx', + version: '0.1.0', + wheel: { + file: 'kaelio_ktx-0.1.0-py3-none-any.whl', + sha256: createHash('sha256').update(contents).digest('hex'), + bytes: Buffer.byteLength(contents), + }, + }, + null, + 2, + )}\n`, + ); + return { assetDir, wheelPath }; +} + +describe('managedPythonRuntimeLayout', () => { + it('uses the macOS application-support runtime root', () => { + const layout = managedPythonRuntimeLayout({ + cliVersion: '0.2.0', + platform: 'darwin', + env: {}, + homeDir: '/Users/alex', + assetDir: '/repo/packages/cli/assets/python', + }); + + expect(layout.runtimeRoot).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime'); + expect(layout.versionDir).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0'); + expect(layout.venvDir).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv'); + expect(layout.pythonPath).toBe( + '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv/bin/python', + ); + expect(layout.daemonPath).toBe( + '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv/bin/ktx-daemon', + ); + expect(layout.assetManifestPath).toBe('/repo/packages/cli/assets/python/manifest.json'); + }); + + it('honors XDG_DATA_HOME on Linux', () => { + const layout = managedPythonRuntimeLayout({ + cliVersion: '0.2.0', + platform: 'linux', + env: { XDG_DATA_HOME: '/var/xdg' }, + homeDir: '/home/alex', + assetDir: '/repo/packages/cli/assets/python', + }); + + expect(layout.runtimeRoot).toBe('/var/xdg/kaelio/ktx/runtime'); + expect(layout.versionDir).toBe('/var/xdg/kaelio/ktx/runtime/0.2.0'); + }); + + it('uses LocalAppData on Windows', () => { + const layout = managedPythonRuntimeLayout({ + cliVersion: '0.2.0', + platform: 'win32', + env: { LOCALAPPDATA: 'C:\\Users\\Alex\\AppData\\Local' }, + homeDir: 'C:\\Users\\Alex', + assetDir: 'C:\\repo\\packages\\cli\\assets\\python', + }); + + expect(layout.runtimeRoot).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime'); + expect(layout.pythonPath).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime/0.2.0/.venv/Scripts/python.exe'); + expect(layout.daemonPath).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime/0.2.0/.venv/Scripts/ktx-daemon.exe'); + }); +}); + +describe('verifyRuntimeAsset', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-asset-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('reads the manifest and verifies the wheel checksum', async () => { + const { assetDir, wheelPath } = await writeAsset(tempDir, '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); + }); + + it('rejects a wheel whose checksum does not match the manifest', async () => { + const { assetDir, wheelPath } = await writeAsset(tempDir, 'original'); + await writeFile(wheelPath, 'tampered'); + + await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow( + /Bundled Python runtime wheel checksum mismatch/, + ); + }); + + it('rejects an unsafe wheel filename in the manifest', async () => { + const { assetDir } = await writeAsset(tempDir, 'valid-wheel'); + await writeFile( + join(assetDir, 'manifest.json'), + `${JSON.stringify({ + schemaVersion: 1, + distributionName: 'kaelio-ktx', + normalizedName: 'kaelio_ktx', + version: '0.1.0', + wheel: { + file: '../kaelio_ktx-0.1.0-py3-none-any.whl', + sha256: 'a'.repeat(64), + bytes: 1, + }, + })}\n`, + ); + + await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Unsafe runtime wheel filename/); + }); +}); + +describe('installManagedPythonRuntime', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-install-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('creates a venv, installs the core wheel, and writes a manifest', async () => { + const { assetDir } = await writeAsset(tempDir, 'core-wheel'); + const commands: Array<{ command: string; args: string[] }> = []; + const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => { + commands.push({ command, args }); + return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; + }); + + const result = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['core'], + exec, + }); + + expect(result.status).toBe('installed'); + expect(commands).toEqual([ + { command: 'uv', args: ['--version'] }, + { command: 'uv', args: ['venv', result.layout.venvDir] }, + { + command: 'uv', + args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath], + }, + ]); + const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as { + cliVersion: string; + features: string[]; + python: { executable: string; daemonExecutable: string }; + }; + expect(manifest.cliVersion).toBe('0.2.0'); + expect(manifest.features).toEqual(['core']); + expect(manifest.python.executable).toBe(result.layout.pythonPath); + expect(manifest.python.daemonExecutable).toBe(result.layout.daemonPath); + }); + + it('installs the local-embeddings extra when requested', async () => { + const { assetDir } = await writeAsset(tempDir, 'embedding-wheel'); + const commands: Array<{ command: string; args: string[] }> = []; + const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => { + commands.push({ command, args }); + return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; + }); + + const result = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['local-embeddings'], + exec, + }); + + expect(commands.at(-1)).toEqual({ + command: 'uv', + args: ['pip', 'install', '--python', result.layout.pythonPath, `${result.asset.wheelPath}[local-embeddings]`], + }); + const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as { features: string[] }; + expect(manifest.features).toEqual(['core', 'local-embeddings']); + }); + + it('reuses an existing compatible runtime when force is false', async () => { + const { assetDir } = await writeAsset(tempDir, 'core-wheel'); + const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ + stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stderr: '', + })); + + const first = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['core'], + exec, + }); + await mkdir(join(first.layout.venvDir, 'bin'), { recursive: true }); + await writeFile(first.layout.pythonPath, '#!/usr/bin/env python\n'); + await writeFile(first.layout.daemonPath, '#!/usr/bin/env python\n'); + + const second = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['core'], + exec, + }); + + expect(second.status).toBe('ready'); + expect(exec).toHaveBeenCalledTimes(3); + }); + + it('keeps failed install logs in the versioned runtime directory', async () => { + const { assetDir } = await writeAsset(tempDir, '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' }); + } + return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; + }); + + await expect( + installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['core'], + exec, + }), + ).rejects.toThrow(/Python runtime install failed/); + + const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8'); + expect(log).toContain('$ uv venv'); + expect(log).toContain('bad python'); + }); +}); + +describe('readManagedPythonRuntimeStatus', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-status-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('reports missing before install', async () => { + const status = await readManagedPythonRuntimeStatus({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir: join(tempDir, 'assets', 'python'), + }); + + expect(status.kind).toBe('missing'); + expect(status.detail).toContain('No runtime manifest'); + }); + + it('reports ready when manifest and executables exist', async () => { + const { assetDir } = await writeAsset(tempDir, 'core-wheel'); + const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ + stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stderr: '', + })); + const install = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['core'], + exec, + }); + await mkdir(join(install.layout.venvDir, 'bin'), { recursive: true }); + await writeFile(install.layout.pythonPath, '#!/usr/bin/env python\n'); + await writeFile(install.layout.daemonPath, '#!/usr/bin/env python\n'); + + const status = await readManagedPythonRuntimeStatus({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + }); + + expect(status.kind).toBe('ready'); + expect(status.manifest?.features).toEqual(['core']); + }); + + it('reports broken when an executable is missing', async () => { + const { assetDir } = await writeAsset(tempDir, 'core-wheel'); + const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ + stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stderr: '', + })); + await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + features: ['core'], + exec, + }); + + const status = await readManagedPythonRuntimeStatus({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + }); + + expect(status.kind).toBe('broken'); + expect(status.detail).toContain('Missing Python executable'); + }); +}); + +describe('doctorManagedPythonRuntime', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-doctor-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('checks uv, bundled assets, and installed runtime status', async () => { + const { assetDir } = await writeAsset(tempDir, 'core-wheel'); + const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ + stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stderr: '', + })); + + const checks = await doctorManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot: join(tempDir, 'runtime'), + assetDir, + exec, + }); + + expect(checks.map((check) => [check.id, check.status])).toEqual([ + ['uv', 'pass'], + ['asset', 'pass'], + ['runtime', 'fail'], + ]); + expect(checks[2]?.fix).toBe('Run: ktx runtime install --yes'); + }); +}); + +describe('pruneManagedPythonRuntimes', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-prune-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('removes stale version directories and keeps the current version', async () => { + const runtimeRoot = join(tempDir, 'runtime'); + await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true }); + await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true }); + await writeFile(join(runtimeRoot, 'README.txt'), 'not a runtime directory\n'); + + const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot }); + + expect(result.removed).toEqual([join(runtimeRoot, '0.1.0')]); + expect(result.kept).toEqual([join(runtimeRoot, '0.2.0')]); + await expect(stat(join(runtimeRoot, '0.1.0'))).rejects.toThrow(); + expect(await readdir(runtimeRoot)).toEqual(['0.2.0', 'README.txt']); + }); + + it('supports dry-run without deleting stale directories', async () => { + const runtimeRoot = join(tempDir, 'runtime'); + await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true }); + await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true }); + + const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot, dryRun: true }); + + expect(result.removed).toEqual([]); + expect(result.stale).toEqual([join(runtimeRoot, '0.1.0')]); + expect(await readdir(runtimeRoot)).toEqual(['0.1.0', '0.2.0']); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/managed-python-runtime.test.ts +``` + +Expected: FAIL with an import error for `./managed-python-runtime.js`. + +- [ ] **Step 3: Commit the failing tests** + +Run: + +```bash +git add packages/cli/src/managed-python-runtime.test.ts +git commit -m "test: cover managed python runtime lifecycle" +``` + +### Task 2: Implement the managed-runtime library + +**Files:** + +- Create: `packages/cli/src/managed-python-runtime.ts` +- Test: `packages/cli/src/managed-python-runtime.test.ts` + +- [ ] **Step 1: Create the managed-runtime implementation** + +Create `packages/cli/src/managed-python-runtime.ts` with this content: + +```typescript +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { access, appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { z } from 'zod'; + +const execFileAsync = promisify(execFile); + +export const runtimeFeatureSchema = z.enum(['core', 'local-embeddings']); +export type KtxRuntimeFeature = z.infer; + +const runtimeAssetManifestSchema = z.object({ + schemaVersion: z.literal(1), + distributionName: z.literal('kaelio-ktx'), + normalizedName: z.literal('kaelio_ktx'), + version: z.string().min(1), + wheel: z.object({ + file: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + bytes: z.number().int().nonnegative(), + }), +}); + +export type KtxRuntimeAssetManifest = z.infer; + +const installedRuntimeManifestSchema = z.object({ + schemaVersion: z.literal(1), + cliVersion: z.string().min(1), + installedAt: z.string().min(1), + asset: runtimeAssetManifestSchema, + features: z.array(runtimeFeatureSchema).min(1), + python: z.object({ + executable: z.string().min(1), + daemonExecutable: z.string().min(1), + }), + installLog: z.string().min(1), +}); + +export type InstalledKtxRuntimeManifest = z.infer; + +export interface ManagedPythonRuntimeLayoutOptions { + cliVersion: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + runtimeRoot?: string; + assetDir?: string; +} + +export interface ManagedPythonRuntimeLayout { + cliVersion: string; + runtimeRoot: string; + versionDir: string; + venvDir: string; + manifestPath: string; + installLogPath: string; + assetDir: string; + assetManifestPath: string; + pythonPath: string; + daemonPath: string; +} + +export interface ManagedRuntimeAsset { + manifest: KtxRuntimeAssetManifest; + wheelPath: string; +} + +export type ManagedPythonRuntimeExec = ( + command: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv }, +) => Promise<{ stdout: string; stderr: string }>; + +export interface ManagedPythonRuntimeInstallOptions extends ManagedPythonRuntimeLayoutOptions { + features: KtxRuntimeFeature[]; + force?: boolean; + exec?: ManagedPythonRuntimeExec; +} + +export interface ManagedPythonRuntimeInstallResult { + status: 'ready' | 'installed'; + layout: ManagedPythonRuntimeLayout; + asset: ManagedRuntimeAsset; + manifest: InstalledKtxRuntimeManifest; +} + +export type ManagedPythonRuntimeStatusKind = 'missing' | 'ready' | 'mismatched' | 'broken'; + +export interface ManagedPythonRuntimeStatus { + kind: ManagedPythonRuntimeStatusKind; + detail: string; + layout: ManagedPythonRuntimeLayout; + manifest?: InstalledKtxRuntimeManifest; +} + +export interface ManagedPythonRuntimeDoctorCheck { + id: 'uv' | 'asset' | 'runtime'; + label: string; + status: 'pass' | 'fail'; + detail: string; + fix?: string; +} + +export interface ManagedPythonRuntimePruneResult { + runtimeRoot: string; + stale: string[]; + kept: string[]; + removed: string[]; +} + +function defaultAssetDir(): string { + return fileURLToPath(new URL('../assets/python/', import.meta.url)); +} + +function runtimeRootFor(input: Required>): string { + if (input.platform === 'darwin') { + return join(input.homeDir, 'Library', 'Application Support', 'kaelio', 'ktx', 'runtime'); + } + if (input.platform === 'win32') { + return join(input.env.LOCALAPPDATA ?? join(input.homeDir, 'AppData', 'Local'), 'Kaelio', 'KTX', 'runtime'); + } + return join(input.env.XDG_DATA_HOME ?? join(input.homeDir, '.local', 'share'), 'kaelio', 'ktx', 'runtime'); +} + +function executablePath(venvDir: string, platform: NodeJS.Platform, name: string): string { + if (platform === 'win32') { + return join(venvDir, 'Scripts', `${name}.exe`); + } + return join(venvDir, 'bin', name); +} + +export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOptions): ManagedPythonRuntimeLayout { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + const runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ platform, env, homeDir }); + const versionDir = join(runtimeRoot, options.cliVersion); + const venvDir = join(versionDir, '.venv'); + const assetDir = options.assetDir ?? defaultAssetDir(); + + return { + cliVersion: options.cliVersion, + runtimeRoot, + versionDir, + venvDir, + manifestPath: join(versionDir, 'manifest.json'), + installLogPath: join(versionDir, 'install.log'), + assetDir, + assetManifestPath: join(assetDir, 'manifest.json'), + pythonPath: executablePath(venvDir, platform, 'python'), + daemonPath: executablePath(venvDir, platform, 'ktx-daemon'), + }; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function assertSafeWheelFilename(file: string): void { + if (file !== basename(file) || file.includes('/') || file.includes('\\')) { + throw new Error(`Unsafe runtime wheel filename in bundled manifest: ${file}`); + } +} + +async function readJsonFile(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as unknown; +} + +export async function verifyRuntimeAsset(input: { assetDir: string }): Promise { + const manifestPath = join(input.assetDir, 'manifest.json'); + const manifest = runtimeAssetManifestSchema.parse(await readJsonFile(manifestPath)); + assertSafeWheelFilename(manifest.wheel.file); + const wheelPath = join(input.assetDir, manifest.wheel.file); + const wheel = await readFile(wheelPath); + const sha256 = createHash('sha256').update(wheel).digest('hex'); + if (sha256 !== manifest.wheel.sha256 || wheel.byteLength !== manifest.wheel.bytes) { + throw new Error(`Bundled Python runtime wheel checksum mismatch: ${wheelPath}`); + } + return { manifest, wheelPath }; +} + +function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] { + const requested = new Set(['core', ...features]); + return runtimeFeatureSchema.options.filter((feature) => requested.has(feature)); +} + +async function readInstalledManifest(path: string): Promise { + if (!(await pathExists(path))) { + return undefined; + } + return installedRuntimeManifestSchema.parse(await readJsonFile(path)); +} + +function hasFeatures(manifest: InstalledKtxRuntimeManifest, features: KtxRuntimeFeature[]): boolean { + return normalizeFeatures(features).every((feature) => manifest.features.includes(feature)); +} + +async function defaultExec( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise<{ stdout: string; stderr: string }> { + const result = await execFileAsync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 20, + }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +function errorOutput(error: unknown): { stdout: string; stderr: string } { + const value = error as { stdout?: unknown; stderr?: unknown }; + return { + stdout: typeof value.stdout === 'string' ? value.stdout : '', + stderr: typeof value.stderr === 'string' ? value.stderr : '', + }; +} + +async function runLogged(input: { + exec: ManagedPythonRuntimeExec; + logPath: string; + command: string; + args: string[]; + cwd?: string; +}): Promise<{ stdout: string; stderr: string }> { + await appendFile(input.logPath, `$ ${input.command} ${input.args.join(' ')}\n`); + try { + const result = await input.exec(input.command, input.args, { cwd: input.cwd }); + if (result.stdout) { + await appendFile(input.logPath, result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`); + } + if (result.stderr) { + await appendFile(input.logPath, result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`); + } + return result; + } catch (error) { + const output = errorOutput(error); + if (output.stdout) { + await appendFile(input.logPath, output.stdout.endsWith('\n') ? output.stdout : `${output.stdout}\n`); + } + 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}`); + } +} + +async function ensureUv(exec: ManagedPythonRuntimeExec): Promise { + try { + const result = await exec('uv', ['--version']); + return result.stdout.trim() || 'uv available'; + } catch { + throw new Error( + 'uv is required to install the KTX Python runtime. Install uv and retry: ktx runtime install --yes', + ); + } +} + +export async function installManagedPythonRuntime( + options: ManagedPythonRuntimeInstallOptions, +): Promise { + const layout = managedPythonRuntimeLayout(options); + const exec = options.exec ?? defaultExec; + const features = normalizeFeatures(options.features); + const asset = await verifyRuntimeAsset({ assetDir: layout.assetDir }); + const existing = await readInstalledManifest(layout.manifestPath); + if ( + options.force !== true && + existing && + existing.cliVersion === options.cliVersion && + existing.asset.wheel.sha256 === asset.manifest.wheel.sha256 && + hasFeatures(existing, features) && + (await pathExists(existing.python.executable)) && + (await pathExists(existing.python.daemonExecutable)) + ) { + return { status: 'ready', layout, asset, manifest: existing }; + } + + await rm(layout.versionDir, { recursive: true, force: true }); + await mkdir(layout.versionDir, { recursive: true }); + await writeFile(layout.installLogPath, ''); + await ensureUv(exec); + await runLogged({ exec, logPath: layout.installLogPath, command: 'uv', args: ['venv', layout.venvDir] }); + const wheelSpec = features.includes('local-embeddings') ? `${asset.wheelPath}[local-embeddings]` : asset.wheelPath; + await runLogged({ + exec, + logPath: layout.installLogPath, + command: 'uv', + args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec], + }); + + const manifest: InstalledKtxRuntimeManifest = { + schemaVersion: 1, + cliVersion: options.cliVersion, + installedAt: new Date().toISOString(), + asset: asset.manifest, + features, + python: { + executable: layout.pythonPath, + daemonExecutable: layout.daemonPath, + }, + installLog: layout.installLogPath, + }; + await writeFile(layout.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + return { status: 'installed', layout, asset, manifest }; +} + +export async function readManagedPythonRuntimeStatus( + options: ManagedPythonRuntimeLayoutOptions, +): Promise { + const layout = managedPythonRuntimeLayout(options); + let manifest: InstalledKtxRuntimeManifest | undefined; + try { + manifest = await readInstalledManifest(layout.manifestPath); + } catch (error) { + return { + kind: 'broken', + detail: `Runtime manifest is invalid: ${error instanceof Error ? error.message : String(error)}`, + layout, + }; + } + if (!manifest) { + return { kind: 'missing', detail: `No runtime manifest at ${layout.manifestPath}`, layout }; + } + if (manifest.cliVersion !== options.cliVersion) { + return { + kind: 'mismatched', + detail: `Runtime is for CLI ${manifest.cliVersion}, current CLI is ${options.cliVersion}`, + layout, + manifest, + }; + } + if (!(await pathExists(manifest.python.executable))) { + return { kind: 'broken', detail: `Missing Python executable: ${manifest.python.executable}`, layout, manifest }; + } + if (!(await pathExists(manifest.python.daemonExecutable))) { + return { kind: 'broken', detail: `Missing ktx-daemon executable: ${manifest.python.daemonExecutable}`, layout, manifest }; + } + return { kind: 'ready', detail: `Runtime ready at ${layout.versionDir}`, layout, manifest }; +} + +function check(status: ManagedPythonRuntimeDoctorCheck['status'], input: Omit) { + return { status, ...input }; +} + +export async function doctorManagedPythonRuntime( + options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec }, +): Promise { + const exec = options.exec ?? defaultExec; + const checks: ManagedPythonRuntimeDoctorCheck[] = []; + try { + const version = await ensureUv(exec); + checks.push(check('pass', { id: 'uv', label: 'uv', detail: version })); + } catch (error) { + checks.push( + check('fail', { + id: 'uv', + label: 'uv', + detail: error instanceof Error ? error.message : String(error), + fix: 'Install uv, then run: ktx runtime install --yes', + }), + ); + } + + try { + const asset = await verifyRuntimeAsset({ assetDir: managedPythonRuntimeLayout(options).assetDir }); + checks.push(check('pass', { id: 'asset', label: 'Bundled Python wheel', detail: asset.wheelPath })); + } catch (error) { + checks.push( + check('fail', { + id: 'asset', + label: 'Bundled Python wheel', + detail: error instanceof Error ? error.message : String(error), + fix: 'Run: pnpm run artifacts:check', + }), + ); + } + + const status = await readManagedPythonRuntimeStatus(options); + checks.push( + check(status.kind === 'ready' ? 'pass' : 'fail', { + id: 'runtime', + label: 'Managed Python runtime', + detail: status.detail, + ...(status.kind === 'ready' ? {} : { fix: 'Run: ktx runtime install --yes' }), + }), + ); + return checks; +} + +export async function pruneManagedPythonRuntimes(options: { + cliVersion: string; + runtimeRoot: string; + dryRun?: boolean; +}): Promise { + if (!(await pathExists(options.runtimeRoot))) { + return { runtimeRoot: options.runtimeRoot, stale: [], kept: [], removed: [] }; + } + const entries = await readdir(options.runtimeRoot); + const stale: string[] = []; + const kept: string[] = []; + for (const entry of entries) { + const path = join(options.runtimeRoot, entry); + const info = await stat(path); + if (!info.isDirectory()) { + continue; + } + if (entry === options.cliVersion) { + kept.push(path); + } else { + stale.push(path); + } + } + const removed: string[] = []; + if (options.dryRun !== true) { + for (const path of stale) { + await rm(path, { recursive: true, force: true }); + removed.push(path); + } + } + return { runtimeRoot: options.runtimeRoot, stale, kept, removed }; +} +``` + +- [ ] **Step 2: Run the managed-runtime tests** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/managed-python-runtime.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run the CLI type checker** + +Run: + +```bash +pnpm --filter @ktx/cli run type-check +``` + +Expected: PASS. + +- [ ] **Step 4: Commit the implementation** + +Run: + +```bash +git add packages/cli/src/managed-python-runtime.ts packages/cli/src/managed-python-runtime.test.ts +git commit -m "feat: add managed python runtime installer" +``` + +### Task 3: Add the runtime command runner + +**Files:** + +- Create: `packages/cli/src/runtime.ts` +- Create: `packages/cli/src/runtime.test.ts` +- Test: `packages/cli/src/runtime.test.ts` + +- [ ] **Step 1: Write the failing command-runner tests** + +Create `packages/cli/src/runtime.test.ts` with this content: + +```typescript +import { describe, expect, it, vi } from 'vitest'; +import { runKtxRuntime, type KtxRuntimeDeps } from './runtime.js'; + +function makeIo() { + let stdout = ''; + let stderr = ''; + return { + io: { + stdout: { + write: (chunk: string) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + }, + }, + }, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +describe('runKtxRuntime', () => { + it('installs the requested runtime feature and prints the manifest path', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + installRuntime: vi.fn(async () => ({ + status: 'installed', + 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', + }, + asset: { + wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl', + manifest: { + schemaVersion: 1, + distributionName: 'kaelio-ktx', + normalizedName: 'kaelio_ktx', + version: '0.1.0', + wheel: { + file: 'kaelio_ktx-0.1.0-py3-none-any.whl', + sha256: 'a'.repeat(64), + bytes: 10, + }, + }, + }, + manifest: { + schemaVersion: 1, + cliVersion: '0.2.0', + installedAt: '2026-05-11T00:00:00.000Z', + asset: { + schemaVersion: 1, + distributionName: 'kaelio-ktx', + normalizedName: 'kaelio_ktx', + version: '0.1.0', + wheel: { + file: 'kaelio_ktx-0.1.0-py3-none-any.whl', + sha256: 'a'.repeat(64), + bytes: 10, + }, + }, + features: ['core', 'local-embeddings'], + python: { + executable: '/runtime/0.2.0/.venv/bin/python', + daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon', + }, + installLog: '/runtime/0.2.0/install.log', + }, + })), + }; + + await expect( + runKtxRuntime( + { command: 'install', cliVersion: '0.2.0', feature: 'local-embeddings', force: true }, + io.io, + deps, + ), + ).resolves.toBe(0); + + expect(deps.installRuntime).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + features: ['local-embeddings'], + force: true, + }); + expect(io.stdout()).toContain('Installed KTX Python runtime'); + expect(io.stdout()).toContain('features: core, local-embeddings'); + expect(io.stdout()).toContain('manifest: /runtime/0.2.0/manifest.json'); + expect(io.stderr()).toBe(''); + }); + + it('prints runtime status as JSON', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + readStatus: vi.fn(async () => ({ + kind: 'missing', + detail: 'No runtime manifest at /runtime/0.2.0/manifest.json', + 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', + }, + })), + }; + + await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(0); + + expect(JSON.parse(io.stdout())).toMatchObject({ + kind: 'missing', + detail: 'No runtime manifest at /runtime/0.2.0/manifest.json', + layout: { runtimeRoot: '/runtime' }, + }); + }); + + it('returns failure for doctor when any check fails', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + doctorRuntime: vi.fn(async () => [ + { id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' }, + { + id: 'runtime', + label: 'Managed Python runtime', + status: 'fail', + detail: 'No runtime manifest', + fix: 'Run: ktx runtime install --yes', + }, + ]), + }; + + await expect(runKtxRuntime({ command: 'doctor', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(1); + + expect(io.stdout()).toContain('PASS uv: uv 0.9.5'); + expect(io.stdout()).toContain('FAIL Managed Python runtime: No runtime manifest'); + expect(io.stdout()).toContain('Fix: Run: ktx runtime install --yes'); + }); + + it('requires --yes before pruning stale runtime directories', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + pruneRuntime: vi.fn(async () => { + throw new Error('should not prune without --yes'); + }), + }; + + await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: false, yes: false }, io.io, deps)) + .resolves.toBe(1); + + expect(io.stderr()).toContain('Refusing to prune without --yes'); + expect(deps.pruneRuntime).not.toHaveBeenCalled(); + }); + + it('prints stale directories during prune dry-run', async () => { + const io = makeIo(); + const deps: KtxRuntimeDeps = { + readStatus: vi.fn(async () => ({ + kind: 'missing', + detail: 'No runtime manifest at /runtime/0.2.0/manifest.json', + 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', + }, + })), + pruneRuntime: vi.fn(async () => ({ + runtimeRoot: '/runtime', + stale: ['/runtime/0.1.0'], + kept: ['/runtime/0.2.0'], + removed: [], + })), + }; + + await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: true, yes: false }, io.io, deps)) + .resolves.toBe(0); + + expect(io.stdout()).toContain('Stale KTX Python runtimes'); + expect(io.stdout()).toContain('/runtime/0.1.0'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/runtime.test.ts +``` + +Expected: FAIL with an import error for `./runtime.js`. + +- [ ] **Step 3: Create the command runner** + +Create `packages/cli/src/runtime.ts` with this content: + +```typescript +import { + doctorManagedPythonRuntime, + installManagedPythonRuntime, + pruneManagedPythonRuntimes, + readManagedPythonRuntimeStatus, + type KtxRuntimeFeature, + type ManagedPythonRuntimeDoctorCheck, + type ManagedPythonRuntimeInstallOptions, + type ManagedPythonRuntimeInstallResult, + type ManagedPythonRuntimeLayoutOptions, + type ManagedPythonRuntimePruneResult, + type ManagedPythonRuntimeStatus, +} from './managed-python-runtime.js'; +import type { KtxCliIo } from './cli-runtime.js'; + +export type KtxRuntimeArgs = + | { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } + | { 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; + readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; + doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; + pruneRuntime?: (options: { cliVersion: string; runtimeRoot: string; dryRun?: boolean }) => Promise; +} + +function writeJson(io: KtxCliIo, value: unknown): void { + io.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function writeInstallResult(io: KtxCliIo, result: ManagedPythonRuntimeInstallResult): void { + const verb = result.status === 'ready' ? 'Using existing' : 'Installed'; + io.stdout.write(`${verb} KTX Python runtime\n`); + io.stdout.write(`version: ${result.manifest.cliVersion}\n`); + io.stdout.write(`features: ${result.manifest.features.join(', ')}\n`); + io.stdout.write(`python: ${result.manifest.python.executable}\n`); + io.stdout.write(`daemon: ${result.manifest.python.daemonExecutable}\n`); + io.stdout.write(`manifest: ${result.layout.manifestPath}\n`); + io.stdout.write(`install log: ${result.layout.installLogPath}\n`); +} + +function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void { + io.stdout.write('KTX Python runtime\n'); + io.stdout.write(`status: ${status.kind}\n`); + io.stdout.write(`detail: ${status.detail}\n`); + io.stdout.write(`runtime root: ${status.layout.runtimeRoot}\n`); + io.stdout.write(`version dir: ${status.layout.versionDir}\n`); + if (status.manifest) { + io.stdout.write(`features: ${status.manifest.features.join(', ')}\n`); + io.stdout.write(`python: ${status.manifest.python.executable}\n`); + io.stdout.write(`daemon: ${status.manifest.python.daemonExecutable}\n`); + } +} + +function writeDoctor(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void { + io.stdout.write('KTX Python runtime doctor\n'); + for (const check of checks) { + io.stdout.write(`${check.status.toUpperCase()} ${check.label}: ${check.detail}\n`); + if (check.fix) { + io.stdout.write(` Fix: ${check.fix}\n`); + } + } +} + +function writePrune(io: KtxCliIo, result: ManagedPythonRuntimePruneResult, dryRun: boolean): void { + if (result.stale.length === 0) { + io.stdout.write(`No stale KTX Python runtimes found under ${result.runtimeRoot}\n`); + return; + } + io.stdout.write(dryRun ? 'Stale KTX Python runtimes\n' : 'Removed stale KTX Python runtimes\n'); + for (const path of dryRun ? result.stale : result.removed) { + io.stdout.write(`${path}\n`); + } +} + +export async function runKtxRuntime( + args: KtxRuntimeArgs, + io: KtxCliIo = process, + deps: KtxRuntimeDeps = {}, +): Promise { + try { + if (args.command === 'install') { + const installRuntime = deps.installRuntime ?? installManagedPythonRuntime; + const result = await installRuntime({ + cliVersion: args.cliVersion, + features: [args.feature], + force: args.force, + }); + writeInstallResult(io, result); + return 0; + } + if (args.command === 'status') { + const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus; + const status = await readStatus({ cliVersion: args.cliVersion }); + if (args.json) { + writeJson(io, status); + } else { + writeStatus(io, status); + } + return 0; + } + if (args.command === 'doctor') { + const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime; + const checks = await doctorRuntime({ cliVersion: args.cliVersion }); + if (args.json) { + writeJson(io, { checks }); + } else { + writeDoctor(io, checks); + } + return checks.some((check) => check.status === 'fail') ? 1 : 0; + } + if (!args.dryRun && !args.yes) { + io.stderr.write('Refusing to prune without --yes. Preview with: ktx runtime prune --dry-run\n'); + return 1; + } + const status = await (deps.readStatus ?? readManagedPythonRuntimeStatus)({ cliVersion: args.cliVersion }); + const pruneRuntime = deps.pruneRuntime ?? pruneManagedPythonRuntimes; + const result = await pruneRuntime({ + cliVersion: args.cliVersion, + runtimeRoot: status.layout.runtimeRoot, + dryRun: args.dryRun, + }); + writePrune(io, result, args.dryRun); + return 0; + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} +``` + +- [ ] **Step 4: Run the command-runner tests** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/runtime.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the command runner** + +Run: + +```bash +git add packages/cli/src/runtime.ts packages/cli/src/runtime.test.ts +git commit -m "feat: add runtime command runner" +``` + +### Task 4: Register `ktx runtime` commands + +**Files:** + +- Create: `packages/cli/src/commands/runtime-commands.ts` +- Modify: `packages/cli/src/cli-runtime.ts` +- Modify: `packages/cli/src/cli-program.ts` +- Modify: `packages/cli/src/index.ts` +- Modify: `packages/cli/src/index.test.ts` +- Test: `packages/cli/src/index.test.ts` + +- [ ] **Step 1: Create the runtime command registration** + +Create `packages/cli/src/commands/runtime-commands.ts` with this content: + +```typescript +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, + }); + }); +} +``` + +- [ ] **Step 2: Add runtime dependency injection to CLI runtime** + +In `packages/cli/src/cli-runtime.ts`, add this import after the existing +`KtxPublicIngestArgs` import: + +```typescript +import type { KtxRuntimeArgs } from './runtime.js'; +``` + +Then add this property to `KtxCliDeps` after `publicIngest`: + +```typescript + runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise; +``` + +- [ ] **Step 3: Add package info to command context and register the command** + +In `packages/cli/src/cli-program.ts`, add this import after the +`registerPublicIngestCommands` import: + +```typescript +import { registerRuntimeCommands } from './commands/runtime-commands.js'; +``` + +Add this property to `KtxCliCommandContext` after `deps`: + +```typescript + packageInfo: KtxCliPackageInfo; +``` + +Add this property to the `context` object inside `runCommanderKtxCli` after +`deps`: + +```typescript + packageInfo: info, +``` + +Register the runtime commands after `registerSlCommands(program, context);`: + +```typescript + registerRuntimeCommands(program, context); + profileMark('commander:register-runtime'); +``` + +- [ ] **Step 4: Export runtime APIs from the CLI package** + +In `packages/cli/src/index.ts`, add this export after the setup exports: + +```typescript +export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runtime.js'; +``` + +- [ ] **Step 5: Update root help and routing tests** + +In `packages/cli/src/index.test.ts`, update the root help command list in the +test named `prints the May 6 public command surface in root help` from: + +```typescript + for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'serve', 'status']) { +``` + +to: + +```typescript + for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'runtime', 'serve', 'status']) { +``` + +Then add this test after the root help test: + +```typescript + 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, + ); + }); +``` + +- [ ] **Step 6: Run the CLI routing tests** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/index.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the command registration** + +Run: + +```bash +git add packages/cli/src/commands/runtime-commands.ts packages/cli/src/cli-runtime.ts packages/cli/src/cli-program.ts packages/cli/src/index.ts packages/cli/src/index.test.ts +git commit -m "feat: expose runtime management commands" +``` + +### Task 5: Verify the managed runtime installer end to end + +**Files:** + +- Verify: `packages/cli/src/managed-python-runtime.ts` +- Verify: `packages/cli/src/runtime.ts` +- Verify: `packages/cli/src/commands/runtime-commands.ts` +- Verify: `packages/cli/src/index.test.ts` + +- [ ] **Step 1: Run focused Vitest coverage** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/managed-python-runtime.test.ts src/runtime.test.ts src/index.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run the CLI type checker** + +Run: + +```bash +pnpm --filter @ktx/cli run type-check +``` + +Expected: PASS. + +- [ ] **Step 3: Build CLI artifacts so bundled Python assets exist** + +Run: + +```bash +pnpm run artifacts:check +``` + +Expected: PASS. The command must leave these generated files: + +```text +packages/cli/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl +packages/cli/assets/python/manifest.json +``` + +- [ ] **Step 4: Smoke the status command without installing** + +Run: + +```bash +pnpm --filter @ktx/cli run build +node packages/cli/dist/bin.js runtime status --json +``` + +Expected: PASS with JSON containing `"kind": "missing"` or `"kind": "ready"`. +Both are valid because a developer machine might already have a runtime for +the current CLI version. + +- [ ] **Step 5: Smoke the doctor command** + +Run: + +```bash +node packages/cli/dist/bin.js runtime doctor +``` + +Expected: command exits `0` if the runtime is ready and exits `1` if the +runtime is missing. In both cases, stdout must include: + +```text +KTX Python runtime doctor +``` + +- [ ] **Step 6: Run pre-commit for changed files** + +Run: + +```bash +uv run pre-commit run --files packages/cli/src/managed-python-runtime.ts packages/cli/src/managed-python-runtime.test.ts packages/cli/src/runtime.ts packages/cli/src/runtime.test.ts packages/cli/src/commands/runtime-commands.ts packages/cli/src/cli-runtime.ts packages/cli/src/cli-program.ts packages/cli/src/index.ts packages/cli/src/index.test.ts +``` + +Expected: PASS. If pre-commit cannot run because this checkout lacks a +compatible pre-commit environment, record the exact failure and keep the +Vitest, type-check, and build results. + +- [ ] **Step 7: Commit final verification fixes** + +If verification required edits, run: + +```bash +git add packages/cli/src/managed-python-runtime.ts packages/cli/src/managed-python-runtime.test.ts packages/cli/src/runtime.ts packages/cli/src/runtime.test.ts packages/cli/src/commands/runtime-commands.ts packages/cli/src/cli-runtime.ts packages/cli/src/cli-program.ts packages/cli/src/index.ts packages/cli/src/index.test.ts +git commit -m "test: verify managed python runtime commands" +``` + +If no verification edits were needed, do not create an empty commit. + +## Self-review + +Spec coverage: + +- Covers runtime root selection for macOS, Linux, and Windows. +- Covers versioned runtime directories based on the CLI package version. +- Covers locating `uv`, creating a virtual environment, installing the bundled + wheel, and writing a runtime manifest. +- Covers feature levels by installing `core` by default and + `local-embeddings` through the wheel extra when requested. +- Covers focused errors for missing `uv`, failed install logs, status output, + doctor output, and stale runtime pruning. +- Leaves lazy install from normal commands, daemon start/stop/reuse, and + public npm renaming for later plans. + +Placeholder scan: + +- The plan contains no placeholder markers and no unspecified implementation + steps. + +Type and name consistency: + +- Runtime feature strings are consistently `core` and `local-embeddings`. +- Runtime command args use `cliVersion`, `feature`, `force`, `json`, `dryRun`, + and `yes` consistently across command registration, tests, and runner code. +- Asset manifest names are consistently `kaelio-ktx`, `kaelio_ktx`, and + `manifest.json`.