#!/usr/bin/env node // @ts-nocheck import { startServer } from './server.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', ]); 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 SUBCOMMAND_MAP = { media: runMedia, mcp: runMcp, }; 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); } // 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 }).then(url => { console.log(`[od] listening on ${url}`); if (open) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; import('node:child_process').then(({ spawn }) => { spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref(); }); } }); function printRootHelp() { console.log(`Usage: od [--port ] [--host ] [--no-open] Start the local daemon and open the web UI. od media generate --surface --model [opts] Generate a media artifact and write it into the active project. Designed to be invoked by a code agent - picks up OD_DAEMON_URL and OD_PROJECT_ID from the env that the daemon injected on spawn. od mcp [--daemon-url ] Run a stdio MCP server that proxies read-only tool calls to a running Open Design daemon. Wire it into a coding agent (Claude Code, Cursor, VS Code, Zed, Windsurf) in another repo to pull files from a local Open Design project without exporting a zip. Options: --port Port to listen on (default: 7456, env: OD_PORT). --host Interface address to bind to (default: 127.0.0.1, env: OD_BIND_HOST). Set to a specific IP (e.g. a Tailscale address) to restrict access to that interface only. --no-open Do not open the browser after start. What the daemon does: * scans PATH for installed code-agent CLIs (claude, codex, devin, gemini, opencode, cursor-agent, ...) * serves the chat UI at http://: * proxies messages (text + images) to the selected agent via child-process spawn * exposes /api/projects/:id/media/generate — the unified image/video/audio dispatcher that the agent calls via \`od media generate\`.`); } // --------------------------------------------------------------------------- // Subcommand: od media … // --------------------------------------------------------------------------- async function runMedia(args) { const sub = args.find((a) => !a.startsWith('-')) || ''; if (sub === 'help' || sub === '-h' || sub === '--help' || sub === '') { printMediaHelp(); return; } if (sub !== 'generate' && sub !== 'wait') { console.error(`unknown subcommand: od media ${sub}`); printMediaHelp(); process.exit(1); } const idx = args.indexOf(sub); const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)]; if (sub === 'wait') return runMediaWait(subArgs); return runMediaGenerate(subArgs); } async function runMediaGenerate(rawArgs) { let flags; try { flags = parseFlags(rawArgs, { string: MEDIA_GENERATE_STRING_FLAGS, boolean: MEDIA_GENERATE_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); printMediaHelp(); process.exit(2); } const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; const projectId = flags.project || process.env.OD_PROJECT_ID; if (!projectId) { console.error( 'project id required. Pass --project or set OD_PROJECT_ID. The daemon injects this when it spawns the code agent.', ); process.exit(2); } const surface = flags.surface; if (!surface || !['image', 'video', 'audio'].includes(surface)) { console.error('--surface must be one of: image | video | audio'); process.exit(2); } if (!flags.model) { console.error('--model required (see http:///api/media/models)'); process.exit(2); } const body = { surface, model: flags.model, prompt: flags.prompt, output: flags.output, aspect: flags.aspect, voice: flags.voice, audioKind: flags['audio-kind'], compositionDir: flags['composition-dir'], image: flags.image, }; if (flags.length != null) body.length = Number(flags.length); if (flags.duration != null) body.duration = Number(flags.duration); const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`; let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } catch (err) { surfaceFetchError(err, daemonUrl); process.exit(3); } if (!resp.ok) { const text = await resp.text(); console.error(`daemon ${resp.status}: ${text}`); process.exit(4); } const accepted = await resp.json(); const { taskId } = accepted; if (!taskId) { console.error('daemon did not return a taskId'); process.exit(4); } console.error(`task ${taskId} queued (${accepted.status || 'queued'})`); await pollUntilDoneOrBudget(daemonUrl, taskId, 0); } async function runMediaWait(rawArgs) { const taskId = rawArgs.find((a) => a && !a.startsWith('--')); if (!taskId) { console.error('usage: od media wait [--since ] [--daemon-url ]'); process.exit(2); } const flagsOnly = rawArgs.filter((a) => a !== taskId); let flags; try { flags = parseFlags(flagsOnly, { string: new Set(['since', 'daemon-url']), boolean: new Set(['help', 'h']), }); } catch (err) { console.error(err.message); printMediaHelp(); process.exit(2); } const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; const since = Number.isFinite(Number(flags.since)) ? Number(flags.since) : 0; await pollUntilDoneOrBudget(daemonUrl, taskId, since); } async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart) { const totalBudgetMs = 25_000; const perCallTimeoutMs = 4_000; const startedAt = Date.now(); const url = `${daemonUrl.replace(/\/$/, '')}/api/media/tasks/${encodeURIComponent(taskId)}/wait`; let since = Number.isFinite(sinceStart) ? sinceStart : 0; let lastSnapshot = null; while (Date.now() - startedAt < totalBudgetMs) { const remaining = totalBudgetMs - (Date.now() - startedAt); const callTimeout = Math.max(500, Math.min(perCallTimeoutMs, remaining)); let resp; try { resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ since, timeoutMs: callTimeout }), }); } catch (err) { surfaceFetchError(err, daemonUrl); process.exit(3); } if (resp.status === 404) { console.error(`task ${taskId} not found (expired or never queued)`); process.exit(4); } if (!resp.ok) { const text = await resp.text(); console.error(`daemon ${resp.status}: ${text}`); process.exit(4); } let snap; try { snap = await resp.json(); } catch { console.error('daemon returned non-JSON for /wait'); process.exit(4); } lastSnapshot = snap; if (Array.isArray(snap.progress)) { for (const line of snap.progress) { process.stderr.write(line + '\n'); process.stdout.write(`# ${line}\n`); } } if (typeof snap.nextSince === 'number') since = snap.nextSince; if (snap.status === 'done') { const file = snap.file || {}; const warnings = Array.isArray(file.warnings) ? file.warnings : []; for (const w of warnings) { if (typeof w === 'string' && w) console.error(`WARN: ${w}`); } if (file.providerError) { const provider = file.providerId || 'provider'; console.error( `WARN: ${provider} call failed — wrote stub fallback (${file.size} bytes) to ${file.name}`, ); console.error(`WARN: reason: ${file.providerError}`); console.error( 'WARN: surface this verbatim to the user. Do NOT claim the stub is the final result.', ); } process.stdout.write(JSON.stringify({ file }) + '\n'); process.exit(file.providerError ? 5 : 0); } if (snap.status === 'failed') { const msg = snap.error?.message || 'task failed'; console.error(`task failed: ${msg}`); process.stdout.write( JSON.stringify({ taskId, status: 'failed', error: snap.error || {} }) + '\n', ); process.exit(snap.error?.status || 5); } } 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 media wait ${taskId} --since ${since}\` to continue ` + `(exit code 2 = still running).\n`, ); process.exit(2); } function surfaceFetchError(err, daemonUrl) { const cause = err && typeof err === 'object' ? err.cause : null; const code = cause && typeof cause === 'object' && typeof cause.code === 'string' ? cause.code : null; const causeMsg = cause && typeof cause === 'object' && typeof cause.message === 'string' ? cause.message : ''; let detail = err && err.message ? err.message : String(err); if (code) detail = `${code}${causeMsg ? ` — ${causeMsg}` : ''}`; else if (causeMsg) detail = causeMsg; console.error(`failed to reach daemon at ${daemonUrl}: ${detail}`); if (code === 'EPERM' || code === 'ENETUNREACH') { console.error( 'hint: outbound connect was denied by a sandbox. If you launched ' + 'this command from a code agent, check the agent\'s sandbox / ' + 'network policy. The Open Design daemon itself is unaffected - it can be ' + 'reached from a regular shell.', ); } } function parseFlags(argv, opts = {}) { const stringFlags = opts.string instanceof Set ? opts.string : new Set(); const booleanFlags = opts.boolean instanceof Set ? opts.boolean : new Set(); const knownFlags = new Set([...stringFlags, ...booleanFlags]); const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (!a || !a.startsWith('--')) { throw new Error(`unexpected positional argument: ${a}`); } const eq = a.indexOf('='); const key = eq >= 0 ? a.slice(2, eq) : a.slice(2); if (knownFlags.size > 0 && !knownFlags.has(key)) { throw new Error( `unknown flag: --${key}. Run with --help for the list of accepted flags.`, ); } if (eq >= 0) { out[key] = a.slice(eq + 1); continue; } if (booleanFlags.has(key)) { out[key] = true; continue; } if (stringFlags.has(key)) { const next = argv[i + 1]; if (next == null) { throw new Error(`flag --${key} requires a value`); } out[key] = next; i++; continue; } const next = argv[i + 1]; if (next != null && !next.startsWith('--')) { out[key] = next; i++; } else { out[key] = true; } } return out; } function printMediaHelp() { console.log(`Usage: od media generate --surface --model [opts] Required: --surface image | video | audio --model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5). --project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon. Common options: --prompt "" Generation prompt. --output File to write under the project. Auto-named if omitted. --aspect 1:1|16:9|9:16|4:3|3:4 --length Video length. --duration Audio duration. --voice Speech / TTS voice. --audio-kind music|speech|sfx --composition-dir hyperframes-html only — project-relative path to the dir containing hyperframes.json / meta.json / index.html. The daemon runs \`npx hyperframes render\` against it. --image Project-relative path to a reference image (image-to-video for Seedance i2v models, or future image-edit endpoints). Daemon reads the file from the project, base64-encodes it, and forwards it to the upstream API. --daemon-url http://127.0.0.1:7456 Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}. Skills should call this and then reference the returned filename in their artifact / message body. The daemon writes the bytes into the project's files folder so the FileViewer can preview them immediately.`); } // --------------------------------------------------------------------------- // Subcommand: od mcp // --------------------------------------------------------------------------- async function runMcp(args) { let flags; try { flags = parseFlags(args, { string: MCP_STRING_FLAGS, boolean: MCP_BOOLEAN_FLAGS, }); } catch (err) { console.error(err.message); printMcpHelp(); process.exit(2); } if (flags.help || flags.h) { printMcpHelp(); return; } const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; const { runMcpStdio } = await import('./mcp.js'); await runMcpStdio({ daemonUrl }); } function printMcpHelp() { console.log(`Usage: od mcp [--daemon-url ] Run a stdio MCP (Model Context Protocol) server that proxies read-only tool calls to a running Open Design daemon. Wire it into a coding agent in another repo so the agent can pull files from a local Open Design project without exporting a zip every iteration. Options: --daemon-url Open Design daemon HTTP base URL (default: env OD_DAEMON_URL, falling back to http://127.0.0.1:7456). 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.`); }