-
Notifications
You must be signed in to change notification settings - Fork 0
Scaffold prompd ai command (Phase 1) #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <name>', '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 <name>', '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 <name>', '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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Catalog> { | ||
| 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<void> { | ||
| await fs.ensureDir(aiRoot()); | ||
| await fs.writeJSON(catalogPath(), catalog, { spaces: 2 }); | ||
| } | ||
|
|
||
| export async function findModel(name: string): Promise<ModelEntry | undefined> { | ||
| const catalog = await loadCatalog(); | ||
| return catalog.models.find(m => m.name === name); | ||
| } | ||
|
|
||
| export async function getDefaultModel(): Promise<ModelEntry | undefined> { | ||
| const catalog = await loadCatalog(); | ||
| return catalog.models.find(m => m.isDefault) ?? catalog.models[0]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DaemonLock | null> { | ||
| 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<void> { | ||
| 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; | ||
|
Comment on lines
+38
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| export async function getStatus(): Promise<DaemonStatus> { | ||
| 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, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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[]; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
loadCatalogonly checks thatmodelsis an array, so partially corrupt data like{"version":1,"models":[null]}is treated as valid. Theai statusrenderer then dereferences each entry (m.isDefault/m.name) and crashes onnullentries, which breaks the intended “graceful handling” path for corrupted catalog files.Useful? React with 👍 / 👎.