mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
- Added a new GenUI module for managing user interface surfaces, including creation, response handling, and state synchronization. - Implemented API endpoints for listing and responding to GenUI surfaces associated with runs and projects. - Introduced event types and payload helpers for GenUI surface events, enhancing the interaction model for headless operations. - Established a persistent state writer for GenUI surfaces, ensuring reliable data management and retrieval. - Enhanced the plugin system to support auto-derived OAuth prompts for required connectors, improving user experience during plugin application.
1296 lines
47 KiB
JavaScript
1296 lines
47 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',
|
|
]);
|
|
const PLUGIN_BOOLEAN_FLAGS = new Set([
|
|
'help',
|
|
'h',
|
|
'json',
|
|
]);
|
|
|
|
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,
|
|
};
|
|
|
|
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 …
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
default:
|
|
console.error(`unknown subcommand: od plugin ${sub}`);
|
|
printPluginHelp();
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
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 <path>');
|
|
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);
|
|
if (!id) {
|
|
console.error('Usage: od plugin apply <id> [--inputs <json>] [--project <id>]');
|
|
process.exit(2);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`;
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ inputs, projectId: flags.project }),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
if (resp.status === 422 && data?.fields) {
|
|
console.error(`[apply] missing inputs: ${data.fields.join(', ')}`);
|
|
} else {
|
|
console.error(`POST /api/plugins/${id}/apply failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
|
|
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. Phase 2A wires trust capabilities (per-id
|
|
// connector grants etc.); Phase 3 layers in the marketplace trust UI on
|
|
// top. The CLI is the canonical write surface (invariant I4).
|
|
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');
|
|
process.exit(2);
|
|
}
|
|
const caps = typeof flags.capabilities === 'string' && flags.capabilities.trim().length > 0
|
|
? flags.capabilities.split(',').map((c) => c.trim()).filter(Boolean)
|
|
: [];
|
|
// Trust mutation goes through the existing `installed_plugins` row;
|
|
// Phase 3 adds the dedicated `/api/plugins/:id/trust` endpoint so this
|
|
// CLI handler will switch endpoints without changing the user surface.
|
|
// Until then, surface the action as a no-op against the v1 daemon and
|
|
// document the deferred behaviour.
|
|
process.stdout.write(JSON.stringify({
|
|
ok: true,
|
|
pluginId: id,
|
|
granted: caps,
|
|
note: 'Phase 2A registers the CLI surface; the /api/plugins/:id/trust mutation lands in Phase 3.',
|
|
}, null, 2) + '\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.`);
|
|
}
|