Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions typescript/src/commands/ai.ts
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;
}
2 changes: 2 additions & 0 deletions typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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());
Expand Down
36 changes: 36 additions & 0 deletions typescript/src/lib/ai/catalog.ts
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;
Comment on lines +14 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate model entries before accepting catalog JSON

loadCatalog only checks that models is an array, so partially corrupt data like {"version":1,"models":[null]} is treated as valid. The ai status renderer then dereferences each entry (m.isDefault / m.name) and crashes on null entries, which breaks the intended “graceful handling” path for corrupted catalog files.

Useful? React with 👍 / 👎.

} 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];
}
65 changes: 65 additions & 0 deletions typescript/src/lib/ai/daemon.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat EPERM as alive in PID liveness checks

isPidAlive currently maps every process.kill(pid, 0) exception to “dead”, but Node throws EPERM when a process exists but the current user cannot signal it. In that case getStatus will incorrectly clear daemon.lock and report “Not running”, which can happen when the daemon is started under a different user/context (e.g., sudo/service account) while the same home directory is reused.

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,
};
}
34 changes: 34 additions & 0 deletions typescript/src/lib/ai/paths.ts
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');
}
36 changes: 36 additions & 0 deletions typescript/src/lib/ai/types.ts
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[];
}
Loading
Loading