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
509 lines
17 KiB
JavaScript
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.`);
|
|
}
|