#!/usr/bin/env bun import { spawn } from "node:child_process"; import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { program } from "commander"; import { findSoulfiles } from "./agent/parser.ts"; import { agentLogPath, getDaemonLogPath, getMozartDir } from "./agent/paths.ts"; import { PROJECT_ROOT } from "./root.ts"; import { getDaemonPid, isDaemonRunning, PORT, startDaemon } from "./server.ts"; import { runSetup, runSetupRebuild } from "./setup.ts"; const DAEMON_FLAG = "__mozart_daemon__"; if (process.argv.includes(DAEMON_FLAG)) { startDaemon(); } else { program.name("mozart").description("AI agent orchestrator").version("0.1.0"); program .command("setup") .description("Install Podman, initialize machine, and build the Mozart agent image") .option("--rebuild", "Force rebuild the agent container image") .action(async (opts: { rebuild?: boolean }) => { try { if (opts.rebuild) { await runSetupRebuild(); } else { await runSetup(); } } catch (err) { console.error(`Setup failed: ${err instanceof Error ? err.message : err}`); process.exit(1); } }); program .command("up") .description("Register and start agent(s) from .soul files") .argument("[path]", "Path to .soul file or directory", ".") .option("--api-key ", "OpenRouter API key") .option("--foreground", "Run daemon in foreground") .option("--dev", "Dev mode: bind-mount src/ into containers, auto-restart daemon on changes") .option("--template ", "Scaffold agents from a built-in template before starting") .action(async (path: string, opts: { apiKey?: string; foreground?: boolean; dev?: boolean; template?: string }) => { if (opts.dev) { process.env.MOZART_DEV = "1"; } const apiKey = opts.apiKey ?? process.env.OPENROUTER_API_KEY; if (!apiKey) { console.error("API key required. Use --api-key or set OPENROUTER_API_KEY."); process.exit(1); } if (opts.template) { const templatesDir = join(PROJECT_ROOT, "templates"); const templateDir = join(templatesDir, opts.template); if (!existsSync(templateDir)) { const available = readdirSync(templatesDir).filter( (d) => d !== "default" && existsSync(join(templatesDir, d)) && readdirSync(join(templatesDir, d)).some((f) => f.endsWith(".soul")), ); console.error(`Unknown template '${opts.template}'. Available templates: ${available.join(", ")}`); process.exit(1); } const targetDir = resolve(path); const existingSoulfiles = findSoulfiles(targetDir); if (existingSoulfiles.length > 0) { console.warn(`Warning: .soul files already exist in ${targetDir} — skipping scaffolding.`); } else { mkdirSync(targetDir, { recursive: true }); const templateFiles = readdirSync(templateDir).filter((f) => f.endsWith(".soul")); for (const file of templateFiles) { cpSync(join(templateDir, file), join(targetDir, file)); } const skillsSource = join(PROJECT_ROOT, "skills"); const skillsDest = join(targetDir, "skills"); if (existsSync(skillsSource) && !existsSync(skillsDest)) { cpSync(skillsSource, skillsDest, { recursive: true }); } console.log( `Scaffolded ${templateFiles.length} agent(s) from '${opts.template}' template into ${targetDir}/`, ); } } const userSoulfiles = findSoulfiles(resolve(path)); const defaultTemplateDir = join(PROJECT_ROOT, "templates", "default"); const defaultSoulfiles = findSoulfiles(defaultTemplateDir); const userIds = new Set(userSoulfiles.map((f) => basename(f).replace(/\.soul$/, ""))); const soulfiles = [ ...userSoulfiles, ...defaultSoulfiles.filter((f) => !userIds.has(basename(f).replace(/\.soul$/, ""))), ]; if (soulfiles.length === 0) { console.error(`No .soul files found in ${resolve(path)}`); process.exit(1); } if (!isDaemonRunning()) { if (opts.foreground) { console.log( `Starting Mozart daemon in foreground${opts.dev ? " (dev mode)" : ""} on http://localhost:${PORT}`, ); startDaemon(); let fgCount = 0; for (const af of soulfiles) { try { const resp = await fetch(`http://localhost:${PORT}/api/agents`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: af }), }); const data = (await resp.json()) as Record; if (resp.status === 201) { fgCount++; console.log(` ✓ ${data.id} (${data.model})`); } else if (!resp.ok) { console.error(` ✗ ${af}: ${data.error}`); } } catch (err) { console.error(` ✗ ${af}: ${err}`); } } console.log(`\n${fgCount} agent(s) registered.`); return; } console.log(`Starting Mozart daemon${opts.dev ? " (dev mode)" : ""}...`); const daemonArgs = opts.dev ? ["--watch", process.argv[1]!, DAEMON_FLAG] : [process.argv[1]!, DAEMON_FLAG]; const child = spawn(process.execPath, daemonArgs, { detached: true, stdio: "ignore", env: { ...process.env }, }); child.unref(); let ready = false; for (let i = 0; i < 30; i++) { await Bun.sleep(200); try { const resp = await fetch(`http://localhost:${PORT}/health`); if (resp.ok) { ready = true; break; } } catch {} } if (!ready) { console.error("Failed to start daemon. Check logs at ~/.mozart/daemon.log"); process.exit(1); } console.log(`Mozart daemon running on http://localhost:${PORT}`); } else { console.log(`Mozart daemon already running on http://localhost:${PORT}`); } const beforeIds = new Set(); try { const resp = await fetch(`http://localhost:${PORT}/api/agents`); const agents = (await resp.json()) as Array<{ id: string }>; for (const a of agents) beforeIds.add(a.id); } catch {} const newIds = new Set(); for (const af of soulfiles) { try { const resp = await fetch(`http://localhost:${PORT}/api/agents`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: af }), }); const data = (await resp.json()) as Record; if (resp.status === 201) { newIds.add(data.id as string); console.log(` ✓ ${data.id} (${data.model})`); } else if (!resp.ok) { console.error(` ✗ ${af}: ${data.error}`); } } catch (err) { console.error(` ✗ ${af}: ${err}`); } } try { const resp = await fetch(`http://localhost:${PORT}/api/agents`); const allAgents = (await resp.json()) as Array<{ id: string; model: string }>; for (const a of allAgents) { if (!beforeIds.has(a.id) && !newIds.has(a.id)) { newIds.add(a.id); console.log(` ✓ ${a.id} (${a.model}) [restored]`); } } } catch {} if (newIds.size > 0) { console.log(`\n${newIds.size} new agent(s) registered.`); } else { console.log("\nAll agents already running."); } console.log(`Open http://localhost:${PORT} to chat.`); }); program .command("stop") .description("Stop an agent (keeps it registered)") .argument("", "Agent id to stop") .action(async (id: string) => { if (!isDaemonRunning()) { console.error("Mozart daemon is not running."); process.exit(1); } const resp = await fetch(`http://localhost:${PORT}/api/agents/${encodeURIComponent(id)}/stop`, { method: "POST", }); const data = (await resp.json()) as Record; if (resp.ok) { console.log(`Agent '${id}' stopped.`); } else { console.error(`Error: ${data.error}`); } }); program .command("start") .description("Start a stopped agent") .argument("", "Agent id to start") .action(async (id: string) => { if (!isDaemonRunning()) { console.error("Mozart daemon is not running."); process.exit(1); } const resp = await fetch(`http://localhost:${PORT}/api/agents/${encodeURIComponent(id)}/start`, { method: "POST", }); const data = (await resp.json()) as Record; if (resp.ok) { console.log(`Agent '${id}' started.`); } else { console.error(`Error: ${data.error}`); } }); program .command("rm") .description("Permanently remove an agent (or --all to remove all and kill daemon)") .argument("[id]", "Agent id to remove") .option("--all", "Remove all agents and kill the daemon") .action(async (id: string | undefined, opts: { all?: boolean }) => { if (opts.all) { if (isDaemonRunning()) { await fetch(`http://localhost:${PORT}/api/agents`, { method: "DELETE" }); const pid = getDaemonPid(); if (pid) { process.kill(pid, "SIGTERM"); console.log("Mozart daemon stopped."); } } const agentsDir = join(getMozartDir(), "agents"); if (existsSync(agentsDir)) { rmSync(agentsDir, { recursive: true, force: true }); } console.log("All agents removed."); return; } if (!isDaemonRunning()) { console.log("Mozart daemon is not running."); return; } if (!id) { console.error("Specify an agent id, or use --all to remove everything."); process.exit(1); } const resp = await fetch(`http://localhost:${PORT}/api/agents/${encodeURIComponent(id)}`, { method: "DELETE" }); const data = (await resp.json()) as Record; if (resp.ok) { console.log(`Agent '${id}' removed.`); } else { console.error(`Error: ${data.error}`); } }); program .command("ps") .description("List all agents") .action(async () => { if (!isDaemonRunning()) { console.log("Mozart daemon is not running."); return; } const resp = await fetch(`http://localhost:${PORT}/api/agents`); const agents = (await resp.json()) as Array<{ id: string; state: string; model: string; uptime: number; messageCount: number; restartCount: number; }>; if (agents.length === 0) { console.log("No agents registered."); return; } const stateColors: Record = { running: "\x1b[32m", starting: "\x1b[33m", error: "\x1b[31m", restarting: "\x1b[33m", stopped: "\x1b[90m", }; const reset = "\x1b[0m"; console.log( `${"ID".padEnd(24)} ${"STATE".padEnd(12)} ${"MODEL".padEnd(30)} ${"UPTIME".padEnd(12)} ${"MSGS".padEnd(8)} RESTARTS`, ); console.log("─".repeat(100)); for (const a of agents) { const color = stateColors[a.state] ?? ""; const uptime = formatUptime(a.uptime); console.log( `${a.id.padEnd(24)} ${color}${a.state.padEnd(12)}${reset} ${a.model.padEnd(30)} ${uptime.padEnd(12)} ${String(a.messageCount).padEnd(8)} ${a.restartCount}`, ); } }); program .command("seal") .description("Seal an agent into a .horcrux file (captures full container state)") .argument("", "Agent id to seal") .option("-o, --output ", "Output path for the .horcrux file") .action(async (id: string, opts: { output?: string }) => { if (!isDaemonRunning()) { console.error("Mozart daemon is not running."); process.exit(1); } const url = `http://localhost:${PORT}/api/agents/${encodeURIComponent(id)}/seal`; const resp = await fetch(url, { method: "POST" }); if (!resp.ok) { const data = (await resp.json()) as Record; console.error(`Error: ${data.error}`); process.exit(1); } const outputPath = opts.output ?? `${id}.horcrux`; const buffer = await resp.arrayBuffer(); const { writeFileSync } = await import("node:fs"); writeFileSync(outputPath, Buffer.from(buffer)); console.log(`Sealed '${id}' to ${outputPath} (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB)`); }); program .command("unseal") .description("Unseal a .horcrux file to create an agent from it") .argument("", "Path to the .horcrux file") .option("--name ", "Override the agent name") .action(async (path: string, opts: { name?: string }) => { if (!isDaemonRunning()) { console.error("Mozart daemon is not running."); process.exit(1); } const { readFileSync: readFs } = await import("node:fs"); const fileBytes = readFs(resolve(path)); const formData = new FormData(); formData.append("file", new Blob([fileBytes]), "agent.horcrux"); if (opts.name) formData.append("name", opts.name); const resp = await fetch(`http://localhost:${PORT}/api/agents/unseal`, { method: "POST", body: formData, }); let data: Record | null = null; try { data = (await resp.json()) as Record; } catch { // Response body wasn't valid JSON } if (resp.ok && data) { console.log(`Unsealed '${data.id}' (${data.model})`); } else { const message = data?.error ?? `Server returned ${resp.status} (no details)`; console.error(`Error: ${message}`); process.exit(1); } }); program .command("logs") .argument("[id]", "Agent id (or omit for daemon logs)") .option("--no-follow", "Don't follow (just print current content)") .action(async (id: string | undefined, opts: { follow?: boolean }) => { const logFile = id ? agentLogPath(id) : getDaemonLogPath(); if (!existsSync(logFile)) { console.error(`No log file found: ${logFile}`); process.exit(1); } console.log(readFileSync(logFile, "utf-8")); if (opts.follow !== false) { let lastSize = Bun.file(logFile).size; setInterval(async () => { const file = Bun.file(logFile); if (file.size > lastSize) { const delta = await file.slice(lastSize).text(); process.stdout.write(delta); lastSize = file.size; } }, 500); } }); program.parse(); } function formatUptime(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ${seconds % 60}s`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ${minutes % 60}m`; const days = Math.floor(hours / 24); return `${days}d ${hours % 24}h`; }