#!/usr/bin/env node // @ts-nocheck import { runDaemonCliStartup } from './daemon-startup.js'; import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js'; import { runArtifactsCli } from './artifacts-cli.js'; import { runProjectHandoff } from './handoff-cli.js'; import { runConnectorsToolCli } from './tools-connectors-cli.js'; import { runDesignSystemsToolCli } from './tools-design-systems-cli.js'; import { DESIGN_SYSTEMS_USAGE, isDesignSystemsHelpArg } from './design-systems-cli-help.js'; import { parseDesignSystemRenameArgs } from './design-system-rename-args.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; import { resolveDaemonUrl } from './daemon-url.js'; import { requestJsonIpc } from '@open-design/sidecar'; import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; 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', 'prompt-influence', 'voice', 'audio-kind', 'composition-dir', 'image', 'daemon-url', 'language', ]); const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'loop', ]); 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', 'conversation', 'message', 'agent', 'model', 'snapshot-id', 'capabilities', 'grant-caps', 'before', 'trust', 'tag', 'policy', 'version', 'reason', 'catalog', 'host', ]); const PLUGIN_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'revoke', 'follow', 'strict', ]); 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', // Plan §6 Phase 2A.5 — `od ui show --schema` returns just the // surface's JSON Schema (or `null` when the surface declares // none). Lets a code agent inspect the contract before piping a // value back through `od ui respond --value-json`. 'schema', ]); // Hoist flag set bindings consumed by handlers reachable through // the top-of-file dispatcher. The dispatch block runs synchronously // during module load; any const declared further down the file is // still in TDZ when the handler executes, so `od status` / // `od atoms list` / etc. would crash with `Cannot access X before // initialization`. The actual definitions stay further down (next // to their handlers); we just export the bindings up here so the // dispatch path always sees an initialized value. const DAEMON_STRING_FLAGS = new Set([ 'daemon-url', 'port', 'host', ]); const DAEMON_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'headless', 'serve-web', 'no-open', ]); const LIBRARY_STRING_FLAGS = new Set(['daemon-url', 'query', 'tag']); const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); 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', 'editor', 'title', 'against', ]); const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']); // `od automation …` mirrors the Automations tab. Same surface, same // /api/routines store. The CLI form is the embeddability contract: // external agents (hermes-agent, openclaw, etc.) can drive Open Design // automations headlessly without going through the web UI. const AUTOMATION_STRING_FLAGS = new Set([ 'daemon-url', 'name', 'prompt', 'prompt-file', 'schedule', 'target', 'project', 'skill', 'agent', 'limit', 'plugin', 'mcp', 'connector', 'status', 'reason', 'template', 'source-kind', 'source-ref', 'title', 'body', 'body-file', 'compression', 'sensitivity', 'account', 'candidate-sinks', 'memory-type', ]); const AUTOMATION_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'disabled', 'enabled', ]); const MEMORY_STRING_FLAGS = new Set([ 'daemon-url', 'name', 'description', 'type', 'body', 'body-file', ]); const MEMORY_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', ]); // Hoisted because `runAutomation` is reachable through the top-of-file // SUBCOMMAND_MAP dispatch, which runs during module evaluation — // any `const` declared further down would still be in TDZ when // `parseScheduleFlag` reads this map. Same reason the other dispatch- // touched constants live near the top. const AUTOMATION_WEEKDAY_TOKENS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, }; 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, 'desktop-auth-pending': 74, 'desktop-import-token-rejected': 75, }; const PLUGIN_LIST_FILTER_FLAGS = new Set([ ...PLUGIN_STRING_FLAGS, 'task-kind', 'mode', 'tag', 'trust', ]); const PLUGIN_LIST_BOOLEAN_FLAGS = new Set([ ...PLUGIN_BOOLEAN_FLAGS, 'bundled', 'no-bundled', ]); const SUBCOMMAND_MAP = { artifacts: runArtifacts, media: runMedia, mcp: runMcp, research: runResearch, plugin: runPlugin, ui: runUi, marketplace: runMarketplace, project: runProject, automation: runAutomation, automations: runAutomation, memory: runMemory, run: runRun, files: runFiles, conversation: runConversation, daemon: runDaemon, atoms: runAtoms, skills: runSkills, 'design-systems': runDesignSystems, craft: runCraft, diagnostics: runDiagnostics, 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 if (argv[0] === 'tools' && argv[1] === 'design-systems') { runDesignSystemsToolCli(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 { await runDaemonCliStartup(argv, { printHelp: printRootHelp }); } 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 artifacts create --name --input [--project ] Create a normal project artifact through the local daemon. od tools connectors [options] Discover and execute configured connectors. od tools design-systems read --path Read active design-system pull-layer files through daemon wrapper commands. 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 plugin publish-repo Create/update the author's GitHub repo for a local plugin folder. od plugin open-design-pr Push a community-catalog branch and open the Open Design PR form. od automation [args] Drive the Automations surface headlessly. Same store as the UI's Automations tab, so an external agent (hermes, openclaw, ...) can schedule, trigger, or harvest results from a routine without opening the web UI. od memory tree [args] Inspect and edit the memory tree that is injected into agent prompts. od ui [args] Read and answer GenUI surfaces (form / choice / confirmation / oauth-prompt) headlessly. od diagnostics export [] [--json] Bundle daemon/web/desktop logs, machine info, and recent crash reports into a zip for support tickets. Same output as Settings → About → Export diagnostics. "$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 project 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 and create project-scoped artifacts 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 = await cliDaemonUrl(flags); 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`); } async function runArtifacts(args) { const { exitCode } = await runArtifactsCli(args); process.exit(exitCode); } 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, OD_SIDECAR_IPC_PATH discovery, 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 = await cliDaemonUrl(flags); const projectId = flags.project || process.env.OD_PROJECT_ID; const token = process.env.OD_TOOL_TOKEN; if (!projectId && !token) { 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); if (flags['prompt-influence'] != null) body.promptInfluence = Number(flags['prompt-influence']); if (flags.loop === true) body.loop = true; const url = token ? `${daemonUrl.replace(/\/$/, '')}/api/tools/media/generate` : `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`; let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, 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, { stillRunningExitCode: 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 = await cliDaemonUrl(flags); const since = Number.isFinite(Number(flags.since)) ? Number(flags.since) : 0; await pollUntilDoneOrBudget(daemonUrl, taskId, since, { totalBudgetMs: 120_000 }); } async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) { const totalBudgetMs = typeof options.totalBudgetMs === 'number' ? options.totalBudgetMs : 25_000; const perCallTimeoutMs = 4_000; const stillRunningExitCode = typeof options.stillRunningExitCode === 'number' ? options.stillRunningExitCode : 2; 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'); const stillRunningHint = stillRunningExitCode === 0 ? 'This is a successful queued/running handoff, not a failure.' : `exit code ${stillRunningExitCode} = still running.`; 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 ` + `(${stillRunningHint}).\n`, ); process.exit(stillRunningExitCode); } 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]); // Positionals collected silently; callers that take `` style // positional args (e.g. `od plugin info `) re-scan `argv` // themselves to pick them up. Strict positional rejection here // would break those commands, so we only enforce strict-flag // semantics for things that *are* prefixed with `--`. const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (!a || !a.startsWith('--')) { // Positional — let the caller decide what to do with it. continue; } 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 positionalArgs(argv, stringFlags = new Set()) { const out = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (!a) continue; if (!a.startsWith('--')) { out.push(a); continue; } const eq = a.indexOf('='); const key = eq >= 0 ? a.slice(2, eq) : a.slice(2); if (eq < 0 && stringFlags.has(key)) i++; } return out; } async function cliDaemonUrl(flags) { return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] }); } async function cliDaemonBaseUrl(flags) { return (await cliDaemonUrl(flags)).replace(/\/$/, ''); } 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. ElevenLabs SFX prompts must stay under 450 characters. --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. --prompt-influence <0-1> ElevenLabs SFX prompt adherence. Higher values follow the prompt more closely. --loop ElevenLabs SFX only: request a seamless loop. --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 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 daemonUrl = await cliDaemonUrl(flags); 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 project 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 and create project-scoped artifacts without exporting a zip every iteration. Options: --daemon-url Open Design daemon HTTP base URL. Resolution order: this flag, OD_DAEMON_URL, OD_SIDECAR_IPC_PATH, 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 create_artifact(name, content) create one normal artifact entry file When project is omitted, get_artifact / get_project / get_file / search_files / list_files / create_artifact 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. 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.). 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 = normalizeRecoverableErrorCode(parsed?.error?.code, parsed?.error?.message); if (errCode && errCode in RECOVERABLE_EXIT_CODES) { exitWithStructuredError({ code: errCode, message: parsed.error.message ?? `HTTP ${resp.status}`, data: structuredErrorData(parsed.error), }); } exitWithStructuredError({ code: fallbackCode, message: parsed?.error?.message ?? `HTTP ${resp.status}: ${await resp.text().catch(() => '')}`, data: structuredErrorData(parsed?.error), }); } function normalizeRecoverableErrorCode(code, message) { if (code === 'DESKTOP_AUTH_PENDING') return 'desktop-auth-pending'; if (code === 'FORBIDDEN' && /desktop import token rejected/i.test(String(message ?? ''))) { return 'desktop-import-token-rejected'; } return code; } function structuredErrorData(error) { if (!error || typeof error !== 'object') return undefined; const data = {}; if ('data' in error && error.data !== undefined) Object.assign(data, error.data); if ('details' in error && error.details !== undefined) data.details = error.details; if (typeof error.retryable === 'boolean') data.retryable = error.retryable; return Object.keys(data).length > 0 ? data : undefined; } 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 'search': return runPluginSearch(rest); case 'stats': return runPluginStats(rest); case 'sources': return runPluginSources(rest); case 'info': return runPluginInfo(rest); case 'manifest': return runPluginManifest(rest); case 'install': return runPluginInstall(rest); case 'upgrade': return runPluginUpgrade(rest); case 'uninstall': return runPluginUninstall(rest); case 'apply': return runPluginApply(rest); case 'canon': return runPluginCanon(rest); case 'diff': return runPluginDiff(rest); case 'doctor': return runPluginDoctor(rest); case 'replay': return runPluginReplay(rest); case 'trust': return runPluginTrust(rest); case 'snapshots': return runPluginSnapshots(rest); case 'simulate': return runPluginSimulate(rest); case 'verify': return runPluginVerify(rest); case 'events': return runPluginEvents(rest); case 'run': return runPluginRun(rest); case 'scaffold': return runPluginScaffold(rest); case 'validate': return runPluginValidate(rest); case 'pack': return runPluginPack(rest); case 'candidates': return runPluginCandidates(rest); case 'login': return runPluginLogin(rest); case 'whoami': return runPluginWhoami(rest); case 'export': return runPluginExport(rest); case 'publish': return runPluginPublish(rest); case 'publish-repo': return runPluginPublishRepo(rest); case 'open-design-pr': return runPluginOpenDesignPr(rest); case 'yank': return runPluginYank(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 §11.5 / plan §3.W1 — `od plugin validate <folder>`. // // Pre-install lint pass against an author's working dir. Optionally // fetches the daemon's registry view so skill / DS / atom refs in // the manifest can be checked too; falls back to an empty registry // when --no-daemon is set or the daemon is unreachable. async function runPluginValidate(rest) { const flags = parseFlags(rest, { string: new Set(['daemon-url']), boolean: new Set(['help', 'h', 'json', 'no-daemon']), }); if (flags.help || flags.h || rest.length === 0 || rest[0]?.startsWith('-')) { console.log(`Usage: od plugin validate <folder> [--json] [--no-daemon] [--daemon-url <url>] Runs the plugin doctor against an unfinished plugin folder before install. Validates manifest shape, atom ids, until expressions, and context refs against the live daemon registry (skip with --no-daemon). Exit codes: 0 doctor.ok = true 4 doctor.ok = false (errors present) 2 CLI usage error / folder unreadable`); process.exit(rest.length === 0 ? 2 : 0); } const folder = rest[0]; // Try to load the daemon's registry view; the validator works // offline too — emits warnings instead of errors for refs we // can't resolve. let registry; if (!flags['no-daemon']) { const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); try { const [skillsResp, dsResp, atomsResp] = await Promise.all([ fetch(`${base}/api/skills`).catch(() => null), fetch(`${base}/api/design-systems`).catch(() => null), fetch(`${base}/api/atoms`).catch(() => null), ]); const skills = (skillsResp?.ok ? (await skillsResp.json())?.skills : []) ?? []; const designSystems = (dsResp?.ok ? (await dsResp.json())?.designSystems : []) ?? []; const atoms = (atomsResp?.ok ? (await atomsResp.json())?.atoms : []) ?? []; registry = { skills: skills.map((s) => ({ id: s.id, title: s.name ?? s.title, description: s.description })), designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })), craft: [], atoms: atoms.map((a) => ({ id: a.id, label: a.label })), }; } catch { registry = undefined; } } let result; try { const { validatePluginFolder, flattenValidationDiagnostics } = await import('./plugins/validate.js'); result = await validatePluginFolder({ folder, ...(registry ? { registry } : {}) }); if (flags.json) { const flat = flattenValidationDiagnostics(result); process.stdout.write(JSON.stringify({ ok: result.ok, folder: result.folder, ...(result.doctor ? { freshDigest: result.doctor.freshDigest, pluginId: result.doctor.pluginId } : {}), diagnostics: flat, }, null, 2) + '\n'); } else { console.log(`[validate] folder: ${result.folder}`); if (result.doctor) { console.log(`[validate] pluginId: ${result.doctor.pluginId}`); console.log(`[validate] freshDigest: ${result.doctor.freshDigest.slice(0, 12)}\u2026`); } const diagnostics = (await import('./plugins/validate.js')).flattenValidationDiagnostics(result); const errors = diagnostics.filter((d) => d.severity === 'error'); const warnings = diagnostics.filter((d) => d.severity === 'warning'); const infos = diagnostics.filter((d) => d.severity === 'info'); for (const d of errors) console.error(` [error] ${d.code}: ${d.message}`); for (const d of warnings) console.warn (` [warning] ${d.code}: ${d.message}`); for (const d of infos) console.log (` [info] ${d.code}: ${d.message}`); if (errors.length === 0 && warnings.length === 0 && infos.length === 0) { console.log('[validate] no issues'); } console.log(`[validate] ok=${result.ok}`); } } catch (err) { console.error(`[validate] failed: ${err?.message ?? err}`); process.exit(2); } process.exit(result.ok ? 0 : 4); } // Phase 4 / spec §14 / plan §3.X1 — `od plugin pack <folder>`. // // Produces a gzip-compressed tar archive ready to install via the // installer's HTTPS-tarball path. The output path is folder-base + // version when the manifest exposes a version, otherwise folder-base. async function runPluginPack(rest) { const flags = parseFlags(rest, { string: new Set(['out']), boolean: new Set(['help', 'h', 'json']), }); if (flags.help || flags.h || rest.length === 0 || rest[0]?.startsWith('-')) { console.log(`Usage: od plugin pack <folder> [--out <path>] [--json] Builds a gzip-compressed tar archive of <folder> at --out (default '<folder>/../<basename>-<manifest.version>.tgz'). The archive is the exact shape \`od plugin install --source <https://...>\` consumes. Skipped when packing: node_modules / .git / .next / dist / build / out / coverage / .turbo / .cache / .pnpm-store / .parcel-cache / .svelte-kit / .nuxt / .astro / .vercel / .vscode / .DS_Store / Thumbs.db (matches the installer's tarball-extract skiplist). Symlinks are rejected at pack time (consistent with extract-time rejection at install). Exit codes: 0 archive written 2 CLI usage error 4 pack-time error (missing open-design.json, invalid JSON, etc)`); process.exit(rest.length === 0 ? 2 : 0); } const folder = rest[0]; try { const { packPlugin, PackPluginError } = await import('./plugins/pack.js'); let result; try { result = await packPlugin({ folder, ...(typeof flags.out === 'string' ? { out: flags.out } : {}), }); } catch (err) { if (err instanceof PackPluginError) { if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: err.message }, null, 2) + '\n'); } else { console.error(`[pack] ${err.message}`); } process.exit(4); } throw err; } if (flags.json) { process.stdout.write(JSON.stringify({ ok: true, outPath: result.outPath, bytes: result.bytes, fileCount: result.files.length, pluginId: result.pluginId, pluginVersion: result.pluginVersion, }, null, 2) + '\n'); } else { const idStr = result.pluginVersion ? `${result.pluginId ?? 'plugin'}@${result.pluginVersion}` : result.pluginId ?? 'plugin'; console.log(`[pack] packed ${idStr}`); console.log(`[pack] out: ${result.outPath}`); console.log(`[pack] files: ${result.files.length}`); console.log(`[pack] bytes: ${result.bytes}`); console.log(`\nNext: od plugin install --source ${result.outPath}`); } } catch (err) { console.error(`[pack] failed: ${err?.message ?? err}`); process.exit(2); } } async function runPluginLogin(rest) { const flags = parseFlags(rest, { string: new Set(['host']), boolean: new Set(['help', 'h']), }); if (flags.help || flags.h) { console.log(`Usage: od plugin login [--host github.com] Wraps GitHub CLI auth for Open Design registry publishing. The token stays in gh.`); return; } const host = typeof flags.host === 'string' ? flags.host : 'github.com'; const version = await execGhBuffered(['--version'], { timeout: 10_000 }); if (!version.ok) { console.error('[plugin login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.'); process.exit(1); } const result = await spawnGhPassthrough(['auth', 'login', '--hostname', host, '--web']); process.exit(result.code ?? 0); } async function runPluginWhoami(rest) { const flags = parseFlags(rest, { string: new Set(['host']), boolean: new Set(['help', 'h', 'json']), }); if (flags.help || flags.h) { console.log(`Usage: od plugin whoami [--host github.com] [--json] Shows the GitHub account gh will use for Open Design registry publishing.`); return; } const host = typeof flags.host === 'string' ? flags.host : 'github.com'; const auth = await execGhBuffered(['auth', 'status', '--hostname', host], { timeout: 10_000 }); if (!auth.ok) { if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, host, message: 'GitHub CLI is not authenticated for this host.', log: auth.stderr || auth.stdout, }, null, 2) + '\n'); return; } console.error(`[plugin whoami] gh is not authenticated for ${host}. Run: od plugin login --host ${host}`); if (auth.stderr || auth.stdout) console.error(auth.stderr || auth.stdout); process.exit(1); } const user = await execGhBuffered(['api', 'user', '--hostname', host], { timeout: 10_000 }); let login = ''; let name = ''; try { const parsed = JSON.parse(user.stdout || '{}'); login = typeof parsed.login === 'string' ? parsed.login : ''; name = typeof parsed.name === 'string' ? parsed.name : ''; } catch { // Keep the auth status useful even if gh api output is unavailable. } const payload = { ok: true, host, login, name, auth: auth.stderr || auth.stdout, }; if (flags.json) { process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); } else { console.log(`[plugin whoami] ${login || 'authenticated'}${name ? ` (${name})` : ''} @ ${host}`); } } async function execFileBuffered(command, args, opts = {}) { const { execFile } = await import('node:child_process'); return new Promise((resolve) => { execFile(command, args, { timeout: 30_000, maxBuffer: 1024 * 1024, ...opts, }, (error, stdout, stderr) => { resolve({ ok: !error, code: error?.code, stdout: String(stdout ?? '').trim(), stderr: String(stderr ?? '').trim(), error, }); }); }); } function quotePosixShellArg(value) { const text = String(value ?? ''); return `'${text.replace(/'/g, `'\\''`)}'`; } function buildGhShellCommand(args) { return ['gh', ...args].map(quotePosixShellArg).join(' '); } function buildLoginShellCommand(innerCommand) { return `export PATH=${quotePosixShellArg(process.env.PATH ?? '')}; ${innerCommand}`; } async function execGhBuffered(args, opts = {}) { if (process.platform === 'win32') return execFileBuffered('gh', args, opts); const shell = process.env.SHELL && process.env.SHELL.trim() ? process.env.SHELL.trim() : '/bin/zsh'; return execFileBuffered(shell, ['-c', buildLoginShellCommand(buildGhShellCommand(args))], { env: process.env, ...opts, }); } async function spawnPassthrough(command, args, opts = {}) { const { spawn } = await import('node:child_process'); return await new Promise((resolve) => { const child = spawn(command, args, { stdio: 'inherit', ...opts }); child.on('error', (error) => resolve({ code: 1, error })); child.on('close', (code) => resolve({ code })); }); } async function spawnGhPassthrough(args) { if (process.platform === 'win32') return spawnPassthrough('gh', args); const shell = process.env.SHELL && process.env.SHELL.trim() ? process.env.SHELL.trim() : '/bin/zsh'; return spawnPassthrough(shell, ['-c', buildLoginShellCommand(buildGhShellCommand(args))], { env: process.env, }); } function inferGithubHost(target) { if (!target || target === 'github.com') return 'github.com'; try { const parsed = new URL(target); return parsed.hostname || 'github.com'; } catch { // Marketplace ids are not URLs; v1 GitHub-backed auth defaults to github.com. return 'github.com'; } } // 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 = (await 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 plugins <id> [--json] List cached plugin entries for one marketplace. od marketplace search <query> [--json] Search cached marketplace entries. od marketplace doctor [id] [--strict] [--json] Validate cached marketplace entries. od marketplace login <id|url> [--host github.com] Authenticate gh for private GitHub catalogs. 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, OD_SIDECAR_IPC_PATH discovery, 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 = (await 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} version=${m.version ?? 'unknown'} spec=${m.specVersion ?? 'unknown'} 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, marketplaceVersion: mp.version, name: p.name, version: p.version, 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}@${m.version}\t${m.source}\t${m.marketplaceId}@${m.marketplaceVersion}\t${m.description}`); } return; } case 'plugins': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od marketplace plugins <id> [--json]'); process.exit(2); } const resp = await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}/plugins`); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`plugins failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } const plugins = Array.isArray(data?.plugins) ? data.plugins : []; if (flags.json) { process.stdout.write(JSON.stringify({ marketplaceId: id, plugins }, null, 2) + '\n'); return; } if (plugins.length === 0) { console.log(`No plugins in marketplace ${id}.`); return; } for (const p of plugins) { console.log(`${p.name}@${p.version}\t${p.source}\t${p.description ?? ''}`); } return; } case 'doctor': { const strict = flags.strict === true; const id = rest.find((a) => !a.startsWith('-')); const resp = id ? await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}`) : await fetch(`${base}/api/marketplaces`); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`doctor failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } const rows = id ? [data] : (data?.marketplaces ?? []); const { doctorMarketplace } = await import('./plugins/marketplace-doctor.js'); const reports = []; for (const row of rows) { reports.push(await doctorMarketplace({ id: row.id, trust: row.trust, manifest: row.manifest, strict, })); } const ok = reports.every((report) => report.ok); if (flags.json) { process.stdout.write(JSON.stringify({ ok, reports }, null, 2) + '\n'); } else { for (const report of reports) { console.log(`[marketplace doctor] ${report.backendId}: ${report.ok ? 'ok' : 'issues'} (${report.entriesChecked} entries)`); for (const issue of report.issues) { console.log(` [${issue.severity}] ${issue.code}${issue.pluginName ? ` ${issue.pluginName}` : ''}: ${issue.message}`); } } } process.exit(ok ? 0 : 1); } case 'login': { const target = rest.find((a) => !a.startsWith('-')); const host = typeof flags.host === 'string' ? flags.host : inferGithubHost(target ?? 'github.com'); const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 }); if (!version.ok) { console.error('[marketplace login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.'); process.exit(1); } console.log(`[marketplace login] authenticating gh for ${host}. Tokens stay in gh, not Open Design.`); const result = await spawnPassthrough('gh', ['auth', 'login', '--hostname', host, '--web']); process.exit(result.code ?? 0); } 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 show <snapshotId> [--json] Print one snapshot's full contents. od plugin snapshots diff <id-a> <id-b> [--json] Compare two snapshots field-by-field. 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 = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); if (sub === 'show') { const positional = args.slice(1).filter((a) => !a.startsWith('-')); const id = positional[0]; if (!id) { console.error('Usage: od plugin snapshots show <snapshotId>'); process.exit(2); } const url = `${base}/api/applied-plugins/${encodeURIComponent(id)}`; const resp = await fetch(url); if (resp.status === 404) { console.error(`snapshot ${id} not found`); process.exit(72); } 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 === 'diff') { const positional = args.slice(1).filter((a) => !a.startsWith('-')); if (positional.length < 2) { console.error('Usage: od plugin snapshots diff <id-a> <id-b>'); process.exit(2); } const [idA, idB] = positional; const [respA, respB] = await Promise.all([ fetch(`${base}/api/applied-plugins/${encodeURIComponent(idA)}`), fetch(`${base}/api/applied-plugins/${encodeURIComponent(idB)}`), ]); if (respA.status === 404) { console.error(`snapshot ${idA} not found`); process.exit(72); } if (respB.status === 404) { console.error(`snapshot ${idB} not found`); process.exit(72); } if (!respA.ok || !respB.ok) { console.error(`fetch failed: ${respA.status} / ${respB.status}`); process.exit(1); } const a = await respA.json(); const b = await respB.json(); const { diffSnapshots } = await import('./plugins/snapshot-diff.js'); const report = diffSnapshots({ a, b }); if (flags.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); return; } const digestNote = report.digestEqual ? '\u2713 manifestSourceDigest equal (e2e-2 invariant holds)' : '\u2717 manifestSourceDigest DIFFERS (replay would diverge)'; console.log(`[snapshots diff] ${idA} \u2194 ${idB}`); console.log(` ${digestNote}`); console.log(` ${report.added} added, ${report.removed} removed, ${report.changed} changed`); if (report.entries.length === 0) { console.log(' (no field-level differences)'); return; } for (const e of report.entries) { const tag = e.kind === 'added' ? '+' : e.kind === 'removed' ? '-' : '~'; if (e.summary) { console.log(` ${tag} ${e.field} (${e.summary})`); } else if (e.kind === 'changed') { console.log(` ${tag} ${e.field}: ${e.before ?? ''} \u2192 ${e.after ?? ''}`); } else if (e.kind === 'added') { console.log(` ${tag} ${e.field}: ${e.after ?? ''}`); } else { console.log(` ${tag} ${e.field}: ${e.before ?? ''}`); } } return; } 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. 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.conversation && a !== flags.message && a !== flags.agent && a !== flags.model && a !== flags['snapshot-id'] && a !== flags.capabilities && a !== flags['grant-caps']); if (!id) { console.error('Usage: od plugin run <id> --project <projectId> [--inputs <json>] [--agent <id>] [--message "<text>"] [--grant-caps a,b] [--follow]'); 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 = (await 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, ...(flags.conversation ? { conversationId: flags.conversation } : {}), ...(flags.message ? { message: flags.message } : {}), ...(flags.agent ? { agentId: flags.agent } : {}), ...(flags.model ? { model: flags.model } : {}), ...(flags['snapshot-id'] ? { appliedPluginSnapshotId: flags['snapshot-id'] } : {}), }), }); 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'); if (flags.follow) await streamRunEvents(base, runData.runId); return; } console.log(`[run] started run ${runData.runId} (snapshot ${runData.appliedPluginSnapshotId ?? applyData?.appliedPlugin?.snapshotId ?? 'n/a'})`); if (flags.follow) { await streamRunEvents(base, runData.runId); } } async function pluginDaemonUrl(flags) { return cliDaemonUrl(flags); } // Plan §3.Y1 — filter knobs on `od plugin list` (and feeds // `od plugin search` below). Recognising these as string flags // keeps the parseFlags() argv consumer happy. async function runPluginList(rest) { const flags = parseFlags(rest, { string: PLUGIN_LIST_FILTER_FLAGS, boolean: PLUGIN_LIST_BOOLEAN_FLAGS, }); if (flags.help || flags.h) { console.log(`Usage: od plugin list [--task-kind <kind>] [--mode <mode>] [--tag <tag>] \\ [--trust <tier>] [--bundled | --no-bundled] [--json] Lists installed plugins. Filters AND together: --task-kind=code-migration + --tag=phase-7 returns only code-migration plugins tagged 'phase-7'. --task-kind Match od.taskKind (new-generation / figma-migration / code-migration / tune-collab). --mode Match od.mode. --tag Match an entry in tags[]. --trust Match trust tier (trusted / restricted / bundled). --bundled Restrict to bundled plugins (sourceKind='bundled' OR trust='bundled'). --no-bundled Exclude bundled plugins.`); process.exit(0); } const data = await fetchPluginList(flags); const filtered = await applyPluginFilters(data?.plugins ?? [], flags); emitPluginList({ entries: filtered, json: !!flags.json, emptyMessage: 'No plugins matched the filter.' }); } // Plan §3.Y1 — `od plugin search <query>`. async function runPluginSearch(rest) { const flags = parseFlags(rest, { string: PLUGIN_LIST_FILTER_FLAGS, boolean: PLUGIN_LIST_BOOLEAN_FLAGS, }); const positional = rest.filter((a) => !a.startsWith('-')); const query = positional[0]; if (flags.help || flags.h || !query) { console.log(`Usage: od plugin search <query> [--task-kind <kind>] [--mode <mode>] \\ [--tag <tag>] [--trust <tier>] \\ [--bundled | --no-bundled] [--json] Free-text search across installed plugins. Matches case-insensitively on id / title / description / tags. Combines with the same filter flags as 'od plugin list'.`); process.exit(query ? 0 : 2); } const data = await fetchPluginList(flags); const filtered = await applyPluginFilters(data?.plugins ?? [], flags, query); emitPluginList({ entries: filtered, json: !!flags.json, emptyMessage: `No installed plugins matched "${query}".`, showRank: true, }); } // Plan §3.DD1 — `od plugin stats`. Pretty-prints the // pluginInventoryStats + snapshotInventoryStats aggregation. The // daemon-side route owns the SQLite reads; the CLI is a thin // formatter. async function runPluginStats(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS, }); if (flags.help || flags.h) { console.log(`Usage: od plugin stats [--json] Prints an at-a-glance plugin + snapshot inventory: - Plugin counts by sourceKind, trust, taskKind. - Bundled vs. third-party split. - Plugins with elevated capabilities (fs:write, subprocess, bash, network, connector:*). - Snapshot total, status breakdown, project / run linkage. - Oldest / newest applied snapshot timestamps.`); process.exit(0); } const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const url = `${base}/api/plugins/stats`; 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 p = data?.plugins ?? {}; const s = data?.snapshots ?? {}; const lastInstalled = formatTimestamp(p.lastInstalledAt); const lastUpdated = formatTimestamp(p.lastUpdatedAt); const oldestApplied = formatTimestamp(s.oldestAppliedAt); const newestApplied = formatTimestamp(s.newestAppliedAt); console.log('# Plugins'); console.log(` total: ${p.total ?? 0}`); console.log(` bundled: ${p.bundled ?? 0}`); console.log(` third-party: ${p.thirdParty ?? 0}`); console.log(` with elevated: ${p.withElevatedCapabilities ?? 0}`); console.log(` by sourceKind: ${formatCounts(p.bySourceKind)}`); console.log(` by trust: ${formatCounts(p.byTrust)}`); console.log(` by taskKind: ${formatCounts(p.byTaskKind)}`); console.log(` last installed: ${lastInstalled}`); console.log(` last updated: ${lastUpdated}`); console.log(''); console.log('# Snapshots'); console.log(` total: ${s.total ?? 0}`); console.log(` by status: ${formatCounts(s.byStatus)}`); console.log(` with project: ${s.withProject ?? 0}`); console.log(` with run: ${s.withRun ?? 0}`); console.log(` oldest applied: ${oldestApplied}`); console.log(` newest applied: ${newestApplied}`); } function formatCounts(counts) { if (!counts || typeof counts !== 'object') return '(none)'; const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); if (entries.length === 0) return '(none)'; return entries.map(([k, v]) => `${k}=${v}`).join(', '); } function formatTimestamp(ts) { if (typeof ts !== 'number' || !Number.isFinite(ts)) return '(none)'; try { return new Date(ts).toISOString(); } catch { return String(ts); } } async function fetchPluginList(flags) { const url = `${(await 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(); return data; } async function applyPluginFilters(plugins, flags, query) { if (!Array.isArray(plugins) || plugins.length === 0) return []; const { searchInstalledPlugins } = await import('./plugins/search.js'); const trustFlag = typeof flags.trust === 'string' ? flags.trust : undefined; const taskKind = typeof flags['task-kind'] === 'string' ? flags['task-kind'] : undefined; const mode = typeof flags.mode === 'string' ? flags.mode : undefined; const tag = typeof flags.tag === 'string' ? flags.tag : undefined; let bundled; if (flags.bundled === true) bundled = true; if (flags['no-bundled'] === true) bundled = false; const result = searchInstalledPlugins({ plugins, ...(typeof query === 'string' && query.trim() ? { query } : {}), ...(taskKind ? { taskKind } : {}), ...(mode ? { mode } : {}), ...(tag ? { tag } : {}), ...(trustFlag === 'trusted' || trustFlag === 'restricted' || trustFlag === 'bundled' ? { trust: trustFlag } : {}), ...(typeof bundled === 'boolean' ? { bundled } : {}), }); return result.entries; } function emitPluginList({ entries, json, emptyMessage, showRank }) { if (json) { process.stdout.write(JSON.stringify({ total: entries.length, plugins: entries.map((e) => ({ ...e.plugin, ...(showRank ? { matched: e.matched, rank: e.rank } : {}), })), }, null, 2) + '\n'); return; } if (entries.length === 0) { console.log(emptyMessage ?? 'No plugins matched.'); return; } for (const entry of entries) { const p = entry.plugin; const tail = showRank && entry.matched.length > 0 ? ` matched=[${entry.matched.join(',')}]` : ''; console.log(`${p.id}@${p.version} trust=${p.trust} source=${p.sourceKind} title="${p.title}"${tail}`); } } 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 && a !== flags.version); if (!id) { console.error('Usage: od plugin info <id-or-marketplace-name> [--version <version|tag|range>] [--json]'); process.exit(2); } const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const url = `${base}/api/plugins/${encodeURIComponent(id)}`; const resp = await fetch(url); if (resp.ok && !flags.version) { const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } const mpResp = await fetch(`${base}/api/marketplaces`); if (mpResp.ok) { const mpData = await mpResp.json().catch(() => ({})); const resolved = resolveMarketplacePluginFromList( mpData?.marketplaces ?? [], flags.version ? `${id}@${flags.version}` : id, ); if (resolved) { process.stdout.write(JSON.stringify({ marketplace: resolved }, null, 2) + '\n'); return; } } 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'); } function resolveMarketplacePluginFromList(marketplaces, specifier) { const parsed = parseCliPluginSpecifier(specifier); const target = parsed.name.toLowerCase(); for (const marketplace of marketplaces) { for (const entry of marketplace?.manifest?.plugins ?? []) { if (String(entry.name ?? '').toLowerCase() !== target) continue; const version = resolveCliEntryVersion(entry, parsed.range); if (!version) return null; return { marketplaceId: marketplace.id, marketplaceTrust: marketplace.trust, name: entry.name, version: version.version, source: version.source, ref: version.ref, integrity: version.integrity, manifestDigest: version.manifestDigest, entry, }; } } return null; } function parseCliPluginSpecifier(input) { const trimmed = String(input ?? '').trim(); const slash = trimmed.indexOf('/'); const at = trimmed.lastIndexOf('@'); if (slash > 0 && at > slash + 1) { return { name: trimmed.slice(0, at), range: trimmed.slice(at + 1) }; } return { name: trimmed, range: undefined }; } function resolveCliEntryVersion(entry, range) { if (entry?.yanked) return null; const versions = Array.isArray(entry?.versions) ? entry.versions : []; const target = range && range !== 'latest' ? (entry?.distTags?.[range] ?? range) : (entry?.distTags?.latest ?? entry?.version); const version = versions.find((item) => item.version === target) ?? null; if (version?.yanked) return null; return { version: target, source: version?.source ?? entry?.source, ref: version?.ref ?? entry?.ref, integrity: version?.integrity ?? version?.dist?.integrity ?? entry?.integrity ?? entry?.dist?.integrity, manifestDigest: version?.manifestDigest ?? version?.dist?.manifestDigest ?? entry?.manifestDigest ?? entry?.dist?.manifestDigest, }; } // Plan §3.MM1 — `od plugin manifest <id>`. Prints just the parsed // manifest JSON, no wrapper. Useful for plugin authors who want to // compare the daemon's view to their on-disk open-design.json // without scrolling past the registry record fields (sourceKind / // fsPath / installedAt etc). async function runPluginManifest(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 manifest <id>'); process.exit(2); } const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`; const resp = await fetch(url); if (resp.status === 404) { console.error(`plugin ${id} not found`); process.exit(65); } if (!resp.ok) { console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); if (!data?.manifest) { console.error(`plugin ${id} has no recorded manifest (registry row is incomplete)`); process.exit(1); } process.stdout.write(JSON.stringify(data.manifest, null, 2) + '\n'); } // Plan §3.MM2 — `od plugin sources`. Lists every distinct install // source string + count of plugins installed from it, ordered by // count descending then source ascending. Useful for ops audits // ('which github repos do my plugins come from') + for plugin // authors comparing their fork to its upstream installs. async function runPluginSources(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const url = `${(await 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(); const plugins = Array.isArray(data?.plugins) ? data.plugins : []; const buckets = new Map(); for (const p of plugins) { const key = `${p.sourceKind ?? 'unknown'}\t${p.source ?? '(none)'}`; const entry = buckets.get(key) ?? { sourceKind: p.sourceKind ?? 'unknown', source: p.source ?? '(none)', count: 0, plugins: [] }; entry.count += 1; entry.plugins.push({ id: p.id, version: p.version }); buckets.set(key, entry); } const rows = [...buckets.values()].sort((a, b) => { if (a.count !== b.count) return b.count - a.count; if (a.sourceKind !== b.sourceKind) return a.sourceKind.localeCompare(b.sourceKind); return a.source.localeCompare(b.source); }); if (flags.json) { process.stdout.write(JSON.stringify({ total: plugins.length, sources: rows }, null, 2) + '\n'); return; } if (rows.length === 0) { console.log('No plugins installed.'); return; } console.log(`# Plugin install sources (total: ${plugins.length})`); for (const row of rows) { console.log(` ${row.sourceKind.padEnd(11)} ${String(row.count).padStart(3)} ${row.source}`); for (const plug of row.plugins) { console.log(` \u2514\u2500 ${plug.id}@${plug.version}`); } } } 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>[@version|tag|range] # resolves through configured marketplaces'); process.exit(2); } const url = `${(await 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; const events = []; let finalEvent = null; 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; events.push({ event, data }); if (event === 'progress') { if (!flags.json) console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`); } else if (event === 'success') { finalEvent = data; if (!flags.json) console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`); if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) { for (const w of data.warnings) console.log(`[install] warn: ${w}`); } } else if (event === 'error') { finalEvent = data; if (!flags.json) console.error(`[install] error: ${data?.message ?? 'unknown'}`); exitCode = 1; } } } if (flags.json) { process.stdout.write(JSON.stringify({ ok: exitCode === 0, result: finalEvent, events, }, null, 2) + '\n'); } process.exit(exitCode); } // Plan §3.Z2 — `od plugin upgrade <id>`. Re-installs the plugin // from its recorded source. Streams the same SSE event shape as // install, so 'progress' / 'success' / 'error' arrive verbatim. // Plan §3.II1 — `od plugin events tail`. Tails the daemon's // in-memory plugin event ring buffer via SSE. -f keeps the // connection open and prints live events; otherwise prints the // backlog and exits when the daemon closes the stream. async function runPluginEvents(rest) { const sub = rest[0]; if (!sub || sub === 'help' || rest.includes('--help') || rest.includes('-h')) { console.log(`Usage: od plugin events tail [-f] [--since <id>] [--kind <k>] [--plugin-id <id>] [--json] od plugin events snapshot [--since <id>] [--kind <k>] [--plugin-id <id>] [--json] od plugin events stats [--json] od plugin events purge [--confirm] [--json] (loopback-only) Tail / snapshot / stats / purge over the daemon's in-memory plugin event ring buffer (capped at 1000 entries; resets on daemon restart). Lifecycle vocabulary: plugin.installed | plugin.upgraded | plugin.uninstalled plugin.trust-changed | plugin.snapshot-pruned plugin.marketplace-refreshed | plugin.applied --since <id> Trim backlog to events strictly after id. --kind <k> Filter to a single kind. --plugin-id <id> Filter to events touching one plugin id. -f / --follow tail-only: keep the SSE stream open. --json Emit raw JSON (one event per line on tail, full report on snapshot/stats).`); process.exit(sub ? 0 : 2); } const flags = parseFlags(rest.slice(1), { string: new Set([...PLUGIN_STRING_FLAGS, 'since', 'kind', 'plugin-id']), boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'f', 'follow']), }); const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const since = typeof flags.since === 'string' ? Number(flags.since) : 0; const kindFilter = typeof flags.kind === 'string' && flags.kind.length > 0 ? flags.kind : null; const pluginIdFilter = typeof flags['plugin-id'] === 'string' && flags['plugin-id'].length > 0 ? flags['plugin-id'] : null; const matches = (ev) => { if (!ev) return false; if (kindFilter && ev.kind !== kindFilter) return false; if (pluginIdFilter && ev.pluginId !== pluginIdFilter) return false; return true; }; if (sub === 'snapshot') { const url = `${base}/api/plugins/events/snapshot${Number.isFinite(since) && since > 0 ? `?since=${since}` : ''}`; 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(); const events = (Array.isArray(data?.events) ? data.events : []).filter(matches); if (flags.json) { process.stdout.write(JSON.stringify({ events, count: events.length, generatedAt: data?.generatedAt }, null, 2) + '\n'); return; } if (events.length === 0) { console.log('[events snapshot] no events match filter'); return; } for (const ev of events) { const ts = ev.at ? new Date(ev.at).toISOString() : '?'; const detailKeys = ev.details ? Object.keys(ev.details).slice(0, 3).join(',') : ''; console.log(`#${ev.id} ${ts} ${ev.kind} pluginId=${ev.pluginId || '-'}` + (detailKeys ? ` details=${detailKeys}` : '')); } return; } if (sub === 'purge') { // Refuse to run without an explicit --confirm so 'od plugin // events purge' alone never drops audit data accidentally. const purgeFlags = parseFlags(rest.slice(1), { string: new Set(['daemon-url']), boolean: new Set(['help', 'h', 'json', 'confirm']), }); if (!purgeFlags.confirm) { console.error('[events purge] refusing without --confirm. This drops every event in the in-memory buffer.'); process.exit(2); } const resp = await fetch(`${base}/api/plugins/events/purge`, { method: 'POST' }); if (!resp.ok) { console.error(`POST /api/plugins/events/purge failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const data = await resp.json(); if (purgeFlags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } else { console.log(`[events purge] dropped ${data.purged ?? 0} event${(data.purged ?? 0) === 1 ? '' : 's'} (id range: ${data.firstId ?? '(none)'} \u2192 ${data.lastId ?? '(none)'}; preNextId=${data.preNextId})`); } return; } if (sub === 'stats') { const resp = await fetch(`${base}/api/plugins/events/stats`); if (!resp.ok) { console.error(`GET /api/plugins/events/stats 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 s = data?.stats ?? {}; console.log('# Plugin events'); console.log(` total: ${s.total ?? 0}`); console.log(` by kind: ${formatCounts(s.byKind)}`); console.log(` by pluginId: ${formatCounts(s.byPluginId)}`); console.log(` oldest at: ${formatTimestamp(s.oldestAt)}`); console.log(` newest at: ${formatTimestamp(s.newestAt)}`); console.log(` id range: ${s.firstId ?? '(none)'} \u2192 ${s.lastId ?? '(none)'}`); return; } if (sub !== 'tail') { console.error(`unknown subcommand: od plugin events ${sub}`); process.exit(2); } const follow = flags.f === true || flags.follow === true; const url = `${base}/api/plugins/events${Number.isFinite(since) && since > 0 ? `?since=${since}` : ''}`; const resp = await fetch(url, { headers: { accept: 'text/event-stream' } }); if (!resp.ok || !resp.body) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const renderEvent = (channel, data) => { if (!matches(data)) return; if (flags.json) { process.stdout.write(JSON.stringify({ channel, ...data }) + '\n'); return; } const ts = data?.at ? new Date(data.at).toISOString() : '?'; const id = data?.id ?? '?'; const tag = channel === 'backlog' ? '[bk]' : '[ev]'; const detailKeys = data?.details ? Object.keys(data.details).slice(0, 3).join(',') : ''; console.log(`${tag} #${id} ${ts} ${data?.kind ?? '?'} pluginId=${data?.pluginId ?? '-'}` + (detailKeys ? ` details=${detailKeys}` : '')); }; // Read until the daemon closes the stream OR --follow keeps it open // forever. Without --follow we still let the daemon drain the // backlog naturally; the route emits all backlog entries first, // and our reader exits when the connection closes (which the // daemon never does on its own, so we add a small idle timer). if (!follow) { // Non-follow: drain backlog, then exit after a short idle period // (the route never naturally closes; the SSE backlog is a one-shot // stream of event entries). let lastChunkAt = Date.now(); const idleMs = 200; const idleTimer = setInterval(() => { if (Date.now() - lastChunkAt > idleMs) { clearInterval(idleTimer); try { reader.cancel(); } catch { /* ignore */ } } }, 100); try { while (true) { const { value, done } = await reader.read(); if (done) break; lastChunkAt = Date.now(); 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 ev = lines.find((l) => l.startsWith('event: '))?.slice('event: '.length) ?? 'message'; const dat = lines.find((l) => l.startsWith('data: '))?.slice('data: '.length); if (!dat) continue; try { renderEvent(ev, JSON.parse(dat)); } catch { /* ignore */ } } } } finally { clearInterval(idleTimer); } return; } // Follow mode: read forever. 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 ev = lines.find((l) => l.startsWith('event: '))?.slice('event: '.length) ?? 'message'; const dat = lines.find((l) => l.startsWith('data: '))?.slice('data: '.length); if (!dat) continue; try { renderEvent(ev, JSON.parse(dat)); } catch { /* ignore */ } } } } // Plan §3.FF1 — `od plugin verify <pluginId>` CI meta-command. // // Reads an optional .od-verify.json config from the plugin folder // or --config <path> and runs the enabled subset of: // // doctor — calls /api/plugins/<id>/doctor // simulate — calls /api/plugins/<id> + simulatePipeline() // canon — fetches /api/applied-plugins/<snapshotId>/canon and // compares against the on-disk fixture // // Aggregates into a unified pass/fail report. Exit 4 on any failed // check; useful as a one-liner CI check for a plugin's repo. async function runPluginVerify(rest) { const flags = parseFlags(rest, { string: new Set([...PLUGIN_STRING_FLAGS, 'config']), boolean: PLUGIN_BOOLEAN_FLAGS, }); const positional = rest.filter((a) => !a.startsWith('-')); const id = positional[0]; if (flags.help || flags.h || !id) { console.log(`Usage: od plugin verify <pluginId> [--config <path>] [--json] CI meta-command. Reads an optional config from '<plugin-folder>/.od-verify.json' (or --config <path>) and runs: doctor — manifest + atom + ref lint simulate — convergence dry-run for every until expression, with per-stage signals from config.simulate.signals canon — byte-equality check against config.canon.fixturePath using the snapshot at config.canon.snapshotId Sample .od-verify.json: { "enabled": ["doctor", "simulate"], "simulate": { "signals": { "critique.score": 5, "build.passing": true }, "iterationCap": 5 }, "canon": { "snapshotId": "snap-abc", "fixturePath": "tests/expected-block.md" } } Exit codes: 0 every enabled check passed 4 one or more enabled checks failed 2 CLI usage error / plugin not found / config malformed`); process.exit(id ? 0 : 2); } const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); // 1. Resolve the plugin record (fsPath + manifest). const pluginResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`); if (pluginResp.status === 404) { console.error(`plugin ${id} not found`); process.exit(65); } if (!pluginResp.ok) { console.error(`GET /api/plugins/${id} failed: ${pluginResp.status} ${await pluginResp.text()}`); process.exit(1); } const plugin = await pluginResp.json(); // 2. Load .od-verify.json from --config or <fsPath>/.od-verify.json. const fs = await import('node:fs/promises'); const path = await import('node:path'); const configPath = typeof flags.config === 'string' ? path.resolve(flags.config) : (typeof plugin?.fsPath === 'string' ? path.join(plugin.fsPath, '.od-verify.json') : null); let config = { enabled: ['doctor', 'simulate', 'canon'] }; if (configPath) { try { const raw = await fs.readFile(configPath, 'utf8'); config = JSON.parse(raw); } catch (err) { const e = err; if (e?.code !== 'ENOENT') { console.error(`[verify] cannot read config ${configPath}: ${e?.message ?? e}`); process.exit(2); } // ENOENT → run with defaults. canon will skip cleanly because no // config.canon entry was supplied. } } // 3. doctor (when enabled) const enabledSet = new Set((config.enabled ?? ['doctor', 'simulate', 'canon']).filter((c) => c === 'doctor' || c === 'simulate' || c === 'canon')); let doctorReport = null; if (enabledSet.has('doctor')) { const doctorResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/doctor`); if (doctorResp.ok) { doctorReport = await doctorResp.json(); } } // 4. simulate (when enabled) let simulateReport = null; if (enabledSet.has('simulate')) { const pipeline = plugin?.manifest?.od?.pipeline; if (pipeline && Array.isArray(pipeline.stages) && pipeline.stages.length > 0) { const { simulatePipeline } = await import('./plugins/simulate.js'); simulateReport = simulatePipeline({ pipeline, signals: config.simulate?.signals ?? {}, ...(typeof config.simulate?.iterationCap === 'number' && config.simulate.iterationCap > 0 ? { iterationCap: config.simulate.iterationCap } : {}), }); } } // 5. canon (when enabled + fixture supplied) let canonActual = null; let canonExpected = null; if (enabledSet.has('canon') && config.canon?.snapshotId && config.canon?.fixturePath) { const fixturePath = path.resolve( typeof flags.config === 'string' ? path.dirname(path.resolve(flags.config)) : (typeof plugin?.fsPath === 'string' ? plugin.fsPath : process.cwd()), config.canon.fixturePath, ); try { canonExpected = await fs.readFile(fixturePath, 'utf8'); } catch { canonExpected = null; } if (canonExpected !== null) { const canonResp = await fetch( `${base}/api/applied-plugins/${encodeURIComponent(config.canon.snapshotId)}/canon`, { headers: { accept: 'text/plain' } }, ); if (canonResp.ok) { canonActual = await canonResp.text(); } } } // 6. Aggregate. const { verifyPlugin } = await import('./plugins/verify.js'); const report = verifyPlugin({ config: { enabled: [...enabledSet], ...(config.strict === true ? { strict: true } : {}), ...(config.simulate ? { simulate: config.simulate } : {}), ...(config.canon ? { canon: config.canon } : {}), }, ...(doctorReport ? { doctor: doctorReport } : {}), ...(simulateReport ? { simulate: simulateReport } : {}), ...(canonActual ? { canon: canonActual } : {}), ...(canonExpected ? { canonExpected: canonExpected } : {}), }); if (flags.json) { process.stdout.write(JSON.stringify({ pluginId: id, ...report }, null, 2) + '\n'); } else { console.log(`[verify] plugin ${id} \u2014 ${report.passed ? 'PASSED' : 'FAILED'}`); for (const o of report.outcomes) { const tag = o.status === 'passed' ? '\u2713' : o.status === 'failed' ? '\u2717' : o.status === 'skipped' ? '-' : '!'; console.log(` ${tag} ${o.summary}`); } } process.exit(report.passed ? 0 : 4); } // Plan §3.EE1 — `od plugin simulate <pluginId> [-s key=value ...]`. // // Walks the plugin's pipeline against caller-supplied signals and // reports per-stage convergence (iterations + outcome). No LLM is // invoked — this is a pure devloop dry-run for testing 'until' // expressions. // // Signals are supplied via repeatable -s key=value flags. The // closed UntilSignals vocabulary applies (critique.score / // iterations / user.confirmed / preview.ok / build.passing / // tests.passing); unknown keys surface as warnings. async function runPluginSimulate(rest) { const flags = parseFlags(rest, { string: new Set([...PLUGIN_STRING_FLAGS, 's', 'cap']), boolean: PLUGIN_BOOLEAN_FLAGS, }); const positional = rest.filter((a) => !a.startsWith('-')); const id = positional[0]; if (flags.help || flags.h || !id) { console.log(`Usage: od plugin simulate <pluginId> [-s key=value ...] [--cap <n>] [--json] Walks the plugin's pipeline against caller-supplied signals and reports per-stage convergence. No LLM is invoked. Examples: # critique-theater stage that exits when score >= 4 od plugin simulate my-plugin -s critique.score=5 # build-test devloop where both signals must hold od plugin simulate code-migration \\ -s build.passing=true -s tests.passing=true # raise the per-stage iteration cap (default 10) od plugin simulate my-plugin -s critique.score=2 --cap 20 Closed signal vocabulary: critique.score (number) iterations (number) user.confirmed (boolean) preview.ok (boolean) build.passing (boolean) tests.passing (boolean)`); process.exit(id ? 0 : 2); } // Collect every -s value (parseFlags returns the last only). const sValues = []; for (let i = 0; i < rest.length; i++) { if ((rest[i] === '-s' || rest[i] === '--signal') && typeof rest[i + 1] === 'string') { sValues.push(rest[i + 1]); } } // Fetch the plugin from the daemon so we get the resolved // manifest (including pipeline). const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const resp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`); if (resp.status === 404) { console.error(`plugin ${id} not found`); process.exit(65); } if (!resp.ok) { console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } const plugin = await resp.json(); const pipeline = plugin?.manifest?.od?.pipeline; if (!pipeline || !Array.isArray(pipeline.stages) || pipeline.stages.length === 0) { if (flags.json) { process.stdout.write(JSON.stringify({ outcome: 'no-pipeline', stages: [] }, null, 2) + '\n'); } else { console.log(`[simulate] plugin ${id} has no od.pipeline (or it is empty); nothing to walk.`); } return; } const { simulatePipeline, parseSignalKv } = await import('./plugins/simulate.js'); const parsedSignals = parseSignalKv(sValues); for (const w of parsedSignals.warnings) console.warn(`[simulate] warn: ${w}`); const cap = typeof flags.cap === 'string' ? Number(flags.cap) : undefined; const result = simulatePipeline({ pipeline, signals: parsedSignals.signals, ...(Number.isFinite(cap) && cap > 0 ? { iterationCap: cap } : {}), }); if (flags.json) { process.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; } console.log(`[simulate] plugin ${id} \u2014 outcome: ${result.outcome}, totalIterations: ${result.totalIterations}`); for (const stage of result.stages) { const tag = stage.outcome === 'converged' ? '\u2713' : stage.outcome === 'cap' ? '\u2717' : stage.outcome === 'unparsable' ? '!' : '\u2014'; const reason = stage.reason ? ` (${stage.reason})` : ''; const matched = stage.matched && stage.matched.length > 0 ? ` matched=[${stage.matched.map((c) => `${c.signal}${c.op}${c.value}`).join(' && ')}]` : ''; console.log(` ${tag} ${stage.stageId}: ${stage.outcome} (${stage.iterations} iter)${reason}${matched}`); } // Exit non-zero on cap-hit / unparsable so CI can wire this // into a pipeline check easily. if (result.outcome === 'cap-hit' || result.outcome === 'unparsable') process.exit(4); } // Plan §3.CC1 / §3.DD2 — `od plugin canon <snapshotId>`. Prints the // canonical `## Active plugin` block a snapshot will splice into // the system prompt. Useful for understanding what the agent // reads + locking byte-equality regression tests against the // daemon's renderPluginBlock() output. // // --check <file> mode: compares the canon output against an // on-disk fixture (typically committed under tests/fixtures/) and // exits 4 on byte-mismatch. Lets a plugin author lock byte- // equality without writing a new test harness. async function runPluginCanon(rest) { const flags = parseFlags(rest, { string: new Set([...PLUGIN_STRING_FLAGS, 'check']), boolean: PLUGIN_BOOLEAN_FLAGS, }); const positional = rest.filter((a) => !a.startsWith('-')); const id = positional[0]; if (flags.help || flags.h || !id) { console.log(`Usage: od plugin canon <snapshotId> [--json] od plugin canon <snapshotId> --check <expected-file> Prints the canonical '## Active plugin' / '## Plugin inputs' / '## Plugin atoms' block this snapshot would splice into the system prompt. Default output is plain text; --json wraps the block in { snapshotId, pluginId, block }. --check <file> compares the canon output to the file's bytes and exits 4 on mismatch. Useful for committing renderPluginBlock() fixtures into a plugin's own tests/.`); process.exit(id ? 0 : 2); } const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const url = `${base}/api/applied-plugins/${encodeURIComponent(id)}/canon`; const checkPath = typeof flags.check === 'string' ? flags.check : null; // --check always wants the raw text output; force text/plain. const wantsText = !flags.json || checkPath !== null; const headers = { accept: wantsText ? 'text/plain' : 'application/json' }; const resp = await fetch(url, { headers }); if (resp.status === 404) { console.error(`snapshot ${id} not found`); process.exit(72); } if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); } if (checkPath) { const fs = await import('node:fs/promises'); let expected; try { expected = await fs.readFile(checkPath, 'utf8'); } catch (err) { console.error(`[canon --check] cannot read ${checkPath}: ${err?.message ?? err}`); process.exit(2); } const actual = await resp.text(); if (actual === expected) { console.log(`[canon] \u2713 byte-equal to ${checkPath}`); return; } // Surface a small unified-diff preview so the author sees what // drifted. Full diff is left to the user's preferred tool. console.error(`[canon --check] \u2717 mismatch with ${checkPath}`); console.error(` expected length: ${expected.length} bytes`); console.error(` actual length: ${actual.length} bytes`); const expectedLines = expected.split('\n'); const actualLines = actual.split('\n'); const limit = Math.min(Math.max(expectedLines.length, actualLines.length), 40); for (let i = 0; i < limit; i++) { if (expectedLines[i] !== actualLines[i]) { console.error(` line ${i + 1}:`); if (expectedLines[i] !== undefined) console.error(` - ${expectedLines[i]}`); if (actualLines[i] !== undefined) console.error(` + ${actualLines[i]}`); } } process.exit(4); } if (flags.json) { const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } const body = await resp.text(); process.stdout.write(body); if (!body.endsWith('\n')) process.stdout.write('\n'); } // Plan §3.AA1 — `od plugin diff <a> <b>`. Compares two installed // plugins (by id) and prints a structured report. Useful for // debugging replay invariance + reviewing version bumps. async function runPluginDiff(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); const positional = rest.filter((a) => !a.startsWith('-')); if (flags.help || flags.h || positional.length < 2) { console.log(`Usage: od plugin diff <id-a> <id-b> [--json] Compares two installed plugins (or two installs of the same id at different versions) and prints every changed field. Output groups into 'added' / 'removed' / 'changed' with one line per field.`); process.exit(positional.length < 2 ? 2 : 0); } const [idA, idB] = positional; const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const [respA, respB] = await Promise.all([ fetch(`${base}/api/plugins/${encodeURIComponent(idA)}`), fetch(`${base}/api/plugins/${encodeURIComponent(idB)}`), ]); if (!respA.ok) { console.error(`GET /api/plugins/${idA} failed: ${respA.status}`); process.exit(1); } if (!respB.ok) { console.error(`GET /api/plugins/${idB} failed: ${respB.status}`); process.exit(1); } const a = await respA.json(); const b = await respB.json(); const { diffPlugins } = await import('./plugins/diff.js'); const report = diffPlugins({ a, b }); if (flags.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); return; } if (report.entries.length === 0) { console.log(`[diff] ${idA} and ${idB} are equivalent on every recorded field.`); return; } console.log(`[diff] ${idA} \u2194 ${idB} — ${report.added} added, ${report.removed} removed, ${report.changed} changed`); for (const e of report.entries) { const tag = e.kind === 'added' ? '+' : e.kind === 'removed' ? '-' : '~'; if (e.summary) { console.log(` ${tag} ${e.field} (${e.summary})`); } else if (e.kind === 'changed') { console.log(` ${tag} ${e.field}: ${e.before ?? ''} \u2192 ${e.after ?? ''}`); } else if (e.kind === 'added') { console.log(` ${tag} ${e.field}: ${e.after ?? ''}`); } else { console.log(` ${tag} ${e.field}: ${e.before ?? ''}`); } } } async function runPluginUpgrade(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 upgrade <id> [--policy latest|pinned] [--json]'); process.exit(2); } const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ policy: flags.policy === 'pinned' ? 'pinned' : 'latest', }), }); if (!resp.ok || !resp.body) { let msg = ''; try { msg = await resp.text(); } catch { msg = ''; } console.error(`POST /api/plugins/${id}/upgrade failed: ${resp.status} ${msg}`); process.exit(1); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let exitCode = 0; const events = []; let finalEvent = null; 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; events.push({ event, data }); if (event === 'progress') { if (!flags.json) console.log(`[upgrade] ${data?.phase ?? '...'}: ${data?.message ?? ''}`); } else if (event === 'success') { finalEvent = data; if (!flags.json) console.log(`[upgrade] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`); if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) { for (const w of data.warnings) console.log(`[upgrade] warn: ${w}`); } } else if (event === 'error') { finalEvent = data; if (!flags.json) console.error(`[upgrade] error: ${data?.message ?? 'unknown'}`); exitCode = 1; } } } if (flags.json) { process.stdout.write(JSON.stringify({ ok: exitCode === 0, policy: flags.policy === 'pinned' ? 'pinned' : 'latest', result: finalEvent, events, }, null, 2) + '\n'); } 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 = `${(await 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 = `${(await 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 ${await 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; } async function runPluginCandidates(rest) { const sub = rest[0]; const args = rest.slice(1); const flags = parseFlags(args, { string: new Set(['daemon-url', 'project', 'action']), boolean: new Set(['help', 'h', 'json', 'include-dismissed']), }); if (!sub || flags.help || flags.h) { console.log(`Usage: od plugin candidates list --project <projectId> [--json] [--include-dismissed] od plugin candidates draft <candidateId> --project <projectId> [--json] od plugin candidates dismiss <candidateId> --project <projectId> [--json] Lists and formalizes persisted skill-to-plugin candidates.`); process.exit(!sub ? 2 : 0); } const projectId = typeof flags.project === 'string' && flags.project.length > 0 ? flags.project : ''; if (!projectId) { console.error('--project <projectId> is required'); process.exit(2); } const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); if (sub === 'list') { const qs = flags['include-dismissed'] ? '?includeDismissed=true' : ''; const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates${qs}`); const data = await resp.json().catch(() => null); if (!resp.ok) { console.error(`GET plugin candidates failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const candidates = Array.isArray(data?.candidates) ? data.candidates : []; if (candidates.length === 0) { console.log('No plugin candidates.'); return; } for (const candidate of candidates) { console.log(`${candidate.id}\t${candidate.status}\t${candidate.title}\t${candidate.draftPath ?? ''}`); } return; } const candidateId = args.find((a) => !a.startsWith('-') && a !== flags.project && a !== flags.action); if (!candidateId) { console.error(`candidate id is required for ${sub}`); process.exit(2); } if (sub === 'draft') { const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/draft`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); const data = await resp.json().catch(() => null); if (flags.json) { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } else if (resp.ok) { console.log(`[candidate] draft: ${data.draftPath}`); console.log(`[candidate] validation ok=${data.validation?.ok}`); } else { console.error(`[candidate] draft failed: ${data?.message ?? JSON.stringify(data)}`); } process.exit(resp.ok ? 0 : resp.status === 422 ? 4 : 1); } if (sub === 'dismiss') { const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/dismiss`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); const data = await resp.json().catch(() => null); if (flags.json) process.stdout.write(JSON.stringify(data, null, 2) + '\n'); else if (resp.ok) console.log(`[candidate] dismissed ${candidateId}`); else console.error(`[candidate] dismiss failed: ${data?.message ?? JSON.stringify(data)}`); process.exit(resp.ok ? 0 : 1); } console.error(`unknown subcommand: od plugin candidates ${sub}`); process.exit(2); } // 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', 'catalog']), boolean: new Set(['help', 'h', 'json', 'open']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin publish <pluginId> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh [--repo <github-url>] [--snapshot-id <id>] [--open] [--json] od plugin publish <pluginId> --to marketplace-json --catalog ./open-design-marketplace.json --repo <github-url> 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: open-design, anthropics-skills, awesome-agent-skills, clawhub, skills-sh)'); process.exit(2); } const base = (await 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(); // The daemon's plugin row carries a stored `version` plus the full // manifest. For project-local plugins (`generated-plugin/`, snapshots, // freshly imported folders) the stored `version` is `'0.0.0'` until // the registry handshake runs, but the manifest's `version` is the // real value the author wrote. Mirror `plugins/marketplaces.ts:298,328` // and prefer the manifest version when the stored row reads as the // pre-handshake sentinel. Closes #1765. const storedVersion = typeof row.version === 'string' && row.version.length > 0 ? row.version : null; const manifestVersion = typeof row.manifest?.version === 'string' && row.manifest.version.length > 0 ? row.manifest.version : null; const resolvedVersion = (storedVersion && storedVersion !== '0.0.0') ? storedVersion : (manifestVersion ?? storedVersion ?? '0.0.0'); meta = { pluginId: row.id ?? id, pluginVersion: resolvedVersion, ...(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; } if (target === 'marketplace-json') { if (typeof flags.catalog !== 'string' || flags.catalog.length === 0) { console.error('--catalog <path> is required for --to marketplace-json'); process.exit(2); } if (!meta.repoUrl) { console.error('--repo <github-url> is required for --to marketplace-json so the source can be reproduced'); process.exit(2); } const outcome = await publishToMarketplaceJson({ catalogPath: flags.catalog, meta, }); if (flags.json) { process.stdout.write(JSON.stringify(outcome, null, 2) + '\n'); } else { console.log(`[publish] updated ${outcome.catalogPath}`); console.log(`[publish] ${outcome.entry.name}@${outcome.entry.version} -> ${outcome.entry.source}`); } return; } 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 runPluginPublishRepo(rest) { const flags = parseFlags(rest, { string: new Set(['host', 'owner']), boolean: new Set(['help', 'h', 'json', 'dry-run']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin publish-repo <folder> [--host github.com] [--owner github-login-or-org] [--dry-run] [--json] Creates or updates the public GitHub repository named by the plugin manifest. If plugin.repo is missing or uses a placeholder owner, the CLI resolves the target from --owner, a trusted manifest owner, local gh auth status, then the GitHub API as a last resort. It never publishes to placeholder owners.`); process.exit(rest.length === 0 ? 2 : 0); } const folder = rest.find((a) => !a.startsWith('-') && a !== flags.host && a !== flags.owner); if (!folder) { console.error('Usage: od plugin publish-repo <folder>'); process.exit(2); } const [{ resolve, join }, { readFile, writeFile, stat, mkdtemp, readdir, rm, mkdir, cp }, { pathToFileURL }, os] = await Promise.all([ import('node:path'), import('node:fs/promises'), import('node:url'), import('node:os'), ]); const absFolder = resolve(process.cwd(), folder); const manifestPath = resolve(absFolder, 'open-design.json'); const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); const host = typeof flags.host === 'string' ? flags.host : 'github.com'; const target = await resolvePluginGithubTarget({ host, owner: flags.owner, manifest, purpose: 'publish-repo' }); const normalized = normalizeManifestRepoForOwner(manifest, target.owner); if (normalized.changed && !flags['dry-run']) { await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); await pluginCliValidateFolder(absFolder); } const repo = parseGithubRepoUrl(normalized.repoUrl); if (!repo) { console.error(`[publish-repo] invalid plugin.repo after normalization: ${normalized.repoUrl}`); process.exit(2); } const steps = []; const run = async (label, command, args, opts = {}) => { steps.push({ label, command: [command, ...args].join(' ') }); if (flags['dry-run']) return { ok: true, stdout: '', stderr: '' }; const result = await (command === 'gh' ? execGhBuffered(args, { cwd: opts.cwd ?? absFolder, timeout: opts.timeout ?? 120_000 }) : execFileBuffered(command, args, { cwd: opts.cwd ?? absFolder, timeout: opts.timeout ?? 120_000 })); steps[steps.length - 1].ok = result.ok; steps[steps.length - 1].stdout = result.stdout; steps[steps.length - 1].stderr = result.stderr; if (!result.ok) { emitPluginWorkflowResult(flags, { ok: false, action: 'publish-repo', folder: absFolder, repoUrl: normalized.repoUrl, login: target.login, owner: target.owner, ownerSource: target.ownerSource, apiRateLimited: target.apiRateLimited, steps, error: { label, stdout: result.stdout, stderr: result.stderr, code: result.code }, }); process.exit(1); } return result; }; let exists = false; const view = flags['dry-run'] ? { ok: false, stderr: 'dry-run' } : await execGhBuffered(['repo', 'view', repo.fullName], { cwd: absFolder, timeout: 30_000 }); steps.push({ label: 'check repo', command: `gh repo view ${repo.fullName}`, ok: view.ok, stdout: view.stdout, stderr: view.stderr }); if (view.ok) { exists = true; } else if (!flags['dry-run'] && !isRepoNotFound(view)) { emitPluginWorkflowResult(flags, { ok: false, action: 'publish-repo', folder: absFolder, repoUrl: normalized.repoUrl, login: target.login, owner: target.owner, ownerSource: target.ownerSource, apiRateLimited: target.apiRateLimited, steps, error: { label: 'check repo', stdout: view.stdout, stderr: view.stderr, code: view.code }, }); process.exit(1); } let workdir = absFolder; let cleanupDir = null; if (exists && !flags['dry-run']) { cleanupDir = await mkdtemp(join(os.tmpdir(), 'od-plugin-publish-sync-')); workdir = join(cleanupDir, repo.name); await run('clone repo', 'gh', ['repo', 'clone', repo.fullName, workdir], { cwd: cleanupDir, timeout: 240_000 }); for (const entry of await readdir(workdir)) { if (entry === '.git') continue; await rm(join(workdir, entry), { recursive: true, force: true }); } await mkdir(workdir, { recursive: true }); for (const entry of await readdir(absFolder)) { if (entry === '.git') continue; await cp(join(absFolder, entry), join(workdir, entry), { recursive: true, force: true }); } } else if (!flags['dry-run']) { let hasGit = false; try { await stat(resolve(absFolder, '.git')); hasGit = true; } catch {} if (!hasGit) await run('git init', 'git', ['init']); } await run('git add', 'git', ['add', '-A'], { cwd: workdir }); const status = flags['dry-run'] ? { stdout: 'dry-run' } : await execFileBuffered('git', ['status', '--porcelain'], { cwd: workdir }); if (status.stdout.trim().length > 0 || !exists) { const commitMessage = exists ? `Update: ${manifest.name} v${manifest.version ?? '0.0.0'}` : `Initial commit: ${manifest.name} v${manifest.version ?? '0.0.0'}`; await run('git commit', 'git', ['commit', '-m', commitMessage], { cwd: workdir }); } const tag = `v${manifest.version ?? '0.0.0'}`; if (!flags['dry-run']) { const localTag = await execFileBuffered('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { cwd: workdir }); if (!localTag.ok) await run('git tag', 'git', ['tag', tag], { cwd: workdir }); } if (exists) { await run('git push', 'git', ['push', 'origin', 'HEAD'], { cwd: workdir }); } else { await run('gh repo create', 'gh', [ 'repo', 'create', repo.fullName, '--public', '--source', '.', '--push', '--description', String(manifest.description ?? ''), ], { cwd: workdir }); } await run('git push tags', 'git', ['push', '--tags'], { cwd: workdir }); const verify = flags['dry-run'] ? { ok: true, stdout: JSON.stringify({ nameWithOwner: repo.fullName, url: normalized.repoUrl }) } : await run('verify repo', 'gh', ['repo', 'view', repo.fullName, '--json', 'url,nameWithOwner'], { cwd: workdir }); const parsedVerify = safeJson(verify.stdout); if (cleanupDir && !flags['dry-run']) { await rm(cleanupDir, { recursive: true, force: true }).catch(() => undefined); } emitPluginWorkflowResult(flags, { ok: true, action: 'publish-repo', folder: absFolder, login: target.login, owner: target.owner, ownerSource: target.ownerSource, apiRateLimited: target.apiRateLimited, repoUrl: parsedVerify?.url ?? normalized.repoUrl, manifestRewritten: normalized.changed, manifestPath: pathToFileURL(manifestPath).pathname, steps, }); } async function runPluginOpenDesignPr(rest) { const flags = parseFlags(rest, { string: new Set(['host', 'owner']), boolean: new Set(['help', 'h', 'json', 'dry-run']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin open-design-pr <folder> [--host github.com] [--owner github-login-or-fork-owner] [--dry-run] [--json] Copies a local plugin folder into plugins/community/<name>/ on the author's fork of nexu-io/open-design, pushes a branch, and opens the PR form with --web.`); process.exit(rest.length === 0 ? 2 : 0); } const folder = rest.find((a) => !a.startsWith('-') && a !== flags.host && a !== flags.owner); if (!folder) { console.error('Usage: od plugin open-design-pr <folder>'); process.exit(2); } const [{ resolve, join }, fsp, os] = await Promise.all([ import('node:path'), import('node:fs/promises'), import('node:os'), ]); const absFolder = resolve(process.cwd(), folder); const manifestPath = resolve(absFolder, 'open-design.json'); const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')); const host = typeof flags.host === 'string' ? flags.host : 'github.com'; const target = await resolvePluginGithubTarget({ host, owner: flags.owner, manifest, purpose: 'open-design-pr' }); const name = String(manifest.name ?? '').trim(); if (!name) { console.error('[open-design-pr] manifest.name is required'); process.exit(2); } const title = String(manifest.title ?? name).trim(); const branch = `plugin/${name}-${Math.floor(Date.now() / 1000)}`; const tmpRoot = await fsp.mkdtemp(join(os.tmpdir(), 'od-open-design-pr-')); const checkout = join(tmpRoot, 'open-design'); const steps = []; const run = async (label, command, args, opts = {}) => { steps.push({ label, command: [command, ...args].join(' ') }); if (flags['dry-run']) return { ok: true, stdout: '', stderr: '' }; const result = await (command === 'gh' ? execGhBuffered(args, { cwd: opts.cwd ?? process.cwd(), timeout: opts.timeout ?? 180_000 }) : execFileBuffered(command, args, { cwd: opts.cwd ?? process.cwd(), timeout: opts.timeout ?? 180_000 })); steps[steps.length - 1].ok = result.ok; steps[steps.length - 1].stdout = result.stdout; steps[steps.length - 1].stderr = result.stderr; if (!result.ok && !opts.tolerate?.(result)) { emitPluginWorkflowResult(flags, { ok: false, action: 'open-design-pr', folder: absFolder, login: target.login, owner: target.owner, ownerSource: target.ownerSource, apiRateLimited: target.apiRateLimited, branch, steps, error: { label, stdout: result.stdout, stderr: result.stderr, code: result.code }, }); process.exit(1); } return result; }; await run('fork', 'gh', ['repo', 'fork', 'nexu-io/open-design'], { tolerate: (r) => /already exists|existing fork/i.test(`${r.stdout}\n${r.stderr}`), }); await run('clone fork', 'git', [ 'clone', '--depth', '1', '--single-branch', '--branch', 'main', '--filter=blob:none', '--sparse', `https://github.com/${target.owner}/open-design.git`, checkout, ], { timeout: 240_000 }); await run('sparse checkout', 'git', ['sparse-checkout', 'set', 'plugins/community'], { cwd: checkout }); await run('checkout branch', 'git', ['checkout', '-b', branch], { cwd: checkout }); const dest = join(checkout, 'plugins', 'community', name); if (!flags['dry-run']) { await fsp.rm(dest, { recursive: true, force: true }); await fsp.mkdir(dest, { recursive: true }); await fsp.cp(absFolder, dest, { recursive: true, force: true, filter: (src) => !src.includes(`${absFolder}/.git`) }); } await run('git add', 'git', ['add', `plugins/community/${name}`], { cwd: checkout }); await run('git commit', 'git', ['commit', '-m', `Add ${title} plugin`], { cwd: checkout }); await run('git push branch', 'git', ['push', '-u', 'origin', branch], { cwd: checkout }); const body = [ `Add ${title} (${name}) plugin.`, '', `Version: ${manifest.version ?? '0.0.0'}`, manifest.description ? `Description: ${manifest.description}` : '', ].filter(Boolean).join('\n'); const pr = await run('open PR form', 'gh', [ 'pr', 'create', '--repo', 'nexu-io/open-design', '--head', `${target.owner}:${branch}`, '--base', 'main', '--title', `Add ${title} plugin`, '--body', body, '--web', ], { cwd: checkout }); const prUrl = extractFirstUrl(pr.stdout || pr.stderr) ?? `https://github.com/${target.owner}/open-design/pull/new/${branch}`; emitPluginWorkflowResult(flags, { ok: true, action: 'open-design-pr', folder: absFolder, login: target.login, owner: target.owner, ownerSource: target.ownerSource, apiRateLimited: target.apiRateLimited, branch, prUrl, checkout, steps, }); } async function publishToMarketplaceJson({ catalogPath, meta }) { const [{ dirname, resolve }, { mkdir, readFile, writeFile }, { PublishError, upsertMarketplaceJsonEntry }] = await Promise.all([ import('node:path'), import('node:fs/promises'), import('./plugins/publish.js'), ]); const resolvedPath = resolve(process.cwd(), catalogPath); let existing = null; try { existing = JSON.parse(await readFile(resolvedPath, 'utf8')); } catch (err) { if (err?.code !== 'ENOENT') { throw err; } } let outcome; try { outcome = upsertMarketplaceJsonEntry({ manifest: existing, meta }); } catch (err) { if (err instanceof PublishError) { console.error(`[publish] ${err.message}`); process.exit(2); } throw err; } await mkdir(dirname(resolvedPath), { recursive: true }); await writeFile(resolvedPath, `${JSON.stringify(outcome.manifest, null, 2)}\n`, 'utf8'); return { catalogPath: resolvedPath, inserted: outcome.inserted, entry: outcome.entry, manifest: { name: outcome.manifest.name, version: outcome.manifest.version, plugins: outcome.manifest.plugins.length, }, }; } async function resolvePluginGithubTarget({ host = 'github.com', owner, manifest, purpose }) { const version = await execGhBuffered(['--version'], { timeout: 10_000 }); if (!version.ok) { console.error('[plugin github] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.'); process.exit(1); } let status = await execGhBuffered(['auth', 'status', '--hostname', host, '--active'], { timeout: 10_000 }); if (!status.ok && /unknown flag: --active/i.test(`${status.stdout}\n${status.stderr}`)) { status = await execGhBuffered(['auth', 'status', '--hostname', host], { timeout: 10_000 }); } if (!status.ok) { console.error(`[plugin github] gh is not authenticated for ${host}.`); if (status.stderr || status.stdout) console.error(status.stderr || status.stdout); console.error('Run: gh auth login -h github.com -s repo,workflow'); process.exit(1); } const manifestRepo = parseGithubRepoUrl(typeof manifest?.plugin?.repo === 'string' ? manifest.plugin.repo.trim() : ''); const trustedManifestOwner = purpose === 'publish-repo' && manifestRepo && !isPlaceholderRepoOwner(manifestRepo.owner) ? manifestRepo.owner : ''; const explicitOwner = typeof owner === 'string' ? owner.trim() : ''; if (explicitOwner && isPlaceholderRepoOwner(explicitOwner)) { console.error(`[plugin github] refusing placeholder owner "${explicitOwner}". Pass a real GitHub login or org.`); process.exit(2); } const statusLogin = parseGhAuthStatusLogin(status.stderr || status.stdout); let login = statusLogin; let resolvedOwner = explicitOwner || trustedManifestOwner || statusLogin; let source = explicitOwner ? '--owner' : trustedManifestOwner ? 'plugin.repo' : statusLogin ? 'gh auth status' : ''; let apiError = null; if (!resolvedOwner || !login) { const user = await execGhBuffered(['api', 'user', '--hostname', host, '--jq', '.login'], { timeout: 20_000 }); if (user.ok && user.stdout.trim()) { login = user.stdout.trim(); if (!resolvedOwner) { resolvedOwner = login; source = 'gh api user'; } } else { apiError = user; } } if (!resolvedOwner) { console.error(`[plugin github] could not resolve the GitHub owner for ${purpose}.`); if (apiError?.stderr || apiError?.stdout) console.error(apiError.stderr || apiError.stdout); if (apiError && isGhApiRateLimit(apiError)) { const ownerHint = purpose === 'open-design-pr' ? '<github-login-or-fork-owner>' : '<github-login-or-org>'; console.error(`GitHub API is rate limited. Re-run with --owner ${ownerHint}, or authenticate/refresh gh and retry.`); } else { console.error('Run: gh auth refresh -h github.com -s repo,workflow'); console.error('Or: gh auth login -h github.com -s repo,workflow'); console.error(purpose === 'open-design-pr' ? 'If the fork owner differs from your auth login, pass --owner <github-login-or-fork-owner>.' : 'If this is an org-owned plugin, pass --owner <github-org>.'); } process.exit(1); } if (apiError && isGhApiRateLimit(apiError)) { console.warn('[plugin github] GitHub API is rate limited; continuing with the owner resolved locally.'); } if (isPlaceholderRepoOwner(resolvedOwner)) { console.error(`[plugin github] refusing placeholder owner "${resolvedOwner}". Pass --owner <github-login-or-org>.`); process.exit(2); } return { host, login: login || resolvedOwner, owner: resolvedOwner, ownerSource: source, apiRateLimited: Boolean(apiError && isGhApiRateLimit(apiError)), version: version.stdout, status: status.stderr || status.stdout, }; } function parseGhAuthStatusLogin(output) { const text = String(output ?? ''); const activeAccount = /Logged in to [^\s]+ account ([^\s()]+)/i.exec(text); if (activeAccount?.[1]) return activeAccount[1].trim(); const tokenAccount = /Token account:\s*([^\s()]+)/i.exec(text); if (tokenAccount?.[1]) return tokenAccount[1].trim(); return ''; } function isGhApiRateLimit(result) { const text = `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`; return /rate limit exceeded|authenticated requests get a higher rate limit/i.test(text); } function normalizeManifestRepoForOwner(manifest, owner) { const name = String(manifest?.name ?? '').trim(); if (!name) { console.error('[plugin repo] manifest.name is required'); process.exit(2); } const rawRepo = typeof manifest?.plugin?.repo === 'string' ? manifest.plugin.repo.trim() : ''; const parsed = parseGithubRepoUrl(rawRepo); const placeholder = parsed ? isPlaceholderRepoOwner(parsed.owner) : false; const shouldRewrite = !parsed || placeholder || parsed.name.toLowerCase() !== name.toLowerCase() || parsed.owner.toLowerCase() !== owner.toLowerCase(); const repoUrl = shouldRewrite ? `https://github.com/${owner}/${name}` : parsed.url; if (shouldRewrite) { if (!manifest.plugin || typeof manifest.plugin !== 'object') manifest.plugin = {}; manifest.plugin.repo = repoUrl; manifest.homepage = repoUrl; if (!manifest.author || typeof manifest.author !== 'object') manifest.author = {}; manifest.author.url = `https://github.com/${owner}`; } return { changed: shouldRewrite, repoUrl, previousRepoUrl: rawRepo || null, }; } function parseGithubRepoUrl(raw) { if (!raw || typeof raw !== 'string') return null; const trimmed = raw.trim().replace(/\.git$/i, ''); let owner = ''; let name = ''; try { const url = new URL(trimmed); if (!/^github\.com$/i.test(url.hostname)) return null; const parts = url.pathname.split('/').filter(Boolean); owner = parts[0] ?? ''; name = parts[1] ?? ''; } catch { const match = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed); if (!match) return null; owner = match[1]; name = match[2]; } if (!owner || !name) return null; return { owner, name, fullName: `${owner}/${name}`, url: `https://github.com/${owner}/${name}`, }; } function isPlaceholderRepoOwner(owner) { return /^(open-design-user|<vendor>|vendor|example-user|your-org|your-username|owner|user|username)$/i.test(String(owner ?? '').trim()); } function isRepoNotFound(result) { const text = `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`; return /could not resolve to a repository|not found|repository not found/i.test(text); } async function pluginCliValidateFolder(folder) { const result = await execFileBuffered(process.execPath, [process.argv[1], 'plugin', 'validate', folder], { timeout: 120_000, }); if (!result.ok) { console.error('[plugin validate] failed after manifest normalization'); if (result.stdout) console.error(result.stdout); if (result.stderr) console.error(result.stderr); process.exit(1); } return result; } function emitPluginWorkflowResult(flags, payload) { if (flags.json) { process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); return; } if (!payload.ok) { console.error(`[${payload.action}] failed${payload.error?.label ? ` at ${payload.error.label}` : ''}`); if (payload.error?.stderr) console.error(payload.error.stderr); if (payload.error?.stdout) console.error(payload.error.stdout); return; } if (payload.action === 'publish-repo') { console.log(`Plugin published: ${payload.repoUrl}`); if (payload.ownerSource) console.log(`[publish-repo] owner resolved from ${payload.ownerSource}: ${payload.owner}`); if (payload.apiRateLimited) console.log('[publish-repo] GitHub API was rate limited; continued with the locally resolved owner.'); if (payload.manifestRewritten) console.log('[publish-repo] manifest repo fields were normalized before publishing.'); return; } if (payload.action === 'open-design-pr') { if (payload.ownerSource) console.log(`[open-design-pr] owner resolved from ${payload.ownerSource}: ${payload.owner}`); if (payload.apiRateLimited) console.log('[open-design-pr] GitHub API was rate limited; continued with the locally resolved owner.'); console.log(`Open this URL and click Create to file the PR: ${payload.prUrl}`); return; } console.log(JSON.stringify(payload, null, 2)); } function safeJson(raw) { try { return JSON.parse(raw); } catch { return null; } } function extractFirstUrl(text) { const match = /https?:\/\/\S+/i.exec(String(text ?? '')); return match ? match[0].replace(/[)\].,]+$/, '') : null; } async function runPluginYank(rest) { const flags = parseFlags(rest, { string: new Set(['daemon-url', 'reason', 'to']), boolean: new Set(['help', 'h', 'json', 'open']), }); if (rest.length === 0 || flags.help || flags.h) { console.log(`Usage: od plugin yank <vendor/plugin-name>@<version> --reason "<why>" [--to open-design] [--json] Yanking never deletes metadata or bytes. It opens the registry review flow that marks a version unresolvable for new installs while preserving lockfile replay.`); process.exit(rest.length === 0 ? 2 : 0); } const spec = rest.find((a) => !a.startsWith('-') && a !== flags.reason && a !== flags.to); const reason = typeof flags.reason === 'string' ? flags.reason.trim() : ''; const parsed = parseCliPluginSpecifier(spec); if (!parsed.name || !parsed.range) { console.error('Usage: od plugin yank <vendor/plugin-name>@<version> --reason "<why>"'); process.exit(2); } if (!reason) { console.error('--reason is required for yanking'); process.exit(2); } const target = flags.to ?? 'open-design'; if (target !== 'open-design') { console.error('Only --to open-design is supported in this v1 GitHub-backed yank flow.'); process.exit(2); } const title = `Yank ${parsed.name}@${parsed.range}`; const body = [ `## Yank ${parsed.name}@${parsed.range}`, '', `Reason: ${reason}`, '', 'Expected registry patch:', '', '```json', JSON.stringify({ name: parsed.name, version: parsed.range, yanked: true, yankReason: reason, }, null, 2), '```', '', 'Generated by `od plugin yank`.', ].join('\n'); const params = new URLSearchParams({ title, body }); const payload = { catalog: 'open-design', name: parsed.name, version: parsed.range, reason, url: `https://github.com/nexu-io/open-design/issues/new?${params.toString()}`, body, }; if (flags.json) { process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); } else { console.log(`[yank] ${payload.url}`); console.log('---'); console.log(body); } if (flags.open) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; const { spawn } = await import('node:child_process'); spawn(opener, [payload.url], { detached: true, stdio: 'ignore' }).unref(); } } async function runPluginDoctor(rest) { // Plan §3.HH1 — --strict promotes warnings to errors so CI can // opt into 'no warnings allowed' mode without parsing the issue // list manually. const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'strict']), }); const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source); if (!id) { console.error('Usage: od plugin doctor <id> [--strict] [--json]'); process.exit(2); } const url = `${(await 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(); const issues = Array.isArray(data?.issues) ? data.issues : []; const warnings = issues.filter((i) => i?.severity === 'warning'); const strict = flags.strict === true; // Strict mode: a clean issue list is still required, but the // pass/fail bit also fails on any warning. const passed = data.ok && (!strict || warnings.length === 0); if (flags.json) { process.stdout.write(JSON.stringify({ ...data, strict, passed }, null, 2) + '\n'); } else { if (passed && issues.length === 0) { console.log(`[doctor] ${data.pluginId} ok (digest ${data.freshDigest.slice(0, 12)}…)`); } else { const tier = !data.ok ? 'errors' : (strict && warnings.length > 0) ? 'warnings (--strict)' : 'warnings'; console.log(`[doctor] ${data.pluginId} ${tier}:`); for (const issue of issues) { console.log(` [${issue.severity}] ${issue.code}: ${issue.message}`); } } } process.exit(passed ? 0 : (data.ok ? 4 : 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 = `${(await 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 = `${(await 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); } } async function uiDaemonUrl(flags) { return cliDaemonUrl(flags); } async function runUiList(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const base = (await 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 = `${(await 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(); // Plan §6 Phase 2A.5 — `--schema` prints the spec's JSON Schema // only (null if the surface declares none). Designed to feed // `od ui respond --value-json "$(...)"` in headless / agent flows. if (flags.schema) { const schema = data?.spec?.schema ?? null; process.stdout.write(JSON.stringify(schema, null, 2) + '\n'); return; } 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 = `${(await 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 = `${(await 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 = `${(await 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> [--schema] Read a single surface (kind / schema / value). --schema prints just the JSON Schema. 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, OD_SIDECAR_IPC_PATH discovery, 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 [--task-kind <kind>] List installed plugins (filterable). od plugin search <query> [--tag <t>] Search installed plugins by id/title/desc/tag. od plugin stats [--json] Inventory + snapshot health report. od plugin info <id> Print a plugin's manifest + trust state as JSON. od plugin manifest <id> Print only the parsed manifest JSON (no wrapper). od plugin sources List distinct install sources + counts. od plugin install --source <path> Install a plugin from a local folder (Phase 1). od plugin upgrade <id> Re-install a plugin from its recorded source. 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 canon <snapshotId> Print the canonical system-prompt block for a snapshot. (--check <file> for byte-equality fixtures.) od plugin simulate <pluginId> [-s k=v] Walk the plugin's pipeline against caller-supplied signals; report stage convergence + iterations (no LLM in the loop). od plugin verify <pluginId> CI meta-command: doctor + simulate + canon --check driven by an .od-verify.json config in the plugin folder. od plugin events tail [-f] [--kind k] Tail the in-memory plugin event ring buffer. od plugin events snapshot One-shot read (filterable, no SSE). od plugin events stats Roll-up: counts by kind / pluginId / time range. od plugin events purge Drop every event in the buffer (loopback-only). od plugin diff <a> <b> [--json] Compare two installed plugins by id. 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). od plugin validate <folder> [--json] Lint a plugin folder before installing (manifest parse + atom + ref checks). od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin folder for distribution. od plugin candidates list --project <id> List persisted skill-to-plugin candidates. od plugin publish-repo <folder> Create/update the author's public GitHub repo for a plugin folder. od plugin open-design-pr <folder> Push a community-catalog branch and open the nexu-io/open-design PR form. od plugin publish <folder> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh Prepare a registry submission link. od plugin login [--host github.com] Authenticate registry publishing via gh. od plugin whoami [--host github.com] Show the gh account used for publishing. Common options: --daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts) instead of human-readable output. Installs support local folders, github:owner/repo refs, HTTPS .tgz archives, and bare marketplace names resolved through configured registry sources.`); } // --------------------------------------------------------------------------- // 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. // --------------------------------------------------------------------------- async function projectDaemonUrl(flags) { return cliDaemonUrl(flags); } 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 import <baseDir> [--name "<title>"] od project list List projects. od project info <id> Print one project. od project delete <id> Delete a project. od project editors List locally-installed editors that can open a project (hand-off targets). od project open-in <id> --editor <slug> Open the project's working directory in the chosen editor (cursor, zed, vscode, finder, terminal, …). od project handoff <id> --conversation <id> --api-key <key> --model <model> [--base-url <url>] [--max-tokens <n>] Synthesize a resume-conversation handoff prompt. 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); // Handoff owns its own flag parsing, daemon-URL resolution, and // structured fail() output. Dispatch it before the generic project // parser below so a malformed `od project handoff` invocation // (`--unknown`, `--max-tokens` with no value) hits handoff-cli's // machine-readable fail() path instead of throwing out of parseFlags. if (sub === 'handoff') { const { exitCode } = await runProjectHandoff(rest); if (exitCode !== 0) process.exit(exitCode); return; } const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); const base = (await 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 'import': { const [baseDir] = positionalArgs(rest, PROJECT_STRING_FLAGS); const importBaseDir = typeof baseDir === 'string' ? baseDir.trim() : ''; if (!importBaseDir) { console.error('Usage: od project import <baseDir> [--name "<title>"]'); process.exit(2); } const body = { baseDir: importBaseDir }; if (typeof flags.name === 'string' && flags.name.length > 0) body.name = flags.name; if (typeof flags.skill === 'string' && flags.skill.length > 0) body.skillId = flags.skill; if (typeof flags['design-system'] === 'string' && flags['design-system'].length > 0) { body.designSystemId = flags['design-system']; } const headers = { 'content-type': 'application/json' }; const importToken = await mintCliImportToken(importBaseDir); if (importToken != null) { headers['x-od-desktop-import-token'] = importToken; } const resp = await fetch(`${base}/api/import/folder`, { method: 'POST', headers, body: JSON.stringify(body), }); 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(`[project] imported ${data.project?.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; } case 'editors': { const resp = await fetch(`${base}/api/editors`); 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 editors = data?.editors ?? []; for (const ed of editors) { const status = ed.available ? 'available' : 'missing'; console.log(`${ed.id}\t${ed.label}\t${status}`); } return; } case 'open-in': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od project open-in <id> --editor <slug>'); process.exit(2); } const editor = typeof flags.editor === 'string' ? flags.editor : ''; if (!editor) { console.error('--editor <slug> is required. Run `od project editors` to list options.'); process.exit(2); } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/open-in`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ editorId: editor }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { if (flags.json) process.stdout.write(JSON.stringify(data, null, 2) + '\n'); else console.error(`POST /api/projects/${id}/open-in 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] opened ${id} in ${editor} (${data.path ?? ''})`); 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 = (await 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. od files diff <projectId> <relpathA> [<relpathB> | --against -] Print a unified diff. 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 = (await 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; } case 'diff': { const positional = positionalArgs(rest, PROJECT_STRING_FLAGS); const [id, relA, relB] = positional; const against = typeof flags.against === 'string' ? flags.against : null; if (!id || !relA || (!relB && !against) || (relB && against)) { console.error('Usage: od files diff <projectId> <relpathA> [<relpathB> | --against -]'); process.exit(2); } const left = await fetchProjectFileText(base, id, relA); const rightLabel = against ?? relB; const right = against === '-' ? await readStdinUtf8() : await fetchProjectFileText(base, id, rightLabel); const diff = createUnifiedDiff(`a/${relA}`, `b/${rightLabel}`, left, right); if (flags.json) return process.stdout.write(JSON.stringify({ diff }, null, 2) + '\n'); process.stdout.write(diff); return; } default: console.error(`unknown subcommand: od files ${sub}`); process.exit(2); } } function encodeProjectRelpath(rel) { return String(rel).split('/').map(encodeURIComponent).join('/'); } async function fetchProjectFileText(base, id, rel) { const resp = await fetch( `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}`, ); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const buf = Buffer.from(await resp.arrayBuffer()); return buf.toString('utf8'); } async function readStdinUtf8() { const fs = await import('node:fs'); return fs.readFileSync(0, 'utf8'); } async function mintCliImportToken(baseDir) { const socketPath = process.env[SIDECAR_ENV.IPC_PATH]; if (typeof socketPath !== 'string' || socketPath.length === 0) return null; let result; try { result = await requestJsonIpc( socketPath, { type: SIDECAR_MESSAGES.MINT_IMPORT_TOKEN, input: { baseDir } }, { timeoutMs: 800 }, ); } catch { return null; } if (result?.ok === true && typeof result.token === 'string' && result.token.length > 0) { return result.token; } if (result?.ok === false && result.code === 'DESKTOP_AUTH_PENDING') { exitWithStructuredError({ code: 'desktop-auth-pending', message: result.message ?? 'desktop auth required but secret not yet registered', data: { retryable: result.retryable === true }, }); } return null; } function createUnifiedDiff(leftLabel, rightLabel, leftText, rightText) { if (leftText === rightText) return ''; const leftLines = splitDiffLines(leftText); const rightLines = splitDiffLines(rightText); let prefix = 0; while ( prefix < leftLines.length && prefix < rightLines.length && leftLines[prefix] === rightLines[prefix] ) { prefix++; } let leftEnd = leftLines.length; let rightEnd = rightLines.length; while ( leftEnd > prefix && rightEnd > prefix && leftLines[leftEnd - 1] === rightLines[rightEnd - 1] ) { leftEnd--; rightEnd--; } const oldMid = leftLines.slice(prefix, leftEnd); const newMid = rightLines.slice(prefix, rightEnd); const body = diffLineBody(oldMid, newMid); if (body.length === 0) { body.push(...oldMid.map((line) => diffLine('-', line)), ...newMid.map((line) => diffLine('+', line))); } const oldStart = oldMid.length === 0 ? prefix : prefix + 1; const newStart = newMid.length === 0 ? prefix : prefix + 1; return [ `--- ${leftLabel}`, `+++ ${rightLabel}`, `@@ -${formatDiffRange(oldStart, oldMid.length)} +${formatDiffRange(newStart, newMid.length)} @@`, ...body, ].join('\n') + '\n'; } function splitDiffLines(text) { const value = String(text); if (value.length === 0) return []; return value.match(/.*?(?:\r\n|\n|\r|$)/gs).filter((line) => line.length > 0); } function formatDiffRange(start, length) { return length === 1 ? String(start) : `${start},${length}`; } function diffLineBody(oldLines, newLines) { if (oldLines.length === 0) return newLines.map((line) => diffLine('+', line)); if (newLines.length === 0) return oldLines.map((line) => diffLine('-', line)); if (oldLines.length * newLines.length > 1_000_000) { return [...oldLines.map((line) => diffLine('-', line)), ...newLines.map((line) => diffLine('+', line))]; } const width = newLines.length + 1; const lcs = Array.from( { length: oldLines.length + 1 }, () => new Uint32Array(width), ); for (let i = oldLines.length - 1; i >= 0; i--) { for (let j = newLines.length - 1; j >= 0; j--) { lcs[i][j] = oldLines[i] === newLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); } } const out = []; let i = 0; let j = 0; while (i < oldLines.length && j < newLines.length) { if (oldLines[i] === newLines[j]) { out.push(diffLine(' ', oldLines[i])); i++; j++; } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push(diffLine('-', oldLines[i])); i++; } else { out.push(diffLine('+', newLines[j])); j++; } } while (i < oldLines.length) out.push(diffLine('-', oldLines[i++])); while (j < newLines.length) out.push(diffLine('+', newLines[j++])); return out; } function diffLine(prefix, line) { const value = String(line); if (value.endsWith('\r\n')) return `${prefix}${renderDiffLineContent(value.slice(0, -1))}`; if (value.endsWith('\n')) return `${prefix}${renderDiffLineContent(value.slice(0, -1))}`; if (value.endsWith('\r')) return `${prefix}${renderDiffLineContent(value)}`; return `${prefix}${renderDiffLineContent(value)}\n\\ No newline at end of file`; } function renderDiffLineContent(value) { return String(value).replace(/\r/g, '\\r'); } async function runConversation(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od conversation new <projectId> [--title "<title>"] Create a conversation in a project. 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 = (await projectDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'new': { const [id] = positionalArgs(rest, PROJECT_STRING_FLAGS); if (!id) { console.error('Usage: od conversation new <projectId> [--title "<title>"]'); process.exit(2); } const body = {}; if (typeof flags.title === 'string') body.title = flags.title; const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); 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'); console.log(`[conversation] created ${data.conversation?.id ?? '-'}`); return; } 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. // --------------------------------------------------------------------------- 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] 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. od daemon db status Print SQLite path + size + table row counts. od daemon db verify [--quick] Run integrity_check + foreign_key_check. od daemon db vacuum Run SQLite VACUUM to reclaim space after deletes. 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); case 'db': return runDaemonDb(rest, flags); default: console.error(`unknown subcommand: od daemon ${sub}`); process.exit(2); } } // Plan §3.GG1 — `od daemon db status`. Prints a SQLite inventory // (file path, size on disk, schema version, per-table row counts). async function runDaemonDb(rest, flags) { const sub = rest[0]; if (!sub || sub === 'help' || rest.includes('--help') || rest.includes('-h')) { console.log(`Usage: od daemon db status [--json] [--daemon-url <url>] od daemon db verify [--quick] [--json] [--daemon-url <url>] od daemon db vacuum [--json] [--daemon-url <url>] status: Prints a structured inventory of the daemon's SQLite backend: - file path (under .od/ by default; OD_DATA_DIR overrides) - size on disk (primary + WAL + SHM) - schema version (user_version PRAGMA) - per-table row counts (system tables excluded) verify: Runs SQLite PRAGMA integrity_check (or quick_check with --quick) + foreign_key_check, returns a structured issues[] report. Exit 0 when ok=true, 4 when any issue is found. vacuum: Runs SQLite VACUUM to reclaim space after large delete batches (snapshot prune, plugin uninstall, etc.). Reports before/after sizes + elapsed ms.`); process.exit(sub ? 0 : 2); } const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); if (sub === 'vacuum') { const resp = await fetch(`${base}/api/daemon/db/vacuum`, { method: 'POST' }); if (!resp.ok) { console.error(`POST /api/daemon/db/vacuum 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(`[db vacuum] reclaimed ${formatBytes(data.reclaimedBytes ?? 0)} (` + `${formatBytes(data.beforeBytes ?? 0)} \u2192 ${formatBytes(data.afterBytes ?? 0)}, ` + `${data.elapsedMs ?? 0}ms)`); return; } if (sub === 'verify') { const verifyFlags = parseFlags(rest.slice(1), { string: new Set(['daemon-url']), boolean: new Set(['help', 'h', 'json', 'quick']), }); const url = `${base}/api/daemon/db/verify${verifyFlags.quick ? '?quick=1' : ''}`; const resp = await fetch(url, { method: 'POST' }); 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'); } else { const issueCount = Array.isArray(data.issues) ? data.issues.length : 0; console.log(`[db verify] mode=${data.mode} ok=${data.ok} issues=${issueCount} ${data.elapsedMs ?? 0}ms`); if (issueCount > 0) { for (const issue of data.issues) { console.error(` [${issue.kind}] ${issue.message}`); } } } process.exit(data.ok ? 0 : 4); } if (sub !== 'status') { console.error(`unknown subcommand: od daemon db ${sub}`); process.exit(2); } const resp = await fetch(`${base}/api/daemon/db`); if (!resp.ok) { console.error(`GET /api/daemon/db 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(`# Daemon DB`); console.log(` kind: ${data.kind ?? 'unknown'}`); console.log(` location: ${data.location ?? '?'}`); console.log(` size on disk: ${formatBytes(data.sizeBytes ?? 0)}`); console.log(` schema version: ${data.schemaVersion ?? '(none)'}`); console.log(` tables:`); const tables = Array.isArray(data.tables) ? data.tables : []; if (tables.length === 0) { console.log(' (none)'); } else { const longest = Math.max(...tables.map((t) => t.name.length)); for (const t of tables) { console.log(` ${t.name.padEnd(longest)} ${t.rowCount}`); } } } function formatBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MiB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`; } 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']); 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 = await cliDaemonBaseUrl(flags); 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}`); } async function runDaemonStop(flags) { const base = await cliDaemonBaseUrl(flags); 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). // --------------------------------------------------------------------------- async function libraryDaemonUrl(flags) { return cliDaemonUrl(flags); } 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. od atoms info <id> Print metadata + the bundled SKILL.md body. 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 = (await 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; } case 'info': { const id = rest.find((a) => !a.startsWith('-')); if (!id) { console.error('Usage: od atoms info <id>'); process.exit(2); } const resp = await fetch(`${base}/api/atoms/${encodeURIComponent(id)}`); if (resp.status === 404) { console.error(`atom ${id} not found`); process.exit(65); } if (!resp.ok) return structuredHttpFailure(resp); const atom = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(atom, null, 2) + '\n'); console.log(`# ${atom.label} (${atom.id})`); console.log(`status: ${atom.status}`); console.log(`taskKinds: ${(atom.taskKinds ?? []).join(', ')}`); console.log(`summary: ${atom.description}`); if (typeof atom.skillBody === 'string' && atom.skillBody.length > 0) { console.log(''); console.log('--- SKILL.md ---'); console.log(atom.skillBody.trimEnd()); } else { console.log(''); console.log('(no bundled SKILL.md body found for this atom)'); } 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 = (await 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 runCraft(args) { return runLibraryList('craft', args); } async function runDesignSystems(args) { if (args[0] === 'rename') return runDesignSystemRename(args.slice(1)); if (!args[0] || isDesignSystemsHelpArg(args[0])) { console.log(DESIGN_SYSTEMS_USAGE); process.exit(isDesignSystemsHelpArg(args[0]) ? 0 : 2); } return runLibraryList('design-systems', args); } // od design-systems rename <id> --title <new-title> [--json] // Renames an editable (user-created) design system via PATCH // /api/design-systems/:id. Built-in systems are read-only and the daemon // returns 404, surfaced here as a structured failure. Arg parsing lives in // design-system-rename-args.ts so it can be unit-tested. async function runDesignSystemRename(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od design-systems rename <id> --title <new-title> [--json] [--daemon-url <url>] od design-systems rename <id> "<new title>" [--json] Renames an editable (user-created) design system. Built-in systems are read-only.`); process.exit(args.length === 0 ? 2 : 0); } const parsed = parseDesignSystemRenameArgs(args); if (!parsed) { console.error('Usage: od design-systems rename <id> --title <new-title>'); process.exit(2); } const flags = parseFlags(args, { string: new Set([...LIBRARY_STRING_FLAGS, 'title']), boolean: LIBRARY_BOOLEAN_FLAGS, }); const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); const resp = await fetch(`${base}/api/design-systems/${encodeURIComponent(parsed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: parsed.title }), }); 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 renamed = data.designSystem ?? data; console.log(`Renamed ${parsed.id} -> ${renamed.title ?? parsed.title}`); } async function runStatus(args) { // Alias of `od daemon status`. return runDaemon(['status', ...args]); } // --------------------------------------------------------------------------- // Subcommand: od diagnostics export <path> [--json] // // CLI surface for the Settings → About “Export diagnostics” feature. The // daemon already exposes the bundle behind a local-loopback HTTP endpoint; // this command is a thin shell over that endpoint so headless callers (CI, // `od doctor` follow-ups, shell scripts) can collect a support bundle // without driving the web UI. // --------------------------------------------------------------------------- const DIAGNOSTICS_STRING_FLAGS = new Set(['daemon-url', 'output']); const DIAGNOSTICS_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); async function runDiagnostics(args) { const sub = args[0]; if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od diagnostics export [<path>] [--output <path>] [--json] [--daemon-url <url>] Bundles daemon/web/desktop logs, machine info, and recent crash reports into a zip. The bundle is the same one Settings → About → Export diagnostics produces. <path> Where to write the zip. Defaults to ./open-design-diagnostics-<timestamp>.zip in the current working directory. Alias: --output <path>. --json Print {path, sizeBytes} on stdout instead of a human-readable summary. The file is still written to <path>. --daemon-url <url> Override the daemon HTTP base URL.`); process.exit(0); } if (sub !== 'export') { console.error(`unknown subcommand: od diagnostics ${sub}`); process.exit(2); } const flags = parseFlags(args.slice(1), { string: DIAGNOSTICS_STRING_FLAGS, boolean: DIAGNOSTICS_BOOLEAN_FLAGS, }); const positional = args.slice(1).filter((a) => !a.startsWith('-')); const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); const { DIAGNOSTICS_EXPORT_PATH, DIAGNOSTICS_FILENAME_PREFIX, diagnosticsFileName } = await import('@open-design/diagnostics'); const fs = await import('node:fs/promises'); const path = await import('node:path'); const explicitOutput = typeof flags.output === 'string' && flags.output.length > 0 ? flags.output : positional[0]; const targetPath = path.resolve(explicitOutput ?? diagnosticsFileName(DIAGNOSTICS_FILENAME_PREFIX)); let resp; try { resp = await fetch(`${base}${DIAGNOSTICS_EXPORT_PATH}`); } catch (err) { return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`, }); } if (!resp.ok) return structuredHttpFailure(resp); const buf = Buffer.from(await resp.arrayBuffer()); await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.writeFile(targetPath, buf); if (flags.json) { process.stdout.write(JSON.stringify({ path: targetPath, sizeBytes: buf.length }) + '\n'); return; } console.log(`Wrote diagnostics bundle to ${targetPath} (${buf.length} bytes).`); } async function runVersion(args) { const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); const base = (await 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 = (await 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 = (await 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); } } // --------------------------------------------------------------------------- // Subcommand: od memory … // // Headless surface for the same editable markdown memory tree shown in // Settings. Agents can inspect what will be injected into future prompts, // edit a node, or move a node between memory buckets without scraping the UI. // --------------------------------------------------------------------------- function printMemoryHelp() { console.log(`Usage: od memory tree list [--json] List derived memory-tree folders and entry nodes. od memory tree view <id> [--json] Print one folder node or entry body. od memory tree edit <id> [--name <title>] [--description <text>] [--type user|feedback|project|reference] [--body <markdown> | --body-file <path|->] [--json] Patch an editable entry node. Folder nodes are derived from entry types. od memory tree move <id> --type user|feedback|project|reference [--json] Move an entry node to a different memory bucket while preserving its id. Common options: --daemon-url <url> Open Design daemon HTTP base.`); } function memoryPositionals(values) { const out = []; for (let i = 0; i < values.length; i++) { const value = values[i]; if (!value) continue; if (value.startsWith('--')) { const eq = value.indexOf('='); const key = eq >= 0 ? value.slice(2, eq) : value.slice(2); if (eq < 0 && MEMORY_STRING_FLAGS.has(key)) i++; continue; } out.push(value); } return out; } async function readMemoryBodyFromFlags(flags) { if (typeof flags.body === 'string') return flags.body; if (typeof flags['body-file'] !== 'string') return undefined; const path = flags['body-file']; if (path === '-') { let body = ''; for await (const chunk of process.stdin) body += chunk; return body; } const { readFile } = await import('node:fs/promises'); return await readFile(path, 'utf8'); } function formatMemoryTreeRow(node) { return [ node.id, node.parentId ?? '-', node.path, node.kind, node.type ?? '-', node.scope, node.name, ].join('\t'); } function printMemoryEntry(entry) { console.log(`# ${entry.name}`); console.log(`id: ${entry.id}`); console.log(`type: ${entry.type}`); console.log(`description: ${entry.description || '-'}`); console.log(''); process.stdout.write(`${entry.body ?? ''}\n`); } async function fetchMemoryTree(base) { let resp; try { resp = await fetch(`${base}/api/memory/tree`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); return await resp.json(); } async function patchMemoryTreeNode(base, id, body) { let resp; try { resp = await fetch(`${base}/api/memory/tree/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); return await resp.json(); } async function runMemory(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { printMemoryHelp(); process.exit(args.length === 0 ? 2 : 0); } const topic = args[0]; if (topic !== 'tree') { console.error(`unknown subcommand: od memory ${topic}`); printMemoryHelp(); process.exit(2); } const rest = args.slice(1); let flags; try { flags = parseFlags(rest, { string: MEMORY_STRING_FLAGS, boolean: MEMORY_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); process.exit(2); } const base = await cliDaemonBaseUrl(flags); const writeJson = (data) => process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const parts = memoryPositionals(rest); const action = parts[0] ?? 'list'; if (action === 'list') { const data = await fetchMemoryTree(base); if (flags.json) return writeJson(data); const tree = data.tree ?? []; if (tree.length === 0) { console.log('No memory tree nodes.'); return; } console.log('# id\tparent\tpath\tkind\ttype\tscope\tname'); for (const node of tree) console.log(formatMemoryTreeRow(node)); return; } if (action === 'view') { const id = parts[1]; if (!id) { console.error('Usage: od memory tree view <id>'); process.exit(2); } const treeData = await fetchMemoryTree(base); const node = (treeData.tree ?? []).find((item) => item.id === id); if (!node) { console.error(`memory tree node not found: ${id}`); process.exit(4); } if (node.kind === 'folder') { if (flags.json) return writeJson({ node }); console.log(`${node.path}\t${node.name}\t${node.childrenCount ?? 0} children`); return; } let resp; try { resp = await fetch(`${base}/api/memory/${encodeURIComponent(id)}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); printMemoryEntry(data.entry ?? data); return; } if (action === 'edit') { const id = parts[1]; if (!id) { console.error('Usage: od memory tree edit <id> [--name ...] [--description ...] [--type ...] [--body ...|--body-file ...]'); process.exit(2); } const body = {}; if (typeof flags.name === 'string') body.name = flags.name; if (typeof flags.description === 'string') body.description = flags.description; if (typeof flags.type === 'string') body.type = flags.type; const nextBody = await readMemoryBodyFromFlags(flags); if (typeof nextBody === 'string') body.body = nextBody; if (Object.keys(body).length === 0) { console.error('nothing to edit; pass --name, --description, --type, --body, or --body-file'); process.exit(2); } const data = await patchMemoryTreeNode(base, id, body); if (flags.json) return writeJson(data); console.log(`[memory] updated ${data.entry?.id ?? id}`); return; } if (action === 'move') { const id = parts[1]; const type = flags.type ?? parts[2]; if (!id || !type) { console.error('Usage: od memory tree move <id> --type user|feedback|project|reference'); process.exit(2); } const data = await patchMemoryTreeNode(base, id, { type }); if (flags.json) return writeJson(data); console.log(`[memory] moved ${data.entry?.id ?? id} to ${data.entry?.type ?? type}`); return; } console.error(`unknown subcommand: od memory tree ${action}`); printMemoryHelp(); process.exit(2); } // --------------------------------------------------------------------------- // Subcommand: od automation … // // Headless surface for the Automations tab. This is the dual-track contract: // every capability the Automations UI exposes is reachable here so an // external agent (hermes-agent, openclaw, custom Slackbot, etc.) can run // the full lifecycle — list, create, fire, harvest, retire — without // rendering a page. Storage is /api/routines on the local daemon; the // "routine" name is the implementation detail, "automation" is the user- // facing surface. // --------------------------------------------------------------------------- function parseScheduleFlag(raw) { if (!raw || typeof raw !== 'string') { throw new Error( '--schedule is required. Forms: hourly:<minute> | daily:HH:MM[:TZ] | weekdays:HH:MM[:TZ] | weekly:DAY:HH:MM[:TZ]', ); } const parts = raw.split(':'); const kind = parts[0]; if (kind === 'hourly') { const minute = Number(parts[1]); if (!Number.isInteger(minute) || minute < 0 || minute > 59) { throw new Error('--schedule hourly requires :<minute>, 0-59'); } return { kind: 'hourly', minute }; } if (kind === 'daily' || kind === 'weekdays') { if (parts.length < 3) { throw new Error(`--schedule ${kind} requires :HH:MM[:TZ]`); } const hh = parts[1]; const mm = parts[2]; const time = `${hh.padStart(2, '0')}:${mm.padStart(2, '0')}`; if (!/^[0-2]\d:[0-5]\d$/.test(time)) { throw new Error(`--schedule ${kind} time must be HH:MM (24h)`); } const timezone = parts.slice(3).join(':') || 'UTC'; return { kind, time, timezone }; } if (kind === 'weekly') { if (parts.length < 4) { throw new Error('--schedule weekly requires :DAY:HH:MM[:TZ] (DAY is 0-6 or sun/mon/...)'); } const dayToken = String(parts[1]).toLowerCase(); let weekday; if (/^[0-6]$/.test(dayToken)) { weekday = Number(dayToken); } else if (AUTOMATION_WEEKDAY_TOKENS[dayToken] !== undefined) { weekday = AUTOMATION_WEEKDAY_TOKENS[dayToken]; } else { throw new Error(`--schedule weekly day must be 0-6 or sun..sat (got "${parts[1]}")`); } const time = `${parts[2].padStart(2, '0')}:${parts[3].padStart(2, '0')}`; if (!/^[0-2]\d:[0-5]\d$/.test(time)) { throw new Error('--schedule weekly time must be HH:MM (24h)'); } const timezone = parts.slice(4).join(':') || 'UTC'; return { kind: 'weekly', weekday, time, timezone }; } throw new Error(`--schedule kind must be hourly|daily|weekdays|weekly (got "${kind}")`); } function parseAutomationTarget(flags) { const raw = flags.target; if (raw == null) { if (flags.project) return { mode: 'reuse', projectId: String(flags.project) }; return { mode: 'create_each_run' }; } const value = String(raw); if ( value === 'worktree' || value === 'new-project' || value === 'create-each-run' || value === 'create_each_run' ) { return { mode: 'create_each_run' }; } if (value === 'reuse') { if (!flags.project) { throw new Error('--target reuse needs --project <id>'); } return { mode: 'reuse', projectId: String(flags.project) }; } const eq = value.indexOf('='); if ((value.startsWith('reuse=') || value.startsWith('reuse:')) && eq > 0) { const projectId = value.slice(eq + 1).trim(); if (!projectId) throw new Error('--target reuse=<projectId> needs a non-empty id'); return { mode: 'reuse', projectId }; } throw new Error( `--target must be "new-project" or "reuse=<projectId>" (got "${value}")`, ); } function describeAutomationScheduleForCli(schedule) { if (!schedule) return '-'; if (schedule.kind === 'hourly') { return `hourly:${String(schedule.minute).padStart(2, '0')}`; } if (schedule.kind === 'weekly') { const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; return `weekly:${days[schedule.weekday] ?? schedule.weekday}:${schedule.time}:${schedule.timezone}`; } return `${schedule.kind}:${schedule.time}:${schedule.timezone}`; } function describeAutomationTargetForCli(target) { if (!target) return '-'; if (target.mode === 'reuse') return `reuse=${target.projectId}`; return 'new-project'; } function splitAutomationIds(value) { if (typeof value !== 'string' || value.trim().length === 0) return []; const seen = new Set(); const out = []; for (const part of value.split(',')) { const id = part.trim(); if (!id || seen.has(id)) continue; seen.add(id); out.push(id); } return out; } function automationContextFromFlags(flags) { const skillIds = splitAutomationIds(flags.skill); const pluginIds = splitAutomationIds(flags.plugin); const mcpServerIds = splitAutomationIds(flags.mcp); const connectorIds = splitAutomationIds(flags.connector); const context = { ...(skillIds.length > 0 ? { skillIds } : {}), ...(pluginIds.length > 0 ? { pluginIds } : {}), ...(mcpServerIds.length > 0 ? { mcpServerIds } : {}), ...(connectorIds.length > 0 ? { connectorIds } : {}), }; return Object.keys(context).length > 0 ? context : null; } function formatAutomationRow(r) { const next = r.nextRunAt ? new Date(r.nextRunAt).toISOString() : (r.enabled ? '-' : 'paused'); return [ r.id, r.name, describeAutomationScheduleForCli(r.schedule), describeAutomationTargetForCli(r.target), r.enabled ? 'enabled' : 'paused', next, ].join('\t'); } async function readPromptFromFlags(flags) { if (typeof flags.prompt === 'string' && flags.prompt.length > 0) { return flags.prompt; } if (typeof flags['prompt-file'] === 'string' && flags['prompt-file'].length > 0) { const path = flags['prompt-file']; if (path === '-') { return await new Promise((resolve, reject) => { let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { buf += chunk; }); process.stdin.on('end', () => resolve(buf)); process.stdin.on('error', reject); }); } const { readFile } = await import('node:fs/promises'); return await readFile(path, 'utf8'); } return null; } function printAutomationHelp() { console.log(`Usage: od automation template list List built-in automation templates. od automation template get <id> Print one built-in automation template. od automation source ingest --source-kind <kind> --title <title> [--source-ref <ref>] [--template <id>] [--body <markdown> | --body-file <path|->] [--connector <id>] [--compression off|balanced|aggressive] [--json] od automation source list [--limit 20] [--json] List ingested source packets. od automation source get <id> [--json] Print one source packet. od automation proposal list [--status pending-review] List self-evolution proposals. od automation proposal get <id> Print one proposal. od automation proposal apply <id> Apply a reviewable proposal. od automation proposal reject <id> [--reason "<why>"] Reject a reviewable proposal. od automation list List automations. od automation get <id> Print one automation. od automation create --name "<title>" --prompt "<text>" --schedule <spec> [--target new-project|reuse=<projectId>] [--disabled] [--json] [--prompt-file <path|->] (alternative to --prompt) [--skill <id>[,<id>]] [--plugin <id>[,<id>]] [--mcp <id>[,<id>]] [--connector <id>[,<id>]] [--agent <id>] od automation update <id> [--name ...] [--prompt ...] [--schedule ...] [--target ...] [--skill ...] [--plugin ...] [--mcp ...] [--connector ...] [--enabled|--disabled] Patch fields. od automation run <id> Trigger a manual run; prints projectId/conversationId. od automation runs <id> [--limit 10] Print run history. od automation crystallize-run <routineId> <runId> [--json] Turn a succeeded run into skill/memory proposals. od automation pause <id> Mark disabled. od automation resume <id> Mark enabled. od automation delete <id> Remove the automation (history retained). Schedule formats: hourly:<minute> Every hour at :MM. daily:HH:MM[:TZ] Daily at HH:MM in TZ (default UTC). weekdays:HH:MM[:TZ] Mon-Fri at HH:MM. weekly:DAY:HH:MM[:TZ] DAY = 0-6 or sun|mon|...|sat. Output: Plain text: tab-separated rows for list, human-readable lines for get / runs. --json Raw JSON for any subcommand. Designed so external agents (hermes-agent, openclaw, scripted jobs) can drive the full automation lifecycle headlessly. Common options: --daemon-url <url> Open Design daemon HTTP base.`); } async function runAutomation(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { printAutomationHelp(); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); let flags; try { flags = parseFlags(rest, { string: AUTOMATION_STRING_FLAGS, boolean: AUTOMATION_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); process.exit(2); } const base = await cliDaemonBaseUrl(flags); const writeJson = (data) => process.stdout.write(JSON.stringify(data, null, 2) + '\n'); const positionalArgs = (values) => { const out = []; for (let i = 0; i < values.length; i++) { const value = values[i]; if (!value) continue; if (value.startsWith('--')) { const eq = value.indexOf('='); const key = eq >= 0 ? value.slice(2, eq) : value.slice(2); if (eq < 0 && AUTOMATION_STRING_FLAGS.has(key)) i++; continue; } out.push(value); } return out; }; const requireId = (label) => { const id = positionalArgs(rest)[0]; if (!id) { console.error(`Usage: od automation ${label} <id>`); process.exit(2); } return id; }; const readAutomationIngestBody = async () => { const direct = await readMemoryBodyFromFlags(flags); if (typeof direct === 'string') return direct; return await readPromptFromFlags(flags); }; switch (sub) { case 'template': case 'templates': { const parts = positionalArgs(rest); const action = parts[0] ?? 'list'; if (action === 'list') { let resp; try { resp = await fetch(`${base}/api/automation-templates`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); const templates = data.templates ?? []; if (templates.length === 0) { console.log('No automation templates available.'); return; } console.log('# id\ttitle\ttriggers\tsources\toutputs\tcompression\treview'); for (const template of templates) { console.log([ template.id, template.title, (template.triggerKinds ?? []).join(','), (template.sourceKinds ?? []).join(','), (template.outputSinks ?? []).join(','), template.tokenCompression, template.reviewPolicy, ].join('\t')); } return; } if (action === 'get') { const id = parts[1]; if (!id) { console.error('Usage: od automation template get <id>'); process.exit(2); } let resp; try { resp = await fetch(`${base}/api/automation-templates/${encodeURIComponent(id)}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); return writeJson(flags.json ? data : (data.template ?? data)); } console.error(`unknown subcommand: od automation template ${action}`); printAutomationHelp(); process.exit(2); } case 'ingest': case 'source': case 'sources': { const parts = positionalArgs(rest); const action = sub === 'ingest' ? 'ingest' : (parts[0] ?? 'list'); if (action === 'ingest') { const sourceKind = flags['source-kind'] ?? (sub === 'ingest' ? parts[0] : parts[1]); if (!sourceKind) { console.error('Usage: od automation source ingest --source-kind <kind> --body-file <path|->'); process.exit(2); } const bodyMarkdown = await readAutomationIngestBody(); if (!bodyMarkdown) { console.error('--body, --body-file, --prompt, or --prompt-file is required'); process.exit(2); } const candidateSinks = typeof flags['candidate-sinks'] === 'string' ? flags['candidate-sinks'].split(',').map((item) => item.trim()).filter(Boolean) : undefined; let resp; try { resp = await fetch(`${base}/api/automation-ingestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ templateId: flags.template, sourceKind, sourceRef: flags['source-ref'], title: flags.title ?? flags.name, bodyMarkdown, projectId: flags.project, connectorId: flags.connector, accountLabel: flags.account, sensitivity: flags.sensitivity, tokenCompression: flags.compression, candidateSinks, memoryType: flags['memory-type'], }), }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); console.log(`[automation source] ingested ${data.packet?.id}`); console.log(`compression: ${data.compressionReport?.status ?? 'unknown'} (${data.compressionReport?.beforeTokens ?? 0} -> ${data.compressionReport?.afterTokens ?? 0} tokens)`); const proposals = data.proposals ?? []; if (proposals.length > 0) { console.log('# proposals'); for (const proposal of proposals) { console.log([ proposal.id, proposal.targetKind, proposal.action, proposal.status, proposal.title, ].join('\t')); } } return; } if (action === 'list') { const query = flags.limit ? `?limit=${encodeURIComponent(String(flags.limit))}` : ''; let resp; try { resp = await fetch(`${base}/api/automation-source-packets${query}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); const packets = data.packets ?? []; if (packets.length === 0) { console.log('No automation source packets.'); return; } console.log('# id\tkind\tcapturedAt\ttokens\ttitle'); for (const packet of packets) { console.log([ packet.id, packet.sourceKind, packet.capturedAt, packet.tokenStats?.originalTokens ?? 0, packet.title, ].join('\t')); } return; } if (action === 'get') { const id = parts[1]; if (!id) { console.error('Usage: od automation source get <id>'); process.exit(2); } let resp; try { resp = await fetch(`${base}/api/automation-source-packets/${encodeURIComponent(id)}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); return writeJson(await resp.json()); } console.error(`unknown subcommand: od automation source ${action}`); printAutomationHelp(); process.exit(2); } case 'proposal': case 'proposals': { const parts = positionalArgs(rest); const action = parts[0] ?? 'list'; if (action === 'list') { const query = flags.status ? `?status=${encodeURIComponent(String(flags.status))}` : ''; let resp; try { resp = await fetch(`${base}/api/automation-proposals${query}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); const proposals = data.proposals ?? []; if (proposals.length === 0) { console.log('No automation proposals.'); return; } console.log('# id\tstatus\ttarget\taction\tupdatedAt\ttitle'); for (const proposal of proposals) { console.log([ proposal.id, proposal.status, proposal.targetKind, proposal.action, proposal.updatedAt, proposal.title, ].join('\t')); } return; } if (action === 'get') { const id = parts[1]; if (!id) { console.error('Usage: od automation proposal get <id>'); process.exit(2); } let resp; try { resp = await fetch(`${base}/api/automation-proposals/${encodeURIComponent(id)}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); return writeJson(await resp.json()); } if (action === 'apply' || action === 'reject') { const id = parts[1]; if (!id) { console.error(`Usage: od automation proposal ${action} <id>`); process.exit(2); } let resp; try { resp = await fetch( `${base}/api/automation-proposals/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: action === 'reject' ? JSON.stringify({ reason: flags.reason ?? '' }) : '{}', }, ); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); console.log(`[automation proposal] ${action === 'apply' ? 'applied' : 'rejected'} ${data.proposal?.id ?? id}`); return; } console.error(`unknown subcommand: od automation proposal ${action}`); printAutomationHelp(); process.exit(2); } case 'list': { let resp; try { resp = await fetch(`${base}/api/routines`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); const routines = data.routines ?? []; if (routines.length === 0) { console.log('No automations. Create one with `od automation create --name "..." --prompt "..." --schedule daily:09:00`.'); return; } console.log('# id\tname\tschedule\ttarget\tstatus\tnextRun'); for (const r of routines) console.log(formatAutomationRow(r)); return; } case 'get': { const id = requireId('get'); let resp; try { resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); writeJson(data.routine ?? data); return; } case 'runs': { const id = requireId('runs'); const limit = Number(flags.limit) > 0 ? Number(flags.limit) : 20; let resp; try { resp = await fetch( `${base}/api/routines/${encodeURIComponent(id)}/runs?limit=${limit}`, ); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); const runs = data.runs ?? []; if (runs.length === 0) { console.log(`No runs yet for ${id}.`); return; } console.log('# runId\tstatus\ttrigger\tstartedAt\tprojectId\tconversationId'); for (const r of runs) { console.log([ r.id, r.status, r.trigger, new Date(r.startedAt).toISOString(), r.projectId, r.conversationId, ].join('\t')); } return; } case 'crystallize-run': { const parts = positionalArgs(rest); const routineId = parts[0]; const runId = parts[1]; if (!routineId || !runId) { console.error('Usage: od automation crystallize-run <routineId> <runId> [--json]'); process.exit(2); } let resp; try { resp = await fetch( `${base}/api/routines/${encodeURIComponent(routineId)}/runs/${encodeURIComponent(runId)}/crystallize`, { method: 'POST' }, ); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return writeJson(data); console.log(`[automation] crystallized ${runId}`); console.log(`sourcePacket\t${data.packet?.id ?? ''}`); console.log(`compression\t${data.compressionReport?.status ?? 'unknown'}\t${data.compressionReport?.beforeTokens ?? 0}->${data.compressionReport?.afterTokens ?? 0}`); const proposals = data.proposals ?? []; if (proposals.length > 0) { console.log('# proposals'); for (const proposal of proposals) { console.log([ proposal.id, proposal.targetKind, proposal.action, proposal.status, proposal.title, ].join('\t')); } } return; } case 'create': { const name = typeof flags.name === 'string' ? flags.name.trim() : ''; if (!name) { console.error('--name is required'); process.exit(2); } const prompt = (await readPromptFromFlags(flags)) || ''; if (!prompt.trim()) { console.error('--prompt or --prompt-file is required'); process.exit(2); } let schedule; let target; try { schedule = parseScheduleFlag(flags.schedule); target = parseAutomationTarget(flags); } catch (err) { console.error(err.message); process.exit(2); } const body = { name, prompt: prompt.trim(), schedule, target, enabled: !flags.disabled, }; const context = automationContextFromFlags(flags); const skillIds = splitAutomationIds(flags.skill); if (skillIds.length > 0) body.skillId = skillIds[0]; if (context) body.context = context; if (flags.agent) body.agentId = String(flags.agent); let resp; try { resp = await fetch(`${base}/api/routines`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST /api/routines failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return writeJson(data); console.log(`[automation] created ${data.routine?.id}`); console.log(formatAutomationRow(data.routine)); return; } case 'update': { const id = requireId('update'); const patch = {}; if (typeof flags.name === 'string') patch.name = flags.name.trim(); const promptPatch = await readPromptFromFlags(flags); if (promptPatch != null) patch.prompt = promptPatch.trim(); if (flags.schedule) { try { patch.schedule = parseScheduleFlag(flags.schedule); } catch (err) { console.error(err.message); process.exit(2); } } if (flags.target || flags.project) { try { patch.target = parseAutomationTarget(flags); } catch (err) { console.error(err.message); process.exit(2); } } if (flags.disabled) patch.enabled = false; if (flags.enabled) patch.enabled = true; const context = automationContextFromFlags(flags); if (context) { const skillIds = splitAutomationIds(flags.skill); if (skillIds.length > 0) patch.skillId = skillIds[0]; patch.context = context; } if (Object.keys(patch).length === 0) { console.error('update needs at least one of --name --prompt(--prompt-file) --schedule --target --skill --plugin --mcp --connector --enabled --disabled'); process.exit(2); } let resp; try { resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch), }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`PATCH /api/routines/${id} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return writeJson(data); console.log(`[automation] updated ${id}`); console.log(formatAutomationRow(data.routine)); return; } case 'pause': case 'resume': { const id = requireId(sub); const enabled = sub === 'resume'; let resp; try { resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled }), }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`PATCH /api/routines/${id} failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return writeJson(data); console.log(`[automation] ${sub}d ${id}`); return; } case 'run': { const id = requireId('run'); let resp; try { resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}/run`, { method: 'POST', }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } const data = await resp.json().catch(() => ({})); if (!resp.ok && resp.status !== 202) { console.error(`POST /api/routines/${id}/run failed: ${resp.status} ${JSON.stringify(data)}`); process.exit(1); } if (flags.json) return writeJson(data); console.log(`[automation] triggered ${id}`); if (data.projectId) console.log(`projectId\t${data.projectId}`); if (data.conversationId) console.log(`conversationId\t${data.conversationId}`); if (data.agentRunId) console.log(`agentRunId\t${data.agentRunId}`); return; } case 'delete': { const id = requireId('delete'); let resp; try { resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`, { method: 'DELETE', }); } catch (err) { surfaceFetchError(err, base); process.exit(3); } if (!resp.ok) return structuredHttpFailure(resp); if (flags.json) return writeJson({ ok: true, id }); console.log(`[automation] deleted ${id}`); return; } default: console.error(`unknown subcommand: od automation ${sub}`); printAutomationHelp(); process.exit(2); } }