#!/usr/bin/env node // @ts-nocheck import { startServer } from './server.js'; import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js'; import { runConnectorsToolCli } from './tools-connectors-cli.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; const argv = process.argv.slice(2); // ---- Subcommand router ---------------------------------------------------- // // `od` is two CLIs glued together: // - default mode: starts the daemon + opens the web UI. // - `od media …`: a thin client that POSTs to the running daemon. This // is what the code agent invokes from inside a chat to actually // produce image / video / audio bytes (the unifying contract). // // We dispatch on the first positional argument so flags like --port keep // working unchanged. Subcommand routing is keyword-based; flags are // parsed inside each handler. // Flags accepted by `od media generate`. Whitelisted so a hallucinated // `--length 5` from the LLM fails fast instead of silently no-op'ing // while we route a bogus body to the daemon. // // Hoisted to the top of the module *before* the subcommand dispatch // below: top-level `await SUBCOMMAND_MAP[first](rest)` runs runMedia // synchronously during module evaluation, and runMedia references these // `const` Sets — leaving them at the bottom of the file would hit the // TDZ ("Cannot access 'MEDIA_GENERATE_STRING_FLAGS' before // initialization") and crash every `od media …` invocation. const MEDIA_GENERATE_STRING_FLAGS = new Set([ 'project', 'surface', 'model', 'prompt', 'output', 'aspect', 'length', 'duration', 'voice', 'audio-kind', 'composition-dir', 'image', 'daemon-url', 'language', ]); const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([ 'help', 'h', ]); const MCP_STRING_FLAGS = new Set([ 'daemon-url', ]); const MCP_BOOLEAN_FLAGS = new Set([ 'help', 'h', ]); const RESEARCH_SEARCH_STRING_FLAGS = new Set([ 'query', 'max-sources', 'daemon-url', ]); const RESEARCH_SEARCH_BOOLEAN_FLAGS = new Set([ 'help', 'h', ]); const PLUGIN_STRING_FLAGS = new Set([ 'daemon-url', 'source', 'inputs', 'project', 'snapshot-id', 'capabilities', 'grant-caps', 'before', 'trust', 'tag', ]); const PLUGIN_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'revoke', 'follow', ]); const UI_STRING_FLAGS = new Set([ 'daemon-url', 'run', 'project', 'value', 'value-json', 'plugin', 'snapshot-id', 'persist', 'kind', ]); const UI_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'skip', ]); const SUBCOMMAND_MAP = { media: runMedia, mcp: runMcp, research: runResearch, plugin: runPlugin, ui: runUi, marketplace: runMarketplace, project: runProject, run: runRun, files: runFiles, conversation: runConversation, daemon: runDaemon, atoms: runAtoms, skills: runSkills, 'design-systems': runDesignSystems, craft: runCraft, status: runStatus, version: runVersion, doctor: runDoctor, config: runConfig, }; if (argv[0] === 'mcp' && argv[1] === 'live-artifacts') { try { const { exitCode } = await runLiveArtifactsMcpServer(); process.exit(exitCode); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`); process.exit(1); } } const first = argv.find((a) => !a.startsWith('-')); if (first && SUBCOMMAND_MAP[first]) { const idx = argv.indexOf(first); const rest = [...argv.slice(0, idx), ...argv.slice(idx + 1)]; await SUBCOMMAND_MAP[first](rest); process.exit(0); } if (argv[0] === 'tools' && argv[1] === 'live-artifacts') { runLiveArtifactsToolCli(argv.slice(2)) .then(({ exitCode }) => { process.exitCode = exitCode; }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`); process.exitCode = 1; }); } else if (argv[0] === 'tools' && argv[1] === 'connectors') { runConnectorsToolCli(argv.slice(2)) .then(({ exitCode }) => { process.exitCode = exitCode; }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`); process.exitCode = 1; }); } else { // Default: daemon mode. let port = Number(process.env.OD_PORT) || 7456; let host = process.env.OD_BIND_HOST || '127.0.0.1'; let open = true; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '-p' || a === '--port') { port = Number(argv[++i]); } else if (a === '--host') { host = argv[++i]; } else if (a === '--no-open') { open = false; } else if (a === '-h' || a === '--help') { printRootHelp(); process.exit(0); } } startServer({ port, host, returnServer: true }).then((started) => { const { url, server, shutdown } = started; const closeTimeoutMs = 5_000; const closeServer = () => new Promise((resolve) => { let resolved = false; const resolveOnce = () => { if (resolved) return; resolved = true; resolve(); }; const idleTimer = setTimeout(() => { server.closeIdleConnections?.(); }, Math.min(1_000, closeTimeoutMs)); const hardTimer = setTimeout(() => { server.closeAllConnections?.(); resolveOnce(); }, closeTimeoutMs); idleTimer.unref?.(); hardTimer.unref?.(); server.close(() => resolveOnce()); }).finally(() => { server.closeIdleConnections?.(); }); let shuttingDown = false; const stop = () => { if (shuttingDown) { process.exit(0); } shuttingDown = true; const closePromise = closeServer(); const shutdownPromise = Promise.resolve().then(() => shutdown?.()); void Promise.resolve() .then(() => Promise.allSettled([shutdownPromise, closePromise])) .finally(() => process.exit(0)); }; process.on('SIGINT', stop); process.on('SIGTERM', stop); console.log(`[od] listening on ${url}`); if (open) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; import('node:child_process').then(({ spawn }) => { spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref(); }); } }); } function printRootHelp() { console.log(`Usage: od [--port ] [--host ] [--no-open] Start the local daemon and open the web UI. od tools live-artifacts [options] Manage live artifacts through daemon wrapper commands. od tools connectors [options] Discover and execute configured connectors. od mcp live-artifacts Start the MCP server exposing live-artifact and connector tools. od research search --query [--max-sources 5] [--daemon-url ] Run agent-callable Tavily research through the local daemon. od plugin [args] Discover, install, and apply plugins through the local daemon. od ui [args] Read and answer GenUI surfaces (form / choice / confirmation / oauth-prompt) headlessly. "$OD_NODE_BIN" "$OD_BIN" tools ... Recommended agent-runtime form; avoids relying on user PATH for od or node. od media generate --surface --model [opts] Generate a media artifact and write it into the active project. Designed to be invoked by a code agent - picks up OD_DAEMON_URL and OD_PROJECT_ID from the env that the daemon injected on spawn. od mcp [--daemon-url ] Run a stdio MCP server that proxies read-only tool calls to a running Open Design daemon. Wire it into a coding agent (Claude Code, Cursor, VS Code, Zed, Windsurf) in another repo to pull files from a local Open Design project without exporting a zip. Options: --port Port to listen on (default: 7456, env: OD_PORT). --host Interface address to bind to (default: 127.0.0.1, env: OD_BIND_HOST). Set to a specific IP (e.g. a Tailscale address) to restrict access to that interface only. --no-open Do not open the browser after start. What the daemon does: * scans PATH for installed code-agent CLIs (claude, codex, devin, gemini, opencode, cursor-agent, ...) * serves the chat UI at http://: * proxies messages (text + images) to the selected agent via child-process spawn * exposes /api/projects/:id/media/generate — the unified image/video/audio dispatcher that the agent calls via \`od media generate\`.`); } // --------------------------------------------------------------------------- // Subcommand: od research … // --------------------------------------------------------------------------- async function runResearch(args) { const { sub, subArgs } = splitResearchSubcommand(args); if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) { printResearchHelp(); process.exit(sub === 'help' || args.includes('--help') || args.includes('-h') ? 0 : 2); } if (sub !== 'search') { console.error(`unknown subcommand: od research ${sub}`); printResearchHelp(); process.exit(2); } return runResearchSearch(subArgs); } async function runResearchSearch(rawArgs) { let flags; try { flags = parseFlags(rawArgs, { string: RESEARCH_SEARCH_STRING_FLAGS, boolean: RESEARCH_SEARCH_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); printResearchHelp(); process.exit(2); } const query = typeof flags.query === 'string' ? flags.query.trim() : ''; if (!query) { console.error('--query required'); process.exit(2); } const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; const maxSources = flags['max-sources'] == null ? undefined : Number(flags['max-sources']); const url = `${daemonUrl.replace(/\/$/, '')}/api/research/search`; let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query, ...(Number.isFinite(maxSources) ? { maxSources } : {}), }), }); } catch (err) { surfaceFetchError(err, daemonUrl); process.exit(3); } if (!resp.ok) { const text = await resp.text(); console.error(`daemon ${resp.status}: ${text}`); process.exit(4); } process.stdout.write(`${await resp.text()}\n`); } function printResearchHelp() { console.log(`Usage: od research search --query [--max-sources 5] [--daemon-url ] Runs Tavily-backed shallow research through the local Open Design daemon. Output is JSON only on stdout: { "query": "...", "summary": "...", "sources": [...], "provider": "tavily", "depth": "shallow", "fetchedAt": 0 } Flags: --query Required search query. --max-sources Optional source cap. Defaults to 5, clamped to Tavily's max. --daemon-url Local daemon URL. Defaults to OD_DAEMON_URL or http://127.0.0.1:7456.`); } // --------------------------------------------------------------------------- // Subcommand: od media … // --------------------------------------------------------------------------- async function runMedia(args) { const sub = args.find((a) => !a.startsWith('-')) || ''; if (sub === 'help' || sub === '-h' || sub === '--help' || sub === '') { printMediaHelp(); return; } if (sub !== 'generate' && sub !== 'wait') { console.error(`unknown subcommand: od media ${sub}`); printMediaHelp(); process.exit(1); } const idx = args.indexOf(sub); const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)]; if (sub === 'wait') return runMediaWait(subArgs); return runMediaGenerate(subArgs); } async function runMediaGenerate(rawArgs) { let flags; try { flags = parseFlags(rawArgs, { string: MEDIA_GENERATE_STRING_FLAGS, boolean: MEDIA_GENERATE_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); printMediaHelp(); process.exit(2); } const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; const projectId = flags.project || process.env.OD_PROJECT_ID; if (!projectId) { console.error( 'project id required. Pass --project or set OD_PROJECT_ID. The daemon injects this when it spawns the code agent.', ); process.exit(2); } const surface = flags.surface; if (!surface || !['image', 'video', 'audio'].includes(surface)) { console.error('--surface must be one of: image | video | audio'); process.exit(2); } if (!flags.model) { console.error('--model required (see http:///api/media/models)'); process.exit(2); } const body = { surface, model: flags.model, prompt: flags.prompt, output: flags.output, aspect: flags.aspect, voice: flags.voice, audioKind: flags['audio-kind'], compositionDir: flags['composition-dir'], image: flags.image, language: flags.language, }; if (flags.length != null) body.length = Number(flags.length); if (flags.duration != null) body.duration = Number(flags.duration); const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`; let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } catch (err) { surfaceFetchError(err, daemonUrl); process.exit(3); } if (!resp.ok) { const text = await resp.text(); console.error(`daemon ${resp.status}: ${text}`); process.exit(4); } const accepted = await resp.json(); const { taskId } = accepted; if (!taskId) { console.error('daemon did not return a taskId'); process.exit(4); } console.error(`task ${taskId} queued (${accepted.status || 'queued'})`); await pollUntilDoneOrBudget(daemonUrl, taskId, 0); } async function runMediaWait(rawArgs) { const taskId = rawArgs.find((a) => a && !a.startsWith('--')); if (!taskId) { console.error('usage: od media wait [--since ] [--daemon-url ]'); process.exit(2); } const flagsOnly = rawArgs.filter((a) => a !== taskId); let flags; try { flags = parseFlags(flagsOnly, { string: new Set(['since', 'daemon-url']), boolean: new Set(['help', 'h']), }); } catch (err) { console.error(err.message); printMediaHelp(); process.exit(2); } const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; const since = Number.isFinite(Number(flags.since)) ? Number(flags.since) : 0; await pollUntilDoneOrBudget(daemonUrl, taskId, since); } async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart) { const totalBudgetMs = 25_000; const perCallTimeoutMs = 4_000; const startedAt = Date.now(); const url = `${daemonUrl.replace(/\/$/, '')}/api/media/tasks/${encodeURIComponent(taskId)}/wait`; let since = Number.isFinite(sinceStart) ? sinceStart : 0; let lastSnapshot = null; while (Date.now() - startedAt < totalBudgetMs) { const remaining = totalBudgetMs - (Date.now() - startedAt); const callTimeout = Math.max(500, Math.min(perCallTimeoutMs, remaining)); let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ since, timeoutMs: callTimeout }), }); } catch (err) { surfaceFetchError(err, daemonUrl); process.exit(3); } if (resp.status === 404) { console.error(`task ${taskId} not found (expired or never queued)`); process.exit(4); } if (!resp.ok) { const text = await resp.text(); console.error(`daemon ${resp.status}: ${text}`); process.exit(4); } let snap; try { snap = await resp.json(); } catch { console.error('daemon returned non-JSON for /wait'); process.exit(4); } lastSnapshot = snap; if (Array.isArray(snap.progress)) { for (const line of snap.progress) { process.stderr.write(line + '\n'); process.stdout.write(`# ${line}\n`); } } if (typeof snap.nextSince === 'number') since = snap.nextSince; if (snap.status === 'done') { const file = snap.file || {}; const warnings = Array.isArray(file.warnings) ? file.warnings : []; for (const w of warnings) { if (typeof w === 'string' && w) console.error(`WARN: ${w}`); } if (file.providerError) { const provider = file.providerId || 'provider'; console.error( `WARN: ${provider} call failed — wrote stub fallback (${file.size} bytes) to ${file.name}`, ); console.error(`WARN: reason: ${file.providerError}`); console.error( 'WARN: surface this verbatim to the user. Do NOT claim the stub is the final result.', ); } process.stdout.write(JSON.stringify({ file }) + '\n'); process.exit(file.providerError ? 5 : 0); } if (snap.status === 'failed') { const msg = snap.error?.message || 'task failed'; console.error(`task failed: ${msg}`); process.stdout.write( JSON.stringify({ taskId, status: 'failed', error: snap.error || {} }) + '\n', ); process.exit(snap.error?.status || 5); } if (snap.status === 'interrupted') { const msg = snap.error?.message || 'task interrupted'; console.error(`task interrupted: ${msg}`); process.stdout.write( JSON.stringify({ taskId, status: 'interrupted', error: snap.error || {} }) + '\n', ); process.exit(snap.error?.status || 5); } } const handoff = { taskId, status: lastSnapshot?.status || 'running', nextSince: since, elapsed: Math.round((Date.now() - startedAt) / 1000), }; process.stdout.write(JSON.stringify(handoff) + '\n'); process.stderr.write( `task ${taskId} still running after ${handoff.elapsed}s. ` + `Run \`"$OD_NODE_BIN" "$OD_BIN" media wait ${taskId} --since ${since}\` to continue in an agent runtime ` + `(exit code 2 = still running).\n`, ); process.exit(2); } function surfaceFetchError(err, daemonUrl) { const cause = err && typeof err === 'object' ? err.cause : null; const code = cause && typeof cause === 'object' && typeof cause.code === 'string' ? cause.code : null; const causeMsg = cause && typeof cause === 'object' && typeof cause.message === 'string' ? cause.message : ''; let detail = err && err.message ? err.message : String(err); if (code) detail = `${code}${causeMsg ? ` — ${causeMsg}` : ''}`; else if (causeMsg) detail = causeMsg; console.error(`failed to reach daemon at ${daemonUrl}: ${detail}`); if (code === 'EPERM' || code === 'ENETUNREACH') { console.error( 'hint: outbound connect was denied by a sandbox. If you launched ' + 'this command from a code agent, check the agent\'s sandbox / ' + 'network policy. The Open Design daemon itself is unaffected - it can be ' + 'reached from a regular shell.', ); } } function parseFlags(argv, opts = {}) { const stringFlags = opts.string instanceof Set ? opts.string : new Set(); const booleanFlags = opts.boolean instanceof Set ? opts.boolean : new Set(); const knownFlags = new Set([...stringFlags, ...booleanFlags]); const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (!a || !a.startsWith('--')) { throw new Error(`unexpected positional argument: ${a}`); } const eq = a.indexOf('='); const key = eq >= 0 ? a.slice(2, eq) : a.slice(2); if (knownFlags.size > 0 && !knownFlags.has(key)) { throw new Error( `unknown flag: --${key}. Run with --help for the list of accepted flags.`, ); } if (eq >= 0) { out[key] = a.slice(eq + 1); continue; } if (booleanFlags.has(key)) { out[key] = true; continue; } if (stringFlags.has(key)) { const next = argv[i + 1]; if (next == null) { throw new Error(`flag --${key} requires a value`); } out[key] = next; i++; continue; } const next = argv[i + 1]; if (next != null && !next.startsWith('--')) { out[key] = next; i++; } else { out[key] = true; } } return out; } function printMediaHelp() { console.log(`Usage: od media generate --surface --model [opts] "$OD_NODE_BIN" "$OD_BIN" media generate --surface --model [opts] Required: --surface image | video | audio --model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5). --project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon. Common options: --prompt "" Generation prompt. --output File to write under the project. Auto-named if omitted. --aspect 1:1|16:9|9:16|4:3|3:4 --length Video length. --duration Audio duration. --voice Speech / TTS voice. --language Language boost for TTS (e.g. Chinese,Yue for Cantonese). --audio-kind music|speech|sfx --composition-dir hyperframes-html only — project-relative path to the dir containing hyperframes.json / meta.json / index.html. The daemon runs \`npx hyperframes render\` against it. --image Project-relative path to a reference image (image-to-video for Seedance i2v models, or future image-edit endpoints). Daemon reads the file from the project, base64-encodes it, and forwards it to the upstream API. --daemon-url http://127.0.0.1:7456 Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}. Skills should call this and then reference the returned filename in their artifact / message body. The daemon writes the bytes into the project's files folder so the FileViewer can preview them immediately.`); } // --------------------------------------------------------------------------- // Subcommand: od mcp // --------------------------------------------------------------------------- async function runMcp(args) { let flags; try { flags = parseFlags(args, { string: MCP_STRING_FLAGS, boolean: MCP_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); printMcpHelp(); process.exit(2); } if (flags.help || flags.h) { printMcpHelp(); return; } const { resolveMcpDaemonUrl } = await import('./mcp-daemon-url.js'); const daemonUrl = await resolveMcpDaemonUrl({ flagUrl: flags['daemon-url'] }); const { runMcpStdio } = await import('./mcp.js'); await runMcpStdio({ daemonUrl }); } function printMcpHelp() { console.log(`Usage: od mcp [--daemon-url ] Run a stdio MCP (Model Context Protocol) server that proxies read-only tool calls to a running Open Design daemon. Wire it into a coding agent in another repo so the agent can pull files from a local Open Design project without exporting a zip every iteration. Options: --daemon-url Open Design daemon HTTP base URL. Resolution order: this flag, OD_DAEMON_URL, the running daemon's sidecar IPC status socket (/tmp/open-design/ipc//daemon.sock), then http://127.0.0.1:7456. Each new MCP spawn discovers the live daemon URL at startup, so MCP client configs stay valid across daemon restarts even when the port is ephemeral. A running MCP server caches the URL; restart the MCP client after a daemon restart to pick up a new port. Tools exposed: list_projects list every Open Design project get_active_context what project/file the user has open right now get_artifact([project, entry]) bundle: entry file + every referenced sibling get_project([project]) single project metadata get_file([project, path]) file contents (textual mimes only for now) search_files(query[, project]) literal substring search across textual files list_files([project]) project files + artifactManifest sidecars When project is omitted, get_artifact / get_project / get_file / search_files / list_files default to the project the user has open in Open Design; get_artifact and get_file additionally default to the active file. The response stamps usedActiveContext so callers can see which project/file got resolved. For the copy-paste, per-client snippet (with absolute paths resolved for your machine, plus a one-click deeplink for Cursor), open Settings → MCP server in the Open Design app. Read-only by design; the daemon must be running locally for tool calls to succeed.`); } // --------------------------------------------------------------------------- // Subcommand: od plugin … // --------------------------------------------------------------------------- // Plan §3.B1 / spec §12.4: CLI structured error helper. Maps a daemon // HTTP error envelope (or a synthetic local error) to a stable exit // code + a JSON envelope on stderr. Code agents read these to decide // whether the failure is recoverable (re-grant capabilities, prompt // the user, retry with --grant-caps, etc.). const RECOVERABLE_EXIT_CODES = { 'daemon-not-running': 64, 'plugin-not-found': 65, 'snapshot-not-found': 65, 'capabilities-required': 66, 'missing-input': 67, 'project-not-found': 68, 'run-not-found': 69, 'provider-not-configured': 70, 'plugin-requires-daemon': 71, 'snapshot-stale': 72, 'genui-surface-awaiting': 73, }; function exitWithStructuredError({ code, message, data }) { const exit = RECOVERABLE_EXIT_CODES[code] ?? 1; const envelope = { error: { code, message, data: data ?? {} } }; process.stderr.write(JSON.stringify(envelope) + '\n'); process.exit(exit); } // Map a daemon HTTP response into the exit-code envelope. Returns the // parsed body (so the caller can keep going if it doesn't want to exit). async function structuredHttpFailure(resp, fallbackCode = 'daemon-not-running') { let parsed; try { parsed = await resp.json(); } catch { parsed = {}; } const errCode = parsed?.error?.code; if (errCode && errCode in RECOVERABLE_EXIT_CODES) { exitWithStructuredError({ code: errCode, message: parsed.error.message ?? `HTTP ${resp.status}`, data: parsed.error.data, }); } exitWithStructuredError({ code: fallbackCode, message: parsed?.error?.message ?? `HTTP ${resp.status}: ${await resp.text().catch(() => '')}`, data: parsed?.error?.data, }); } async function runPlugin(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { printPluginHelp(); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); switch (sub) { case 'list': return runPluginList(rest); case 'info': return runPluginInfo(rest); case 'install': return runPluginInstall(rest); case 'uninstall': return runPluginUninstall(rest); case 'apply': return runPluginApply(rest); case 'doctor': return runPluginDoctor(rest); case 'replay': return runPluginReplay(rest); case 'trust': return runPluginTrust(rest); case 'snapshots': return runPluginSnapshots(rest); case 'run': return runPluginRun(rest); case 'scaffold': return runPluginScaffold(rest); case 'export': return runPluginExport(rest); case 'publish': return runPluginPublish(rest); default: console.error(`unknown subcommand: od plugin ${sub}`); printPluginHelp(); process.exit(2); } } // Phase 4 / spec §14.1 — `od plugin scaffold` interactive starter. // // Side-effect: writes a SKILL.md + open-design.json starter under // `//`. Default targetDir is process.cwd() so a code // agent can drop the scaffold into the current repo root. async function runPluginScaffold(rest) { const flags = parseFlags(rest, { string: new Set([ 'id', 'title', 'description', 'task-kind', 'mode', 'scenario', 'out', ]), boolean: new Set(['help', 'h', 'json', 'with-claude-plugin']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin scaffold --id [--title ""] [--description "<text>"] [--task-kind new-generation|code-migration|figma-migration|tune-collab] [--mode <mode>] [--scenario <scenario>] [--out <dir>] [--with-claude-plugin] Writes <out|cwd>/<id>/{SKILL.md,open-design.json,README.md}.`); process.exit(rest.length === 0 ? 2 : 0); } const id = typeof flags.id === 'string' && flags.id.length > 0 ? flags.id : rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od plugin scaffold --id <id>'); process.exit(2); } const targetDir = typeof flags.out === 'string' && flags.out.length > 0 ? flags.out : process.cwd(); const { scaffoldPlugin, ScaffoldError } = await import('./plugins/scaffold.js'); try { const input = { targetDir, id, ...(flags.title ? { title: flags.title } : {}), ...(flags.description ? { description: flags.description } : {}), ...(flags['task-kind'] ? { taskKind: flags['task-kind'] } : {}), ...(flags.mode ? { mode: flags.mode } : {}), ...(flags.scenario ? { scenario: flags.scenario } : {}), withClaudePlugin: Boolean(flags['with-claude-plugin']), }; const result = await scaffoldPlugin(input); if (flags.json) return process.stdout.write(JSON.stringify(result, null, 2) + '\n'); console.log(`[scaffold] ${result.folder}`); for (const file of result.files) console.log(` ${file}`); console.log(`\nNext: od plugin install ${result.folder}`); } catch (err) { if (err instanceof ScaffoldError) { console.error(`[scaffold] ${err.message}`); process.exit(2); } throw err; } } // Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`. // // Produces a publish-ready folder from the AppliedPluginSnapshot // behind a given project (or directly from a snapshot id). Three // targets: 'od', 'claude-plugin', 'agent-skill'. async function runPluginExport(rest) { const flags = parseFlags(rest, { string: new Set(['daemon-url', 'as', 'out', 'snapshot-id', 'project']), boolean: new Set(['help', 'h', 'json']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin export <projectId> --as od|claude-plugin|agent-skill --out <dir> od plugin export --snapshot-id <id> --as od|claude-plugin|agent-skill --out <dir> The export resolves through the daemon HTTP \`POST /api/applied-plugins/export\` endpoint so the running daemon's installed_plugins / applied_plugin_snapshots view is the single source of truth.`); process.exit(rest.length === 0 ? 2 : 0); } const positional = rest.find((a) => !a.startsWith('-')); const projectId = flags.project ?? positional ?? null; const snapshotId = typeof flags['snapshot-id'] === 'string' ? flags['snapshot-id'] : null; if (!projectId && !snapshotId) { console.error('Usage: od plugin export <projectId> --as <target> --out <dir>'); process.exit(2); } const target = String(flags.as ?? 'od'); if (target !== 'od' && target !== 'claude-plugin' && target !== 'agent-skill') { console.error(`--as must be one of: od, claude-plugin, agent-skill (got "${target}")`); process.exit(2); } const out = typeof flags.out === 'string' && flags.out.length > 0 ? flags.out : process.cwd(); const base = pluginDaemonUrl(flags).replace(/\/$/, ''); const resp = await fetch(`${base}/api/applied-plugins/export`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...(snapshotId ? { snapshotId } : { projectId }), target, outDir: out, }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST /api/applied-plugins/export failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log(`[export] ${data.folder} (snapshot ${data.snapshotId})`); for (const f of data.files ?? []) console.log(` ${f}`); } // Plan §3.B4 / spec §6: `od marketplace …` minimum verbs. Add / list / // refresh / remove / trust. The Phase 3 follow-up wires // `od plugin install <name>` resolution through these catalogs. async function runMarketplace(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od marketplace add <url> [--trust trusted|restricted] Register a federated catalog. od marketplace list List registered marketplaces. od marketplace info <id> Inspect one marketplace + cached manifest. od marketplace refresh <id> Re-fetch the manifest. od marketplace remove <id> Forget a marketplace. od marketplace trust <id> [--trust trusted|restricted|official] Update the marketplace trust tier. Common options: --daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts).`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const base = pluginDaemonUrl(flags).replace(/\/$/, ''); switch (sub) { case 'list': { const resp = await fetch(`${base}/api/marketplaces`); const data = await resp.json().catch(() => ({})); if (!resp.ok) return structuredHttpFailure(resp); if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } const rows = data?.marketplaces ?? []; if (rows.length === 0) { console.log('No marketplaces registered. Run `od marketplace add <url>`.'); return; } for (const m of rows) { console.log(`${m.id} trust=${m.trust} url=${m.url}`); } return; } case 'search': { // Plan §3.H4 / spec §12 — marketplace catalog query. Walks // every configured marketplace's plugins[] entry and matches // by substring on name + description + tags. const query = (rest.find((a) => !a.startsWith('-')) ?? '').toLowerCase(); if (!query) { console.error('Usage: od marketplace search "<query>" [--tag <tag>]'); process.exit(2); } const tag = typeof flags.tag === 'string' ? flags.tag.toLowerCase() : null; const resp = await fetch(`${base}/api/marketplaces`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); const matches = []; for (const mp of data?.marketplaces ?? []) { const plugins = mp.manifest?.plugins ?? []; for (const p of plugins) { const haystack = [ p.name ?? '', p.description ?? '', ...(Array.isArray(p.tags) ? p.tags : []), ].join(' ').toLowerCase(); if (!haystack.includes(query)) continue; if (tag && !(Array.isArray(p.tags) && p.tags.map((t) => t.toLowerCase()).includes(tag))) continue; matches.push({ marketplaceId: mp.id, marketplaceUrl: mp.url, name: p.name, source: p.source, description: p.description ?? '', tags: p.tags ?? [], }); } } if (flags.json) { process.stdout.write(JSON.stringify({ matches }, null, 2) + '\n'); return; } if (matches.length === 0) { console.log(`No matches for "${query}"`); return; } for (const m of matches) { console.log(`${m.name}\t${m.source}\t${m.marketplaceId}\t${m.description}`); } return; } case 'add': { const url = rest.find((a) => !a.startsWith('-')); if (!url) { console.error('Usage: od marketplace add <url> [--trust trusted|restricted]'); process.exit(2); } const trust = flags.trust ?? 'restricted'; const resp = await fetch(`${base}/api/marketplaces`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url, trust }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`add failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } console.log(`[marketplace] added ${data.id} (${data.url}) trust=${data.trust}`); return; } case 'info': case 'refresh': case 'remove': case 'trust': { const id = rest.find((a) => !a.startsWith('-') && a !== flags.trust); if (!id) { console.error(`Usage: od marketplace ${sub} <id>`); process.exit(2); } let url; let method = 'GET'; let body; if (sub === 'info') url = `${base}/api/marketplaces/${encodeURIComponent(id)}`; else if (sub === 'refresh') { url = `${base}/api/marketplaces/${encodeURIComponent(id)}/refresh`; method = 'POST'; } else if (sub === 'remove') { url = `${base}/api/marketplaces/${encodeURIComponent(id)}`; method = 'DELETE'; } else if (sub === 'trust') { const trust = flags.trust ?? 'trusted'; url = `${base}/api/marketplaces/${encodeURIComponent(id)}/trust`; method = 'POST'; body = JSON.stringify({ trust }); } const resp = await fetch(url, { method, ...(body ? { headers: { 'content-type': 'application/json' }, body } : {}), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`${sub} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } default: console.error(`unknown subcommand: od marketplace ${sub}`); process.exit(2); } } // Plan §3.A5 / spec §16 Phase 5: operator escape hatch for snapshot GC. // Two subcommands: // - `od plugin snapshots list [--project <id>]` — list snapshots // - `od plugin snapshots prune [--before <ts>]` — force-delete expired // (and optionally older-than-cutoff unreferenced) rows. async function runPluginSnapshots(args) { const sub = args[0]; if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od plugin snapshots list [--project <id>] List applied plugin snapshots. od plugin snapshots prune [--before <unix-ms>] Delete expired (or older-than-cutoff) snapshots.`); process.exit(args.length === 0 ? 2 : 0); } const flags = parseFlags(args.slice(1), { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const base = pluginDaemonUrl(flags).replace(/\/$/, ''); if (sub === 'list') { const url = flags.project ? `${base}/api/projects/${encodeURIComponent(flags.project)}/applied-plugins` : `${base}/api/applied-plugins`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } if (sub === 'prune') { const url = `${base}/api/applied-plugins/prune`; const before = flags.before ? Number(flags.before) : undefined; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(before ? { before } : {}), }); if (!resp.ok) { console.error(`POST ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } console.log(`[snapshots] pruned ${data.removed ?? 0} snapshot(s)`); return; } console.error(`unknown subcommand: od plugin snapshots ${sub}`); process.exit(2); } // Plan §3.B3: `od plugin run <id>` shorthand. Today this is a thin // wrapper around `od plugin apply` + `POST /api/runs` so a code agent // can drive the apply→start→follow loop without two hops. Phase 4 // adds full ND-JSON event streaming through `od run watch`. async function runPluginRun(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source && a !== flags.inputs && a !== flags.project && a !== flags['snapshot-id'] && a !== flags.capabilities && a !== flags['grant-caps']); if (!id) { console.error('Usage: od plugin run <id> --project <projectId> [--inputs <json>] [--grant-caps a,b]'); process.exit(2); } if (!flags.project) { console.error('--project <projectId> is required (Phase 1.5 will add the auto-create wrapper)'); process.exit(2); } const inputs = flags.inputs ? safeParseJson(flags.inputs) ?? {} : {}; const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0 ? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean) : []; const base = pluginDaemonUrl(flags).replace(/\/$/, ''); // 1. Apply (returns ApplyResult + manifestSourceDigest). const applyResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/apply`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs, grantCaps, projectId: flags.project }), }); const applyData = await applyResp.json().catch(() => ({})); if (!applyResp.ok) { console.error(`apply failed: ${applyResp.status} ${JSON.stringify(applyData)}`); process.exit(applyResp.status === 422 ? 67 : 1); } // 2. Start the run with pluginId so the daemon resolver pins the // snapshot to the run object. const runResp = await fetch(`${base}/api/runs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: flags.project, pluginId: id, pluginInputs: inputs, grantCaps, }), }); const runData = await runResp.json().catch(() => ({})); if (!runResp.ok) { if (runResp.status === 409 && runData?.error?.code === 'capabilities-required') { const missing = (runData.error.data?.missing ?? []).join(','); console.error(`[run] capabilities required: ${missing}`); console.error(`[run] retry with --grant-caps ${missing} or run \`od plugin trust ${id} --capabilities ${missing}\``); process.exit(66); } console.error(`run failed: ${runResp.status} ${JSON.stringify(runData)}`); process.exit(1); } if (flags.json) { process.stdout.write(JSON.stringify({ apply: applyData, run: runData }, null, 2) + '\n'); return; } console.log(`[run] started run ${runData.runId} (snapshot ${applyData?.appliedPlugin?.snapshotId ?? 'n/a'})`); if (flags.follow) { console.log(`[run] follow stream: GET ${base}/api/runs/${runData.runId}/events`); } } function pluginDaemonUrl(flags) { return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; } async function runPluginList(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET /api/plugins failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } const plugins = Array.isArray(data?.plugins) ? data.plugins : []; if (plugins.length === 0) { console.log('No plugins installed. Run `od plugin install --source <path>` to install one.'); return; } for (const p of plugins) { console.log(`${p.id}@${p.version} trust=${p.trust} source=${p.sourceKind} title="${p.title}"`); } } async function runPluginInfo(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const id = rest.find((a) => !a.startsWith('--') && a !== flags['daemon-url'] && a !== flags.source); if (!id) { console.error('Usage: od plugin info <id>'); process.exit(2); } const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } async function runPluginInstall(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const source = typeof flags.source === 'string' ? flags.source : rest.find((a) => !a.startsWith('-')); if (!source) { console.error('Usage: od plugin install <source-or-name>\n' + ' od plugin install ./local-folder\n' + ' od plugin install github:owner/repo[@ref][/subpath]\n' + ' od plugin install https://example.com/plugin.tar.gz\n' + ' od plugin install <name> # resolves through configured marketplaces'); process.exit(2); } const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/install`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ source }), }); if (!resp.ok || !resp.body) { console.error(`POST /api/plugins/install failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let exitCode = 0; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() ?? ''; for (const block of blocks) { const lines = block.split('\n'); const eventLine = lines.find((l) => l.startsWith('event: ')); const dataLine = lines.find((l) => l.startsWith('data: ')); const event = eventLine ? eventLine.slice('event: '.length) : 'message'; const data = dataLine ? safeParseJson(dataLine.slice('data: '.length)) : null; if (event === 'progress') { console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`); } else if (event === 'success') { console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`); if (Array.isArray(data?.warnings) && data.warnings.length > 0) { for (const w of data.warnings) console.log(`[install] warn: ${w}`); } } else if (event === 'error') { console.error(`[install] error: ${data?.message ?? 'unknown'}`); exitCode = 1; } } } process.exit(exitCode); } async function runPluginUninstall(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source); if (!id) { console.error('Usage: od plugin uninstall <id>'); process.exit(2); } const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/uninstall`; const resp = await fetch(url, { method: 'POST' }); if (!resp.ok) { console.error(`POST /api/plugins/${id}/uninstall failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); console.log(`[uninstall] ${data?.removedFolder ? 'ok' : 'no-op'}${data?.warning ? ` (warning: ${data.warning})` : ''}`); } async function runPluginApply(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source && a !== flags.inputs && a !== flags.project && a !== flags['grant-caps']); if (!id) { console.error('Usage: od plugin apply <id> [--inputs <json>] [--input k=v ...] [--project <id>] [--grant-caps a,b]'); process.exit(2); } // Plan §3.B2: support both --inputs <json> and repeated --input k=v // forms so a code agent can build the inputs map without a JSON // shell-escape dance. let inputs = {}; if (typeof flags.inputs === 'string' && flags.inputs.trim().length > 0) { try { inputs = JSON.parse(flags.inputs); } catch (err) { console.error(`--inputs must be valid JSON: ${err.message}`); process.exit(2); } } for (let i = 0; i < rest.length; i++) { if (rest[i] === '--input' && typeof rest[i + 1] === 'string') { const kv = rest[i + 1]; const eq = kv.indexOf('='); if (eq > 0) { const k = kv.slice(0, eq); const v = kv.slice(eq + 1); inputs[k] = coerceCliValue(v); } i += 1; } } const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0 ? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean) : []; const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`; let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs, projectId: flags.project, grantCaps }), }); } catch (err) { return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${pluginDaemonUrl(flags)}: ${err?.message ?? err}`, }); } const data = await resp.json().catch(() => ({})); if (!resp.ok) { if (resp.status === 422 && Array.isArray(data?.fields)) { return exitWithStructuredError({ code: 'missing-input', message: `Plugin "${id}" is missing required inputs: ${data.fields.join(', ')}`, data: { pluginId: id, missing: data.fields }, }); } return structuredHttpFailure(resp); } if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } const snap = data?.appliedPlugin; if (snap) { console.log(`[apply] ${snap.pluginId}@${snap.pluginVersion} digest=${snap.manifestSourceDigest.slice(0, 12)}…`); console.log(`[apply] context: ${(data.contextItems ?? []).map((c) => `${c.kind}:${c.id ?? c.name ?? c.path}`).join(', ')}`); if (Array.isArray(data.warnings) && data.warnings.length > 0) { for (const w of data.warnings) console.log(`[apply] warn: ${w}`); } } else { console.log(JSON.stringify(data)); } } function coerceCliValue(raw) { if (raw === 'true') return true; if (raw === 'false') return false; if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw); return raw; } // Phase 4 / spec §14.1 — `od plugin publish --to <catalog>`. // // Reads the installed plugin's manifest metadata (or the snapshot's // frozen view via --snapshot-id) and prints the catalog submission URL // + PR body. With `--open` the CLI auto-launches the system browser // against the URL so the author lands on the catalog's submission form // in one step. We never POST anywhere — the upstream review flow is // always under the author's control. async function runPluginPublish(rest) { const flags = parseFlags(rest, { string: new Set(['daemon-url', 'to', 'snapshot-id', 'repo']), boolean: new Set(['help', 'h', 'json', 'open']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin publish <pluginId> --to anthropics-skills|awesome-agent-skills|clawhub|skills-sh [--repo <github-url>] [--snapshot-id <id>] [--open] [--json] The CLI prints the catalog's submission URL + a pre-filled PR body. Pass --open to auto-launch the system browser. Use --snapshot-id to publish from a frozen run snapshot rather than the live installed copy.`); process.exit(rest.length === 0 ? 2 : 0); } const id = rest.find((a) => !a.startsWith('-') && a !== flags.to && a !== flags.repo && a !== flags['snapshot-id']); const target = String(flags.to ?? ''); if (!id) { console.error('Usage: od plugin publish <pluginId> --to <catalog>'); process.exit(2); } if (!target) { console.error('--to <catalog> is required (one of: anthropics-skills, awesome-agent-skills, clawhub, skills-sh)'); process.exit(2); } const base = pluginDaemonUrl(flags).replace(/\/$/, ''); // Pull the plugin metadata from the daemon. We do this through the // existing /api/plugins/:id endpoint so the CLI never needs a direct // SQLite handle; everything stays loopback-mediated. let meta = { pluginId: id, pluginVersion: '0.0.0' }; try { const resp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`); if (resp.ok) { const row = await resp.json(); meta = { pluginId: row.id ?? id, pluginVersion: row.version ?? '0.0.0', ...(row.title ? { pluginTitle: row.title } : {}), ...(row.manifest?.description ? { pluginDescription: row.manifest.description } : {}), }; } } catch { // Best-effort; if the daemon isn't reachable we still try to build // a link from the user's flags so the author doesn't need a daemon // to publish. } if (typeof flags.repo === 'string' && flags.repo.length > 0) { meta.repoUrl = flags.repo; } const { buildPublishLink, PublishError } = await import('./plugins/publish.js'); let link; try { link = buildPublishLink({ catalog: target, meta }); } catch (err) { if (err instanceof PublishError) { console.error(`[publish] ${err.message}`); process.exit(2); } throw err; } if (flags.json) { process.stdout.write(JSON.stringify(link, null, 2) + '\n'); } else { console.log(`[publish] ${link.catalogLabel}`); console.log(link.url); console.log('---'); console.log(link.prBody); } if (flags.open) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; const { spawn } = await import('node:child_process'); spawn(opener, [link.url], { detached: true, stdio: 'ignore' }).unref(); } } async function runPluginDoctor(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source); if (!id) { console.error('Usage: od plugin doctor <id>'); process.exit(2); } const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/doctor`; const resp = await fetch(url, { method: 'POST' }); if (!resp.ok) { console.error(`POST /api/plugins/${id}/doctor failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } else { if (data.ok && (data.issues ?? []).length === 0) { console.log(`[doctor] ${data.pluginId} ok (digest ${data.freshDigest.slice(0, 12)}…)`); } else { console.log(`[doctor] ${data.pluginId} ${data.ok ? 'warnings' : 'errors'}:`); for (const issue of data.issues ?? []) { console.log(` [${issue.severity}] ${issue.code}: ${issue.message}`); } } } process.exit(data.ok ? 0 : 1); } function safeParseJson(s) { try { return JSON.parse(s); } catch { return null; } } // `od plugin replay <runId> --snapshot-id <id>` — re-emit the immutable // snapshot the original run was launched against, so the caller (or // another agent) can re-apply the same plugin against fresh state. Phase // 2A keeps replay headless: the CLI prints the snapshot + rerun bundle; // the agent restarts the run via `od plugin apply` followed by a normal // `od run start`. Future Phase 2C `od plugin run` will collapse this // into a one-shot wrapper. async function runPluginReplay(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const runId = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source && a !== flags.inputs && a !== flags.project && a !== flags['snapshot-id'] && a !== flags.capabilities); if (!runId) { console.error('Usage: od plugin replay <runId> --snapshot-id <id>'); process.exit(2); } const snapshotId = flags['snapshot-id']; if (!snapshotId) { console.error('--snapshot-id is required (runs are in-memory in Phase 2A; pass the snapshot id returned by od plugin apply)'); process.exit(2); } const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/replay`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshotId }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST /api/runs/${runId}/replay failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } console.log(`[replay] ${data.rerun?.pluginId}@${data.rerun?.pluginVersion} digest=${(data.rerun?.manifestSourceDigest ?? '').slice(0, 12)}…`); console.log(`[replay] inputs: ${JSON.stringify(data.rerun?.inputs ?? {})}`); console.log('[replay] re-apply via: od plugin apply ' + data.rerun?.pluginId + ' --inputs ' + JSON.stringify(JSON.stringify(data.rerun?.inputs ?? {}))); } // `od plugin trust <id> --capabilities <comma-sep>` — flip a plugin's // capabilities_granted set. Plan §3.A2 / spec §9.1: the CLI is the // canonical write surface (invariant I4). The daemon validates the // capability vocabulary; unknown / malformed entries surface as // exit-2 usage failures. async function runPluginTrust(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source && a !== flags.inputs && a !== flags.project && a !== flags['snapshot-id'] && a !== flags.capabilities); if (!id) { console.error('Usage: od plugin trust <id> --capabilities connector:figma,connector:notion [--revoke]'); process.exit(2); } const capsCsv = typeof flags.capabilities === 'string' ? flags.capabilities : ''; const caps = capsCsv.split(',').map((c) => c.trim()).filter(Boolean); if (caps.length === 0) { console.error('--capabilities is required (comma-separated, e.g. connector:figma,fs:read)'); process.exit(2); } const action = flags.revoke ? 'revoke' : 'grant'; const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/trust`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ capabilities: caps, action }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { if (resp.status === 400 && data?.error?.code === 'invalid-capability') { const rej = (data.error.data?.rejected ?? []) .map((r) => `${r.capability} (${r.reason})`) .join(', '); console.error(`[trust] invalid capabilities: ${rej}`); process.exit(2); } console.error(`POST ${url} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } console.log(`[trust] ${action === 'grant' ? 'granted' : 'revoked'} on ${id}: ${caps.join(', ')}`); console.log(`[trust] now: ${(data.capabilitiesGranted ?? []).join(', ')}`); } // --------------------------------------------------------------------------- // Subcommand: od ui … (spec §10.3.4 headless GenUI surface inbox) // --------------------------------------------------------------------------- async function runUi(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { printUiHelp(); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); switch (sub) { case 'list': return runUiList(rest); case 'show': return runUiShow(rest); case 'respond': return runUiRespond(rest); case 'revoke': return runUiRevoke(rest); case 'prefill': return runUiPrefill(rest); default: console.error(`unknown subcommand: od ui ${sub}`); printUiHelp(); process.exit(2); } } function uiDaemonUrl(flags) { return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; } async function runUiList(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const base = uiDaemonUrl(flags).replace(/\/$/, ''); let url; if (flags.run) url = `${base}/api/runs/${encodeURIComponent(flags.run)}/genui`; else if (flags.project) url = `${base}/api/projects/${encodeURIComponent(flags.project)}/genui`; else { console.error('Usage: od ui list --run <runId> | --project <projectId>'); process.exit(2); } const resp = await fetch(url); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } const surfaces = Array.isArray(data?.surfaces) ? data.surfaces : []; if (surfaces.length === 0) { console.log('No GenUI surfaces.'); return; } for (const s of surfaces) { console.log(`${s.surfaceId} kind=${s.kind} persist=${s.persist} status=${s.status} rowId=${s.id}`); } } async function runUiShow(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const positional = rest.filter((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin && a !== flags['snapshot-id'] && a !== flags.persist && a !== flags.kind); const runId = flags.run ?? positional[0]; const surfaceId = flags['snapshot-id'] ? null : positional[flags.run ? 0 : 1]; if (!runId || !surfaceId) { console.error('Usage: od ui show --run <runId> <surfaceId>'); process.exit(2); } const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } async function runUiRespond(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const positional = rest.filter((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin && a !== flags['snapshot-id'] && a !== flags.persist && a !== flags.kind); const runId = flags.run ?? positional[0]; const surfaceId = positional[flags.run ? 0 : 1]; if (!runId || !surfaceId) { console.error('Usage: od ui respond --run <runId> <surfaceId> [--value <text> | --value-json <json> | --skip]'); process.exit(2); } let value = null; if (flags.skip) { // Skip translates to a null answer; daemon resolves the surface in // `resolved` state with `respondedBy: 'auto'`. Phase 2A keeps the // semantics simple; spec §10.3.4 onTimeout='skip' lands in Phase 4. value = null; } else if (typeof flags['value-json'] === 'string') { try { value = JSON.parse(flags['value-json']); } catch (err) { console.error(`--value-json must be valid JSON: ${err.message}`); process.exit(2); } } else if (typeof flags.value === 'string') { value = flags.value; } const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}/respond`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value, respondedBy: 'user' }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST ${url} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } else { console.log(`[ui] ${surfaceId} resolved (rowId=${data?.surface?.id})`); } } async function runUiRevoke(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const positional = rest.filter((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin && a !== flags['snapshot-id'] && a !== flags.persist && a !== flags.kind); const projectId = flags.project ?? positional[0]; const surfaceId = positional[flags.project ? 0 : 1]; if (!projectId || !surfaceId) { console.error('Usage: od ui revoke --project <projectId> <surfaceId>'); process.exit(2); } const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`; const resp = await fetch(url, { method: 'POST' }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST ${url} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } else { console.log(`[ui] revoked ${data.invalidated} row(s)`); } } async function runUiPrefill(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const positional = rest.filter((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin && a !== flags['snapshot-id'] && a !== flags.persist && a !== flags.kind); const projectId = flags.project ?? positional[0]; const surfaceId = positional[flags.project ? 0 : 1]; const snapshotId = flags['snapshot-id']; if (!projectId || !surfaceId || !snapshotId) { console.error('Usage: od ui prefill --project <projectId> --snapshot-id <id> <surfaceId> [--value <text> | --value-json <json>] [--persist run|conversation|project] [--kind form|choice|confirmation|oauth-prompt]'); process.exit(2); } let value = null; if (typeof flags['value-json'] === 'string') { try { value = JSON.parse(flags['value-json']); } catch (err) { console.error(`--value-json must be valid JSON: ${err.message}`); process.exit(2); } } else if (typeof flags.value === 'string') { value = flags.value; } const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/prefill`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshotId, surfaceId, kind: flags.kind ?? 'confirmation', persist: flags.persist ?? 'project', value, }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST ${url} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } else { console.log(`[ui] prefilled ${surfaceId} (rowId=${data?.surface?.id})`); } } function printUiHelp() { console.log(`Usage: od ui list --run <runId> List GenUI surfaces for a run. od ui list --project <projectId> List GenUI surfaces for a project. od ui show --run <runId> <surfaceId> Read a single surface (kind / schema / value). od ui respond --run <runId> <surfaceId> [--value <txt> | --value-json <json> | --skip] Answer a pending surface from any process. od ui revoke --project <projectId> <surfaceId> Invalidate a project-tier cached answer. od ui prefill --project <projectId> --snapshot-id <id> <surfaceId> [--value <text> | --value-json <json>] [--persist run|conversation|project] Pre-answer a surface so the run never broadcasts it. Common options: --daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts) instead of human-readable output.`); } function printPluginHelp() { console.log(`Usage: od plugin list List installed plugins. od plugin info <id> Print a plugin's manifest + trust state as JSON. od plugin install --source <path> Install a plugin from a local folder (Phase 1). od plugin uninstall <id> Remove a plugin from the registry + on-disk staging. od plugin apply <id> [--inputs <json>] Compute an ApplyResult (preview) for a plugin. od plugin doctor <id> Lint a plugin's manifest, atoms and resolved refs. od plugin replay <runId> --snapshot-id <id> Re-emit the immutable snapshot a run launched against. od plugin trust <id> --capabilities a,b Stage a capability grant (full mutation lands Phase 3). Common options: --daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts) instead of human-readable output. Phase 1 only supports local-folder installs. The github / https tarball sources arrive in Phase 2A. The marketplace surface comes in Phase 4.`); } // --------------------------------------------------------------------------- // Subcommand: od project / od run / od files / od conversation // // Plan §6 Phase 1 follow-up + Phase 2C: thin CLI wrappers over the // existing daemon HTTP endpoints (POST /api/projects, POST /api/runs, // GET /api/projects/:id/files, …). The §12.5 walkthrough relies on // these so a code agent can drive Open Design end-to-end without // hitting `/api/*` directly. Spec §11.7 invariant: every UI feature is // reachable via the CLI; we wrap rather than duplicate. // --------------------------------------------------------------------------- function projectDaemonUrl(flags) { return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; } const PROJECT_STRING_FLAGS = new Set([ 'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json', 'pending-prompt', 'project', 'conversation', 'message', 'path', 'as', 'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', ]); const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']); function safeReadJsonFile(p) { try { const fs = (require ? require('node:fs') : null); if (!fs) return null; if (p === '-') return JSON.parse(fs.readFileSync(0, 'utf8')); return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } } async function runProject(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od project create [--name "<title>"] [--skill <id>] [--design-system <id>] [--plugin <id>] [--inputs <json>] [--metadata-json <path|->] od project list List projects. od project info <id> Print one project. od project delete <id> Delete a project. Common options: --daemon-url <url> Open Design daemon HTTP base. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); const base = projectDaemonUrl(flags).replace(/\/$/, ''); switch (sub) { case 'list': { const resp = await fetch(`${base}/api/projects`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const projects = data?.projects ?? []; if (projects.length === 0) { console.log('No projects. Create one with `od project create --name "..."`.'); return; } for (const p of projects) console.log(`${p.id}\t${p.name}\t${p.skillId ?? '-'}`); return; } case 'info': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od project info <id>'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } case 'create': { const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).slice(2); const name = typeof flags.name === 'string' && flags.name.length > 0 ? flags.name : 'Untitled project'; const body = { id, name, skillId: flags.skill ?? null, designSystemId: flags['design-system'] ?? null, }; if (flags['pending-prompt']) body.pendingPrompt = flags['pending-prompt']; if (flags['metadata-json']) { const mj = safeReadJsonFile(flags['metadata-json']); if (mj && typeof mj === 'object') body.metadata = mj; } if (flags.plugin) body.pluginId = flags.plugin; if (flags.inputs) { try { body.pluginInputs = JSON.parse(flags.inputs); } catch (err) { console.error(`--inputs must be valid JSON: ${err.message}`); process.exit(2); } } if (flags['grant-caps']) { body.grantCaps = String(flags['grant-caps']).split(',').map((c) => c.trim()).filter(Boolean); } const resp = await fetch(`${base}/api/projects`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { if (resp.status === 409 && data?.error?.code === 'capabilities-required') { return exitWithStructuredError({ code: 'capabilities-required', message: data.error.message, data: data.error.data, }); } console.error(`POST /api/projects failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log(`[project] created ${data.project?.id ?? id} (conversation ${data.conversationId})`); return; } case 'delete': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od project delete <id>'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); console.log(`[project] deleted ${id}`); return; } default: console.error(`unknown subcommand: od project ${sub}`); process.exit(2); } } async function runRun(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od run start --project <projectId> [--conversation <id>] [--message "<text>"] [--plugin <id>] [--inputs <json>] [--grant-caps a,b] [--agent claude|codex|gemini] [--model <id>] [--follow] [--json] od run watch <runId> ND-JSON event stream on stdout. od run cancel <runId> Request cancellation. od run list [--project <id>] List recent runs. od run info <runId> One run's status. Common options: --daemon-url <url> Open Design daemon HTTP base. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); const base = projectDaemonUrl(flags).replace(/\/$/, ''); switch (sub) { case 'list': { const url = flags.project ? `${base}/api/runs?projectId=${encodeURIComponent(flags.project)}` : `${base}/api/runs`; const resp = await fetch(url); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const runs = data?.runs ?? []; for (const r of runs) { console.log(`${r.id}\t${r.status}\tproject=${r.projectId ?? '-'}\tplugin=${r.pluginId ?? '-'}`); } return; } case 'info': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od run info <runId>'); process.exit(2); } const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}`); if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found'); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } case 'cancel': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od run cancel <runId>'); process.exit(2); } const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found'); console.log(`[run] cancelled ${id}`); return; } case 'watch': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od run watch <runId>'); process.exit(2); } await streamRunEvents(base, id); return; } case 'start': { if (!flags.project) { console.error('--project <projectId> is required'); process.exit(2); } const body = { projectId: flags.project }; if (flags.conversation) body.conversationId = flags.conversation; if (flags.message) body.message = flags.message; if (flags.plugin) body.pluginId = flags.plugin; if (flags.agent) body.agentId = flags.agent; if (flags.model) body.model = flags.model; if (flags.inputs) { try { body.pluginInputs = JSON.parse(flags.inputs); } catch (err) { console.error(`--inputs must be valid JSON: ${err.message}`); process.exit(2); } } if (flags['grant-caps']) { body.grantCaps = String(flags['grant-caps']).split(',').map((c) => c.trim()).filter(Boolean); } if (flags['snapshot-id']) body.appliedPluginSnapshotId = flags['snapshot-id']; const resp = await fetch(`${base}/api/runs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { if (resp.status === 409 && data?.error?.code === 'capabilities-required') { return exitWithStructuredError({ code: 'capabilities-required', message: data.error.message, data: data.error.data, }); } if (resp.status === 422 && data?.error?.code === 'missing-input') { return exitWithStructuredError({ code: 'missing-input', message: data.error.message, data: data.error.data, }); } console.error(`POST /api/runs failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json && !flags.follow) { return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } console.log(`[run] started ${data.runId}`); if (flags.follow) await streamRunEvents(base, data.runId); return; } default: console.error(`unknown subcommand: od run ${sub}`); process.exit(2); } } // Stream the SSE events at /api/runs/:id/events as ND-JSON on stdout. // Each line is one event: { event, data } so a code agent can parse it // without needing an SSE library. async function streamRunEvents(base, runId) { const resp = await fetch(`${base}/api/runs/${encodeURIComponent(runId)}/events`, { headers: { accept: 'text/event-stream' }, }); if (!resp.ok || !resp.body) { console.error(`run watch failed: ${resp.status}`); process.exit(1); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() ?? ''; for (const block of blocks) { const lines = block.split('\n'); const eventLine = lines.find((l) => l.startsWith('event: ')); const dataLine = lines.find((l) => l.startsWith('data: ')); const event = eventLine ? eventLine.slice('event: '.length) : 'message'; const dataRaw = dataLine ? dataLine.slice('data: '.length) : ''; let parsed; try { parsed = JSON.parse(dataRaw); } catch { parsed = dataRaw; } process.stdout.write(JSON.stringify({ event, data: parsed }) + '\n'); if (event === 'end') { return; } } } } async function runFiles(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od files list <projectId> List files in a project. od files read <projectId> <relpath> Stream file bytes to stdout. od files write <projectId> <relpath> [< stdin] Write content from stdin. od files upload <projectId> <localpath> [--as <relpath>] Upload a local file. od files delete <projectId> <name> Delete a project file. Common options: --daemon-url <url> Open Design daemon HTTP base. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); const base = projectDaemonUrl(flags).replace(/\/$/, ''); switch (sub) { case 'list': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od files list <projectId>'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const files = Array.isArray(data?.files) ? data.files : []; for (const f of files) console.log(`${f.size}\t${f.name ?? f.path}`); return; } case 'read': { const positional = rest.filter((a) => !a.startsWith('-')); const [id, rel] = positional; if (!id || !rel) { console.error('Usage: od files read <projectId> <relpath>'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files/${rel.split('/').map(encodeURIComponent).join('/')}`); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const buf = Buffer.from(await resp.arrayBuffer()); process.stdout.write(buf); return; } case 'upload': { const positional = rest.filter((a) => !a.startsWith('-') && a !== flags.as); const [id, localPath] = positional; if (!id || !localPath) { console.error('Usage: od files upload <projectId> <localpath> [--as <relpath>]'); process.exit(2); } const fs = require('node:fs'); const path = require('node:path'); const buf = fs.readFileSync(localPath); const desiredName = typeof flags.as === 'string' && flags.as.length > 0 ? flags.as : path.basename(localPath); const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: desiredName, content: buf.toString('base64'), encoding: 'base64', }), }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log(`[files] uploaded ${data?.file?.name ?? desiredName}`); return; } case 'write': { const positional = rest.filter((a) => !a.startsWith('-')); const [id, rel] = positional; if (!id || !rel) { console.error('Usage: od files write <projectId> <relpath> [< stdin]'); process.exit(2); } // Read stdin synchronously into a buffer. const fs = require('node:fs'); let chunks = []; try { const stdin = fs.readFileSync(0); chunks = [stdin]; } catch (err) { console.error(`stdin read failed: ${err.message ?? err}`); process.exit(1); } const body = Buffer.concat(chunks); const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: rel, content: body.toString('utf8'), encoding: 'utf8', }), }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log(`[files] wrote ${data?.file?.name ?? rel}`); return; } case 'delete': { const positional = rest.filter((a) => !a.startsWith('-')); const [id, name] = positional; if (!id || !name) { console.error('Usage: od files delete <projectId> <name>'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (!resp.ok) return structuredHttpFailure(resp); console.log(`[files] deleted ${name}`); return; } default: console.error(`unknown subcommand: od files ${sub}`); process.exit(2); } } async function runConversation(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od conversation list <projectId> List conversations in a project. od conversation info <conversationId> Print one conversation. Common options: --daemon-url <url> Open Design daemon HTTP base. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); const base = projectDaemonUrl(flags).replace(/\/$/, ''); switch (sub) { case 'list': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od conversation list <projectId>'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } case 'info': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od conversation info <conversationId>'); process.exit(2); } const resp = await fetch(`${base}/api/conversations/${encodeURIComponent(id)}`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } default: console.error(`unknown subcommand: od conversation ${sub}`); process.exit(2); } } // --------------------------------------------------------------------------- // Subcommand: od daemon (Phase 1.5 lifecycle, plan §6 / §3.F2) // // `od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>]` // - --headless: implies --no-open, never tries to launch a browser. // The default `od` (no subcommand) keeps its // desktop-friendly behaviour for back-compat. // - --serve-web: same as --headless but allows the Next.js bundle to // serve over the existing port. v1 doesn't bundle a // separate web port; the flag is reserved so downstream // packaged callers can branch on it. // // `od daemon status [--json] [--daemon-url <url>]` calls /api/daemon/status. // `od daemon stop [--daemon-url <url>]` calls POST /api/daemon/shutdown. // --------------------------------------------------------------------------- const DAEMON_STRING_FLAGS = new Set([ 'daemon-url', 'port', 'host', 'namespace', ]); const DAEMON_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'headless', 'serve-web', 'no-open', ]); async function runDaemon(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>] [--no-open] [--namespace <ns>] Start the daemon (Phase 1.5 headless mode). od daemon status [--json] [--daemon-url <url>] Print the daemon's runtime snapshot. od daemon stop [--daemon-url <url>] Send a graceful shutdown signal. Common options: --daemon-url <url> Open Design daemon HTTP base. --headless No browser auto-open; aliased --no-open. --serve-web Serve the web UI over the existing port (no electron). --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: DAEMON_STRING_FLAGS, boolean: DAEMON_BOOLEAN_FLAGS }); switch (sub) { case 'start': return runDaemonStart(flags); case 'status': return runDaemonStatus(flags); case 'stop': return runDaemonStop(flags); default: console.error(`unknown subcommand: od daemon ${sub}`); process.exit(2); } } async function runDaemonStart(flags) { // The headless flag implies --no-open AND auto-applies any other // headless-only env defaults. Because the existing default-mode boot // already handles port / host / no-open, we forward into it by // mutating process.argv before re-entering the boot path. // Simpler path: re-implement the boot inline, mirroring the default. const port = Number(flags.port ?? process.env.OD_PORT ?? 7456); const host = String(flags.host ?? process.env.OD_BIND_HOST ?? '127.0.0.1'); const headless = Boolean(flags.headless || flags['no-open'] || flags['serve-web']); if (flags.namespace) process.env.OD_NAMESPACE = String(flags.namespace); process.env.OD_BIND_HOST = host; process.env.OD_PORT = String(port); const { startServer: startHeadless } = await import('./server.js'); const started = await startHeadless({ port, host, returnServer: true }); const url = started.url; const server = started.server; const shutdown = started.shutdown; const closeServer = () => new Promise((resolve) => { let resolved = false; const resolveOnce = () => { if (!resolved) { resolved = true; resolve(); } }; const idleTimer = setTimeout(() => server.closeIdleConnections?.(), 1_000); const hardTimer = setTimeout(() => { server.closeAllConnections?.(); resolveOnce(); }, 5_000); idleTimer.unref?.(); hardTimer.unref?.(); server.close(() => resolveOnce()); }); let shuttingDown = false; const stop = () => { if (shuttingDown) process.exit(0); shuttingDown = true; void Promise.allSettled([ Promise.resolve().then(() => shutdown?.()), closeServer(), ]).finally(() => process.exit(0)); }; process.on('SIGINT', stop); process.on('SIGTERM', stop); console.log(`[od] listening on ${url} (${headless ? 'headless' : 'desktop'})`); if (!headless) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; import('node:child_process').then(({ spawn }) => { spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref(); }); } } async function runDaemonStatus(flags) { const base = (flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456').replace(/\/$/, ''); let resp; try { resp = await fetch(`${base}/api/daemon/status`); } catch (err) { return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`, }); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log(`[daemon] ${data.bindHost}:${data.port} v${data.version} pid=${data.pid} plugins=${data.installedPlugins} namespace=${data.namespace ?? '-'}`); } async function runDaemonStop(flags) { const base = (flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456').replace(/\/$/, ''); let resp; try { resp = await fetch(`${base}/api/daemon/shutdown`, { method: 'POST' }); } catch (err) { return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`, }); } if (!resp.ok) return structuredHttpFailure(resp); console.log(`[daemon] shutdown scheduled`); } // --------------------------------------------------------------------------- // Subcommand: od atoms / od skills / od design-systems / od craft / od status // // Plan §3.H2 / §3.H3 / spec §12.2 — design-library + status introspection // CLI parity. Every UI feature reachable via /api/* gets a CLI mirror // (the §11.7 "headless = canonical" invariant). // --------------------------------------------------------------------------- function libraryDaemonUrl(flags) { return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; } const LIBRARY_STRING_FLAGS = new Set(['daemon-url', 'query', 'tag']); const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); async function runAtoms(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od atoms list List first-party atoms (implemented + planned). od atoms show <id> Print one atom's metadata. Common options: --daemon-url <url> Open Design daemon HTTP base. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); const base = libraryDaemonUrl(flags).replace(/\/$/, ''); switch (sub) { case 'list': { const resp = await fetch(`${base}/api/atoms`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const atoms = data?.atoms ?? []; for (const a of atoms) { console.log(`${a.id}\t${a.status}\t[${(a.taskKinds ?? []).join(', ')}]\t${a.label}`); } return; } case 'show': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od atoms show <id>'); process.exit(2); } const resp = await fetch(`${base}/api/atoms`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); const atom = (data?.atoms ?? []).find((a) => a.id === id); if (!atom) { console.error(`atom ${id} not found`); process.exit(65); } process.stdout.write(JSON.stringify(atom, null, 2) + '\n'); return; } default: console.error(`unknown subcommand: od atoms ${sub}`); process.exit(2); } } async function runLibraryList(name, args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od ${name} list List ${name}. od ${name} show <id> Print one entry.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); const base = libraryDaemonUrl(flags).replace(/\/$/, ''); const apiPath = name === 'design-systems' ? '/api/design-systems' : `/api/${name}`; switch (sub) { case 'list': { const resp = await fetch(`${base}${apiPath}`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const rows = data?.[name === 'design-systems' ? 'designSystems' : name] ?? []; for (const row of rows) { const label = row.title ?? row.name ?? row.id ?? row.label; console.log(`${row.id}\t${label}`); } return; } case 'show': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error(`Usage: od ${name} show <id>`); process.exit(2); } const resp = await fetch(`${base}${apiPath}/${encodeURIComponent(id)}`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } default: console.error(`unknown subcommand: od ${name} ${sub}`); process.exit(2); } } async function runSkills(args) { return runLibraryList('skills', args); } async function runDesignSystems(args) { return runLibraryList('design-systems', args); } async function runCraft(args) { return runLibraryList('craft', args); } async function runStatus(args) { // Alias of `od daemon status`. return runDaemon(['status', ...args]); } async function runVersion(args) { const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); const base = libraryDaemonUrl(flags).replace(/\/$/, ''); let resp; try { resp = await fetch(`${base}/api/version`); } catch (err) { return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`, }); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const version = typeof data?.version === 'string' ? data.version : (data?.version?.version ?? JSON.stringify(data)); console.log(version); } // --------------------------------------------------------------------------- // Subcommand: od doctor / od config (Phase 4 CLI parity tail). // // Plan §3.I2 / spec §12.2. // // `od doctor` — repo-wide diagnostics. Hits /api/daemon/status, lists // installed plugins + runs the per-plugin doctor, lists skills / // design-systems / craft / atoms. Exits non-zero when any plugin // doctor returns ok=false. Useful in CI: a failed exit causes the // pipeline to surface plugin-system regressions. // // `od config get/set/list/unset` — wraps GET/PUT /api/app-config so a // code agent can flip provider keys / orbit settings / pet config // without leaving the terminal. JSON values pass through unchanged; // scalar strings/numbers/booleans are coerced. // --------------------------------------------------------------------------- const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']); const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); async function runDoctor(args) { const flags = parseFlags(args, { string: CONFIG_STRING_FLAGS, boolean: CONFIG_BOOLEAN_FLAGS }); if (flags.help || flags.h) { console.log(`Usage: od doctor [--json] Print a daemon + plugin + design-library health summary. Exit code is non-zero when any installed plugin's doctor returns ok=false or the daemon cannot be reached.`); process.exit(0); } const base = libraryDaemonUrl(flags).replace(/\/$/, ''); const report = { daemon: null, plugins: [], skills: [], designSystems: [], atoms: [], issues: [], }; // Daemon status try { const resp = await fetch(`${base}/api/daemon/status`); if (!resp.ok) { report.issues.push({ severity: 'error', code: 'daemon-status', message: `HTTP ${resp.status}` }); } else { report.daemon = await resp.json(); } } catch (err) { report.issues.push({ severity: 'error', code: 'daemon-not-running', message: String(err?.message ?? err) }); if (flags.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { console.error('[doctor] daemon unreachable:', String(err?.message ?? err)); } process.exit(64); } // Library inventory try { const [skillsResp, dsResp, atomsResp] = await Promise.all([ fetch(`${base}/api/skills`), fetch(`${base}/api/design-systems`), fetch(`${base}/api/atoms`), ]); if (skillsResp.ok) { const data = await skillsResp.json(); report.skills = data?.skills ?? []; } if (dsResp.ok) { const data = await dsResp.json(); report.designSystems = data?.designSystems ?? []; } if (atomsResp.ok) { const data = await atomsResp.json(); report.atoms = data?.atoms ?? []; } } catch (err) { report.issues.push({ severity: 'warn', code: 'library-list-failed', message: String(err?.message ?? err) }); } // Plugin doctor — runs the daemon's per-plugin check on every install. try { const listResp = await fetch(`${base}/api/plugins`); if (listResp.ok) { const list = await listResp.json(); const plugins = list?.plugins ?? []; for (const p of plugins) { try { const doctorResp = await fetch(`${base}/api/plugins/${encodeURIComponent(p.id)}/doctor`, { method: 'POST' }); const data = await doctorResp.json().catch(() => ({})); report.plugins.push({ id: p.id, version: p.version, ok: !!data?.ok, issues: data?.issues ?? [] }); if (!data?.ok) { report.issues.push({ severity: 'error', code: 'plugin-doctor-failed', message: `${p.id}@${p.version}: ${(data?.issues ?? []).map((i) => i.code).join(', ')}`, }); } } catch (err) { report.issues.push({ severity: 'warn', code: 'plugin-doctor-error', message: `${p.id}: ${err?.message ?? err}`, }); } } } } catch (err) { report.issues.push({ severity: 'warn', code: 'plugin-list-failed', message: String(err?.message ?? err) }); } if (flags.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { console.log(`[doctor] daemon ${report.daemon?.bindHost ?? '?'}:${report.daemon?.port ?? '?'} pid=${report.daemon?.pid ?? '?'}`); console.log(`[doctor] plugins: ${report.plugins.length} (skills ${report.skills.length}, design-systems ${report.designSystems.length}, atoms ${report.atoms.length})`); if (report.issues.length === 0) { console.log('[doctor] no issues'); } else { for (const i of report.issues) { console.log(` [${i.severity}] ${i.code}: ${i.message}`); } } } const hasError = report.issues.some((i) => i.severity === 'error'); process.exit(hasError ? 1 : 0); } async function runConfig(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od config list Print the full app config as JSON. od config get <key> Print one top-level key. od config set <key> <value> Set a top-level key (string / number / boolean). od config set <key> --value-json '<json>' Set a key to a JSON value. od config unset <key> Remove a top-level key. Common options: --daemon-url <url> Open Design daemon HTTP base. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: CONFIG_STRING_FLAGS, boolean: CONFIG_BOOLEAN_FLAGS }); const base = libraryDaemonUrl(flags).replace(/\/$/, ''); const fetchConfig = async () => { const resp = await fetch(`${base}/api/app-config`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); return data?.config ?? {}; }; const writeConfig = async (next) => { const resp = await fetch(`${base}/api/app-config`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(next), }); if (!resp.ok) return structuredHttpFailure(resp); return (await resp.json())?.config ?? next; }; switch (sub) { case 'list': { const cfg = await fetchConfig(); process.stdout.write(JSON.stringify(cfg, null, 2) + '\n'); return; } case 'get': { const key = rest.find((a) => !a.startsWith('-')); if (!key) { console.error('Usage: od config get <key>'); process.exit(2); } const cfg = await fetchConfig(); const value = cfg?.[key]; if (flags.json) { process.stdout.write(JSON.stringify(value ?? null, null, 2) + '\n'); } else { console.log(value === undefined ? '' : (typeof value === 'string' ? value : JSON.stringify(value, null, 2))); } return; } case 'set': { const positional = rest.filter((a) => !a.startsWith('-') && a !== flags.value && a !== flags['value-json']); const [key, scalarValue] = positional; if (!key) { console.error('Usage: od config set <key> <value> | od config set <key> --value-json <json>'); process.exit(2); } let parsed; if (typeof flags['value-json'] === 'string') { try { parsed = JSON.parse(flags['value-json']); } catch (err) { console.error(`--value-json must be valid JSON: ${err.message}`); process.exit(2); } } else if (typeof flags.value === 'string') { parsed = coerceCliValue(flags.value); } else if (scalarValue !== undefined) { parsed = coerceCliValue(scalarValue); } else { console.error('Provide a value (positional, --value, or --value-json).'); process.exit(2); } const cfg = await fetchConfig(); const next = { ...cfg, [key]: parsed }; const written = await writeConfig(next); if (flags.json) { process.stdout.write(JSON.stringify(written, null, 2) + '\n'); } else { console.log(`[config] set ${key}`); } return; } case 'unset': { const key = rest.find((a) => !a.startsWith('-')); if (!key) { console.error('Usage: od config unset <key>'); process.exit(2); } const cfg = await fetchConfig(); const next = { ...cfg }; delete next[key]; const written = await writeConfig(next); if (flags.json) { process.stdout.write(JSON.stringify(written, null, 2) + '\n'); } else { console.log(`[config] unset ${key}`); } return; } default: console.error(`unknown subcommand: od config ${sub}`); process.exit(2); } }