Files
open-design/apps/daemon/src/cli.ts
T
Zakaria a46764fb1b
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
first-commit
2026-05-04 14:58:14 -04:00

509 lines
17 KiB
JavaScript

#!/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 <n>] [--host <addr>] [--no-open]
Start the local daemon and open the web UI.
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 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,
};
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);
}
}
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 <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.
--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 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 <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 (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.`);
}