From c22f5a666f8a6375a02890da4eba09820fc11437 Mon Sep 17 00:00:00 2001 From: Stephen Baker Date: Sun, 19 Apr 2026 16:49:20 -0700 Subject: [PATCH] Scaffold prompd ai command (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the five-verb CLI surface for the on-device Prompd AI runtime: install / uninstall / start / stop / status. All verbs are wired through commander with --model and --yes flags where applicable. Only `status` is fully functional in this PR — it reads the catalog and daemon.lock, checks for a live PID, clears stale locks, and prints installed-models + running state. The other four verbs print a "Phase 1 scaffolding — not yet implemented" message with their parsed flags. Supporting lib modules at src/lib/ai/: - paths.ts: resolves ~/.prompd/ai/ and its subdirs - types.ts: ModelEntry, Catalog, DaemonLock, DaemonStatus - catalog.ts: load/save, findModel, getDefaultModel (default-first fallback) - daemon.ts: readLock, clearLock, isPidAlive, getStatus with stale-lock cleanup Tests cover catalog round-trip, default-model resolution, corrupt-file handling, not-running status, and stale-lock cleanup on dead PIDs (8 tests). See docs/PROMPD-AI-PLAN.md in prompd-app for the full plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- typescript/src/commands/ai.ts | 98 ++++++++++++++++++++++ typescript/src/index.ts | 2 + typescript/src/lib/ai/catalog.ts | 36 ++++++++ typescript/src/lib/ai/daemon.ts | 65 ++++++++++++++ typescript/src/lib/ai/paths.ts | 34 ++++++++ typescript/src/lib/ai/types.ts | 36 ++++++++ typescript/tests/ai.test.ts | 140 +++++++++++++++++++++++++++++++ 7 files changed, 411 insertions(+) create mode 100644 typescript/src/commands/ai.ts create mode 100644 typescript/src/lib/ai/catalog.ts create mode 100644 typescript/src/lib/ai/daemon.ts create mode 100644 typescript/src/lib/ai/paths.ts create mode 100644 typescript/src/lib/ai/types.ts create mode 100644 typescript/tests/ai.test.ts diff --git a/typescript/src/commands/ai.ts b/typescript/src/commands/ai.ts new file mode 100644 index 0000000..2d95244 --- /dev/null +++ b/typescript/src/commands/ai.ts @@ -0,0 +1,98 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { getStatus } from '../lib/ai/daemon'; + +export function createAICommand(): Command { + const command = new Command('ai'); + command.description('Manage the on-device Prompd AI runtime'); + + command.addCommand(createInstallSubcommand()); + command.addCommand(createUninstallSubcommand()); + command.addCommand(createStartSubcommand()); + command.addCommand(createStopSubcommand()); + command.addCommand(createStatusSubcommand()); + + return command; +} + +function createInstallSubcommand(): Command { + const cmd = new Command('install'); + cmd + .description('Download and install a model into the local catalog') + .option('-m, --model ', 'Model to install (e.g., gemma-4-e4b-q4, prompd-prmd@0.0.1)') + .option('-y, --yes', 'Assume yes to all prompts (headless mode)') + .action(async (opts: { model?: string; yes?: boolean }) => { + console.log(chalk.yellow('Phase 1 scaffolding — install not yet implemented.')); + console.log(` model: ${opts.model ?? chalk.gray('(not specified)')}`); + console.log(` headless: ${opts.yes ? 'yes' : 'no'}`); + }); + return cmd; +} + +function createUninstallSubcommand(): Command { + const cmd = new Command('uninstall'); + cmd + .description('Remove a model from the catalog, or wipe the entire runtime if no --model is given') + .option('-m, --model ', 'Specific model to remove (omit to wipe everything)') + .option('-y, --yes', 'Assume yes to all prompts (headless mode)') + .action(async (opts: { model?: string; yes?: boolean }) => { + console.log(chalk.yellow('Phase 1 scaffolding — uninstall not yet implemented.')); + console.log(` model: ${opts.model ?? chalk.gray('(wipe everything)')}`); + console.log(` headless: ${opts.yes ? 'yes' : 'no'}`); + }); + return cmd; +} + +function createStartSubcommand(): Command { + const cmd = new Command('start'); + cmd + .description('Start the local AI daemon') + .option('-m, --model ', 'Model to serve (defaults to the first-installed or user-configured default)') + .action(async (opts: { model?: string }) => { + console.log(chalk.yellow('Phase 1 scaffolding — start not yet implemented.')); + console.log(` model: ${opts.model ?? chalk.gray('(default)')}`); + }); + return cmd; +} + +function createStopSubcommand(): Command { + const cmd = new Command('stop'); + cmd + .description('Stop the running local AI daemon') + .action(async () => { + console.log(chalk.yellow('Phase 1 scaffolding — stop not yet implemented.')); + }); + return cmd; +} + +function createStatusSubcommand(): Command { + const cmd = new Command('status'); + cmd + .description('Show daemon state and installed-models catalog') + .action(async () => { + const status = await getStatus(); + + if (status.running) { + console.log(chalk.green('Running')); + console.log(` model: ${status.model}`); + console.log(` port: ${status.port}`); + console.log(` pid: ${status.pid}`); + console.log(` started: ${status.startedAt}`); + console.log(` binary: ${status.binaryPath}`); + } else { + console.log(chalk.gray('Not running')); + } + + console.log(); + console.log(chalk.bold(`Installed models (${status.installedModels.length}):`)); + if (status.installedModels.length === 0) { + console.log(chalk.gray(' (none)')); + } else { + for (const m of status.installedModels) { + const tag = m.isDefault ? chalk.cyan(' [default]') : ''; + console.log(` ${m.name}${tag}`); + } + } + }); + return cmd; +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 7f40d1a..1c9c0b4 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -26,6 +26,7 @@ import { createUninstallCommand } from './commands/uninstall'; import { createNamespaceCommand } from './commands/namespace'; import { createDepsCommand } from './commands/deps'; import { createWorkflowCommand } from './commands/workflow'; +import { createAICommand } from './commands/ai'; const program = new Command(); @@ -52,6 +53,7 @@ program.addCommand(createVersionCommand()); program.addCommand(createGitCommand()); program.addCommand(createMCPCommand()); program.addCommand(createWorkflowCommand()); +program.addCommand(createAICommand()); // Registry operations (top-level for convenience) program.addCommand(createLoginCommand()); diff --git a/typescript/src/lib/ai/catalog.ts b/typescript/src/lib/ai/catalog.ts new file mode 100644 index 0000000..48305f6 --- /dev/null +++ b/typescript/src/lib/ai/catalog.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs-extra'; +import { aiRoot, catalogPath } from './paths'; +import { Catalog, ModelEntry } from './types'; + +const EMPTY_CATALOG: Catalog = { version: 1, models: [] }; + +export async function loadCatalog(): Promise { + const p = catalogPath(); + if (!(await fs.pathExists(p))) { + return { version: 1, models: [] }; + } + try { + const raw = await fs.readJSON(p); + if (!raw || raw.version !== 1 || !Array.isArray(raw.models)) { + return { version: 1, models: [] }; + } + return raw as Catalog; + } catch { + return { version: 1, models: [] }; + } +} + +export async function saveCatalog(catalog: Catalog): Promise { + await fs.ensureDir(aiRoot()); + await fs.writeJSON(catalogPath(), catalog, { spaces: 2 }); +} + +export async function findModel(name: string): Promise { + const catalog = await loadCatalog(); + return catalog.models.find(m => m.name === name); +} + +export async function getDefaultModel(): Promise { + const catalog = await loadCatalog(); + return catalog.models.find(m => m.isDefault) ?? catalog.models[0]; +} diff --git a/typescript/src/lib/ai/daemon.ts b/typescript/src/lib/ai/daemon.ts new file mode 100644 index 0000000..24eba83 --- /dev/null +++ b/typescript/src/lib/ai/daemon.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs-extra'; +import { lockPath } from './paths'; +import { DaemonLock, DaemonStatus } from './types'; +import { loadCatalog } from './catalog'; + +export async function readLock(): Promise { + const p = lockPath(); + if (!(await fs.pathExists(p))) return null; + try { + const raw = await fs.readJSON(p); + if ( + typeof raw?.port !== 'number' || + typeof raw?.pid !== 'number' || + typeof raw?.model !== 'string' || + typeof raw?.startedAt !== 'string' || + typeof raw?.binaryPath !== 'string' + ) { + return null; + } + return raw as DaemonLock; + } catch { + return null; + } +} + +export async function clearLock(): Promise { + const p = lockPath(); + if (await fs.pathExists(p)) { + await fs.remove(p); + } +} + +export function isPidAlive(pid: number): boolean { + try { + // signal 0 performs an existence check without sending a real signal + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function getStatus(): Promise { + const catalog = await loadCatalog(); + const lock = await readLock(); + + if (!lock) { + return { running: false, installedModels: catalog.models }; + } + + if (!isPidAlive(lock.pid)) { + await clearLock(); + return { running: false, installedModels: catalog.models }; + } + + return { + running: true, + model: lock.model, + port: lock.port, + pid: lock.pid, + startedAt: lock.startedAt, + binaryPath: lock.binaryPath, + installedModels: catalog.models, + }; +} diff --git a/typescript/src/lib/ai/paths.ts b/typescript/src/lib/ai/paths.ts new file mode 100644 index 0000000..7f5c159 --- /dev/null +++ b/typescript/src/lib/ai/paths.ts @@ -0,0 +1,34 @@ +import * as path from 'path'; +import * as os from 'os'; + +export function aiRoot(): string { + return path.join(os.homedir(), '.prompd', 'ai'); +} + +export function binDir(): string { + return path.join(aiRoot(), 'bin'); +} + +export function modelsDir(): string { + return path.join(aiRoot(), 'models'); +} + +export function adaptersDir(): string { + return path.join(aiRoot(), 'adapters'); +} + +export function configPath(): string { + return path.join(aiRoot(), 'config.json'); +} + +export function pidPath(): string { + return path.join(aiRoot(), 'daemon.pid'); +} + +export function lockPath(): string { + return path.join(aiRoot(), 'daemon.lock'); +} + +export function catalogPath(): string { + return path.join(aiRoot(), 'catalog.json'); +} diff --git a/typescript/src/lib/ai/types.ts b/typescript/src/lib/ai/types.ts new file mode 100644 index 0000000..40a51ab --- /dev/null +++ b/typescript/src/lib/ai/types.ts @@ -0,0 +1,36 @@ +export interface ModelEntry { + name: string; + family: string; + size: string; + quantization: string; + variant?: string; + version?: string; + weightsPath: string; + sizeBytes: number; + sha256: string; + installedAt: string; + isDefault: boolean; +} + +export interface Catalog { + version: 1; + models: ModelEntry[]; +} + +export interface DaemonLock { + port: number; + pid: number; + model: string; + startedAt: string; + binaryPath: string; +} + +export interface DaemonStatus { + running: boolean; + model?: string; + port?: number; + pid?: number; + startedAt?: string; + binaryPath?: string; + installedModels: ModelEntry[]; +} diff --git a/typescript/tests/ai.test.ts b/typescript/tests/ai.test.ts new file mode 100644 index 0000000..8e2e05d --- /dev/null +++ b/typescript/tests/ai.test.ts @@ -0,0 +1,140 @@ +jest.mock('../src/lib/ai/paths', () => { + const actualPath = jest.requireActual('path'); + const actualOs = jest.requireActual('os'); + const actualFs = jest.requireActual('fs-extra'); + const tmpRoot: string = actualFs.mkdtempSync(actualPath.join(actualOs.tmpdir(), 'prompd-ai-test-')); + return { + aiRoot: () => tmpRoot, + binDir: () => actualPath.join(tmpRoot, 'bin'), + modelsDir: () => actualPath.join(tmpRoot, 'models'), + adaptersDir: () => actualPath.join(tmpRoot, 'adapters'), + configPath: () => actualPath.join(tmpRoot, 'config.json'), + pidPath: () => actualPath.join(tmpRoot, 'daemon.pid'), + lockPath: () => actualPath.join(tmpRoot, 'daemon.lock'), + catalogPath: () => actualPath.join(tmpRoot, 'catalog.json'), + }; +}); + +import * as fs from 'fs-extra'; +import { loadCatalog, saveCatalog, findModel, getDefaultModel } from '../src/lib/ai/catalog'; +import { getStatus, clearLock } from '../src/lib/ai/daemon'; +import { catalogPath, lockPath, aiRoot } from '../src/lib/ai/paths'; + +describe('prompd ai — catalog and daemon scaffolding', () => { + afterAll(() => { + fs.removeSync(aiRoot()); + }); + + beforeEach(async () => { + await clearLock(); + if (await fs.pathExists(catalogPath())) { + await fs.remove(catalogPath()); + } + }); + + describe('catalog', () => { + it('returns empty catalog when file does not exist', async () => { + const catalog = await loadCatalog(); + expect(catalog).toEqual({ version: 1, models: [] }); + }); + + it('round-trips a saved catalog', async () => { + await saveCatalog({ + version: 1, + models: [ + { + name: 'gemma-4-e4b-q4', + family: 'gemma-4', + size: 'e4b', + quantization: 'q4', + weightsPath: '/tmp/weights.gguf', + sizeBytes: 2_500_000_000, + sha256: 'abc123', + installedAt: '2026-04-19T00:00:00.000Z', + isDefault: true, + }, + ], + }); + + const loaded = await loadCatalog(); + expect(loaded.models).toHaveLength(1); + expect(loaded.models[0].name).toBe('gemma-4-e4b-q4'); + }); + + it('findModel returns the matching entry, undefined when missing', async () => { + await saveCatalog({ + version: 1, + models: [makeModel('model-a', false), makeModel('model-b', true)], + }); + + expect((await findModel('model-b'))?.isDefault).toBe(true); + expect(await findModel('nope')).toBeUndefined(); + }); + + it('getDefaultModel prefers explicit default, falls back to first', async () => { + await saveCatalog({ + version: 1, + models: [makeModel('a', false), makeModel('b', true)], + }); + expect((await getDefaultModel())?.name).toBe('b'); + + await saveCatalog({ + version: 1, + models: [makeModel('a', false), makeModel('c', false)], + }); + expect((await getDefaultModel())?.name).toBe('a'); + }); + + it('gracefully handles a corrupt catalog file', async () => { + await fs.ensureFile(catalogPath()); + await fs.writeFile(catalogPath(), '{ not valid json', 'utf-8'); + const catalog = await loadCatalog(); + expect(catalog).toEqual({ version: 1, models: [] }); + }); + }); + + describe('daemon status', () => { + it('reports not running when no lock file exists', async () => { + const status = await getStatus(); + expect(status.running).toBe(false); + expect(status.installedModels).toEqual([]); + }); + + it('reports not running and clears a stale lock (dead pid)', async () => { + await fs.ensureFile(lockPath()); + await fs.writeJSON(lockPath(), { + port: 11434, + pid: 999999999, + model: 'gemma-4-e4b-q4', + startedAt: '2026-04-19T00:00:00.000Z', + binaryPath: '/nonexistent/llama-server', + }); + + const status = await getStatus(); + expect(status.running).toBe(false); + expect(await fs.pathExists(lockPath())).toBe(false); + }); + + it('includes installed-models list even when not running', async () => { + await saveCatalog({ version: 1, models: [makeModel('x', true)] }); + const status = await getStatus(); + expect(status.running).toBe(false); + expect(status.installedModels).toHaveLength(1); + expect(status.installedModels[0].name).toBe('x'); + }); + }); +}); + +function makeModel(name: string, isDefault: boolean) { + return { + name, + family: 'test', + size: 's', + quantization: 'q4', + weightsPath: '', + sizeBytes: 0, + sha256: '', + installedAt: '', + isDefault, + }; +}