Files
open-design/apps/daemon/src/cli.ts
T
Cursor AgentandTom Huang 671293e7f4 feat(cli): od doctor + od config get/set/list/unset
Plan I2 / spec §12.2.

`od doctor` is a single-shot diagnostics command that:
  - hits /api/daemon/status (fails with exit 64 if unreachable)
  - lists installed plugins and runs the per-plugin doctor route
  - lists skills / design-systems / atoms for inventory
  - aggregates issues by severity (warn / error)
  - exits 1 when any plugin doctor returns ok=false, otherwise 0

This is the CLI entry point a CI runner uses to gate a release on
plugin-system health before publishing. --json emits the full
report so downstream tooling (e.g. a GitHub Action summary step)
can render its own table.

`od config list/get/set/unset` wraps GET/PUT /api/app-config so a
code agent can flip provider keys / orbit settings / pet config
headlessly:

  od config list
  od config get <key>            (or --json for the value alone)
  od config set <key> <value>    (positional or --value <text>)
  od config set <key> --value-json '<json>'   (nested values)
  od config unset <key>

set on a top-level key only — nested merges go through --value-json
which keeps the daemon's existing PUT semantics (full-document
replace) intact.

Daemon test suite stays at 1476/1476 (the new CLI verbs are thin
wrappers over endpoints that already have integration coverage).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:54:03 +00:00

2895 lines
108 KiB
JavaScript

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