mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* Add design system import manifest schema * Generate hybrid design system imports * Read design system usage and cached manifests * Add design system pull-file tool * Show design system package evidence * Wire design system import semantics * Add design system package quality guard --------- Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
5793 lines
219 KiB
JavaScript
5793 lines
219 KiB
JavaScript
#!/usr/bin/env node
|
|
// @ts-nocheck
|
|
import { runDaemonCliStartup } from './daemon-startup.js';
|
|
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
|
|
import { runArtifactsCli } from './artifacts-cli.js';
|
|
import { runConnectorsToolCli } from './tools-connectors-cli.js';
|
|
import { runDesignSystemsToolCli } from './tools-design-systems-cli.js';
|
|
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
|
import { splitResearchSubcommand } from './research/cli-args.js';
|
|
import { resolveDaemonUrl } from './daemon-url.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',
|
|
'prompt-influence',
|
|
'voice',
|
|
'audio-kind',
|
|
'composition-dir',
|
|
'image',
|
|
'daemon-url',
|
|
'language',
|
|
]);
|
|
const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([
|
|
'help',
|
|
'h',
|
|
'loop',
|
|
]);
|
|
|
|
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',
|
|
'conversation',
|
|
'message',
|
|
'agent',
|
|
'model',
|
|
'snapshot-id',
|
|
'capabilities',
|
|
'grant-caps',
|
|
'before',
|
|
'trust',
|
|
'tag',
|
|
'policy',
|
|
'version',
|
|
'reason',
|
|
'catalog',
|
|
'host',
|
|
]);
|
|
const PLUGIN_BOOLEAN_FLAGS = new Set([
|
|
'help',
|
|
'h',
|
|
'json',
|
|
'revoke',
|
|
'follow',
|
|
'strict',
|
|
]);
|
|
|
|
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',
|
|
// Plan §6 Phase 2A.5 — `od ui show --schema` returns just the
|
|
// surface's JSON Schema (or `null` when the surface declares
|
|
// none). Lets a code agent inspect the contract before piping a
|
|
// value back through `od ui respond --value-json`.
|
|
'schema',
|
|
]);
|
|
|
|
// Hoist flag set bindings consumed by handlers reachable through
|
|
// the top-of-file dispatcher. The dispatch block runs synchronously
|
|
// during module load; any const declared further down the file is
|
|
// still in TDZ when the handler executes, so `od status` /
|
|
// `od atoms list` / etc. would crash with `Cannot access X before
|
|
// initialization`. The actual definitions stay further down (next
|
|
// to their handlers); we just export the bindings up here so the
|
|
// dispatch path always sees an initialized value.
|
|
const DAEMON_STRING_FLAGS = new Set([
|
|
'daemon-url', 'port', 'host',
|
|
]);
|
|
const DAEMON_BOOLEAN_FLAGS = new Set([
|
|
'help', 'h', 'json', 'headless', 'serve-web', 'no-open',
|
|
]);
|
|
const LIBRARY_STRING_FLAGS = new Set(['daemon-url', 'query', 'tag']);
|
|
const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
|
|
const PROJECT_STRING_FLAGS = new Set([
|
|
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
|
|
'pending-prompt', 'project', 'conversation', 'message', 'path', 'as',
|
|
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps',
|
|
]);
|
|
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
|
|
// `od automation …` mirrors the Automations tab. Same surface, same
|
|
// /api/routines store. The CLI form is the embeddability contract:
|
|
// external agents (hermes-agent, openclaw, etc.) can drive Open Design
|
|
// automations headlessly without going through the web UI.
|
|
const AUTOMATION_STRING_FLAGS = new Set([
|
|
'daemon-url', 'name', 'prompt', 'prompt-file', 'schedule', 'target',
|
|
'project', 'skill', 'agent', 'limit', 'plugin', 'mcp', 'connector',
|
|
'status', 'reason', 'template', 'source-kind', 'source-ref', 'title',
|
|
'body', 'body-file', 'compression', 'sensitivity', 'account',
|
|
'candidate-sinks', 'memory-type',
|
|
]);
|
|
const AUTOMATION_BOOLEAN_FLAGS = new Set([
|
|
'help', 'h', 'json', 'disabled', 'enabled',
|
|
]);
|
|
const MEMORY_STRING_FLAGS = new Set([
|
|
'daemon-url', 'name', 'description', 'type', 'body', 'body-file',
|
|
]);
|
|
const MEMORY_BOOLEAN_FLAGS = new Set([
|
|
'help', 'h', 'json',
|
|
]);
|
|
// Hoisted because `runAutomation` is reachable through the top-of-file
|
|
// SUBCOMMAND_MAP dispatch, which runs during module evaluation —
|
|
// any `const` declared further down would still be in TDZ when
|
|
// `parseScheduleFlag` reads this map. Same reason the other dispatch-
|
|
// touched constants live near the top.
|
|
const AUTOMATION_WEEKDAY_TOKENS = {
|
|
sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
|
|
sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6,
|
|
};
|
|
const RECOVERABLE_EXIT_CODES = {
|
|
'daemon-not-running': 64,
|
|
'plugin-not-found': 65,
|
|
'snapshot-not-found': 65,
|
|
'capabilities-required': 66,
|
|
'missing-input': 67,
|
|
'project-not-found': 68,
|
|
'run-not-found': 69,
|
|
'provider-not-configured': 70,
|
|
'plugin-requires-daemon': 71,
|
|
'snapshot-stale': 72,
|
|
'genui-surface-awaiting': 73,
|
|
};
|
|
const PLUGIN_LIST_FILTER_FLAGS = new Set([
|
|
...PLUGIN_STRING_FLAGS,
|
|
'task-kind', 'mode', 'tag', 'trust',
|
|
]);
|
|
const PLUGIN_LIST_BOOLEAN_FLAGS = new Set([
|
|
...PLUGIN_BOOLEAN_FLAGS,
|
|
'bundled', 'no-bundled',
|
|
]);
|
|
|
|
const SUBCOMMAND_MAP = {
|
|
artifacts: runArtifacts,
|
|
media: runMedia,
|
|
mcp: runMcp,
|
|
research: runResearch,
|
|
plugin: runPlugin,
|
|
ui: runUi,
|
|
marketplace: runMarketplace,
|
|
project: runProject,
|
|
automation: runAutomation,
|
|
automations: runAutomation,
|
|
memory: runMemory,
|
|
run: runRun,
|
|
files: runFiles,
|
|
conversation: runConversation,
|
|
daemon: runDaemon,
|
|
atoms: runAtoms,
|
|
skills: runSkills,
|
|
'design-systems': runDesignSystems,
|
|
craft: runCraft,
|
|
status: runStatus,
|
|
version: runVersion,
|
|
doctor: runDoctor,
|
|
config: runConfig,
|
|
};
|
|
|
|
if (argv[0] === 'mcp' && argv[1] === 'live-artifacts') {
|
|
try {
|
|
const { exitCode } = await runLiveArtifactsMcpServer();
|
|
process.exit(exitCode);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const first = argv.find((a) => !a.startsWith('-'));
|
|
if (first && SUBCOMMAND_MAP[first]) {
|
|
const idx = argv.indexOf(first);
|
|
const rest = [...argv.slice(0, idx), ...argv.slice(idx + 1)];
|
|
await SUBCOMMAND_MAP[first](rest);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (argv[0] === 'tools' && argv[1] === 'live-artifacts') {
|
|
runLiveArtifactsToolCli(argv.slice(2))
|
|
.then(({ exitCode }) => {
|
|
process.exitCode = exitCode;
|
|
})
|
|
.catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
} else if (argv[0] === 'tools' && argv[1] === 'connectors') {
|
|
runConnectorsToolCli(argv.slice(2))
|
|
.then(({ exitCode }) => {
|
|
process.exitCode = exitCode;
|
|
})
|
|
.catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
} else if (argv[0] === 'tools' && argv[1] === 'design-systems') {
|
|
runDesignSystemsToolCli(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 {
|
|
await runDaemonCliStartup(argv, { printHelp: printRootHelp });
|
|
}
|
|
|
|
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 artifacts create --name <path> --input <file> [--project <id-or-name>]
|
|
Create a normal project artifact through the local daemon.
|
|
|
|
od tools connectors <list|execute|github-design-context> [options]
|
|
Discover and execute configured connectors.
|
|
|
|
od tools design-systems read --path <manifest-declared-path>
|
|
Read active design-system pull-layer files through daemon wrapper commands.
|
|
|
|
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 automation <list|get|create|update|run|runs|pause|resume|delete> [args]
|
|
Drive the Automations surface headlessly. Same store as the UI's
|
|
Automations tab, so an external agent (hermes, openclaw, ...) can
|
|
schedule, trigger, or harvest results from a routine without
|
|
opening the web UI.
|
|
|
|
od memory tree <list|view|edit|move> [args]
|
|
Inspect and edit the memory tree that is injected into agent prompts.
|
|
|
|
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 project 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 and create
|
|
project-scoped artifacts 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 = await cliDaemonUrl(flags);
|
|
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`);
|
|
}
|
|
|
|
async function runArtifacts(args) {
|
|
const { exitCode } = await runArtifactsCli(args);
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
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, OD_SIDECAR_IPC_PATH discovery, 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 = await cliDaemonUrl(flags);
|
|
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);
|
|
if (flags['prompt-influence'] != null) body.promptInfluence = Number(flags['prompt-influence']);
|
|
if (flags.loop === true) body.loop = true;
|
|
|
|
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, {
|
|
stillRunningExitCode: 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 = await cliDaemonUrl(flags);
|
|
const since = Number.isFinite(Number(flags.since))
|
|
? Number(flags.since)
|
|
: 0;
|
|
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
|
|
}
|
|
|
|
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
|
|
const totalBudgetMs = 25_000;
|
|
const perCallTimeoutMs = 4_000;
|
|
const stillRunningExitCode =
|
|
typeof options.stillRunningExitCode === 'number'
|
|
? options.stillRunningExitCode
|
|
: 2;
|
|
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');
|
|
const stillRunningHint =
|
|
stillRunningExitCode === 0
|
|
? 'This is a successful queued/running handoff, not a failure.'
|
|
: `exit code ${stillRunningExitCode} = still running.`;
|
|
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 ` +
|
|
`(${stillRunningHint}).\n`,
|
|
);
|
|
process.exit(stillRunningExitCode);
|
|
}
|
|
|
|
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]);
|
|
// Positionals collected silently; callers that take `<id>` style
|
|
// positional args (e.g. `od plugin info <id>`) re-scan `argv`
|
|
// themselves to pick them up. Strict positional rejection here
|
|
// would break those commands, so we only enforce strict-flag
|
|
// semantics for things that *are* prefixed with `--`.
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (!a || !a.startsWith('--')) {
|
|
// Positional — let the caller decide what to do with it.
|
|
continue;
|
|
}
|
|
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;
|
|
}
|
|
|
|
async function cliDaemonUrl(flags) {
|
|
return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] });
|
|
}
|
|
|
|
async function cliDaemonBaseUrl(flags) {
|
|
return (await cliDaemonUrl(flags)).replace(/\/$/, '');
|
|
}
|
|
|
|
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. ElevenLabs SFX prompts must stay under 450 characters.
|
|
--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.
|
|
--prompt-influence <0-1> ElevenLabs SFX prompt adherence. Higher values follow the prompt more closely.
|
|
--loop ElevenLabs SFX only: request a seamless loop.
|
|
--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 <url>
|
|
|
|
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 = await cliDaemonUrl(flags);
|
|
|
|
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 project
|
|
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 and create project-scoped artifacts without exporting a zip
|
|
every iteration.
|
|
|
|
Options:
|
|
--daemon-url <url> Open Design daemon HTTP base URL. Resolution
|
|
order: this flag, OD_DAEMON_URL, OD_SIDECAR_IPC_PATH,
|
|
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
|
|
create_artifact(name, content) create one normal artifact entry file
|
|
|
|
When project is omitted, get_artifact / get_project / get_file /
|
|
search_files / list_files / create_artifact 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. The daemon must be running locally
|
|
for tool calls to succeed.`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od plugin …
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Plan §3.B1 / spec §12.4: CLI structured error helper. Maps a daemon
|
|
// HTTP error envelope (or a synthetic local error) to a stable exit
|
|
// code + a JSON envelope on stderr. Code agents read these to decide
|
|
// whether the failure is recoverable (re-grant capabilities, prompt
|
|
// the user, retry with --grant-caps, etc.).
|
|
function exitWithStructuredError({ code, message, data }) {
|
|
const exit = RECOVERABLE_EXIT_CODES[code] ?? 1;
|
|
const envelope = { error: { code, message, data: data ?? {} } };
|
|
process.stderr.write(JSON.stringify(envelope) + '\n');
|
|
process.exit(exit);
|
|
}
|
|
|
|
// Map a daemon HTTP response into the exit-code envelope. Returns the
|
|
// parsed body (so the caller can keep going if it doesn't want to exit).
|
|
async function structuredHttpFailure(resp, fallbackCode = 'daemon-not-running') {
|
|
let parsed;
|
|
try { parsed = await resp.json(); } catch { parsed = {}; }
|
|
const errCode = parsed?.error?.code;
|
|
if (errCode && errCode in RECOVERABLE_EXIT_CODES) {
|
|
exitWithStructuredError({
|
|
code: errCode,
|
|
message: parsed.error.message ?? `HTTP ${resp.status}`,
|
|
data: parsed.error.data,
|
|
});
|
|
}
|
|
exitWithStructuredError({
|
|
code: fallbackCode,
|
|
message: parsed?.error?.message ?? `HTTP ${resp.status}: ${await resp.text().catch(() => '')}`,
|
|
data: parsed?.error?.data,
|
|
});
|
|
}
|
|
|
|
async function runPlugin(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
printPluginHelp();
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
switch (sub) {
|
|
case 'list': return runPluginList(rest);
|
|
case 'search': return runPluginSearch(rest);
|
|
case 'stats': return runPluginStats(rest);
|
|
case 'sources': return runPluginSources(rest);
|
|
case 'info': return runPluginInfo(rest);
|
|
case 'manifest': return runPluginManifest(rest);
|
|
case 'install': return runPluginInstall(rest);
|
|
case 'upgrade': return runPluginUpgrade(rest);
|
|
case 'uninstall': return runPluginUninstall(rest);
|
|
case 'apply': return runPluginApply(rest);
|
|
case 'canon': return runPluginCanon(rest);
|
|
case 'diff': return runPluginDiff(rest);
|
|
case 'doctor': return runPluginDoctor(rest);
|
|
case 'replay': return runPluginReplay(rest);
|
|
case 'trust': return runPluginTrust(rest);
|
|
case 'snapshots': return runPluginSnapshots(rest);
|
|
case 'simulate': return runPluginSimulate(rest);
|
|
case 'verify': return runPluginVerify(rest);
|
|
case 'events': return runPluginEvents(rest);
|
|
case 'run': return runPluginRun(rest);
|
|
case 'scaffold': return runPluginScaffold(rest);
|
|
case 'validate': return runPluginValidate(rest);
|
|
case 'pack': return runPluginPack(rest);
|
|
case 'login': return runPluginLogin(rest);
|
|
case 'whoami': return runPluginWhoami(rest);
|
|
case 'export': return runPluginExport(rest);
|
|
case 'publish': return runPluginPublish(rest);
|
|
case 'yank': return runPluginYank(rest);
|
|
default:
|
|
console.error(`unknown subcommand: od plugin ${sub}`);
|
|
printPluginHelp();
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Phase 4 / spec §14.1 — `od plugin scaffold` interactive starter.
|
|
//
|
|
// Side-effect: writes a SKILL.md + open-design.json starter under
|
|
// `<targetDir>/<id>/`. Default targetDir is process.cwd() so a code
|
|
// agent can drop the scaffold into the current repo root.
|
|
async function runPluginScaffold(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set([
|
|
'id', 'title', 'description', 'task-kind', 'mode', 'scenario', 'out',
|
|
]),
|
|
boolean: new Set(['help', 'h', 'json', 'with-claude-plugin']),
|
|
});
|
|
if (rest.length === 0 || flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin scaffold --id <id> [--title "<title>"] [--description "<text>"]
|
|
[--task-kind new-generation|code-migration|figma-migration|tune-collab]
|
|
[--mode <mode>] [--scenario <scenario>]
|
|
[--out <dir>] [--with-claude-plugin]
|
|
|
|
Writes <out|cwd>/<id>/{SKILL.md,open-design.json,README.md}.`);
|
|
process.exit(rest.length === 0 ? 2 : 0);
|
|
}
|
|
const id = typeof flags.id === 'string' && flags.id.length > 0
|
|
? flags.id
|
|
: rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od plugin scaffold --id <id>');
|
|
process.exit(2);
|
|
}
|
|
const targetDir = typeof flags.out === 'string' && flags.out.length > 0
|
|
? flags.out
|
|
: process.cwd();
|
|
const { scaffoldPlugin, ScaffoldError } = await import('./plugins/scaffold.js');
|
|
try {
|
|
const input = {
|
|
targetDir,
|
|
id,
|
|
...(flags.title ? { title: flags.title } : {}),
|
|
...(flags.description ? { description: flags.description } : {}),
|
|
...(flags['task-kind']
|
|
? { taskKind: flags['task-kind'] }
|
|
: {}),
|
|
...(flags.mode ? { mode: flags.mode } : {}),
|
|
...(flags.scenario ? { scenario: flags.scenario } : {}),
|
|
withClaudePlugin: Boolean(flags['with-claude-plugin']),
|
|
};
|
|
const result = await scaffoldPlugin(input);
|
|
if (flags.json) return process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
console.log(`[scaffold] ${result.folder}`);
|
|
for (const file of result.files) console.log(` ${file}`);
|
|
console.log(`\nNext: od plugin install ${result.folder}`);
|
|
} catch (err) {
|
|
if (err instanceof ScaffoldError) {
|
|
console.error(`[scaffold] ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Phase 4 / spec §11.5 / plan §3.W1 — `od plugin validate <folder>`.
|
|
//
|
|
// Pre-install lint pass against an author's working dir. Optionally
|
|
// fetches the daemon's registry view so skill / DS / atom refs in
|
|
// the manifest can be checked too; falls back to an empty registry
|
|
// when --no-daemon is set or the daemon is unreachable.
|
|
async function runPluginValidate(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['daemon-url']),
|
|
boolean: new Set(['help', 'h', 'json', 'no-daemon']),
|
|
});
|
|
if (flags.help || flags.h || rest.length === 0 || rest[0]?.startsWith('-')) {
|
|
console.log(`Usage:
|
|
od plugin validate <folder> [--json] [--no-daemon] [--daemon-url <url>]
|
|
|
|
Runs the plugin doctor against an unfinished plugin folder before
|
|
install. Validates manifest shape, atom ids, until expressions, and
|
|
context refs against the live daemon registry (skip with --no-daemon).
|
|
|
|
Exit codes:
|
|
0 doctor.ok = true
|
|
4 doctor.ok = false (errors present)
|
|
2 CLI usage error / folder unreadable`);
|
|
process.exit(rest.length === 0 ? 2 : 0);
|
|
}
|
|
const folder = rest[0];
|
|
|
|
// Try to load the daemon's registry view; the validator works
|
|
// offline too — emits warnings instead of errors for refs we
|
|
// can't resolve.
|
|
let registry;
|
|
if (!flags['no-daemon']) {
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
try {
|
|
const [skillsResp, dsResp, atomsResp] = await Promise.all([
|
|
fetch(`${base}/api/skills`).catch(() => null),
|
|
fetch(`${base}/api/design-systems`).catch(() => null),
|
|
fetch(`${base}/api/atoms`).catch(() => null),
|
|
]);
|
|
const skills = (skillsResp?.ok ? (await skillsResp.json())?.skills : []) ?? [];
|
|
const designSystems = (dsResp?.ok ? (await dsResp.json())?.designSystems : []) ?? [];
|
|
const atoms = (atomsResp?.ok ? (await atomsResp.json())?.atoms : []) ?? [];
|
|
registry = {
|
|
skills: skills.map((s) => ({ id: s.id, title: s.name ?? s.title, description: s.description })),
|
|
designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })),
|
|
craft: [],
|
|
atoms: atoms.map((a) => ({ id: a.id, label: a.label })),
|
|
};
|
|
} catch {
|
|
registry = undefined;
|
|
}
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
const { validatePluginFolder, flattenValidationDiagnostics } = await import('./plugins/validate.js');
|
|
result = await validatePluginFolder({ folder, ...(registry ? { registry } : {}) });
|
|
if (flags.json) {
|
|
const flat = flattenValidationDiagnostics(result);
|
|
process.stdout.write(JSON.stringify({
|
|
ok: result.ok,
|
|
folder: result.folder,
|
|
...(result.doctor ? { freshDigest: result.doctor.freshDigest, pluginId: result.doctor.pluginId } : {}),
|
|
diagnostics: flat,
|
|
}, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[validate] folder: ${result.folder}`);
|
|
if (result.doctor) {
|
|
console.log(`[validate] pluginId: ${result.doctor.pluginId}`);
|
|
console.log(`[validate] freshDigest: ${result.doctor.freshDigest.slice(0, 12)}\u2026`);
|
|
}
|
|
const diagnostics = (await import('./plugins/validate.js')).flattenValidationDiagnostics(result);
|
|
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
const warnings = diagnostics.filter((d) => d.severity === 'warning');
|
|
const infos = diagnostics.filter((d) => d.severity === 'info');
|
|
for (const d of errors) console.error(` [error] ${d.code}: ${d.message}`);
|
|
for (const d of warnings) console.warn (` [warning] ${d.code}: ${d.message}`);
|
|
for (const d of infos) console.log (` [info] ${d.code}: ${d.message}`);
|
|
if (errors.length === 0 && warnings.length === 0 && infos.length === 0) {
|
|
console.log('[validate] no issues');
|
|
}
|
|
console.log(`[validate] ok=${result.ok}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[validate] failed: ${err?.message ?? err}`);
|
|
process.exit(2);
|
|
}
|
|
process.exit(result.ok ? 0 : 4);
|
|
}
|
|
|
|
// Phase 4 / spec §14 / plan §3.X1 — `od plugin pack <folder>`.
|
|
//
|
|
// Produces a gzip-compressed tar archive ready to install via the
|
|
// installer's HTTPS-tarball path. The output path is folder-base +
|
|
// version when the manifest exposes a version, otherwise folder-base.
|
|
async function runPluginPack(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['out']),
|
|
boolean: new Set(['help', 'h', 'json']),
|
|
});
|
|
if (flags.help || flags.h || rest.length === 0 || rest[0]?.startsWith('-')) {
|
|
console.log(`Usage:
|
|
od plugin pack <folder> [--out <path>] [--json]
|
|
|
|
Builds a gzip-compressed tar archive of <folder> at --out (default
|
|
'<folder>/../<basename>-<manifest.version>.tgz'). The archive is the
|
|
exact shape \`od plugin install --source <https://...>\` consumes.
|
|
|
|
Skipped when packing:
|
|
node_modules / .git / .next / dist / build / out / coverage /
|
|
.turbo / .cache / .pnpm-store / .parcel-cache / .svelte-kit /
|
|
.nuxt / .astro / .vercel / .vscode / .DS_Store / Thumbs.db
|
|
(matches the installer's tarball-extract skiplist).
|
|
Symlinks are rejected at pack time (consistent with extract-time
|
|
rejection at install).
|
|
|
|
Exit codes:
|
|
0 archive written
|
|
2 CLI usage error
|
|
4 pack-time error (missing open-design.json, invalid JSON, etc)`);
|
|
process.exit(rest.length === 0 ? 2 : 0);
|
|
}
|
|
const folder = rest[0];
|
|
try {
|
|
const { packPlugin, PackPluginError } = await import('./plugins/pack.js');
|
|
let result;
|
|
try {
|
|
result = await packPlugin({
|
|
folder,
|
|
...(typeof flags.out === 'string' ? { out: flags.out } : {}),
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof PackPluginError) {
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ ok: false, error: err.message }, null, 2) + '\n');
|
|
} else {
|
|
console.error(`[pack] ${err.message}`);
|
|
}
|
|
process.exit(4);
|
|
}
|
|
throw err;
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({
|
|
ok: true,
|
|
outPath: result.outPath,
|
|
bytes: result.bytes,
|
|
fileCount: result.files.length,
|
|
pluginId: result.pluginId,
|
|
pluginVersion: result.pluginVersion,
|
|
}, null, 2) + '\n');
|
|
} else {
|
|
const idStr = result.pluginVersion
|
|
? `${result.pluginId ?? 'plugin'}@${result.pluginVersion}`
|
|
: result.pluginId ?? 'plugin';
|
|
console.log(`[pack] packed ${idStr}`);
|
|
console.log(`[pack] out: ${result.outPath}`);
|
|
console.log(`[pack] files: ${result.files.length}`);
|
|
console.log(`[pack] bytes: ${result.bytes}`);
|
|
console.log(`\nNext: od plugin install --source ${result.outPath}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[pack] failed: ${err?.message ?? err}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
async function runPluginLogin(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['host']),
|
|
boolean: new Set(['help', 'h']),
|
|
});
|
|
if (flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin login [--host github.com]
|
|
|
|
Wraps GitHub CLI auth for Open Design registry publishing. The token stays in gh.`);
|
|
return;
|
|
}
|
|
const host = typeof flags.host === 'string' ? flags.host : 'github.com';
|
|
const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 });
|
|
if (!version.ok) {
|
|
console.error('[plugin login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.');
|
|
process.exit(1);
|
|
}
|
|
const result = await spawnPassthrough('gh', ['auth', 'login', '--hostname', host, '--web']);
|
|
process.exit(result.code ?? 0);
|
|
}
|
|
|
|
async function runPluginWhoami(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['host']),
|
|
boolean: new Set(['help', 'h', 'json']),
|
|
});
|
|
if (flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin whoami [--host github.com] [--json]
|
|
|
|
Shows the GitHub account gh will use for Open Design registry publishing.`);
|
|
return;
|
|
}
|
|
const host = typeof flags.host === 'string' ? flags.host : 'github.com';
|
|
const auth = await execFileBuffered('gh', ['auth', 'status', '--hostname', host], { timeout: 10_000 });
|
|
if (!auth.ok) {
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({
|
|
ok: false,
|
|
host,
|
|
message: 'GitHub CLI is not authenticated for this host.',
|
|
log: auth.stderr || auth.stdout,
|
|
}, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.error(`[plugin whoami] gh is not authenticated for ${host}. Run: od plugin login --host ${host}`);
|
|
if (auth.stderr || auth.stdout) console.error(auth.stderr || auth.stdout);
|
|
process.exit(1);
|
|
}
|
|
const user = await execFileBuffered('gh', ['api', 'user', '--hostname', host], { timeout: 10_000 });
|
|
let login = '';
|
|
let name = '';
|
|
try {
|
|
const parsed = JSON.parse(user.stdout || '{}');
|
|
login = typeof parsed.login === 'string' ? parsed.login : '';
|
|
name = typeof parsed.name === 'string' ? parsed.name : '';
|
|
} catch {
|
|
// Keep the auth status useful even if gh api output is unavailable.
|
|
}
|
|
const payload = {
|
|
ok: true,
|
|
host,
|
|
login,
|
|
name,
|
|
auth: auth.stderr || auth.stdout,
|
|
};
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[plugin whoami] ${login || 'authenticated'}${name ? ` (${name})` : ''} @ ${host}`);
|
|
}
|
|
}
|
|
|
|
async function execFileBuffered(command, args, opts = {}) {
|
|
const { execFile } = await import('node:child_process');
|
|
return new Promise((resolve) => {
|
|
execFile(command, args, {
|
|
timeout: 30_000,
|
|
maxBuffer: 1024 * 1024,
|
|
...opts,
|
|
}, (error, stdout, stderr) => {
|
|
resolve({
|
|
ok: !error,
|
|
code: error?.code,
|
|
stdout: String(stdout ?? '').trim(),
|
|
stderr: String(stderr ?? '').trim(),
|
|
error,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function spawnPassthrough(command, args) {
|
|
const { spawn } = await import('node:child_process');
|
|
return await new Promise((resolve) => {
|
|
const child = spawn(command, args, { stdio: 'inherit' });
|
|
child.on('error', (error) => resolve({ code: 1, error }));
|
|
child.on('close', (code) => resolve({ code }));
|
|
});
|
|
}
|
|
|
|
function inferGithubHost(target) {
|
|
if (!target || target === 'github.com') return 'github.com';
|
|
try {
|
|
const parsed = new URL(target);
|
|
return parsed.hostname || 'github.com';
|
|
} catch {
|
|
// Marketplace ids are not URLs; v1 GitHub-backed auth defaults to github.com.
|
|
return 'github.com';
|
|
}
|
|
}
|
|
|
|
// Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`.
|
|
//
|
|
// Produces a publish-ready folder from the AppliedPluginSnapshot
|
|
// behind a given project (or directly from a snapshot id). Three
|
|
// targets: 'od', 'claude-plugin', 'agent-skill'.
|
|
async function runPluginExport(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['daemon-url', 'as', 'out', 'snapshot-id', 'project']),
|
|
boolean: new Set(['help', 'h', 'json']),
|
|
});
|
|
if (rest.length === 0 || flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin export <projectId> --as od|claude-plugin|agent-skill --out <dir>
|
|
od plugin export --snapshot-id <id> --as od|claude-plugin|agent-skill --out <dir>
|
|
|
|
The export resolves through the daemon HTTP \`POST /api/applied-plugins/export\`
|
|
endpoint so the running daemon's installed_plugins / applied_plugin_snapshots
|
|
view is the single source of truth.`);
|
|
process.exit(rest.length === 0 ? 2 : 0);
|
|
}
|
|
const positional = rest.find((a) => !a.startsWith('-'));
|
|
const projectId = flags.project ?? positional ?? null;
|
|
const snapshotId = typeof flags['snapshot-id'] === 'string' ? flags['snapshot-id'] : null;
|
|
if (!projectId && !snapshotId) {
|
|
console.error('Usage: od plugin export <projectId> --as <target> --out <dir>');
|
|
process.exit(2);
|
|
}
|
|
const target = String(flags.as ?? 'od');
|
|
if (target !== 'od' && target !== 'claude-plugin' && target !== 'agent-skill') {
|
|
console.error(`--as must be one of: od, claude-plugin, agent-skill (got "${target}")`);
|
|
process.exit(2);
|
|
}
|
|
const out = typeof flags.out === 'string' && flags.out.length > 0
|
|
? flags.out
|
|
: process.cwd();
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const resp = await fetch(`${base}/api/applied-plugins/export`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...(snapshotId ? { snapshotId } : { projectId }),
|
|
target,
|
|
outDir: out,
|
|
}),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`POST /api/applied-plugins/export failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
console.log(`[export] ${data.folder} (snapshot ${data.snapshotId})`);
|
|
for (const f of data.files ?? []) console.log(` ${f}`);
|
|
}
|
|
|
|
// Plan §3.B4 / spec §6: `od marketplace …` minimum verbs. Add / list /
|
|
// refresh / remove / trust. The Phase 3 follow-up wires
|
|
// `od plugin install <name>` resolution through these catalogs.
|
|
async function runMarketplace(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od marketplace add <url> [--trust trusted|restricted] Register a federated catalog.
|
|
od marketplace list List registered marketplaces.
|
|
od marketplace info <id> Inspect one marketplace + cached manifest.
|
|
od marketplace plugins <id> [--json] List cached plugin entries for one marketplace.
|
|
od marketplace search <query> [--json] Search cached marketplace entries.
|
|
od marketplace doctor [id] [--strict] [--json] Validate cached marketplace entries.
|
|
od marketplace login <id|url> [--host github.com] Authenticate gh for private GitHub catalogs.
|
|
od marketplace refresh <id> Re-fetch the manifest.
|
|
od marketplace remove <id> Forget a marketplace.
|
|
od marketplace trust <id> [--trust trusted|restricted|official]
|
|
Update the marketplace trust tier.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456).
|
|
--json Emit raw JSON (suitable for scripts).`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
switch (sub) {
|
|
case 'list': {
|
|
const resp = await fetch(`${base}/api/marketplaces`);
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
const rows = data?.marketplaces ?? [];
|
|
if (rows.length === 0) {
|
|
console.log('No marketplaces registered. Run `od marketplace add <url>`.');
|
|
return;
|
|
}
|
|
for (const m of rows) {
|
|
console.log(`${m.id} version=${m.version ?? 'unknown'} spec=${m.specVersion ?? 'unknown'} trust=${m.trust} url=${m.url}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'search': {
|
|
// Plan §3.H4 / spec §12 — marketplace catalog query. Walks
|
|
// every configured marketplace's plugins[] entry and matches
|
|
// by substring on name + description + tags.
|
|
const query = (rest.find((a) => !a.startsWith('-')) ?? '').toLowerCase();
|
|
if (!query) {
|
|
console.error('Usage: od marketplace search "<query>" [--tag <tag>]');
|
|
process.exit(2);
|
|
}
|
|
const tag = typeof flags.tag === 'string' ? flags.tag.toLowerCase() : null;
|
|
const resp = await fetch(`${base}/api/marketplaces`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
const matches = [];
|
|
for (const mp of data?.marketplaces ?? []) {
|
|
const plugins = mp.manifest?.plugins ?? [];
|
|
for (const p of plugins) {
|
|
const haystack = [
|
|
p.name ?? '',
|
|
p.description ?? '',
|
|
...(Array.isArray(p.tags) ? p.tags : []),
|
|
].join(' ').toLowerCase();
|
|
if (!haystack.includes(query)) continue;
|
|
if (tag && !(Array.isArray(p.tags) && p.tags.map((t) => t.toLowerCase()).includes(tag))) continue;
|
|
matches.push({
|
|
marketplaceId: mp.id,
|
|
marketplaceUrl: mp.url,
|
|
marketplaceVersion: mp.version,
|
|
name: p.name,
|
|
version: p.version,
|
|
source: p.source,
|
|
description: p.description ?? '',
|
|
tags: p.tags ?? [],
|
|
});
|
|
}
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ matches }, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (matches.length === 0) {
|
|
console.log(`No matches for "${query}"`);
|
|
return;
|
|
}
|
|
for (const m of matches) {
|
|
console.log(`${m.name}@${m.version}\t${m.source}\t${m.marketplaceId}@${m.marketplaceVersion}\t${m.description}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'plugins': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od marketplace plugins <id> [--json]');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}/plugins`);
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`plugins failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ marketplaceId: id, plugins }, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (plugins.length === 0) {
|
|
console.log(`No plugins in marketplace ${id}.`);
|
|
return;
|
|
}
|
|
for (const p of plugins) {
|
|
console.log(`${p.name}@${p.version}\t${p.source}\t${p.description ?? ''}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'doctor': {
|
|
const strict = flags.strict === true;
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
const resp = id
|
|
? await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}`)
|
|
: await fetch(`${base}/api/marketplaces`);
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`doctor failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
const rows = id ? [data] : (data?.marketplaces ?? []);
|
|
const { doctorMarketplace } = await import('./plugins/marketplace-doctor.js');
|
|
const reports = [];
|
|
for (const row of rows) {
|
|
reports.push(await doctorMarketplace({
|
|
id: row.id,
|
|
trust: row.trust,
|
|
manifest: row.manifest,
|
|
strict,
|
|
}));
|
|
}
|
|
const ok = reports.every((report) => report.ok);
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ ok, reports }, null, 2) + '\n');
|
|
} else {
|
|
for (const report of reports) {
|
|
console.log(`[marketplace doctor] ${report.backendId}: ${report.ok ? 'ok' : 'issues'} (${report.entriesChecked} entries)`);
|
|
for (const issue of report.issues) {
|
|
console.log(` [${issue.severity}] ${issue.code}${issue.pluginName ? ` ${issue.pluginName}` : ''}: ${issue.message}`);
|
|
}
|
|
}
|
|
}
|
|
process.exit(ok ? 0 : 1);
|
|
}
|
|
case 'login': {
|
|
const target = rest.find((a) => !a.startsWith('-'));
|
|
const host = typeof flags.host === 'string'
|
|
? flags.host
|
|
: inferGithubHost(target ?? 'github.com');
|
|
const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 });
|
|
if (!version.ok) {
|
|
console.error('[marketplace login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.');
|
|
process.exit(1);
|
|
}
|
|
console.log(`[marketplace login] authenticating gh for ${host}. Tokens stay in gh, not Open Design.`);
|
|
const result = await spawnPassthrough('gh', ['auth', 'login', '--hostname', host, '--web']);
|
|
process.exit(result.code ?? 0);
|
|
}
|
|
case 'add': {
|
|
const url = rest.find((a) => !a.startsWith('-'));
|
|
if (!url) {
|
|
console.error('Usage: od marketplace add <url> [--trust trusted|restricted]');
|
|
process.exit(2);
|
|
}
|
|
const trust = flags.trust ?? 'restricted';
|
|
const resp = await fetch(`${base}/api/marketplaces`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ url, trust }),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`add failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`[marketplace] added ${data.id} (${data.url}) trust=${data.trust}`);
|
|
return;
|
|
}
|
|
case 'info':
|
|
case 'refresh':
|
|
case 'remove':
|
|
case 'trust': {
|
|
const id = rest.find((a) => !a.startsWith('-')
|
|
&& a !== flags.trust);
|
|
if (!id) {
|
|
console.error(`Usage: od marketplace ${sub} <id>`);
|
|
process.exit(2);
|
|
}
|
|
let url;
|
|
let method = 'GET';
|
|
let body;
|
|
if (sub === 'info') url = `${base}/api/marketplaces/${encodeURIComponent(id)}`;
|
|
else if (sub === 'refresh') { url = `${base}/api/marketplaces/${encodeURIComponent(id)}/refresh`; method = 'POST'; }
|
|
else if (sub === 'remove') { url = `${base}/api/marketplaces/${encodeURIComponent(id)}`; method = 'DELETE'; }
|
|
else if (sub === 'trust') {
|
|
const trust = flags.trust ?? 'trusted';
|
|
url = `${base}/api/marketplaces/${encodeURIComponent(id)}/trust`;
|
|
method = 'POST';
|
|
body = JSON.stringify({ trust });
|
|
}
|
|
const resp = await fetch(url, {
|
|
method,
|
|
...(body ? { headers: { 'content-type': 'application/json' }, body } : {}),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`${sub} failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od marketplace ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Plan §3.A5 / spec §16 Phase 5: operator escape hatch for snapshot GC.
|
|
// Two subcommands:
|
|
// - `od plugin snapshots list [--project <id>]` — list snapshots
|
|
// - `od plugin snapshots prune [--before <ts>]` — force-delete expired
|
|
// (and optionally older-than-cutoff unreferenced) rows.
|
|
async function runPluginSnapshots(args) {
|
|
const sub = args[0];
|
|
if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od plugin snapshots list [--project <id>] List applied plugin snapshots.
|
|
od plugin snapshots show <snapshotId> [--json] Print one snapshot's full contents.
|
|
od plugin snapshots diff <id-a> <id-b> [--json] Compare two snapshots field-by-field.
|
|
od plugin snapshots prune [--before <unix-ms>] Delete expired (or older-than-cutoff) snapshots.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const flags = parseFlags(args.slice(1), { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
if (sub === 'show') {
|
|
const positional = args.slice(1).filter((a) => !a.startsWith('-'));
|
|
const id = positional[0];
|
|
if (!id) {
|
|
console.error('Usage: od plugin snapshots show <snapshotId>');
|
|
process.exit(2);
|
|
}
|
|
const url = `${base}/api/applied-plugins/${encodeURIComponent(id)}`;
|
|
const resp = await fetch(url);
|
|
if (resp.status === 404) {
|
|
console.error(`snapshot ${id} not found`);
|
|
process.exit(72);
|
|
}
|
|
if (!resp.ok) {
|
|
console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (sub === 'diff') {
|
|
const positional = args.slice(1).filter((a) => !a.startsWith('-'));
|
|
if (positional.length < 2) {
|
|
console.error('Usage: od plugin snapshots diff <id-a> <id-b>');
|
|
process.exit(2);
|
|
}
|
|
const [idA, idB] = positional;
|
|
const [respA, respB] = await Promise.all([
|
|
fetch(`${base}/api/applied-plugins/${encodeURIComponent(idA)}`),
|
|
fetch(`${base}/api/applied-plugins/${encodeURIComponent(idB)}`),
|
|
]);
|
|
if (respA.status === 404) { console.error(`snapshot ${idA} not found`); process.exit(72); }
|
|
if (respB.status === 404) { console.error(`snapshot ${idB} not found`); process.exit(72); }
|
|
if (!respA.ok || !respB.ok) {
|
|
console.error(`fetch failed: ${respA.status} / ${respB.status}`);
|
|
process.exit(1);
|
|
}
|
|
const a = await respA.json();
|
|
const b = await respB.json();
|
|
const { diffSnapshots } = await import('./plugins/snapshot-diff.js');
|
|
const report = diffSnapshots({ a, b });
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
return;
|
|
}
|
|
const digestNote = report.digestEqual
|
|
? '\u2713 manifestSourceDigest equal (e2e-2 invariant holds)'
|
|
: '\u2717 manifestSourceDigest DIFFERS (replay would diverge)';
|
|
console.log(`[snapshots diff] ${idA} \u2194 ${idB}`);
|
|
console.log(` ${digestNote}`);
|
|
console.log(` ${report.added} added, ${report.removed} removed, ${report.changed} changed`);
|
|
if (report.entries.length === 0) {
|
|
console.log(' (no field-level differences)');
|
|
return;
|
|
}
|
|
for (const e of report.entries) {
|
|
const tag = e.kind === 'added' ? '+' : e.kind === 'removed' ? '-' : '~';
|
|
if (e.summary) {
|
|
console.log(` ${tag} ${e.field} (${e.summary})`);
|
|
} else if (e.kind === 'changed') {
|
|
console.log(` ${tag} ${e.field}: ${e.before ?? ''} \u2192 ${e.after ?? ''}`);
|
|
} else if (e.kind === 'added') {
|
|
console.log(` ${tag} ${e.field}: ${e.after ?? ''}`);
|
|
} else {
|
|
console.log(` ${tag} ${e.field}: ${e.before ?? ''}`);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (sub === 'list') {
|
|
const url = flags.project
|
|
? `${base}/api/projects/${encodeURIComponent(flags.project)}/applied-plugins`
|
|
: `${base}/api/applied-plugins`;
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) {
|
|
console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (sub === 'prune') {
|
|
const url = `${base}/api/applied-plugins/prune`;
|
|
const before = flags.before ? Number(flags.before) : undefined;
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(before ? { before } : {}),
|
|
});
|
|
if (!resp.ok) {
|
|
console.error(`POST ${url} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.log(`[snapshots] pruned ${data.removed ?? 0} snapshot(s)`);
|
|
return;
|
|
}
|
|
console.error(`unknown subcommand: od plugin snapshots ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
// Plan §3.B3: `od plugin run <id>` shorthand. Today this is a thin
|
|
// wrapper around `od plugin apply` + `POST /api/runs` so a code agent
|
|
// can drive the apply→start→follow loop without two hops.
|
|
async function runPluginRun(rest) {
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const id = rest.find((a) => !a.startsWith('-')
|
|
&& a !== flags['daemon-url']
|
|
&& a !== flags.source
|
|
&& a !== flags.inputs
|
|
&& a !== flags.project
|
|
&& a !== flags.conversation
|
|
&& a !== flags.message
|
|
&& a !== flags.agent
|
|
&& a !== flags.model
|
|
&& a !== flags['snapshot-id']
|
|
&& a !== flags.capabilities
|
|
&& a !== flags['grant-caps']);
|
|
if (!id) {
|
|
console.error('Usage: od plugin run <id> --project <projectId> [--inputs <json>] [--agent <id>] [--message "<text>"] [--grant-caps a,b] [--follow]');
|
|
process.exit(2);
|
|
}
|
|
if (!flags.project) {
|
|
console.error('--project <projectId> is required (Phase 1.5 will add the auto-create wrapper)');
|
|
process.exit(2);
|
|
}
|
|
const inputs = flags.inputs ? safeParseJson(flags.inputs) ?? {} : {};
|
|
const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0
|
|
? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean)
|
|
: [];
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
// 1. Apply (returns ApplyResult + manifestSourceDigest).
|
|
const applyResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/apply`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ inputs, grantCaps, projectId: flags.project }),
|
|
});
|
|
const applyData = await applyResp.json().catch(() => ({}));
|
|
if (!applyResp.ok) {
|
|
console.error(`apply failed: ${applyResp.status} ${JSON.stringify(applyData)}`);
|
|
process.exit(applyResp.status === 422 ? 67 : 1);
|
|
}
|
|
// 2. Start the run with pluginId so the daemon resolver pins the
|
|
// snapshot to the run object.
|
|
const runResp = await fetch(`${base}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
projectId: flags.project,
|
|
pluginId: id,
|
|
pluginInputs: inputs,
|
|
grantCaps,
|
|
...(flags.conversation ? { conversationId: flags.conversation } : {}),
|
|
...(flags.message ? { message: flags.message } : {}),
|
|
...(flags.agent ? { agentId: flags.agent } : {}),
|
|
...(flags.model ? { model: flags.model } : {}),
|
|
...(flags['snapshot-id'] ? { appliedPluginSnapshotId: flags['snapshot-id'] } : {}),
|
|
}),
|
|
});
|
|
const runData = await runResp.json().catch(() => ({}));
|
|
if (!runResp.ok) {
|
|
if (runResp.status === 409 && runData?.error?.code === 'capabilities-required') {
|
|
const missing = (runData.error.data?.missing ?? []).join(',');
|
|
console.error(`[run] capabilities required: ${missing}`);
|
|
console.error(`[run] retry with --grant-caps ${missing} or run \`od plugin trust ${id} --capabilities ${missing}\``);
|
|
process.exit(66);
|
|
}
|
|
console.error(`run failed: ${runResp.status} ${JSON.stringify(runData)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ apply: applyData, run: runData }, null, 2) + '\n');
|
|
if (flags.follow) await streamRunEvents(base, runData.runId);
|
|
return;
|
|
}
|
|
console.log(`[run] started run ${runData.runId} (snapshot ${runData.appliedPluginSnapshotId ?? applyData?.appliedPlugin?.snapshotId ?? 'n/a'})`);
|
|
if (flags.follow) {
|
|
await streamRunEvents(base, runData.runId);
|
|
}
|
|
}
|
|
|
|
async function pluginDaemonUrl(flags) {
|
|
return cliDaemonUrl(flags);
|
|
}
|
|
|
|
// Plan §3.Y1 — filter knobs on `od plugin list` (and feeds
|
|
// `od plugin search` below). Recognising these as string flags
|
|
// keeps the parseFlags() argv consumer happy.
|
|
async function runPluginList(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: PLUGIN_LIST_FILTER_FLAGS,
|
|
boolean: PLUGIN_LIST_BOOLEAN_FLAGS,
|
|
});
|
|
if (flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin list [--task-kind <kind>] [--mode <mode>] [--tag <tag>] \\
|
|
[--trust <tier>] [--bundled | --no-bundled] [--json]
|
|
|
|
Lists installed plugins. Filters AND together: --task-kind=code-migration
|
|
+ --tag=phase-7 returns only code-migration plugins tagged 'phase-7'.
|
|
|
|
--task-kind Match od.taskKind (new-generation / figma-migration /
|
|
code-migration / tune-collab).
|
|
--mode Match od.mode.
|
|
--tag Match an entry in tags[].
|
|
--trust Match trust tier (trusted / restricted / bundled).
|
|
--bundled Restrict to bundled plugins (sourceKind='bundled' OR
|
|
trust='bundled').
|
|
--no-bundled Exclude bundled plugins.`);
|
|
process.exit(0);
|
|
}
|
|
const data = await fetchPluginList(flags);
|
|
const filtered = await applyPluginFilters(data?.plugins ?? [], flags);
|
|
emitPluginList({ entries: filtered, json: !!flags.json, emptyMessage: 'No plugins matched the filter.' });
|
|
}
|
|
|
|
// Plan §3.Y1 — `od plugin search <query>`.
|
|
async function runPluginSearch(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: PLUGIN_LIST_FILTER_FLAGS,
|
|
boolean: PLUGIN_LIST_BOOLEAN_FLAGS,
|
|
});
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const query = positional[0];
|
|
if (flags.help || flags.h || !query) {
|
|
console.log(`Usage:
|
|
od plugin search <query> [--task-kind <kind>] [--mode <mode>] \\
|
|
[--tag <tag>] [--trust <tier>] \\
|
|
[--bundled | --no-bundled] [--json]
|
|
|
|
Free-text search across installed plugins. Matches case-insensitively
|
|
on id / title / description / tags. Combines with the same filter
|
|
flags as 'od plugin list'.`);
|
|
process.exit(query ? 0 : 2);
|
|
}
|
|
const data = await fetchPluginList(flags);
|
|
const filtered = await applyPluginFilters(data?.plugins ?? [], flags, query);
|
|
emitPluginList({
|
|
entries: filtered,
|
|
json: !!flags.json,
|
|
emptyMessage: `No installed plugins matched "${query}".`,
|
|
showRank: true,
|
|
});
|
|
}
|
|
|
|
// Plan §3.DD1 — `od plugin stats`. Pretty-prints the
|
|
// pluginInventoryStats + snapshotInventoryStats aggregation. The
|
|
// daemon-side route owns the SQLite reads; the CLI is a thin
|
|
// formatter.
|
|
async function runPluginStats(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: PLUGIN_STRING_FLAGS,
|
|
boolean: PLUGIN_BOOLEAN_FLAGS,
|
|
});
|
|
if (flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin stats [--json]
|
|
|
|
Prints an at-a-glance plugin + snapshot inventory:
|
|
- Plugin counts by sourceKind, trust, taskKind.
|
|
- Bundled vs. third-party split.
|
|
- Plugins with elevated capabilities (fs:write, subprocess,
|
|
bash, network, connector:*).
|
|
- Snapshot total, status breakdown, project / run linkage.
|
|
- Oldest / newest applied snapshot timestamps.`);
|
|
process.exit(0);
|
|
}
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const url = `${base}/api/plugins/stats`;
|
|
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 p = data?.plugins ?? {};
|
|
const s = data?.snapshots ?? {};
|
|
const lastInstalled = formatTimestamp(p.lastInstalledAt);
|
|
const lastUpdated = formatTimestamp(p.lastUpdatedAt);
|
|
const oldestApplied = formatTimestamp(s.oldestAppliedAt);
|
|
const newestApplied = formatTimestamp(s.newestAppliedAt);
|
|
console.log('# Plugins');
|
|
console.log(` total: ${p.total ?? 0}`);
|
|
console.log(` bundled: ${p.bundled ?? 0}`);
|
|
console.log(` third-party: ${p.thirdParty ?? 0}`);
|
|
console.log(` with elevated: ${p.withElevatedCapabilities ?? 0}`);
|
|
console.log(` by sourceKind: ${formatCounts(p.bySourceKind)}`);
|
|
console.log(` by trust: ${formatCounts(p.byTrust)}`);
|
|
console.log(` by taskKind: ${formatCounts(p.byTaskKind)}`);
|
|
console.log(` last installed: ${lastInstalled}`);
|
|
console.log(` last updated: ${lastUpdated}`);
|
|
console.log('');
|
|
console.log('# Snapshots');
|
|
console.log(` total: ${s.total ?? 0}`);
|
|
console.log(` by status: ${formatCounts(s.byStatus)}`);
|
|
console.log(` with project: ${s.withProject ?? 0}`);
|
|
console.log(` with run: ${s.withRun ?? 0}`);
|
|
console.log(` oldest applied: ${oldestApplied}`);
|
|
console.log(` newest applied: ${newestApplied}`);
|
|
}
|
|
|
|
function formatCounts(counts) {
|
|
if (!counts || typeof counts !== 'object') return '(none)';
|
|
const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
|
if (entries.length === 0) return '(none)';
|
|
return entries.map(([k, v]) => `${k}=${v}`).join(', ');
|
|
}
|
|
|
|
function formatTimestamp(ts) {
|
|
if (typeof ts !== 'number' || !Number.isFinite(ts)) return '(none)';
|
|
try { return new Date(ts).toISOString(); } catch { return String(ts); }
|
|
}
|
|
|
|
async function fetchPluginList(flags) {
|
|
const url = `${(await 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();
|
|
return data;
|
|
}
|
|
|
|
async function applyPluginFilters(plugins, flags, query) {
|
|
if (!Array.isArray(plugins) || plugins.length === 0) return [];
|
|
const { searchInstalledPlugins } = await import('./plugins/search.js');
|
|
const trustFlag = typeof flags.trust === 'string' ? flags.trust : undefined;
|
|
const taskKind = typeof flags['task-kind'] === 'string' ? flags['task-kind'] : undefined;
|
|
const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
|
|
const tag = typeof flags.tag === 'string' ? flags.tag : undefined;
|
|
let bundled;
|
|
if (flags.bundled === true) bundled = true;
|
|
if (flags['no-bundled'] === true) bundled = false;
|
|
const result = searchInstalledPlugins({
|
|
plugins,
|
|
...(typeof query === 'string' && query.trim() ? { query } : {}),
|
|
...(taskKind ? { taskKind } : {}),
|
|
...(mode ? { mode } : {}),
|
|
...(tag ? { tag } : {}),
|
|
...(trustFlag === 'trusted' || trustFlag === 'restricted' || trustFlag === 'bundled' ? { trust: trustFlag } : {}),
|
|
...(typeof bundled === 'boolean' ? { bundled } : {}),
|
|
});
|
|
return result.entries;
|
|
}
|
|
|
|
function emitPluginList({ entries, json, emptyMessage, showRank }) {
|
|
if (json) {
|
|
process.stdout.write(JSON.stringify({
|
|
total: entries.length,
|
|
plugins: entries.map((e) => ({
|
|
...e.plugin,
|
|
...(showRank ? { matched: e.matched, rank: e.rank } : {}),
|
|
})),
|
|
}, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (entries.length === 0) {
|
|
console.log(emptyMessage ?? 'No plugins matched.');
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const p = entry.plugin;
|
|
const tail = showRank && entry.matched.length > 0
|
|
? ` matched=[${entry.matched.join(',')}]`
|
|
: '';
|
|
console.log(`${p.id}@${p.version} trust=${p.trust} source=${p.sourceKind} title="${p.title}"${tail}`);
|
|
}
|
|
}
|
|
|
|
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
|
|
&& a !== flags.version);
|
|
if (!id) {
|
|
console.error('Usage: od plugin info <id-or-marketplace-name> [--version <version|tag|range>] [--json]');
|
|
process.exit(2);
|
|
}
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const url = `${base}/api/plugins/${encodeURIComponent(id)}`;
|
|
const resp = await fetch(url);
|
|
if (resp.ok && !flags.version) {
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
const mpResp = await fetch(`${base}/api/marketplaces`);
|
|
if (mpResp.ok) {
|
|
const mpData = await mpResp.json().catch(() => ({}));
|
|
const resolved = resolveMarketplacePluginFromList(
|
|
mpData?.marketplaces ?? [],
|
|
flags.version ? `${id}@${flags.version}` : id,
|
|
);
|
|
if (resolved) {
|
|
process.stdout.write(JSON.stringify({ marketplace: resolved }, null, 2) + '\n');
|
|
return;
|
|
}
|
|
}
|
|
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');
|
|
}
|
|
|
|
function resolveMarketplacePluginFromList(marketplaces, specifier) {
|
|
const parsed = parseCliPluginSpecifier(specifier);
|
|
const target = parsed.name.toLowerCase();
|
|
for (const marketplace of marketplaces) {
|
|
for (const entry of marketplace?.manifest?.plugins ?? []) {
|
|
if (String(entry.name ?? '').toLowerCase() !== target) continue;
|
|
const version = resolveCliEntryVersion(entry, parsed.range);
|
|
if (!version) return null;
|
|
return {
|
|
marketplaceId: marketplace.id,
|
|
marketplaceTrust: marketplace.trust,
|
|
name: entry.name,
|
|
version: version.version,
|
|
source: version.source,
|
|
ref: version.ref,
|
|
integrity: version.integrity,
|
|
manifestDigest: version.manifestDigest,
|
|
entry,
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseCliPluginSpecifier(input) {
|
|
const trimmed = String(input ?? '').trim();
|
|
const slash = trimmed.indexOf('/');
|
|
const at = trimmed.lastIndexOf('@');
|
|
if (slash > 0 && at > slash + 1) {
|
|
return { name: trimmed.slice(0, at), range: trimmed.slice(at + 1) };
|
|
}
|
|
return { name: trimmed, range: undefined };
|
|
}
|
|
|
|
function resolveCliEntryVersion(entry, range) {
|
|
if (entry?.yanked) return null;
|
|
const versions = Array.isArray(entry?.versions) ? entry.versions : [];
|
|
const target = range && range !== 'latest'
|
|
? (entry?.distTags?.[range] ?? range)
|
|
: (entry?.distTags?.latest ?? entry?.version);
|
|
const version = versions.find((item) => item.version === target) ?? null;
|
|
if (version?.yanked) return null;
|
|
return {
|
|
version: target,
|
|
source: version?.source ?? entry?.source,
|
|
ref: version?.ref ?? entry?.ref,
|
|
integrity: version?.integrity ?? version?.dist?.integrity ?? entry?.integrity ?? entry?.dist?.integrity,
|
|
manifestDigest: version?.manifestDigest ?? version?.dist?.manifestDigest ?? entry?.manifestDigest ?? entry?.dist?.manifestDigest,
|
|
};
|
|
}
|
|
|
|
// Plan §3.MM1 — `od plugin manifest <id>`. Prints just the parsed
|
|
// manifest JSON, no wrapper. Useful for plugin authors who want to
|
|
// compare the daemon's view to their on-disk open-design.json
|
|
// without scrolling past the registry record fields (sourceKind /
|
|
// fsPath / installedAt etc).
|
|
async function runPluginManifest(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 manifest <id>');
|
|
process.exit(2);
|
|
}
|
|
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
|
|
const resp = await fetch(url);
|
|
if (resp.status === 404) {
|
|
console.error(`plugin ${id} not found`);
|
|
process.exit(65);
|
|
}
|
|
if (!resp.ok) {
|
|
console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
if (!data?.manifest) {
|
|
console.error(`plugin ${id} has no recorded manifest (registry row is incomplete)`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(JSON.stringify(data.manifest, null, 2) + '\n');
|
|
}
|
|
|
|
// Plan §3.MM2 — `od plugin sources`. Lists every distinct install
|
|
// source string + count of plugins installed from it, ordered by
|
|
// count descending then source ascending. Useful for ops audits
|
|
// ('which github repos do my plugins come from') + for plugin
|
|
// authors comparing their fork to its upstream installs.
|
|
async function runPluginSources(rest) {
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const url = `${(await 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();
|
|
const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
|
|
const buckets = new Map();
|
|
for (const p of plugins) {
|
|
const key = `${p.sourceKind ?? 'unknown'}\t${p.source ?? '(none)'}`;
|
|
const entry = buckets.get(key) ?? { sourceKind: p.sourceKind ?? 'unknown', source: p.source ?? '(none)', count: 0, plugins: [] };
|
|
entry.count += 1;
|
|
entry.plugins.push({ id: p.id, version: p.version });
|
|
buckets.set(key, entry);
|
|
}
|
|
const rows = [...buckets.values()].sort((a, b) => {
|
|
if (a.count !== b.count) return b.count - a.count;
|
|
if (a.sourceKind !== b.sourceKind) return a.sourceKind.localeCompare(b.sourceKind);
|
|
return a.source.localeCompare(b.source);
|
|
});
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ total: plugins.length, sources: rows }, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (rows.length === 0) {
|
|
console.log('No plugins installed.');
|
|
return;
|
|
}
|
|
console.log(`# Plugin install sources (total: ${plugins.length})`);
|
|
for (const row of rows) {
|
|
console.log(` ${row.sourceKind.padEnd(11)} ${String(row.count).padStart(3)} ${row.source}`);
|
|
for (const plug of row.plugins) {
|
|
console.log(` \u2514\u2500 ${plug.id}@${plug.version}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runPluginInstall(rest) {
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const source = typeof flags.source === 'string' ? flags.source : rest.find((a) => !a.startsWith('-'));
|
|
if (!source) {
|
|
console.error('Usage: od plugin install <source-or-name>\n' +
|
|
' od plugin install ./local-folder\n' +
|
|
' od plugin install github:owner/repo[@ref][/subpath]\n' +
|
|
' od plugin install https://example.com/plugin.tar.gz\n' +
|
|
' od plugin install <name>[@version|tag|range] # resolves through configured marketplaces');
|
|
process.exit(2);
|
|
}
|
|
const url = `${(await 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;
|
|
const events = [];
|
|
let finalEvent = null;
|
|
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;
|
|
events.push({ event, data });
|
|
if (event === 'progress') {
|
|
if (!flags.json) console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
|
|
} else if (event === 'success') {
|
|
finalEvent = data;
|
|
if (!flags.json) console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
|
|
if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) {
|
|
for (const w of data.warnings) console.log(`[install] warn: ${w}`);
|
|
}
|
|
} else if (event === 'error') {
|
|
finalEvent = data;
|
|
if (!flags.json) console.error(`[install] error: ${data?.message ?? 'unknown'}`);
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({
|
|
ok: exitCode === 0,
|
|
result: finalEvent,
|
|
events,
|
|
}, null, 2) + '\n');
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
// Plan §3.Z2 — `od plugin upgrade <id>`. Re-installs the plugin
|
|
// from its recorded source. Streams the same SSE event shape as
|
|
// install, so 'progress' / 'success' / 'error' arrive verbatim.
|
|
// Plan §3.II1 — `od plugin events tail`. Tails the daemon's
|
|
// in-memory plugin event ring buffer via SSE. -f keeps the
|
|
// connection open and prints live events; otherwise prints the
|
|
// backlog and exits when the daemon closes the stream.
|
|
async function runPluginEvents(rest) {
|
|
const sub = rest[0];
|
|
if (!sub || sub === 'help' || rest.includes('--help') || rest.includes('-h')) {
|
|
console.log(`Usage:
|
|
od plugin events tail [-f] [--since <id>] [--kind <k>] [--plugin-id <id>] [--json]
|
|
od plugin events snapshot [--since <id>] [--kind <k>] [--plugin-id <id>] [--json]
|
|
od plugin events stats [--json]
|
|
od plugin events purge [--confirm] [--json] (loopback-only)
|
|
|
|
Tail / snapshot / stats / purge over the daemon's in-memory
|
|
plugin event ring buffer (capped at 1000 entries; resets on
|
|
daemon restart).
|
|
Lifecycle vocabulary:
|
|
plugin.installed | plugin.upgraded | plugin.uninstalled
|
|
plugin.trust-changed | plugin.snapshot-pruned
|
|
plugin.marketplace-refreshed | plugin.applied
|
|
|
|
--since <id> Trim backlog to events strictly after id.
|
|
--kind <k> Filter to a single kind.
|
|
--plugin-id <id> Filter to events touching one plugin id.
|
|
-f / --follow tail-only: keep the SSE stream open.
|
|
--json Emit raw JSON (one event per line on tail,
|
|
full report on snapshot/stats).`);
|
|
process.exit(sub ? 0 : 2);
|
|
}
|
|
const flags = parseFlags(rest.slice(1), {
|
|
string: new Set([...PLUGIN_STRING_FLAGS, 'since', 'kind', 'plugin-id']),
|
|
boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'f', 'follow']),
|
|
});
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const since = typeof flags.since === 'string' ? Number(flags.since) : 0;
|
|
const kindFilter = typeof flags.kind === 'string' && flags.kind.length > 0 ? flags.kind : null;
|
|
const pluginIdFilter = typeof flags['plugin-id'] === 'string' && flags['plugin-id'].length > 0
|
|
? flags['plugin-id']
|
|
: null;
|
|
const matches = (ev) => {
|
|
if (!ev) return false;
|
|
if (kindFilter && ev.kind !== kindFilter) return false;
|
|
if (pluginIdFilter && ev.pluginId !== pluginIdFilter) return false;
|
|
return true;
|
|
};
|
|
|
|
if (sub === 'snapshot') {
|
|
const url = `${base}/api/plugins/events/snapshot${Number.isFinite(since) && since > 0 ? `?since=${since}` : ''}`;
|
|
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();
|
|
const events = (Array.isArray(data?.events) ? data.events : []).filter(matches);
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ events, count: events.length, generatedAt: data?.generatedAt }, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (events.length === 0) {
|
|
console.log('[events snapshot] no events match filter');
|
|
return;
|
|
}
|
|
for (const ev of events) {
|
|
const ts = ev.at ? new Date(ev.at).toISOString() : '?';
|
|
const detailKeys = ev.details ? Object.keys(ev.details).slice(0, 3).join(',') : '';
|
|
console.log(`#${ev.id} ${ts} ${ev.kind} pluginId=${ev.pluginId || '-'}` +
|
|
(detailKeys ? ` details=${detailKeys}` : ''));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (sub === 'purge') {
|
|
// Refuse to run without an explicit --confirm so 'od plugin
|
|
// events purge' alone never drops audit data accidentally.
|
|
const purgeFlags = parseFlags(rest.slice(1), {
|
|
string: new Set(['daemon-url']),
|
|
boolean: new Set(['help', 'h', 'json', 'confirm']),
|
|
});
|
|
if (!purgeFlags.confirm) {
|
|
console.error('[events purge] refusing without --confirm. This drops every event in the in-memory buffer.');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/plugins/events/purge`, { method: 'POST' });
|
|
if (!resp.ok) {
|
|
console.error(`POST /api/plugins/events/purge failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
if (purgeFlags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[events purge] dropped ${data.purged ?? 0} event${(data.purged ?? 0) === 1 ? '' : 's'} (id range: ${data.firstId ?? '(none)'} \u2192 ${data.lastId ?? '(none)'}; preNextId=${data.preNextId})`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (sub === 'stats') {
|
|
const resp = await fetch(`${base}/api/plugins/events/stats`);
|
|
if (!resp.ok) {
|
|
console.error(`GET /api/plugins/events/stats 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 s = data?.stats ?? {};
|
|
console.log('# Plugin events');
|
|
console.log(` total: ${s.total ?? 0}`);
|
|
console.log(` by kind: ${formatCounts(s.byKind)}`);
|
|
console.log(` by pluginId: ${formatCounts(s.byPluginId)}`);
|
|
console.log(` oldest at: ${formatTimestamp(s.oldestAt)}`);
|
|
console.log(` newest at: ${formatTimestamp(s.newestAt)}`);
|
|
console.log(` id range: ${s.firstId ?? '(none)'} \u2192 ${s.lastId ?? '(none)'}`);
|
|
return;
|
|
}
|
|
|
|
if (sub !== 'tail') {
|
|
console.error(`unknown subcommand: od plugin events ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
const follow = flags.f === true || flags.follow === true;
|
|
const url = `${base}/api/plugins/events${Number.isFinite(since) && since > 0 ? `?since=${since}` : ''}`;
|
|
const resp = await fetch(url, { headers: { accept: 'text/event-stream' } });
|
|
if (!resp.ok || !resp.body) {
|
|
console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
const renderEvent = (channel, data) => {
|
|
if (!matches(data)) return;
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ channel, ...data }) + '\n');
|
|
return;
|
|
}
|
|
const ts = data?.at ? new Date(data.at).toISOString() : '?';
|
|
const id = data?.id ?? '?';
|
|
const tag = channel === 'backlog' ? '[bk]' : '[ev]';
|
|
const detailKeys = data?.details ? Object.keys(data.details).slice(0, 3).join(',') : '';
|
|
console.log(`${tag} #${id} ${ts} ${data?.kind ?? '?'} pluginId=${data?.pluginId ?? '-'}` +
|
|
(detailKeys ? ` details=${detailKeys}` : ''));
|
|
};
|
|
// Read until the daemon closes the stream OR --follow keeps it open
|
|
// forever. Without --follow we still let the daemon drain the
|
|
// backlog naturally; the route emits all backlog entries first,
|
|
// and our reader exits when the connection closes (which the
|
|
// daemon never does on its own, so we add a small idle timer).
|
|
if (!follow) {
|
|
// Non-follow: drain backlog, then exit after a short idle period
|
|
// (the route never naturally closes; the SSE backlog is a one-shot
|
|
// stream of event entries).
|
|
let lastChunkAt = Date.now();
|
|
const idleMs = 200;
|
|
const idleTimer = setInterval(() => {
|
|
if (Date.now() - lastChunkAt > idleMs) {
|
|
clearInterval(idleTimer);
|
|
try { reader.cancel(); } catch { /* ignore */ }
|
|
}
|
|
}, 100);
|
|
try {
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
lastChunkAt = Date.now();
|
|
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 ev = lines.find((l) => l.startsWith('event: '))?.slice('event: '.length) ?? 'message';
|
|
const dat = lines.find((l) => l.startsWith('data: '))?.slice('data: '.length);
|
|
if (!dat) continue;
|
|
try { renderEvent(ev, JSON.parse(dat)); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
} finally {
|
|
clearInterval(idleTimer);
|
|
}
|
|
return;
|
|
}
|
|
// Follow mode: read forever.
|
|
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 ev = lines.find((l) => l.startsWith('event: '))?.slice('event: '.length) ?? 'message';
|
|
const dat = lines.find((l) => l.startsWith('data: '))?.slice('data: '.length);
|
|
if (!dat) continue;
|
|
try { renderEvent(ev, JSON.parse(dat)); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Plan §3.FF1 — `od plugin verify <pluginId>` CI meta-command.
|
|
//
|
|
// Reads an optional .od-verify.json config from the plugin folder
|
|
// or --config <path> and runs the enabled subset of:
|
|
//
|
|
// doctor — calls /api/plugins/<id>/doctor
|
|
// simulate — calls /api/plugins/<id> + simulatePipeline()
|
|
// canon — fetches /api/applied-plugins/<snapshotId>/canon and
|
|
// compares against the on-disk fixture
|
|
//
|
|
// Aggregates into a unified pass/fail report. Exit 4 on any failed
|
|
// check; useful as a one-liner CI check for a plugin's repo.
|
|
async function runPluginVerify(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set([...PLUGIN_STRING_FLAGS, 'config']),
|
|
boolean: PLUGIN_BOOLEAN_FLAGS,
|
|
});
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const id = positional[0];
|
|
if (flags.help || flags.h || !id) {
|
|
console.log(`Usage:
|
|
od plugin verify <pluginId> [--config <path>] [--json]
|
|
|
|
CI meta-command. Reads an optional config from
|
|
'<plugin-folder>/.od-verify.json' (or --config <path>) and runs:
|
|
|
|
doctor — manifest + atom + ref lint
|
|
simulate — convergence dry-run for every until expression,
|
|
with per-stage signals from config.simulate.signals
|
|
canon — byte-equality check against
|
|
config.canon.fixturePath using the snapshot at
|
|
config.canon.snapshotId
|
|
|
|
Sample .od-verify.json:
|
|
|
|
{
|
|
"enabled": ["doctor", "simulate"],
|
|
"simulate": {
|
|
"signals": { "critique.score": 5, "build.passing": true },
|
|
"iterationCap": 5
|
|
},
|
|
"canon": {
|
|
"snapshotId": "snap-abc",
|
|
"fixturePath": "tests/expected-block.md"
|
|
}
|
|
}
|
|
|
|
Exit codes:
|
|
0 every enabled check passed
|
|
4 one or more enabled checks failed
|
|
2 CLI usage error / plugin not found / config malformed`);
|
|
process.exit(id ? 0 : 2);
|
|
}
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
|
|
// 1. Resolve the plugin record (fsPath + manifest).
|
|
const pluginResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`);
|
|
if (pluginResp.status === 404) {
|
|
console.error(`plugin ${id} not found`);
|
|
process.exit(65);
|
|
}
|
|
if (!pluginResp.ok) {
|
|
console.error(`GET /api/plugins/${id} failed: ${pluginResp.status} ${await pluginResp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const plugin = await pluginResp.json();
|
|
|
|
// 2. Load .od-verify.json from --config or <fsPath>/.od-verify.json.
|
|
const fs = await import('node:fs/promises');
|
|
const path = await import('node:path');
|
|
const configPath = typeof flags.config === 'string'
|
|
? path.resolve(flags.config)
|
|
: (typeof plugin?.fsPath === 'string' ? path.join(plugin.fsPath, '.od-verify.json') : null);
|
|
let config = { enabled: ['doctor', 'simulate', 'canon'] };
|
|
if (configPath) {
|
|
try {
|
|
const raw = await fs.readFile(configPath, 'utf8');
|
|
config = JSON.parse(raw);
|
|
} catch (err) {
|
|
const e = err;
|
|
if (e?.code !== 'ENOENT') {
|
|
console.error(`[verify] cannot read config ${configPath}: ${e?.message ?? e}`);
|
|
process.exit(2);
|
|
}
|
|
// ENOENT → run with defaults. canon will skip cleanly because no
|
|
// config.canon entry was supplied.
|
|
}
|
|
}
|
|
|
|
// 3. doctor (when enabled)
|
|
const enabledSet = new Set((config.enabled ?? ['doctor', 'simulate', 'canon']).filter((c) =>
|
|
c === 'doctor' || c === 'simulate' || c === 'canon'));
|
|
let doctorReport = null;
|
|
if (enabledSet.has('doctor')) {
|
|
const doctorResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/doctor`);
|
|
if (doctorResp.ok) {
|
|
doctorReport = await doctorResp.json();
|
|
}
|
|
}
|
|
|
|
// 4. simulate (when enabled)
|
|
let simulateReport = null;
|
|
if (enabledSet.has('simulate')) {
|
|
const pipeline = plugin?.manifest?.od?.pipeline;
|
|
if (pipeline && Array.isArray(pipeline.stages) && pipeline.stages.length > 0) {
|
|
const { simulatePipeline } = await import('./plugins/simulate.js');
|
|
simulateReport = simulatePipeline({
|
|
pipeline,
|
|
signals: config.simulate?.signals ?? {},
|
|
...(typeof config.simulate?.iterationCap === 'number' && config.simulate.iterationCap > 0
|
|
? { iterationCap: config.simulate.iterationCap }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 5. canon (when enabled + fixture supplied)
|
|
let canonActual = null;
|
|
let canonExpected = null;
|
|
if (enabledSet.has('canon') && config.canon?.snapshotId && config.canon?.fixturePath) {
|
|
const fixturePath = path.resolve(
|
|
typeof flags.config === 'string'
|
|
? path.dirname(path.resolve(flags.config))
|
|
: (typeof plugin?.fsPath === 'string' ? plugin.fsPath : process.cwd()),
|
|
config.canon.fixturePath,
|
|
);
|
|
try {
|
|
canonExpected = await fs.readFile(fixturePath, 'utf8');
|
|
} catch {
|
|
canonExpected = null;
|
|
}
|
|
if (canonExpected !== null) {
|
|
const canonResp = await fetch(
|
|
`${base}/api/applied-plugins/${encodeURIComponent(config.canon.snapshotId)}/canon`,
|
|
{ headers: { accept: 'text/plain' } },
|
|
);
|
|
if (canonResp.ok) {
|
|
canonActual = await canonResp.text();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Aggregate.
|
|
const { verifyPlugin } = await import('./plugins/verify.js');
|
|
const report = verifyPlugin({
|
|
config: {
|
|
enabled: [...enabledSet],
|
|
...(config.strict === true ? { strict: true } : {}),
|
|
...(config.simulate ? { simulate: config.simulate } : {}),
|
|
...(config.canon ? { canon: config.canon } : {}),
|
|
},
|
|
...(doctorReport ? { doctor: doctorReport } : {}),
|
|
...(simulateReport ? { simulate: simulateReport } : {}),
|
|
...(canonActual ? { canon: canonActual } : {}),
|
|
...(canonExpected ? { canonExpected: canonExpected } : {}),
|
|
});
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ pluginId: id, ...report }, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[verify] plugin ${id} \u2014 ${report.passed ? 'PASSED' : 'FAILED'}`);
|
|
for (const o of report.outcomes) {
|
|
const tag = o.status === 'passed' ? '\u2713'
|
|
: o.status === 'failed' ? '\u2717'
|
|
: o.status === 'skipped' ? '-'
|
|
: '!';
|
|
console.log(` ${tag} ${o.summary}`);
|
|
}
|
|
}
|
|
process.exit(report.passed ? 0 : 4);
|
|
}
|
|
|
|
// Plan §3.EE1 — `od plugin simulate <pluginId> [-s key=value ...]`.
|
|
//
|
|
// Walks the plugin's pipeline against caller-supplied signals and
|
|
// reports per-stage convergence (iterations + outcome). No LLM is
|
|
// invoked — this is a pure devloop dry-run for testing 'until'
|
|
// expressions.
|
|
//
|
|
// Signals are supplied via repeatable -s key=value flags. The
|
|
// closed UntilSignals vocabulary applies (critique.score /
|
|
// iterations / user.confirmed / preview.ok / build.passing /
|
|
// tests.passing); unknown keys surface as warnings.
|
|
async function runPluginSimulate(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set([...PLUGIN_STRING_FLAGS, 's', 'cap']),
|
|
boolean: PLUGIN_BOOLEAN_FLAGS,
|
|
});
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const id = positional[0];
|
|
if (flags.help || flags.h || !id) {
|
|
console.log(`Usage:
|
|
od plugin simulate <pluginId> [-s key=value ...] [--cap <n>] [--json]
|
|
|
|
Walks the plugin's pipeline against caller-supplied signals and
|
|
reports per-stage convergence. No LLM is invoked.
|
|
|
|
Examples:
|
|
# critique-theater stage that exits when score >= 4
|
|
od plugin simulate my-plugin -s critique.score=5
|
|
|
|
# build-test devloop where both signals must hold
|
|
od plugin simulate code-migration \\
|
|
-s build.passing=true -s tests.passing=true
|
|
|
|
# raise the per-stage iteration cap (default 10)
|
|
od plugin simulate my-plugin -s critique.score=2 --cap 20
|
|
|
|
Closed signal vocabulary:
|
|
critique.score (number)
|
|
iterations (number)
|
|
user.confirmed (boolean)
|
|
preview.ok (boolean)
|
|
build.passing (boolean)
|
|
tests.passing (boolean)`);
|
|
process.exit(id ? 0 : 2);
|
|
}
|
|
// Collect every -s value (parseFlags returns the last only).
|
|
const sValues = [];
|
|
for (let i = 0; i < rest.length; i++) {
|
|
if ((rest[i] === '-s' || rest[i] === '--signal') && typeof rest[i + 1] === 'string') {
|
|
sValues.push(rest[i + 1]);
|
|
}
|
|
}
|
|
// Fetch the plugin from the daemon so we get the resolved
|
|
// manifest (including pipeline).
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const resp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`);
|
|
if (resp.status === 404) {
|
|
console.error(`plugin ${id} not found`);
|
|
process.exit(65);
|
|
}
|
|
if (!resp.ok) {
|
|
console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const plugin = await resp.json();
|
|
const pipeline = plugin?.manifest?.od?.pipeline;
|
|
if (!pipeline || !Array.isArray(pipeline.stages) || pipeline.stages.length === 0) {
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ outcome: 'no-pipeline', stages: [] }, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[simulate] plugin ${id} has no od.pipeline (or it is empty); nothing to walk.`);
|
|
}
|
|
return;
|
|
}
|
|
const { simulatePipeline, parseSignalKv } = await import('./plugins/simulate.js');
|
|
const parsedSignals = parseSignalKv(sValues);
|
|
for (const w of parsedSignals.warnings) console.warn(`[simulate] warn: ${w}`);
|
|
const cap = typeof flags.cap === 'string' ? Number(flags.cap) : undefined;
|
|
const result = simulatePipeline({
|
|
pipeline,
|
|
signals: parsedSignals.signals,
|
|
...(Number.isFinite(cap) && cap > 0 ? { iterationCap: cap } : {}),
|
|
});
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.log(`[simulate] plugin ${id} \u2014 outcome: ${result.outcome}, totalIterations: ${result.totalIterations}`);
|
|
for (const stage of result.stages) {
|
|
const tag = stage.outcome === 'converged' ? '\u2713'
|
|
: stage.outcome === 'cap' ? '\u2717'
|
|
: stage.outcome === 'unparsable' ? '!'
|
|
: '\u2014';
|
|
const reason = stage.reason ? ` (${stage.reason})` : '';
|
|
const matched = stage.matched && stage.matched.length > 0
|
|
? ` matched=[${stage.matched.map((c) => `${c.signal}${c.op}${c.value}`).join(' && ')}]`
|
|
: '';
|
|
console.log(` ${tag} ${stage.stageId}: ${stage.outcome} (${stage.iterations} iter)${reason}${matched}`);
|
|
}
|
|
// Exit non-zero on cap-hit / unparsable so CI can wire this
|
|
// into a pipeline check easily.
|
|
if (result.outcome === 'cap-hit' || result.outcome === 'unparsable') process.exit(4);
|
|
}
|
|
|
|
// Plan §3.CC1 / §3.DD2 — `od plugin canon <snapshotId>`. Prints the
|
|
// canonical `## Active plugin` block a snapshot will splice into
|
|
// the system prompt. Useful for understanding what the agent
|
|
// reads + locking byte-equality regression tests against the
|
|
// daemon's renderPluginBlock() output.
|
|
//
|
|
// --check <file> mode: compares the canon output against an
|
|
// on-disk fixture (typically committed under tests/fixtures/) and
|
|
// exits 4 on byte-mismatch. Lets a plugin author lock byte-
|
|
// equality without writing a new test harness.
|
|
async function runPluginCanon(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set([...PLUGIN_STRING_FLAGS, 'check']),
|
|
boolean: PLUGIN_BOOLEAN_FLAGS,
|
|
});
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const id = positional[0];
|
|
if (flags.help || flags.h || !id) {
|
|
console.log(`Usage:
|
|
od plugin canon <snapshotId> [--json]
|
|
od plugin canon <snapshotId> --check <expected-file>
|
|
|
|
Prints the canonical '## Active plugin' / '## Plugin inputs' /
|
|
'## Plugin atoms' block this snapshot would splice into the
|
|
system prompt. Default output is plain text; --json wraps the
|
|
block in { snapshotId, pluginId, block }.
|
|
|
|
--check <file> compares the canon output to the file's bytes and
|
|
exits 4 on mismatch. Useful for committing renderPluginBlock()
|
|
fixtures into a plugin's own tests/.`);
|
|
process.exit(id ? 0 : 2);
|
|
}
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const url = `${base}/api/applied-plugins/${encodeURIComponent(id)}/canon`;
|
|
const checkPath = typeof flags.check === 'string' ? flags.check : null;
|
|
// --check always wants the raw text output; force text/plain.
|
|
const wantsText = !flags.json || checkPath !== null;
|
|
const headers = { accept: wantsText ? 'text/plain' : 'application/json' };
|
|
const resp = await fetch(url, { headers });
|
|
if (resp.status === 404) {
|
|
console.error(`snapshot ${id} not found`);
|
|
process.exit(72);
|
|
}
|
|
if (!resp.ok) {
|
|
console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
if (checkPath) {
|
|
const fs = await import('node:fs/promises');
|
|
let expected;
|
|
try {
|
|
expected = await fs.readFile(checkPath, 'utf8');
|
|
} catch (err) {
|
|
console.error(`[canon --check] cannot read ${checkPath}: ${err?.message ?? err}`);
|
|
process.exit(2);
|
|
}
|
|
const actual = await resp.text();
|
|
if (actual === expected) {
|
|
console.log(`[canon] \u2713 byte-equal to ${checkPath}`);
|
|
return;
|
|
}
|
|
// Surface a small unified-diff preview so the author sees what
|
|
// drifted. Full diff is left to the user's preferred tool.
|
|
console.error(`[canon --check] \u2717 mismatch with ${checkPath}`);
|
|
console.error(` expected length: ${expected.length} bytes`);
|
|
console.error(` actual length: ${actual.length} bytes`);
|
|
const expectedLines = expected.split('\n');
|
|
const actualLines = actual.split('\n');
|
|
const limit = Math.min(Math.max(expectedLines.length, actualLines.length), 40);
|
|
for (let i = 0; i < limit; i++) {
|
|
if (expectedLines[i] !== actualLines[i]) {
|
|
console.error(` line ${i + 1}:`);
|
|
if (expectedLines[i] !== undefined) console.error(` - ${expectedLines[i]}`);
|
|
if (actualLines[i] !== undefined) console.error(` + ${actualLines[i]}`);
|
|
}
|
|
}
|
|
process.exit(4);
|
|
}
|
|
if (flags.json) {
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
const body = await resp.text();
|
|
process.stdout.write(body);
|
|
if (!body.endsWith('\n')) process.stdout.write('\n');
|
|
}
|
|
|
|
// Plan §3.AA1 — `od plugin diff <a> <b>`. Compares two installed
|
|
// plugins (by id) and prints a structured report. Useful for
|
|
// debugging replay invariance + reviewing version bumps.
|
|
async function runPluginDiff(rest) {
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
if (flags.help || flags.h || positional.length < 2) {
|
|
console.log(`Usage:
|
|
od plugin diff <id-a> <id-b> [--json]
|
|
|
|
Compares two installed plugins (or two installs of the same id at
|
|
different versions) and prints every changed field. Output groups
|
|
into 'added' / 'removed' / 'changed' with one line per field.`);
|
|
process.exit(positional.length < 2 ? 2 : 0);
|
|
}
|
|
const [idA, idB] = positional;
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
const [respA, respB] = await Promise.all([
|
|
fetch(`${base}/api/plugins/${encodeURIComponent(idA)}`),
|
|
fetch(`${base}/api/plugins/${encodeURIComponent(idB)}`),
|
|
]);
|
|
if (!respA.ok) {
|
|
console.error(`GET /api/plugins/${idA} failed: ${respA.status}`);
|
|
process.exit(1);
|
|
}
|
|
if (!respB.ok) {
|
|
console.error(`GET /api/plugins/${idB} failed: ${respB.status}`);
|
|
process.exit(1);
|
|
}
|
|
const a = await respA.json();
|
|
const b = await respB.json();
|
|
const { diffPlugins } = await import('./plugins/diff.js');
|
|
const report = diffPlugins({ a, b });
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (report.entries.length === 0) {
|
|
console.log(`[diff] ${idA} and ${idB} are equivalent on every recorded field.`);
|
|
return;
|
|
}
|
|
console.log(`[diff] ${idA} \u2194 ${idB} — ${report.added} added, ${report.removed} removed, ${report.changed} changed`);
|
|
for (const e of report.entries) {
|
|
const tag = e.kind === 'added' ? '+'
|
|
: e.kind === 'removed' ? '-'
|
|
: '~';
|
|
if (e.summary) {
|
|
console.log(` ${tag} ${e.field} (${e.summary})`);
|
|
} else if (e.kind === 'changed') {
|
|
console.log(` ${tag} ${e.field}: ${e.before ?? ''} \u2192 ${e.after ?? ''}`);
|
|
} else if (e.kind === 'added') {
|
|
console.log(` ${tag} ${e.field}: ${e.after ?? ''}`);
|
|
} else {
|
|
console.log(` ${tag} ${e.field}: ${e.before ?? ''}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runPluginUpgrade(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 upgrade <id> [--policy latest|pinned] [--json]');
|
|
process.exit(2);
|
|
}
|
|
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`;
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({
|
|
policy: flags.policy === 'pinned' ? 'pinned' : 'latest',
|
|
}),
|
|
});
|
|
if (!resp.ok || !resp.body) {
|
|
let msg = '';
|
|
try { msg = await resp.text(); } catch { msg = ''; }
|
|
console.error(`POST /api/plugins/${id}/upgrade failed: ${resp.status} ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let exitCode = 0;
|
|
const events = [];
|
|
let finalEvent = null;
|
|
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;
|
|
events.push({ event, data });
|
|
if (event === 'progress') {
|
|
if (!flags.json) console.log(`[upgrade] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
|
|
} else if (event === 'success') {
|
|
finalEvent = data;
|
|
if (!flags.json) console.log(`[upgrade] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
|
|
if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) {
|
|
for (const w of data.warnings) console.log(`[upgrade] warn: ${w}`);
|
|
}
|
|
} else if (event === 'error') {
|
|
finalEvent = data;
|
|
if (!flags.json) console.error(`[upgrade] error: ${data?.message ?? 'unknown'}`);
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({
|
|
ok: exitCode === 0,
|
|
policy: flags.policy === 'pinned' ? 'pinned' : 'latest',
|
|
result: finalEvent,
|
|
events,
|
|
}, null, 2) + '\n');
|
|
}
|
|
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 = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/uninstall`;
|
|
const resp = await fetch(url, { method: 'POST' });
|
|
if (!resp.ok) {
|
|
console.error(`POST /api/plugins/${id}/uninstall failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
console.log(`[uninstall] ${data?.removedFolder ? 'ok' : 'no-op'}${data?.warning ? ` (warning: ${data.warning})` : ''}`);
|
|
}
|
|
|
|
async function runPluginApply(rest) {
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const id = rest.find((a) => !a.startsWith('-')
|
|
&& a !== flags['daemon-url']
|
|
&& a !== flags.source
|
|
&& a !== flags.inputs
|
|
&& a !== flags.project
|
|
&& a !== flags['grant-caps']);
|
|
if (!id) {
|
|
console.error('Usage: od plugin apply <id> [--inputs <json>] [--input k=v ...] [--project <id>] [--grant-caps a,b]');
|
|
process.exit(2);
|
|
}
|
|
// Plan §3.B2: support both --inputs <json> and repeated --input k=v
|
|
// forms so a code agent can build the inputs map without a JSON
|
|
// shell-escape dance.
|
|
let inputs = {};
|
|
if (typeof flags.inputs === 'string' && flags.inputs.trim().length > 0) {
|
|
try { inputs = JSON.parse(flags.inputs); } catch (err) {
|
|
console.error(`--inputs must be valid JSON: ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
for (let i = 0; i < rest.length; i++) {
|
|
if (rest[i] === '--input' && typeof rest[i + 1] === 'string') {
|
|
const kv = rest[i + 1];
|
|
const eq = kv.indexOf('=');
|
|
if (eq > 0) {
|
|
const k = kv.slice(0, eq);
|
|
const v = kv.slice(eq + 1);
|
|
inputs[k] = coerceCliValue(v);
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0
|
|
? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean)
|
|
: [];
|
|
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`;
|
|
let resp;
|
|
try {
|
|
resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ inputs, projectId: flags.project, grantCaps }),
|
|
});
|
|
} catch (err) {
|
|
return exitWithStructuredError({
|
|
code: 'daemon-not-running',
|
|
message: `Cannot reach daemon at ${await pluginDaemonUrl(flags)}: ${err?.message ?? err}`,
|
|
});
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
if (resp.status === 422 && Array.isArray(data?.fields)) {
|
|
return exitWithStructuredError({
|
|
code: 'missing-input',
|
|
message: `Plugin "${id}" is missing required inputs: ${data.fields.join(', ')}`,
|
|
data: { pluginId: id, missing: data.fields },
|
|
});
|
|
}
|
|
return structuredHttpFailure(resp);
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
const snap = data?.appliedPlugin;
|
|
if (snap) {
|
|
console.log(`[apply] ${snap.pluginId}@${snap.pluginVersion} digest=${snap.manifestSourceDigest.slice(0, 12)}…`);
|
|
console.log(`[apply] context: ${(data.contextItems ?? []).map((c) => `${c.kind}:${c.id ?? c.name ?? c.path}`).join(', ')}`);
|
|
if (Array.isArray(data.warnings) && data.warnings.length > 0) {
|
|
for (const w of data.warnings) console.log(`[apply] warn: ${w}`);
|
|
}
|
|
} else {
|
|
console.log(JSON.stringify(data));
|
|
}
|
|
}
|
|
|
|
function coerceCliValue(raw) {
|
|
if (raw === 'true') return true;
|
|
if (raw === 'false') return false;
|
|
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
|
return raw;
|
|
}
|
|
|
|
// Phase 4 / spec §14.1 — `od plugin publish --to <catalog>`.
|
|
//
|
|
// Reads the installed plugin's manifest metadata (or the snapshot's
|
|
// frozen view via --snapshot-id) and prints the catalog submission URL
|
|
// + PR body. With `--open` the CLI auto-launches the system browser
|
|
// against the URL so the author lands on the catalog's submission form
|
|
// in one step. We never POST anywhere — the upstream review flow is
|
|
// always under the author's control.
|
|
async function runPluginPublish(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['daemon-url', 'to', 'snapshot-id', 'repo', 'catalog']),
|
|
boolean: new Set(['help', 'h', 'json', 'open']),
|
|
});
|
|
if (rest.length === 0 || flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin publish <pluginId> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh
|
|
[--repo <github-url>] [--snapshot-id <id>] [--open] [--json]
|
|
od plugin publish <pluginId> --to marketplace-json --catalog ./open-design-marketplace.json --repo <github-url>
|
|
|
|
The CLI prints the catalog's submission URL + a pre-filled PR body.
|
|
Pass --open to auto-launch the system browser. Use --snapshot-id to
|
|
publish from a frozen run snapshot rather than the live installed copy.`);
|
|
process.exit(rest.length === 0 ? 2 : 0);
|
|
}
|
|
const id = rest.find((a) => !a.startsWith('-')
|
|
&& a !== flags.to
|
|
&& a !== flags.repo
|
|
&& a !== flags['snapshot-id']);
|
|
const target = String(flags.to ?? '');
|
|
if (!id) {
|
|
console.error('Usage: od plugin publish <pluginId> --to <catalog>');
|
|
process.exit(2);
|
|
}
|
|
if (!target) {
|
|
console.error('--to <catalog> is required (one of: open-design, anthropics-skills, awesome-agent-skills, clawhub, skills-sh)');
|
|
process.exit(2);
|
|
}
|
|
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
|
// Pull the plugin metadata from the daemon. We do this through the
|
|
// existing /api/plugins/:id endpoint so the CLI never needs a direct
|
|
// SQLite handle; everything stays loopback-mediated.
|
|
let meta = { pluginId: id, pluginVersion: '0.0.0' };
|
|
try {
|
|
const resp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`);
|
|
if (resp.ok) {
|
|
const row = await resp.json();
|
|
// The daemon's plugin row carries a stored `version` plus the full
|
|
// manifest. For project-local plugins (`generated-plugin/`, snapshots,
|
|
// freshly imported folders) the stored `version` is `'0.0.0'` until
|
|
// the registry handshake runs, but the manifest's `version` is the
|
|
// real value the author wrote. Mirror `plugins/marketplaces.ts:298,328`
|
|
// and prefer the manifest version when the stored row reads as the
|
|
// pre-handshake sentinel. Closes #1765.
|
|
const storedVersion = typeof row.version === 'string' && row.version.length > 0
|
|
? row.version
|
|
: null;
|
|
const manifestVersion = typeof row.manifest?.version === 'string' && row.manifest.version.length > 0
|
|
? row.manifest.version
|
|
: null;
|
|
const resolvedVersion = (storedVersion && storedVersion !== '0.0.0')
|
|
? storedVersion
|
|
: (manifestVersion ?? storedVersion ?? '0.0.0');
|
|
meta = {
|
|
pluginId: row.id ?? id,
|
|
pluginVersion: resolvedVersion,
|
|
...(row.title ? { pluginTitle: row.title } : {}),
|
|
...(row.manifest?.description ? { pluginDescription: row.manifest.description } : {}),
|
|
};
|
|
}
|
|
} catch {
|
|
// Best-effort; if the daemon isn't reachable we still try to build
|
|
// a link from the user's flags so the author doesn't need a daemon
|
|
// to publish.
|
|
}
|
|
if (typeof flags.repo === 'string' && flags.repo.length > 0) {
|
|
meta.repoUrl = flags.repo;
|
|
}
|
|
if (target === 'marketplace-json') {
|
|
if (typeof flags.catalog !== 'string' || flags.catalog.length === 0) {
|
|
console.error('--catalog <path> is required for --to marketplace-json');
|
|
process.exit(2);
|
|
}
|
|
if (!meta.repoUrl) {
|
|
console.error('--repo <github-url> is required for --to marketplace-json so the source can be reproduced');
|
|
process.exit(2);
|
|
}
|
|
const outcome = await publishToMarketplaceJson({
|
|
catalogPath: flags.catalog,
|
|
meta,
|
|
});
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[publish] updated ${outcome.catalogPath}`);
|
|
console.log(`[publish] ${outcome.entry.name}@${outcome.entry.version} -> ${outcome.entry.source}`);
|
|
}
|
|
return;
|
|
}
|
|
const { buildPublishLink, PublishError } = await import('./plugins/publish.js');
|
|
let link;
|
|
try {
|
|
link = buildPublishLink({ catalog: target, meta });
|
|
} catch (err) {
|
|
if (err instanceof PublishError) {
|
|
console.error(`[publish] ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
throw err;
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(link, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[publish] ${link.catalogLabel}`);
|
|
console.log(link.url);
|
|
console.log('---');
|
|
console.log(link.prBody);
|
|
}
|
|
if (flags.open) {
|
|
const opener = process.platform === 'darwin' ? 'open'
|
|
: process.platform === 'win32' ? 'start'
|
|
: 'xdg-open';
|
|
const { spawn } = await import('node:child_process');
|
|
spawn(opener, [link.url], { detached: true, stdio: 'ignore' }).unref();
|
|
}
|
|
}
|
|
|
|
async function publishToMarketplaceJson({ catalogPath, meta }) {
|
|
const [{ dirname, resolve }, { mkdir, readFile, writeFile }, { PublishError, upsertMarketplaceJsonEntry }] = await Promise.all([
|
|
import('node:path'),
|
|
import('node:fs/promises'),
|
|
import('./plugins/publish.js'),
|
|
]);
|
|
const resolvedPath = resolve(process.cwd(), catalogPath);
|
|
let existing = null;
|
|
try {
|
|
existing = JSON.parse(await readFile(resolvedPath, 'utf8'));
|
|
} catch (err) {
|
|
if (err?.code !== 'ENOENT') {
|
|
throw err;
|
|
}
|
|
}
|
|
let outcome;
|
|
try {
|
|
outcome = upsertMarketplaceJsonEntry({ manifest: existing, meta });
|
|
} catch (err) {
|
|
if (err instanceof PublishError) {
|
|
console.error(`[publish] ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
throw err;
|
|
}
|
|
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
await writeFile(resolvedPath, `${JSON.stringify(outcome.manifest, null, 2)}\n`, 'utf8');
|
|
return {
|
|
catalogPath: resolvedPath,
|
|
inserted: outcome.inserted,
|
|
entry: outcome.entry,
|
|
manifest: {
|
|
name: outcome.manifest.name,
|
|
version: outcome.manifest.version,
|
|
plugins: outcome.manifest.plugins.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runPluginYank(rest) {
|
|
const flags = parseFlags(rest, {
|
|
string: new Set(['daemon-url', 'reason', 'to']),
|
|
boolean: new Set(['help', 'h', 'json', 'open']),
|
|
});
|
|
if (rest.length === 0 || flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od plugin yank <vendor/plugin-name>@<version> --reason "<why>" [--to open-design] [--json]
|
|
|
|
Yanking never deletes metadata or bytes. It opens the registry review flow that
|
|
marks a version unresolvable for new installs while preserving lockfile replay.`);
|
|
process.exit(rest.length === 0 ? 2 : 0);
|
|
}
|
|
const spec = rest.find((a) => !a.startsWith('-') && a !== flags.reason && a !== flags.to);
|
|
const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
|
|
const parsed = parseCliPluginSpecifier(spec);
|
|
if (!parsed.name || !parsed.range) {
|
|
console.error('Usage: od plugin yank <vendor/plugin-name>@<version> --reason "<why>"');
|
|
process.exit(2);
|
|
}
|
|
if (!reason) {
|
|
console.error('--reason is required for yanking');
|
|
process.exit(2);
|
|
}
|
|
const target = flags.to ?? 'open-design';
|
|
if (target !== 'open-design') {
|
|
console.error('Only --to open-design is supported in this v1 GitHub-backed yank flow.');
|
|
process.exit(2);
|
|
}
|
|
const title = `Yank ${parsed.name}@${parsed.range}`;
|
|
const body = [
|
|
`## Yank ${parsed.name}@${parsed.range}`,
|
|
'',
|
|
`Reason: ${reason}`,
|
|
'',
|
|
'Expected registry patch:',
|
|
'',
|
|
'```json',
|
|
JSON.stringify({
|
|
name: parsed.name,
|
|
version: parsed.range,
|
|
yanked: true,
|
|
yankReason: reason,
|
|
}, null, 2),
|
|
'```',
|
|
'',
|
|
'Generated by `od plugin yank`.',
|
|
].join('\n');
|
|
const params = new URLSearchParams({ title, body });
|
|
const payload = {
|
|
catalog: 'open-design',
|
|
name: parsed.name,
|
|
version: parsed.range,
|
|
reason,
|
|
url: `https://github.com/nexu-io/open-design/issues/new?${params.toString()}`,
|
|
body,
|
|
};
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[yank] ${payload.url}`);
|
|
console.log('---');
|
|
console.log(body);
|
|
}
|
|
if (flags.open) {
|
|
const opener = process.platform === 'darwin' ? 'open'
|
|
: process.platform === 'win32' ? 'start'
|
|
: 'xdg-open';
|
|
const { spawn } = await import('node:child_process');
|
|
spawn(opener, [payload.url], { detached: true, stdio: 'ignore' }).unref();
|
|
}
|
|
}
|
|
|
|
async function runPluginDoctor(rest) {
|
|
// Plan §3.HH1 — --strict promotes warnings to errors so CI can
|
|
// opt into 'no warnings allowed' mode without parsing the issue
|
|
// list manually.
|
|
const flags = parseFlags(rest, {
|
|
string: PLUGIN_STRING_FLAGS,
|
|
boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'strict']),
|
|
});
|
|
const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source);
|
|
if (!id) {
|
|
console.error('Usage: od plugin doctor <id> [--strict] [--json]');
|
|
process.exit(2);
|
|
}
|
|
const url = `${(await 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();
|
|
const issues = Array.isArray(data?.issues) ? data.issues : [];
|
|
const warnings = issues.filter((i) => i?.severity === 'warning');
|
|
const strict = flags.strict === true;
|
|
// Strict mode: a clean issue list is still required, but the
|
|
// pass/fail bit also fails on any warning.
|
|
const passed = data.ok && (!strict || warnings.length === 0);
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify({ ...data, strict, passed }, null, 2) + '\n');
|
|
} else {
|
|
if (passed && issues.length === 0) {
|
|
console.log(`[doctor] ${data.pluginId} ok (digest ${data.freshDigest.slice(0, 12)}…)`);
|
|
} else {
|
|
const tier = !data.ok ? 'errors' : (strict && warnings.length > 0) ? 'warnings (--strict)' : 'warnings';
|
|
console.log(`[doctor] ${data.pluginId} ${tier}:`);
|
|
for (const issue of issues) {
|
|
console.log(` [${issue.severity}] ${issue.code}: ${issue.message}`);
|
|
}
|
|
}
|
|
}
|
|
process.exit(passed ? 0 : (data.ok ? 4 : 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 = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/replay`;
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ snapshotId }),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`POST /api/runs/${runId}/replay failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.log(`[replay] ${data.rerun?.pluginId}@${data.rerun?.pluginVersion} digest=${(data.rerun?.manifestSourceDigest ?? '').slice(0, 12)}…`);
|
|
console.log(`[replay] inputs: ${JSON.stringify(data.rerun?.inputs ?? {})}`);
|
|
console.log('[replay] re-apply via: od plugin apply ' + data.rerun?.pluginId + ' --inputs ' + JSON.stringify(JSON.stringify(data.rerun?.inputs ?? {})));
|
|
}
|
|
|
|
// `od plugin trust <id> --capabilities <comma-sep>` — flip a plugin's
|
|
// capabilities_granted set. Plan §3.A2 / spec §9.1: the CLI is the
|
|
// canonical write surface (invariant I4). The daemon validates the
|
|
// capability vocabulary; unknown / malformed entries surface as
|
|
// exit-2 usage failures.
|
|
async function runPluginTrust(rest) {
|
|
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
|
const id = rest.find((a) => !a.startsWith('-')
|
|
&& a !== flags['daemon-url']
|
|
&& a !== flags.source
|
|
&& a !== flags.inputs
|
|
&& a !== flags.project
|
|
&& a !== flags['snapshot-id']
|
|
&& a !== flags.capabilities);
|
|
if (!id) {
|
|
console.error('Usage: od plugin trust <id> --capabilities connector:figma,connector:notion [--revoke]');
|
|
process.exit(2);
|
|
}
|
|
const capsCsv = typeof flags.capabilities === 'string' ? flags.capabilities : '';
|
|
const caps = capsCsv.split(',').map((c) => c.trim()).filter(Boolean);
|
|
if (caps.length === 0) {
|
|
console.error('--capabilities is required (comma-separated, e.g. connector:figma,fs:read)');
|
|
process.exit(2);
|
|
}
|
|
const action = flags.revoke ? 'revoke' : 'grant';
|
|
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/trust`;
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ capabilities: caps, action }),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
if (resp.status === 400 && data?.error?.code === 'invalid-capability') {
|
|
const rej = (data.error.data?.rejected ?? [])
|
|
.map((r) => `${r.capability} (${r.reason})`)
|
|
.join(', ');
|
|
console.error(`[trust] invalid capabilities: ${rej}`);
|
|
process.exit(2);
|
|
}
|
|
console.error(`POST ${url} failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.log(`[trust] ${action === 'grant' ? 'granted' : 'revoked'} on ${id}: ${caps.join(', ')}`);
|
|
console.log(`[trust] now: ${(data.capabilitiesGranted ?? []).join(', ')}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od ui … (spec §10.3.4 headless GenUI surface inbox)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runUi(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
printUiHelp();
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
switch (sub) {
|
|
case 'list': return runUiList(rest);
|
|
case 'show': return runUiShow(rest);
|
|
case 'respond': return runUiRespond(rest);
|
|
case 'revoke': return runUiRevoke(rest);
|
|
case 'prefill': return runUiPrefill(rest);
|
|
default:
|
|
console.error(`unknown subcommand: od ui ${sub}`);
|
|
printUiHelp();
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
async function uiDaemonUrl(flags) {
|
|
return cliDaemonUrl(flags);
|
|
}
|
|
|
|
async function runUiList(rest) {
|
|
const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS });
|
|
const base = (await 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 = `${(await 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();
|
|
// Plan §6 Phase 2A.5 — `--schema` prints the spec's JSON Schema
|
|
// only (null if the surface declares none). Designed to feed
|
|
// `od ui respond --value-json "$(...)"` in headless / agent flows.
|
|
if (flags.schema) {
|
|
const schema = data?.spec?.schema ?? null;
|
|
process.stdout.write(JSON.stringify(schema, null, 2) + '\n');
|
|
return;
|
|
}
|
|
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 = `${(await 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 = `${(await 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 = `${(await 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> [--schema] Read a single surface (kind / schema / value). --schema prints just the JSON Schema.
|
|
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, OD_SIDECAR_IPC_PATH discovery, 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 [--task-kind <kind>] List installed plugins (filterable).
|
|
od plugin search <query> [--tag <t>] Search installed plugins by id/title/desc/tag.
|
|
od plugin stats [--json] Inventory + snapshot health report.
|
|
od plugin info <id> Print a plugin's manifest + trust state as JSON.
|
|
od plugin manifest <id> Print only the parsed manifest JSON (no wrapper).
|
|
od plugin sources List distinct install sources + counts.
|
|
od plugin install --source <path> Install a plugin from a local folder (Phase 1).
|
|
od plugin upgrade <id> Re-install a plugin from its recorded source.
|
|
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 canon <snapshotId> Print the canonical system-prompt block for a snapshot.
|
|
(--check <file> for byte-equality fixtures.)
|
|
od plugin simulate <pluginId> [-s k=v] Walk the plugin's pipeline against caller-supplied
|
|
signals; report stage convergence + iterations
|
|
(no LLM in the loop).
|
|
od plugin verify <pluginId> CI meta-command: doctor + simulate + canon --check
|
|
driven by an .od-verify.json config in the plugin folder.
|
|
od plugin events tail [-f] [--kind k] Tail the in-memory plugin event ring buffer.
|
|
od plugin events snapshot One-shot read (filterable, no SSE).
|
|
od plugin events stats Roll-up: counts by kind / pluginId / time range.
|
|
od plugin events purge Drop every event in the buffer (loopback-only).
|
|
od plugin diff <a> <b> [--json] Compare two installed plugins by id.
|
|
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).
|
|
od plugin validate <folder> [--json] Lint a plugin folder before installing
|
|
(manifest parse + atom + ref checks).
|
|
od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin
|
|
folder for distribution.
|
|
od plugin publish <folder> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh
|
|
Prepare a registry submission link.
|
|
od plugin login [--host github.com] Authenticate registry publishing via gh.
|
|
od plugin whoami [--host github.com] Show the gh account used for publishing.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456).
|
|
--json Emit raw JSON (suitable for scripts) instead of human-readable output.
|
|
|
|
Installs support local folders, github:owner/repo refs, HTTPS .tgz archives,
|
|
and bare marketplace names resolved through configured registry sources.`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od project / od run / od files / od conversation
|
|
//
|
|
// Plan §6 Phase 1 follow-up + Phase 2C: thin CLI wrappers over the
|
|
// existing daemon HTTP endpoints (POST /api/projects, POST /api/runs,
|
|
// GET /api/projects/:id/files, …). The §12.5 walkthrough relies on
|
|
// these so a code agent can drive Open Design end-to-end without
|
|
// hitting `/api/*` directly. Spec §11.7 invariant: every UI feature is
|
|
// reachable via the CLI; we wrap rather than duplicate.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function projectDaemonUrl(flags) {
|
|
return cliDaemonUrl(flags);
|
|
}
|
|
|
|
function safeReadJsonFile(p) {
|
|
try {
|
|
const fs = (require ? require('node:fs') : null);
|
|
if (!fs) return null;
|
|
if (p === '-') return JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function runProject(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od project create [--name "<title>"] [--skill <id>] [--design-system <id>]
|
|
[--plugin <id>] [--inputs <json>] [--metadata-json <path|->]
|
|
od project list List projects.
|
|
od project info <id> Print one project.
|
|
od project delete <id> Delete a project.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
|
|
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
|
|
switch (sub) {
|
|
case 'list': {
|
|
const resp = await fetch(`${base}/api/projects`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const projects = data?.projects ?? [];
|
|
if (projects.length === 0) {
|
|
console.log('No projects. Create one with `od project create --name "..."`.');
|
|
return;
|
|
}
|
|
for (const p of projects) console.log(`${p.id}\t${p.name}\t${p.skillId ?? '-'}`);
|
|
return;
|
|
}
|
|
case 'info': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od project info <id>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`);
|
|
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
case 'create': {
|
|
const id = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
? crypto.randomUUID()
|
|
: Math.random().toString(36).slice(2);
|
|
const name = typeof flags.name === 'string' && flags.name.length > 0
|
|
? flags.name
|
|
: 'Untitled project';
|
|
const body = {
|
|
id,
|
|
name,
|
|
skillId: flags.skill ?? null,
|
|
designSystemId: flags['design-system'] ?? null,
|
|
};
|
|
if (flags['pending-prompt']) body.pendingPrompt = flags['pending-prompt'];
|
|
if (flags['metadata-json']) {
|
|
const mj = safeReadJsonFile(flags['metadata-json']);
|
|
if (mj && typeof mj === 'object') body.metadata = mj;
|
|
}
|
|
if (flags.plugin) body.pluginId = flags.plugin;
|
|
if (flags.inputs) {
|
|
try { body.pluginInputs = JSON.parse(flags.inputs); } catch (err) {
|
|
console.error(`--inputs must be valid JSON: ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
if (flags['grant-caps']) {
|
|
body.grantCaps = String(flags['grant-caps']).split(',').map((c) => c.trim()).filter(Boolean);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
if (resp.status === 409 && data?.error?.code === 'capabilities-required') {
|
|
return exitWithStructuredError({
|
|
code: 'capabilities-required',
|
|
message: data.error.message,
|
|
data: data.error.data,
|
|
});
|
|
}
|
|
console.error(`POST /api/projects failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
console.log(`[project] created ${data.project?.id ?? id} (conversation ${data.conversationId})`);
|
|
return;
|
|
}
|
|
case 'delete': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od project delete <id>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
|
|
console.log(`[project] deleted ${id}`);
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od project ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
async function runRun(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od run start --project <projectId> [--conversation <id>] [--message "<text>"]
|
|
[--plugin <id>] [--inputs <json>] [--grant-caps a,b]
|
|
[--agent claude|codex|gemini] [--model <id>] [--follow] [--json]
|
|
od run watch <runId> ND-JSON event stream on stdout.
|
|
od run cancel <runId> Request cancellation.
|
|
od run list [--project <id>] List recent runs.
|
|
od run info <runId> One run's status.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
|
|
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
|
|
switch (sub) {
|
|
case 'list': {
|
|
const url = flags.project
|
|
? `${base}/api/runs?projectId=${encodeURIComponent(flags.project)}`
|
|
: `${base}/api/runs`;
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const runs = data?.runs ?? [];
|
|
for (const r of runs) {
|
|
console.log(`${r.id}\t${r.status}\tproject=${r.projectId ?? '-'}\tplugin=${r.pluginId ?? '-'}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'info': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od run info <runId>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}`);
|
|
if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found');
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
case 'cancel': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od run cancel <runId>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
|
|
if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found');
|
|
console.log(`[run] cancelled ${id}`);
|
|
return;
|
|
}
|
|
case 'watch': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od run watch <runId>');
|
|
process.exit(2);
|
|
}
|
|
await streamRunEvents(base, id);
|
|
return;
|
|
}
|
|
case 'start': {
|
|
if (!flags.project) {
|
|
console.error('--project <projectId> is required');
|
|
process.exit(2);
|
|
}
|
|
const body = { projectId: flags.project };
|
|
if (flags.conversation) body.conversationId = flags.conversation;
|
|
if (flags.message) body.message = flags.message;
|
|
if (flags.plugin) body.pluginId = flags.plugin;
|
|
if (flags.agent) body.agentId = flags.agent;
|
|
if (flags.model) body.model = flags.model;
|
|
if (flags.inputs) {
|
|
try { body.pluginInputs = JSON.parse(flags.inputs); } catch (err) {
|
|
console.error(`--inputs must be valid JSON: ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
if (flags['grant-caps']) {
|
|
body.grantCaps = String(flags['grant-caps']).split(',').map((c) => c.trim()).filter(Boolean);
|
|
}
|
|
if (flags['snapshot-id']) body.appliedPluginSnapshotId = flags['snapshot-id'];
|
|
const resp = await fetch(`${base}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
if (resp.status === 409 && data?.error?.code === 'capabilities-required') {
|
|
return exitWithStructuredError({
|
|
code: 'capabilities-required',
|
|
message: data.error.message,
|
|
data: data.error.data,
|
|
});
|
|
}
|
|
if (resp.status === 422 && data?.error?.code === 'missing-input') {
|
|
return exitWithStructuredError({
|
|
code: 'missing-input',
|
|
message: data.error.message,
|
|
data: data.error.data,
|
|
});
|
|
}
|
|
console.error(`POST /api/runs failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json && !flags.follow) {
|
|
return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
}
|
|
console.log(`[run] started ${data.runId}`);
|
|
if (flags.follow) await streamRunEvents(base, data.runId);
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od run ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Stream the SSE events at /api/runs/:id/events as ND-JSON on stdout.
|
|
// Each line is one event: { event, data } so a code agent can parse it
|
|
// without needing an SSE library.
|
|
async function streamRunEvents(base, runId) {
|
|
const resp = await fetch(`${base}/api/runs/${encodeURIComponent(runId)}/events`, {
|
|
headers: { accept: 'text/event-stream' },
|
|
});
|
|
if (!resp.ok || !resp.body) {
|
|
console.error(`run watch failed: ${resp.status}`);
|
|
process.exit(1);
|
|
}
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const blocks = buffer.split('\n\n');
|
|
buffer = blocks.pop() ?? '';
|
|
for (const block of blocks) {
|
|
const lines = block.split('\n');
|
|
const eventLine = lines.find((l) => l.startsWith('event: '));
|
|
const dataLine = lines.find((l) => l.startsWith('data: '));
|
|
const event = eventLine ? eventLine.slice('event: '.length) : 'message';
|
|
const dataRaw = dataLine ? dataLine.slice('data: '.length) : '';
|
|
let parsed;
|
|
try { parsed = JSON.parse(dataRaw); } catch { parsed = dataRaw; }
|
|
process.stdout.write(JSON.stringify({ event, data: parsed }) + '\n');
|
|
if (event === 'end') {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runFiles(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od files list <projectId> List files in a project.
|
|
od files read <projectId> <relpath> Stream file bytes to stdout.
|
|
od files write <projectId> <relpath> [< stdin]
|
|
Write content from stdin.
|
|
od files upload <projectId> <localpath> [--as <relpath>]
|
|
Upload a local file.
|
|
od files delete <projectId> <name> Delete a project file.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
|
|
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
|
|
switch (sub) {
|
|
case 'list': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od files list <projectId>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`);
|
|
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const files = Array.isArray(data?.files) ? data.files : [];
|
|
for (const f of files) console.log(`${f.size}\t${f.name ?? f.path}`);
|
|
return;
|
|
}
|
|
case 'read': {
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const [id, rel] = positional;
|
|
if (!id || !rel) {
|
|
console.error('Usage: od files read <projectId> <relpath>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files/${rel.split('/').map(encodeURIComponent).join('/')}`);
|
|
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
|
|
const buf = Buffer.from(await resp.arrayBuffer());
|
|
process.stdout.write(buf);
|
|
return;
|
|
}
|
|
case 'upload': {
|
|
const positional = rest.filter((a) => !a.startsWith('-')
|
|
&& a !== flags.as);
|
|
const [id, localPath] = positional;
|
|
if (!id || !localPath) {
|
|
console.error('Usage: od files upload <projectId> <localpath> [--as <relpath>]');
|
|
process.exit(2);
|
|
}
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const buf = fs.readFileSync(localPath);
|
|
const desiredName = typeof flags.as === 'string' && flags.as.length > 0
|
|
? flags.as
|
|
: path.basename(localPath);
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: desiredName,
|
|
content: buf.toString('base64'),
|
|
encoding: 'base64',
|
|
}),
|
|
});
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
console.log(`[files] uploaded ${data?.file?.name ?? desiredName}`);
|
|
return;
|
|
}
|
|
case 'write': {
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const [id, rel] = positional;
|
|
if (!id || !rel) {
|
|
console.error('Usage: od files write <projectId> <relpath> [< stdin]');
|
|
process.exit(2);
|
|
}
|
|
// Read stdin synchronously into a buffer.
|
|
const fs = require('node:fs');
|
|
let chunks = [];
|
|
try {
|
|
const stdin = fs.readFileSync(0);
|
|
chunks = [stdin];
|
|
} catch (err) {
|
|
console.error(`stdin read failed: ${err.message ?? err}`);
|
|
process.exit(1);
|
|
}
|
|
const body = Buffer.concat(chunks);
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: rel,
|
|
content: body.toString('utf8'),
|
|
encoding: 'utf8',
|
|
}),
|
|
});
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
console.log(`[files] wrote ${data?.file?.name ?? rel}`);
|
|
return;
|
|
}
|
|
case 'delete': {
|
|
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
const [id, name] = positional;
|
|
if (!id || !name) {
|
|
console.error('Usage: od files delete <projectId> <name>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
console.log(`[files] deleted ${name}`);
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od files ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
async function runConversation(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od conversation list <projectId> List conversations in a project.
|
|
od conversation info <conversationId> Print one conversation.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
|
|
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
|
|
switch (sub) {
|
|
case 'list': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od conversation list <projectId>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
case 'info': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od conversation info <conversationId>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/conversations/${encodeURIComponent(id)}`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od conversation ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od daemon (Phase 1.5 lifecycle, plan §6 / §3.F2)
|
|
//
|
|
// `od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>]`
|
|
// - --headless: implies --no-open, never tries to launch a browser.
|
|
// The default `od` (no subcommand) keeps its
|
|
// desktop-friendly behaviour for back-compat.
|
|
// - --serve-web: same as --headless but allows the Next.js bundle to
|
|
// serve over the existing port. v1 doesn't bundle a
|
|
// separate web port; the flag is reserved so downstream
|
|
// packaged callers can branch on it.
|
|
//
|
|
// `od daemon status [--json] [--daemon-url <url>]` calls /api/daemon/status.
|
|
// `od daemon stop [--daemon-url <url>]` calls POST /api/daemon/shutdown.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runDaemon(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>] [--no-open]
|
|
Start the daemon (Phase 1.5 headless mode).
|
|
od daemon status [--json] [--daemon-url <url>]
|
|
Print the daemon's runtime snapshot.
|
|
od daemon stop [--daemon-url <url>] Send a graceful shutdown signal.
|
|
od daemon db status Print SQLite path + size + table row counts.
|
|
od daemon db verify [--quick] Run integrity_check + foreign_key_check.
|
|
od daemon db vacuum Run SQLite VACUUM to reclaim space after deletes.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--headless No browser auto-open; aliased --no-open.
|
|
--serve-web Serve the web UI over the existing port (no electron).
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: DAEMON_STRING_FLAGS, boolean: DAEMON_BOOLEAN_FLAGS });
|
|
switch (sub) {
|
|
case 'start': return runDaemonStart(flags);
|
|
case 'status': return runDaemonStatus(flags);
|
|
case 'stop': return runDaemonStop(flags);
|
|
case 'db': return runDaemonDb(rest, flags);
|
|
default:
|
|
console.error(`unknown subcommand: od daemon ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Plan §3.GG1 — `od daemon db status`. Prints a SQLite inventory
|
|
// (file path, size on disk, schema version, per-table row counts).
|
|
async function runDaemonDb(rest, flags) {
|
|
const sub = rest[0];
|
|
if (!sub || sub === 'help' || rest.includes('--help') || rest.includes('-h')) {
|
|
console.log(`Usage:
|
|
od daemon db status [--json] [--daemon-url <url>]
|
|
od daemon db verify [--quick] [--json] [--daemon-url <url>]
|
|
od daemon db vacuum [--json] [--daemon-url <url>]
|
|
|
|
status:
|
|
Prints a structured inventory of the daemon's SQLite backend:
|
|
- file path (under .od/ by default; OD_DATA_DIR overrides)
|
|
- size on disk (primary + WAL + SHM)
|
|
- schema version (user_version PRAGMA)
|
|
- per-table row counts (system tables excluded)
|
|
|
|
verify:
|
|
Runs SQLite PRAGMA integrity_check (or quick_check with --quick)
|
|
+ foreign_key_check, returns a structured issues[] report.
|
|
Exit 0 when ok=true, 4 when any issue is found.
|
|
|
|
vacuum:
|
|
Runs SQLite VACUUM to reclaim space after large delete batches
|
|
(snapshot prune, plugin uninstall, etc.). Reports before/after
|
|
sizes + elapsed ms.`);
|
|
process.exit(sub ? 0 : 2);
|
|
}
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
if (sub === 'vacuum') {
|
|
const resp = await fetch(`${base}/api/daemon/db/vacuum`, { method: 'POST' });
|
|
if (!resp.ok) {
|
|
console.error(`POST /api/daemon/db/vacuum failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.log(`[db vacuum] reclaimed ${formatBytes(data.reclaimedBytes ?? 0)} (`
|
|
+ `${formatBytes(data.beforeBytes ?? 0)} \u2192 ${formatBytes(data.afterBytes ?? 0)}, `
|
|
+ `${data.elapsedMs ?? 0}ms)`);
|
|
return;
|
|
}
|
|
if (sub === 'verify') {
|
|
const verifyFlags = parseFlags(rest.slice(1), {
|
|
string: new Set(['daemon-url']),
|
|
boolean: new Set(['help', 'h', 'json', 'quick']),
|
|
});
|
|
const url = `${base}/api/daemon/db/verify${verifyFlags.quick ? '?quick=1' : ''}`;
|
|
const resp = await fetch(url, { method: 'POST' });
|
|
if (!resp.ok) {
|
|
console.error(`POST ${url} failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
} else {
|
|
const issueCount = Array.isArray(data.issues) ? data.issues.length : 0;
|
|
console.log(`[db verify] mode=${data.mode} ok=${data.ok} issues=${issueCount} ${data.elapsedMs ?? 0}ms`);
|
|
if (issueCount > 0) {
|
|
for (const issue of data.issues) {
|
|
console.error(` [${issue.kind}] ${issue.message}`);
|
|
}
|
|
}
|
|
}
|
|
process.exit(data.ok ? 0 : 4);
|
|
}
|
|
if (sub !== 'status') {
|
|
console.error(`unknown subcommand: od daemon db ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/daemon/db`);
|
|
if (!resp.ok) {
|
|
console.error(`GET /api/daemon/db failed: ${resp.status} ${await resp.text()}`);
|
|
process.exit(1);
|
|
}
|
|
const data = await resp.json();
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
console.log(`# Daemon DB`);
|
|
console.log(` kind: ${data.kind ?? 'unknown'}`);
|
|
console.log(` location: ${data.location ?? '?'}`);
|
|
console.log(` size on disk: ${formatBytes(data.sizeBytes ?? 0)}`);
|
|
console.log(` schema version: ${data.schemaVersion ?? '(none)'}`);
|
|
console.log(` tables:`);
|
|
const tables = Array.isArray(data.tables) ? data.tables : [];
|
|
if (tables.length === 0) {
|
|
console.log(' (none)');
|
|
} else {
|
|
const longest = Math.max(...tables.map((t) => t.name.length));
|
|
for (const t of tables) {
|
|
console.log(` ${t.name.padEnd(longest)} ${t.rowCount}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
|
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MiB`;
|
|
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`;
|
|
}
|
|
|
|
async function runDaemonStart(flags) {
|
|
// The headless flag implies --no-open AND auto-applies any other
|
|
// headless-only env defaults. Because the existing default-mode boot
|
|
// already handles port / host / no-open, we forward into it by
|
|
// mutating process.argv before re-entering the boot path.
|
|
// Simpler path: re-implement the boot inline, mirroring the default.
|
|
const port = Number(flags.port ?? process.env.OD_PORT ?? 7456);
|
|
const host = String(flags.host ?? process.env.OD_BIND_HOST ?? '127.0.0.1');
|
|
const headless = Boolean(flags.headless || flags['no-open'] || flags['serve-web']);
|
|
process.env.OD_BIND_HOST = host;
|
|
process.env.OD_PORT = String(port);
|
|
const { startServer: startHeadless } = await import('./server.js');
|
|
const started = await startHeadless({ port, host, returnServer: true });
|
|
const url = started.url;
|
|
const server = started.server;
|
|
const shutdown = started.shutdown;
|
|
const closeServer = () => new Promise((resolve) => {
|
|
let resolved = false;
|
|
const resolveOnce = () => { if (!resolved) { resolved = true; resolve(); } };
|
|
const idleTimer = setTimeout(() => server.closeIdleConnections?.(), 1_000);
|
|
const hardTimer = setTimeout(() => { server.closeAllConnections?.(); resolveOnce(); }, 5_000);
|
|
idleTimer.unref?.();
|
|
hardTimer.unref?.();
|
|
server.close(() => resolveOnce());
|
|
});
|
|
let shuttingDown = false;
|
|
const stop = () => {
|
|
if (shuttingDown) process.exit(0);
|
|
shuttingDown = true;
|
|
void Promise.allSettled([
|
|
Promise.resolve().then(() => shutdown?.()),
|
|
closeServer(),
|
|
]).finally(() => process.exit(0));
|
|
};
|
|
process.on('SIGINT', stop);
|
|
process.on('SIGTERM', stop);
|
|
console.log(`[od] listening on ${url} (${headless ? 'headless' : 'desktop'})`);
|
|
if (!headless) {
|
|
const opener = process.platform === 'darwin' ? 'open'
|
|
: process.platform === 'win32' ? 'start'
|
|
: 'xdg-open';
|
|
import('node:child_process').then(({ spawn }) => {
|
|
spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
});
|
|
}
|
|
}
|
|
|
|
async function runDaemonStatus(flags) {
|
|
const base = await cliDaemonBaseUrl(flags);
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/daemon/status`);
|
|
} catch (err) {
|
|
return exitWithStructuredError({
|
|
code: 'daemon-not-running',
|
|
message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`,
|
|
});
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
console.log(`[daemon] ${data.bindHost}:${data.port} v${data.version} pid=${data.pid} plugins=${data.installedPlugins}`);
|
|
}
|
|
|
|
async function runDaemonStop(flags) {
|
|
const base = await cliDaemonBaseUrl(flags);
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/daemon/shutdown`, { method: 'POST' });
|
|
} catch (err) {
|
|
return exitWithStructuredError({
|
|
code: 'daemon-not-running',
|
|
message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`,
|
|
});
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
console.log(`[daemon] shutdown scheduled`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od atoms / od skills / od design-systems / od craft / od status
|
|
//
|
|
// Plan §3.H2 / §3.H3 / spec §12.2 — design-library + status introspection
|
|
// CLI parity. Every UI feature reachable via /api/* gets a CLI mirror
|
|
// (the §11.7 "headless = canonical" invariant).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function libraryDaemonUrl(flags) {
|
|
return cliDaemonUrl(flags);
|
|
}
|
|
|
|
async function runAtoms(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od atoms list List first-party atoms (implemented + planned).
|
|
od atoms show <id> Print one atom's metadata.
|
|
od atoms info <id> Print metadata + the bundled SKILL.md body.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
switch (sub) {
|
|
case 'list': {
|
|
const resp = await fetch(`${base}/api/atoms`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const atoms = data?.atoms ?? [];
|
|
for (const a of atoms) {
|
|
console.log(`${a.id}\t${a.status}\t[${(a.taskKinds ?? []).join(', ')}]\t${a.label}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'show': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od atoms show <id>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/atoms`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
const atom = (data?.atoms ?? []).find((a) => a.id === id);
|
|
if (!atom) {
|
|
console.error(`atom ${id} not found`);
|
|
process.exit(65);
|
|
}
|
|
process.stdout.write(JSON.stringify(atom, null, 2) + '\n');
|
|
return;
|
|
}
|
|
case 'info': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error('Usage: od atoms info <id>');
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}/api/atoms/${encodeURIComponent(id)}`);
|
|
if (resp.status === 404) {
|
|
console.error(`atom ${id} not found`);
|
|
process.exit(65);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const atom = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(atom, null, 2) + '\n');
|
|
console.log(`# ${atom.label} (${atom.id})`);
|
|
console.log(`status: ${atom.status}`);
|
|
console.log(`taskKinds: ${(atom.taskKinds ?? []).join(', ')}`);
|
|
console.log(`summary: ${atom.description}`);
|
|
if (typeof atom.skillBody === 'string' && atom.skillBody.length > 0) {
|
|
console.log('');
|
|
console.log('--- SKILL.md ---');
|
|
console.log(atom.skillBody.trimEnd());
|
|
} else {
|
|
console.log('');
|
|
console.log('(no bundled SKILL.md body found for this atom)');
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od atoms ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
async function runLibraryList(name, args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od ${name} list List ${name}.
|
|
od ${name} show <id> Print one entry.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
const apiPath = name === 'design-systems' ? '/api/design-systems' : `/api/${name}`;
|
|
switch (sub) {
|
|
case 'list': {
|
|
const resp = await fetch(`${base}${apiPath}`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const rows = data?.[name === 'design-systems' ? 'designSystems' : name] ?? [];
|
|
for (const row of rows) {
|
|
const label = row.title ?? row.name ?? row.id ?? row.label;
|
|
console.log(`${row.id}\t${label}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'show': {
|
|
const id = rest.find((a) => !a.startsWith('-'));
|
|
if (!id) {
|
|
console.error(`Usage: od ${name} show <id>`);
|
|
process.exit(2);
|
|
}
|
|
const resp = await fetch(`${base}${apiPath}/${encodeURIComponent(id)}`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od ${name} ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
async function runSkills(args) { return runLibraryList('skills', args); }
|
|
async function runDesignSystems(args) { return runLibraryList('design-systems', args); }
|
|
async function runCraft(args) { return runLibraryList('craft', args); }
|
|
|
|
async function runStatus(args) {
|
|
// Alias of `od daemon status`.
|
|
return runDaemon(['status', ...args]);
|
|
}
|
|
|
|
async function runVersion(args) {
|
|
const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/version`);
|
|
} catch (err) {
|
|
return exitWithStructuredError({
|
|
code: 'daemon-not-running',
|
|
message: `Cannot reach daemon at ${base}: ${err?.message ?? err}`,
|
|
});
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const version = typeof data?.version === 'string'
|
|
? data.version
|
|
: (data?.version?.version ?? JSON.stringify(data));
|
|
console.log(version);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od doctor / od config (Phase 4 CLI parity tail).
|
|
//
|
|
// Plan §3.I2 / spec §12.2.
|
|
//
|
|
// `od doctor` — repo-wide diagnostics. Hits /api/daemon/status, lists
|
|
// installed plugins + runs the per-plugin doctor, lists skills /
|
|
// design-systems / craft / atoms. Exits non-zero when any plugin
|
|
// doctor returns ok=false. Useful in CI: a failed exit causes the
|
|
// pipeline to surface plugin-system regressions.
|
|
//
|
|
// `od config get/set/list/unset` — wraps GET/PUT /api/app-config so a
|
|
// code agent can flip provider keys / orbit settings / pet config
|
|
// without leaving the terminal. JSON values pass through unchanged;
|
|
// scalar strings/numbers/booleans are coerced.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']);
|
|
const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
|
|
|
|
async function runDoctor(args) {
|
|
const flags = parseFlags(args, { string: CONFIG_STRING_FLAGS, boolean: CONFIG_BOOLEAN_FLAGS });
|
|
if (flags.help || flags.h) {
|
|
console.log(`Usage:
|
|
od doctor [--json] Print a daemon + plugin + design-library health summary.
|
|
|
|
Exit code is non-zero when any installed plugin's doctor returns ok=false
|
|
or the daemon cannot be reached.`);
|
|
process.exit(0);
|
|
}
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
const report = {
|
|
daemon: null,
|
|
plugins: [],
|
|
skills: [],
|
|
designSystems: [],
|
|
atoms: [],
|
|
issues: [],
|
|
};
|
|
|
|
// Daemon status
|
|
try {
|
|
const resp = await fetch(`${base}/api/daemon/status`);
|
|
if (!resp.ok) {
|
|
report.issues.push({ severity: 'error', code: 'daemon-status', message: `HTTP ${resp.status}` });
|
|
} else {
|
|
report.daemon = await resp.json();
|
|
}
|
|
} catch (err) {
|
|
report.issues.push({ severity: 'error', code: 'daemon-not-running', message: String(err?.message ?? err) });
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
} else {
|
|
console.error('[doctor] daemon unreachable:', String(err?.message ?? err));
|
|
}
|
|
process.exit(64);
|
|
}
|
|
|
|
// Library inventory
|
|
try {
|
|
const [skillsResp, dsResp, atomsResp] = await Promise.all([
|
|
fetch(`${base}/api/skills`),
|
|
fetch(`${base}/api/design-systems`),
|
|
fetch(`${base}/api/atoms`),
|
|
]);
|
|
if (skillsResp.ok) {
|
|
const data = await skillsResp.json();
|
|
report.skills = data?.skills ?? [];
|
|
}
|
|
if (dsResp.ok) {
|
|
const data = await dsResp.json();
|
|
report.designSystems = data?.designSystems ?? [];
|
|
}
|
|
if (atomsResp.ok) {
|
|
const data = await atomsResp.json();
|
|
report.atoms = data?.atoms ?? [];
|
|
}
|
|
} catch (err) {
|
|
report.issues.push({ severity: 'warn', code: 'library-list-failed', message: String(err?.message ?? err) });
|
|
}
|
|
|
|
// Plugin doctor — runs the daemon's per-plugin check on every install.
|
|
try {
|
|
const listResp = await fetch(`${base}/api/plugins`);
|
|
if (listResp.ok) {
|
|
const list = await listResp.json();
|
|
const plugins = list?.plugins ?? [];
|
|
for (const p of plugins) {
|
|
try {
|
|
const doctorResp = await fetch(`${base}/api/plugins/${encodeURIComponent(p.id)}/doctor`, { method: 'POST' });
|
|
const data = await doctorResp.json().catch(() => ({}));
|
|
report.plugins.push({ id: p.id, version: p.version, ok: !!data?.ok, issues: data?.issues ?? [] });
|
|
if (!data?.ok) {
|
|
report.issues.push({
|
|
severity: 'error',
|
|
code: 'plugin-doctor-failed',
|
|
message: `${p.id}@${p.version}: ${(data?.issues ?? []).map((i) => i.code).join(', ')}`,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
report.issues.push({
|
|
severity: 'warn',
|
|
code: 'plugin-doctor-error',
|
|
message: `${p.id}: ${err?.message ?? err}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
report.issues.push({ severity: 'warn', code: 'plugin-list-failed', message: String(err?.message ?? err) });
|
|
}
|
|
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[doctor] daemon ${report.daemon?.bindHost ?? '?'}:${report.daemon?.port ?? '?'} pid=${report.daemon?.pid ?? '?'}`);
|
|
console.log(`[doctor] plugins: ${report.plugins.length} (skills ${report.skills.length}, design-systems ${report.designSystems.length}, atoms ${report.atoms.length})`);
|
|
if (report.issues.length === 0) {
|
|
console.log('[doctor] no issues');
|
|
} else {
|
|
for (const i of report.issues) {
|
|
console.log(` [${i.severity}] ${i.code}: ${i.message}`);
|
|
}
|
|
}
|
|
}
|
|
const hasError = report.issues.some((i) => i.severity === 'error');
|
|
process.exit(hasError ? 1 : 0);
|
|
}
|
|
|
|
async function runConfig(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage:
|
|
od config list Print the full app config as JSON.
|
|
od config get <key> Print one top-level key.
|
|
od config set <key> <value> Set a top-level key (string / number / boolean).
|
|
od config set <key> --value-json '<json>'
|
|
Set a key to a JSON value.
|
|
od config unset <key> Remove a top-level key.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.
|
|
--json Emit raw JSON.`);
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
const flags = parseFlags(rest, { string: CONFIG_STRING_FLAGS, boolean: CONFIG_BOOLEAN_FLAGS });
|
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
|
|
|
const fetchConfig = async () => {
|
|
const resp = await fetch(`${base}/api/app-config`);
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
return data?.config ?? {};
|
|
};
|
|
const writeConfig = async (next) => {
|
|
const resp = await fetch(`${base}/api/app-config`, {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(next),
|
|
});
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
return (await resp.json())?.config ?? next;
|
|
};
|
|
|
|
switch (sub) {
|
|
case 'list': {
|
|
const cfg = await fetchConfig();
|
|
process.stdout.write(JSON.stringify(cfg, null, 2) + '\n');
|
|
return;
|
|
}
|
|
case 'get': {
|
|
const key = rest.find((a) => !a.startsWith('-'));
|
|
if (!key) {
|
|
console.error('Usage: od config get <key>');
|
|
process.exit(2);
|
|
}
|
|
const cfg = await fetchConfig();
|
|
const value = cfg?.[key];
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(value ?? null, null, 2) + '\n');
|
|
} else {
|
|
console.log(value === undefined ? '' : (typeof value === 'string' ? value : JSON.stringify(value, null, 2)));
|
|
}
|
|
return;
|
|
}
|
|
case 'set': {
|
|
const positional = rest.filter((a) => !a.startsWith('-')
|
|
&& a !== flags.value
|
|
&& a !== flags['value-json']);
|
|
const [key, scalarValue] = positional;
|
|
if (!key) {
|
|
console.error('Usage: od config set <key> <value> | od config set <key> --value-json <json>');
|
|
process.exit(2);
|
|
}
|
|
let parsed;
|
|
if (typeof flags['value-json'] === 'string') {
|
|
try { parsed = JSON.parse(flags['value-json']); } catch (err) {
|
|
console.error(`--value-json must be valid JSON: ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
} else if (typeof flags.value === 'string') {
|
|
parsed = coerceCliValue(flags.value);
|
|
} else if (scalarValue !== undefined) {
|
|
parsed = coerceCliValue(scalarValue);
|
|
} else {
|
|
console.error('Provide a value (positional, --value, or --value-json).');
|
|
process.exit(2);
|
|
}
|
|
const cfg = await fetchConfig();
|
|
const next = { ...cfg, [key]: parsed };
|
|
const written = await writeConfig(next);
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(written, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[config] set ${key}`);
|
|
}
|
|
return;
|
|
}
|
|
case 'unset': {
|
|
const key = rest.find((a) => !a.startsWith('-'));
|
|
if (!key) {
|
|
console.error('Usage: od config unset <key>');
|
|
process.exit(2);
|
|
}
|
|
const cfg = await fetchConfig();
|
|
const next = { ...cfg };
|
|
delete next[key];
|
|
const written = await writeConfig(next);
|
|
if (flags.json) {
|
|
process.stdout.write(JSON.stringify(written, null, 2) + '\n');
|
|
} else {
|
|
console.log(`[config] unset ${key}`);
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od config ${sub}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od memory …
|
|
//
|
|
// Headless surface for the same editable markdown memory tree shown in
|
|
// Settings. Agents can inspect what will be injected into future prompts,
|
|
// edit a node, or move a node between memory buckets without scraping the UI.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function printMemoryHelp() {
|
|
console.log(`Usage:
|
|
od memory tree list [--json]
|
|
List derived memory-tree folders and entry nodes.
|
|
|
|
od memory tree view <id> [--json]
|
|
Print one folder node or entry body.
|
|
|
|
od memory tree edit <id> [--name <title>] [--description <text>]
|
|
[--type user|feedback|project|reference]
|
|
[--body <markdown> | --body-file <path|->] [--json]
|
|
Patch an editable entry node. Folder nodes are derived from entry types.
|
|
|
|
od memory tree move <id> --type user|feedback|project|reference [--json]
|
|
Move an entry node to a different memory bucket while preserving its id.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.`);
|
|
}
|
|
|
|
function memoryPositionals(values) {
|
|
const out = [];
|
|
for (let i = 0; i < values.length; i++) {
|
|
const value = values[i];
|
|
if (!value) continue;
|
|
if (value.startsWith('--')) {
|
|
const eq = value.indexOf('=');
|
|
const key = eq >= 0 ? value.slice(2, eq) : value.slice(2);
|
|
if (eq < 0 && MEMORY_STRING_FLAGS.has(key)) i++;
|
|
continue;
|
|
}
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function readMemoryBodyFromFlags(flags) {
|
|
if (typeof flags.body === 'string') return flags.body;
|
|
if (typeof flags['body-file'] !== 'string') return undefined;
|
|
const path = flags['body-file'];
|
|
if (path === '-') {
|
|
let body = '';
|
|
for await (const chunk of process.stdin) body += chunk;
|
|
return body;
|
|
}
|
|
const { readFile } = await import('node:fs/promises');
|
|
return await readFile(path, 'utf8');
|
|
}
|
|
|
|
function formatMemoryTreeRow(node) {
|
|
return [
|
|
node.id,
|
|
node.parentId ?? '-',
|
|
node.path,
|
|
node.kind,
|
|
node.type ?? '-',
|
|
node.scope,
|
|
node.name,
|
|
].join('\t');
|
|
}
|
|
|
|
function printMemoryEntry(entry) {
|
|
console.log(`# ${entry.name}`);
|
|
console.log(`id: ${entry.id}`);
|
|
console.log(`type: ${entry.type}`);
|
|
console.log(`description: ${entry.description || '-'}`);
|
|
console.log('');
|
|
process.stdout.write(`${entry.body ?? ''}\n`);
|
|
}
|
|
|
|
async function fetchMemoryTree(base) {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/memory/tree`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
return await resp.json();
|
|
}
|
|
|
|
async function patchMemoryTreeNode(base, id, body) {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/memory/tree/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
return await resp.json();
|
|
}
|
|
|
|
async function runMemory(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
printMemoryHelp();
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const topic = args[0];
|
|
if (topic !== 'tree') {
|
|
console.error(`unknown subcommand: od memory ${topic}`);
|
|
printMemoryHelp();
|
|
process.exit(2);
|
|
}
|
|
const rest = args.slice(1);
|
|
let flags;
|
|
try {
|
|
flags = parseFlags(rest, {
|
|
string: MEMORY_STRING_FLAGS,
|
|
boolean: MEMORY_BOOLEAN_FLAGS,
|
|
});
|
|
} catch (err) {
|
|
console.error(err.message);
|
|
process.exit(2);
|
|
}
|
|
const base = await cliDaemonBaseUrl(flags);
|
|
const writeJson = (data) =>
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
const parts = memoryPositionals(rest);
|
|
const action = parts[0] ?? 'list';
|
|
|
|
if (action === 'list') {
|
|
const data = await fetchMemoryTree(base);
|
|
if (flags.json) return writeJson(data);
|
|
const tree = data.tree ?? [];
|
|
if (tree.length === 0) {
|
|
console.log('No memory tree nodes.');
|
|
return;
|
|
}
|
|
console.log('# id\tparent\tpath\tkind\ttype\tscope\tname');
|
|
for (const node of tree) console.log(formatMemoryTreeRow(node));
|
|
return;
|
|
}
|
|
|
|
if (action === 'view') {
|
|
const id = parts[1];
|
|
if (!id) {
|
|
console.error('Usage: od memory tree view <id>');
|
|
process.exit(2);
|
|
}
|
|
const treeData = await fetchMemoryTree(base);
|
|
const node = (treeData.tree ?? []).find((item) => item.id === id);
|
|
if (!node) {
|
|
console.error(`memory tree node not found: ${id}`);
|
|
process.exit(4);
|
|
}
|
|
if (node.kind === 'folder') {
|
|
if (flags.json) return writeJson({ node });
|
|
console.log(`${node.path}\t${node.name}\t${node.childrenCount ?? 0} children`);
|
|
return;
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/memory/${encodeURIComponent(id)}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
printMemoryEntry(data.entry ?? data);
|
|
return;
|
|
}
|
|
|
|
if (action === 'edit') {
|
|
const id = parts[1];
|
|
if (!id) {
|
|
console.error('Usage: od memory tree edit <id> [--name ...] [--description ...] [--type ...] [--body ...|--body-file ...]');
|
|
process.exit(2);
|
|
}
|
|
const body = {};
|
|
if (typeof flags.name === 'string') body.name = flags.name;
|
|
if (typeof flags.description === 'string') body.description = flags.description;
|
|
if (typeof flags.type === 'string') body.type = flags.type;
|
|
const nextBody = await readMemoryBodyFromFlags(flags);
|
|
if (typeof nextBody === 'string') body.body = nextBody;
|
|
if (Object.keys(body).length === 0) {
|
|
console.error('nothing to edit; pass --name, --description, --type, --body, or --body-file');
|
|
process.exit(2);
|
|
}
|
|
const data = await patchMemoryTreeNode(base, id, body);
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[memory] updated ${data.entry?.id ?? id}`);
|
|
return;
|
|
}
|
|
|
|
if (action === 'move') {
|
|
const id = parts[1];
|
|
const type = flags.type ?? parts[2];
|
|
if (!id || !type) {
|
|
console.error('Usage: od memory tree move <id> --type user|feedback|project|reference');
|
|
process.exit(2);
|
|
}
|
|
const data = await patchMemoryTreeNode(base, id, { type });
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[memory] moved ${data.entry?.id ?? id} to ${data.entry?.type ?? type}`);
|
|
return;
|
|
}
|
|
|
|
console.error(`unknown subcommand: od memory tree ${action}`);
|
|
printMemoryHelp();
|
|
process.exit(2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: od automation …
|
|
//
|
|
// Headless surface for the Automations tab. This is the dual-track contract:
|
|
// every capability the Automations UI exposes is reachable here so an
|
|
// external agent (hermes-agent, openclaw, custom Slackbot, etc.) can run
|
|
// the full lifecycle — list, create, fire, harvest, retire — without
|
|
// rendering a page. Storage is /api/routines on the local daemon; the
|
|
// "routine" name is the implementation detail, "automation" is the user-
|
|
// facing surface.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseScheduleFlag(raw) {
|
|
if (!raw || typeof raw !== 'string') {
|
|
throw new Error(
|
|
'--schedule is required. Forms: hourly:<minute> | daily:HH:MM[:TZ] | weekdays:HH:MM[:TZ] | weekly:DAY:HH:MM[:TZ]',
|
|
);
|
|
}
|
|
const parts = raw.split(':');
|
|
const kind = parts[0];
|
|
if (kind === 'hourly') {
|
|
const minute = Number(parts[1]);
|
|
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
throw new Error('--schedule hourly requires :<minute>, 0-59');
|
|
}
|
|
return { kind: 'hourly', minute };
|
|
}
|
|
if (kind === 'daily' || kind === 'weekdays') {
|
|
if (parts.length < 3) {
|
|
throw new Error(`--schedule ${kind} requires :HH:MM[:TZ]`);
|
|
}
|
|
const hh = parts[1];
|
|
const mm = parts[2];
|
|
const time = `${hh.padStart(2, '0')}:${mm.padStart(2, '0')}`;
|
|
if (!/^[0-2]\d:[0-5]\d$/.test(time)) {
|
|
throw new Error(`--schedule ${kind} time must be HH:MM (24h)`);
|
|
}
|
|
const timezone = parts.slice(3).join(':') || 'UTC';
|
|
return { kind, time, timezone };
|
|
}
|
|
if (kind === 'weekly') {
|
|
if (parts.length < 4) {
|
|
throw new Error('--schedule weekly requires :DAY:HH:MM[:TZ] (DAY is 0-6 or sun/mon/...)');
|
|
}
|
|
const dayToken = String(parts[1]).toLowerCase();
|
|
let weekday;
|
|
if (/^[0-6]$/.test(dayToken)) {
|
|
weekday = Number(dayToken);
|
|
} else if (AUTOMATION_WEEKDAY_TOKENS[dayToken] !== undefined) {
|
|
weekday = AUTOMATION_WEEKDAY_TOKENS[dayToken];
|
|
} else {
|
|
throw new Error(`--schedule weekly day must be 0-6 or sun..sat (got "${parts[1]}")`);
|
|
}
|
|
const time = `${parts[2].padStart(2, '0')}:${parts[3].padStart(2, '0')}`;
|
|
if (!/^[0-2]\d:[0-5]\d$/.test(time)) {
|
|
throw new Error('--schedule weekly time must be HH:MM (24h)');
|
|
}
|
|
const timezone = parts.slice(4).join(':') || 'UTC';
|
|
return { kind: 'weekly', weekday, time, timezone };
|
|
}
|
|
throw new Error(`--schedule kind must be hourly|daily|weekdays|weekly (got "${kind}")`);
|
|
}
|
|
|
|
function parseAutomationTarget(flags) {
|
|
const raw = flags.target;
|
|
if (raw == null) {
|
|
if (flags.project) return { mode: 'reuse', projectId: String(flags.project) };
|
|
return { mode: 'create_each_run' };
|
|
}
|
|
const value = String(raw);
|
|
if (
|
|
value === 'worktree' ||
|
|
value === 'new-project' ||
|
|
value === 'create-each-run' ||
|
|
value === 'create_each_run'
|
|
) {
|
|
return { mode: 'create_each_run' };
|
|
}
|
|
if (value === 'reuse') {
|
|
if (!flags.project) {
|
|
throw new Error('--target reuse needs --project <id>');
|
|
}
|
|
return { mode: 'reuse', projectId: String(flags.project) };
|
|
}
|
|
const eq = value.indexOf('=');
|
|
if ((value.startsWith('reuse=') || value.startsWith('reuse:')) && eq > 0) {
|
|
const projectId = value.slice(eq + 1).trim();
|
|
if (!projectId) throw new Error('--target reuse=<projectId> needs a non-empty id');
|
|
return { mode: 'reuse', projectId };
|
|
}
|
|
throw new Error(
|
|
`--target must be "new-project" or "reuse=<projectId>" (got "${value}")`,
|
|
);
|
|
}
|
|
|
|
function describeAutomationScheduleForCli(schedule) {
|
|
if (!schedule) return '-';
|
|
if (schedule.kind === 'hourly') {
|
|
return `hourly:${String(schedule.minute).padStart(2, '0')}`;
|
|
}
|
|
if (schedule.kind === 'weekly') {
|
|
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
return `weekly:${days[schedule.weekday] ?? schedule.weekday}:${schedule.time}:${schedule.timezone}`;
|
|
}
|
|
return `${schedule.kind}:${schedule.time}:${schedule.timezone}`;
|
|
}
|
|
|
|
function describeAutomationTargetForCli(target) {
|
|
if (!target) return '-';
|
|
if (target.mode === 'reuse') return `reuse=${target.projectId}`;
|
|
return 'new-project';
|
|
}
|
|
|
|
function splitAutomationIds(value) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) return [];
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const part of value.split(',')) {
|
|
const id = part.trim();
|
|
if (!id || seen.has(id)) continue;
|
|
seen.add(id);
|
|
out.push(id);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function automationContextFromFlags(flags) {
|
|
const skillIds = splitAutomationIds(flags.skill);
|
|
const pluginIds = splitAutomationIds(flags.plugin);
|
|
const mcpServerIds = splitAutomationIds(flags.mcp);
|
|
const connectorIds = splitAutomationIds(flags.connector);
|
|
const context = {
|
|
...(skillIds.length > 0 ? { skillIds } : {}),
|
|
...(pluginIds.length > 0 ? { pluginIds } : {}),
|
|
...(mcpServerIds.length > 0 ? { mcpServerIds } : {}),
|
|
...(connectorIds.length > 0 ? { connectorIds } : {}),
|
|
};
|
|
return Object.keys(context).length > 0 ? context : null;
|
|
}
|
|
|
|
function formatAutomationRow(r) {
|
|
const next = r.nextRunAt
|
|
? new Date(r.nextRunAt).toISOString()
|
|
: (r.enabled ? '-' : 'paused');
|
|
return [
|
|
r.id,
|
|
r.name,
|
|
describeAutomationScheduleForCli(r.schedule),
|
|
describeAutomationTargetForCli(r.target),
|
|
r.enabled ? 'enabled' : 'paused',
|
|
next,
|
|
].join('\t');
|
|
}
|
|
|
|
async function readPromptFromFlags(flags) {
|
|
if (typeof flags.prompt === 'string' && flags.prompt.length > 0) {
|
|
return flags.prompt;
|
|
}
|
|
if (typeof flags['prompt-file'] === 'string' && flags['prompt-file'].length > 0) {
|
|
const path = flags['prompt-file'];
|
|
if (path === '-') {
|
|
return await new Promise((resolve, reject) => {
|
|
let buf = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { buf += chunk; });
|
|
process.stdin.on('end', () => resolve(buf));
|
|
process.stdin.on('error', reject);
|
|
});
|
|
}
|
|
const { readFile } = await import('node:fs/promises');
|
|
return await readFile(path, 'utf8');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function printAutomationHelp() {
|
|
console.log(`Usage:
|
|
od automation template list List built-in automation templates.
|
|
od automation template get <id> Print one built-in automation template.
|
|
od automation source ingest --source-kind <kind> --title <title>
|
|
[--source-ref <ref>] [--template <id>]
|
|
[--body <markdown> | --body-file <path|->]
|
|
[--connector <id>] [--compression off|balanced|aggressive]
|
|
[--json]
|
|
od automation source list [--limit 20] [--json] List ingested source packets.
|
|
od automation source get <id> [--json] Print one source packet.
|
|
od automation proposal list [--status pending-review] List self-evolution proposals.
|
|
od automation proposal get <id> Print one proposal.
|
|
od automation proposal apply <id> Apply a reviewable proposal.
|
|
od automation proposal reject <id> [--reason "<why>"] Reject a reviewable proposal.
|
|
od automation list List automations.
|
|
od automation get <id> Print one automation.
|
|
od automation create --name "<title>" --prompt "<text>"
|
|
--schedule <spec>
|
|
[--target new-project|reuse=<projectId>]
|
|
[--disabled] [--json]
|
|
[--prompt-file <path|->] (alternative to --prompt)
|
|
[--skill <id>[,<id>]] [--plugin <id>[,<id>]]
|
|
[--mcp <id>[,<id>]] [--connector <id>[,<id>]]
|
|
[--agent <id>]
|
|
od automation update <id> [--name ...] [--prompt ...]
|
|
[--schedule ...] [--target ...]
|
|
[--skill ...] [--plugin ...] [--mcp ...]
|
|
[--connector ...] [--enabled|--disabled]
|
|
Patch fields.
|
|
od automation run <id> Trigger a manual run; prints projectId/conversationId.
|
|
od automation runs <id> [--limit 10] Print run history.
|
|
od automation crystallize-run <routineId> <runId> [--json] Turn a succeeded run into skill/memory proposals.
|
|
od automation pause <id> Mark disabled.
|
|
od automation resume <id> Mark enabled.
|
|
od automation delete <id> Remove the automation (history retained).
|
|
|
|
Schedule formats:
|
|
hourly:<minute> Every hour at :MM.
|
|
daily:HH:MM[:TZ] Daily at HH:MM in TZ (default UTC).
|
|
weekdays:HH:MM[:TZ] Mon-Fri at HH:MM.
|
|
weekly:DAY:HH:MM[:TZ] DAY = 0-6 or sun|mon|...|sat.
|
|
|
|
Output:
|
|
Plain text: tab-separated rows for list, human-readable lines for get / runs.
|
|
--json Raw JSON for any subcommand.
|
|
Designed so external agents (hermes-agent, openclaw, scripted jobs)
|
|
can drive the full automation lifecycle headlessly.
|
|
|
|
Common options:
|
|
--daemon-url <url> Open Design daemon HTTP base.`);
|
|
}
|
|
|
|
async function runAutomation(args) {
|
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
printAutomationHelp();
|
|
process.exit(args.length === 0 ? 2 : 0);
|
|
}
|
|
const sub = args[0];
|
|
const rest = args.slice(1);
|
|
let flags;
|
|
try {
|
|
flags = parseFlags(rest, {
|
|
string: AUTOMATION_STRING_FLAGS,
|
|
boolean: AUTOMATION_BOOLEAN_FLAGS,
|
|
});
|
|
} catch (err) {
|
|
console.error(err.message);
|
|
process.exit(2);
|
|
}
|
|
const base = await cliDaemonBaseUrl(flags);
|
|
|
|
const writeJson = (data) =>
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
|
|
const positionalArgs = (values) => {
|
|
const out = [];
|
|
for (let i = 0; i < values.length; i++) {
|
|
const value = values[i];
|
|
if (!value) continue;
|
|
if (value.startsWith('--')) {
|
|
const eq = value.indexOf('=');
|
|
const key = eq >= 0 ? value.slice(2, eq) : value.slice(2);
|
|
if (eq < 0 && AUTOMATION_STRING_FLAGS.has(key)) i++;
|
|
continue;
|
|
}
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const requireId = (label) => {
|
|
const id = positionalArgs(rest)[0];
|
|
if (!id) {
|
|
console.error(`Usage: od automation ${label} <id>`);
|
|
process.exit(2);
|
|
}
|
|
return id;
|
|
};
|
|
|
|
const readAutomationIngestBody = async () => {
|
|
const direct = await readMemoryBodyFromFlags(flags);
|
|
if (typeof direct === 'string') return direct;
|
|
return await readPromptFromFlags(flags);
|
|
};
|
|
|
|
switch (sub) {
|
|
case 'template':
|
|
case 'templates': {
|
|
const parts = positionalArgs(rest);
|
|
const action = parts[0] ?? 'list';
|
|
if (action === 'list') {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-templates`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
const templates = data.templates ?? [];
|
|
if (templates.length === 0) {
|
|
console.log('No automation templates available.');
|
|
return;
|
|
}
|
|
console.log('# id\ttitle\ttriggers\tsources\toutputs\tcompression\treview');
|
|
for (const template of templates) {
|
|
console.log([
|
|
template.id,
|
|
template.title,
|
|
(template.triggerKinds ?? []).join(','),
|
|
(template.sourceKinds ?? []).join(','),
|
|
(template.outputSinks ?? []).join(','),
|
|
template.tokenCompression,
|
|
template.reviewPolicy,
|
|
].join('\t'));
|
|
}
|
|
return;
|
|
}
|
|
if (action === 'get') {
|
|
const id = parts[1];
|
|
if (!id) {
|
|
console.error('Usage: od automation template get <id>');
|
|
process.exit(2);
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-templates/${encodeURIComponent(id)}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
return writeJson(flags.json ? data : (data.template ?? data));
|
|
}
|
|
console.error(`unknown subcommand: od automation template ${action}`);
|
|
printAutomationHelp();
|
|
process.exit(2);
|
|
}
|
|
case 'ingest':
|
|
case 'source':
|
|
case 'sources': {
|
|
const parts = positionalArgs(rest);
|
|
const action = sub === 'ingest' ? 'ingest' : (parts[0] ?? 'list');
|
|
if (action === 'ingest') {
|
|
const sourceKind = flags['source-kind'] ?? (sub === 'ingest' ? parts[0] : parts[1]);
|
|
if (!sourceKind) {
|
|
console.error('Usage: od automation source ingest --source-kind <kind> --body-file <path|->');
|
|
process.exit(2);
|
|
}
|
|
const bodyMarkdown = await readAutomationIngestBody();
|
|
if (!bodyMarkdown) {
|
|
console.error('--body, --body-file, --prompt, or --prompt-file is required');
|
|
process.exit(2);
|
|
}
|
|
const candidateSinks = typeof flags['candidate-sinks'] === 'string'
|
|
? flags['candidate-sinks'].split(',').map((item) => item.trim()).filter(Boolean)
|
|
: undefined;
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-ingestions`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
templateId: flags.template,
|
|
sourceKind,
|
|
sourceRef: flags['source-ref'],
|
|
title: flags.title ?? flags.name,
|
|
bodyMarkdown,
|
|
projectId: flags.project,
|
|
connectorId: flags.connector,
|
|
accountLabel: flags.account,
|
|
sensitivity: flags.sensitivity,
|
|
tokenCompression: flags.compression,
|
|
candidateSinks,
|
|
memoryType: flags['memory-type'],
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation source] ingested ${data.packet?.id}`);
|
|
console.log(`compression: ${data.compressionReport?.status ?? 'unknown'} (${data.compressionReport?.beforeTokens ?? 0} -> ${data.compressionReport?.afterTokens ?? 0} tokens)`);
|
|
const proposals = data.proposals ?? [];
|
|
if (proposals.length > 0) {
|
|
console.log('# proposals');
|
|
for (const proposal of proposals) {
|
|
console.log([
|
|
proposal.id,
|
|
proposal.targetKind,
|
|
proposal.action,
|
|
proposal.status,
|
|
proposal.title,
|
|
].join('\t'));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (action === 'list') {
|
|
const query = flags.limit ? `?limit=${encodeURIComponent(String(flags.limit))}` : '';
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-source-packets${query}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
const packets = data.packets ?? [];
|
|
if (packets.length === 0) {
|
|
console.log('No automation source packets.');
|
|
return;
|
|
}
|
|
console.log('# id\tkind\tcapturedAt\ttokens\ttitle');
|
|
for (const packet of packets) {
|
|
console.log([
|
|
packet.id,
|
|
packet.sourceKind,
|
|
packet.capturedAt,
|
|
packet.tokenStats?.originalTokens ?? 0,
|
|
packet.title,
|
|
].join('\t'));
|
|
}
|
|
return;
|
|
}
|
|
if (action === 'get') {
|
|
const id = parts[1];
|
|
if (!id) {
|
|
console.error('Usage: od automation source get <id>');
|
|
process.exit(2);
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-source-packets/${encodeURIComponent(id)}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
return writeJson(await resp.json());
|
|
}
|
|
console.error(`unknown subcommand: od automation source ${action}`);
|
|
printAutomationHelp();
|
|
process.exit(2);
|
|
}
|
|
case 'proposal':
|
|
case 'proposals': {
|
|
const parts = positionalArgs(rest);
|
|
const action = parts[0] ?? 'list';
|
|
if (action === 'list') {
|
|
const query = flags.status ? `?status=${encodeURIComponent(String(flags.status))}` : '';
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-proposals${query}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
const proposals = data.proposals ?? [];
|
|
if (proposals.length === 0) {
|
|
console.log('No automation proposals.');
|
|
return;
|
|
}
|
|
console.log('# id\tstatus\ttarget\taction\tupdatedAt\ttitle');
|
|
for (const proposal of proposals) {
|
|
console.log([
|
|
proposal.id,
|
|
proposal.status,
|
|
proposal.targetKind,
|
|
proposal.action,
|
|
proposal.updatedAt,
|
|
proposal.title,
|
|
].join('\t'));
|
|
}
|
|
return;
|
|
}
|
|
if (action === 'get') {
|
|
const id = parts[1];
|
|
if (!id) {
|
|
console.error('Usage: od automation proposal get <id>');
|
|
process.exit(2);
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/automation-proposals/${encodeURIComponent(id)}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
return writeJson(await resp.json());
|
|
}
|
|
if (action === 'apply' || action === 'reject') {
|
|
const id = parts[1];
|
|
if (!id) {
|
|
console.error(`Usage: od automation proposal ${action} <id>`);
|
|
process.exit(2);
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(
|
|
`${base}/api/automation-proposals/${encodeURIComponent(id)}/${action}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: action === 'reject'
|
|
? JSON.stringify({ reason: flags.reason ?? '' })
|
|
: '{}',
|
|
},
|
|
);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation proposal] ${action === 'apply' ? 'applied' : 'rejected'} ${data.proposal?.id ?? id}`);
|
|
return;
|
|
}
|
|
console.error(`unknown subcommand: od automation proposal ${action}`);
|
|
printAutomationHelp();
|
|
process.exit(2);
|
|
}
|
|
case 'list': {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
const routines = data.routines ?? [];
|
|
if (routines.length === 0) {
|
|
console.log('No automations. Create one with `od automation create --name "..." --prompt "..." --schedule daily:09:00`.');
|
|
return;
|
|
}
|
|
console.log('# id\tname\tschedule\ttarget\tstatus\tnextRun');
|
|
for (const r of routines) console.log(formatAutomationRow(r));
|
|
return;
|
|
}
|
|
case 'get': {
|
|
const id = requireId('get');
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
writeJson(data.routine ?? data);
|
|
return;
|
|
}
|
|
case 'runs': {
|
|
const id = requireId('runs');
|
|
const limit = Number(flags.limit) > 0 ? Number(flags.limit) : 20;
|
|
let resp;
|
|
try {
|
|
resp = await fetch(
|
|
`${base}/api/routines/${encodeURIComponent(id)}/runs?limit=${limit}`,
|
|
);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
const runs = data.runs ?? [];
|
|
if (runs.length === 0) {
|
|
console.log(`No runs yet for ${id}.`);
|
|
return;
|
|
}
|
|
console.log('# runId\tstatus\ttrigger\tstartedAt\tprojectId\tconversationId');
|
|
for (const r of runs) {
|
|
console.log([
|
|
r.id,
|
|
r.status,
|
|
r.trigger,
|
|
new Date(r.startedAt).toISOString(),
|
|
r.projectId,
|
|
r.conversationId,
|
|
].join('\t'));
|
|
}
|
|
return;
|
|
}
|
|
case 'crystallize-run': {
|
|
const parts = positionalArgs(rest);
|
|
const routineId = parts[0];
|
|
const runId = parts[1];
|
|
if (!routineId || !runId) {
|
|
console.error('Usage: od automation crystallize-run <routineId> <runId> [--json]');
|
|
process.exit(2);
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(
|
|
`${base}/api/routines/${encodeURIComponent(routineId)}/runs/${encodeURIComponent(runId)}/crystallize`,
|
|
{ method: 'POST' },
|
|
);
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
const data = await resp.json();
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation] crystallized ${runId}`);
|
|
console.log(`sourcePacket\t${data.packet?.id ?? ''}`);
|
|
console.log(`compression\t${data.compressionReport?.status ?? 'unknown'}\t${data.compressionReport?.beforeTokens ?? 0}->${data.compressionReport?.afterTokens ?? 0}`);
|
|
const proposals = data.proposals ?? [];
|
|
if (proposals.length > 0) {
|
|
console.log('# proposals');
|
|
for (const proposal of proposals) {
|
|
console.log([
|
|
proposal.id,
|
|
proposal.targetKind,
|
|
proposal.action,
|
|
proposal.status,
|
|
proposal.title,
|
|
].join('\t'));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
case 'create': {
|
|
const name = typeof flags.name === 'string' ? flags.name.trim() : '';
|
|
if (!name) {
|
|
console.error('--name is required');
|
|
process.exit(2);
|
|
}
|
|
const prompt = (await readPromptFromFlags(flags)) || '';
|
|
if (!prompt.trim()) {
|
|
console.error('--prompt or --prompt-file is required');
|
|
process.exit(2);
|
|
}
|
|
let schedule;
|
|
let target;
|
|
try {
|
|
schedule = parseScheduleFlag(flags.schedule);
|
|
target = parseAutomationTarget(flags);
|
|
} catch (err) {
|
|
console.error(err.message);
|
|
process.exit(2);
|
|
}
|
|
const body = {
|
|
name,
|
|
prompt: prompt.trim(),
|
|
schedule,
|
|
target,
|
|
enabled: !flags.disabled,
|
|
};
|
|
const context = automationContextFromFlags(flags);
|
|
const skillIds = splitAutomationIds(flags.skill);
|
|
if (skillIds.length > 0) body.skillId = skillIds[0];
|
|
if (context) body.context = context;
|
|
if (flags.agent) body.agentId = String(flags.agent);
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`POST /api/routines failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation] created ${data.routine?.id}`);
|
|
console.log(formatAutomationRow(data.routine));
|
|
return;
|
|
}
|
|
case 'update': {
|
|
const id = requireId('update');
|
|
const patch = {};
|
|
if (typeof flags.name === 'string') patch.name = flags.name.trim();
|
|
const promptPatch = await readPromptFromFlags(flags);
|
|
if (promptPatch != null) patch.prompt = promptPatch.trim();
|
|
if (flags.schedule) {
|
|
try {
|
|
patch.schedule = parseScheduleFlag(flags.schedule);
|
|
} catch (err) {
|
|
console.error(err.message);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
if (flags.target || flags.project) {
|
|
try {
|
|
patch.target = parseAutomationTarget(flags);
|
|
} catch (err) {
|
|
console.error(err.message);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
if (flags.disabled) patch.enabled = false;
|
|
if (flags.enabled) patch.enabled = true;
|
|
const context = automationContextFromFlags(flags);
|
|
if (context) {
|
|
const skillIds = splitAutomationIds(flags.skill);
|
|
if (skillIds.length > 0) patch.skillId = skillIds[0];
|
|
patch.context = context;
|
|
}
|
|
if (Object.keys(patch).length === 0) {
|
|
console.error('update needs at least one of --name --prompt(--prompt-file) --schedule --target --skill --plugin --mcp --connector --enabled --disabled');
|
|
process.exit(2);
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(patch),
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`PATCH /api/routines/${id} failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation] updated ${id}`);
|
|
console.log(formatAutomationRow(data.routine));
|
|
return;
|
|
}
|
|
case 'pause':
|
|
case 'resume': {
|
|
const id = requireId(sub);
|
|
const enabled = sub === 'resume';
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
console.error(`PATCH /api/routines/${id} failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation] ${sub}d ${id}`);
|
|
return;
|
|
}
|
|
case 'run': {
|
|
const id = requireId('run');
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}/run`, {
|
|
method: 'POST',
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok && resp.status !== 202) {
|
|
console.error(`POST /api/routines/${id}/run failed: ${resp.status} ${JSON.stringify(data)}`);
|
|
process.exit(1);
|
|
}
|
|
if (flags.json) return writeJson(data);
|
|
console.log(`[automation] triggered ${id}`);
|
|
if (data.projectId) console.log(`projectId\t${data.projectId}`);
|
|
if (data.conversationId) console.log(`conversationId\t${data.conversationId}`);
|
|
if (data.agentRunId) console.log(`agentRunId\t${data.agentRunId}`);
|
|
return;
|
|
}
|
|
case 'delete': {
|
|
const id = requireId('delete');
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${base}/api/routines/${encodeURIComponent(id)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (err) {
|
|
surfaceFetchError(err, base);
|
|
process.exit(3);
|
|
}
|
|
if (!resp.ok) return structuredHttpFailure(resp);
|
|
if (flags.json) return writeJson({ ok: true, id });
|
|
console.log(`[automation] deleted ${id}`);
|
|
return;
|
|
}
|
|
default:
|
|
console.error(`unknown subcommand: od automation ${sub}`);
|
|
printAutomationHelp();
|
|
process.exit(2);
|
|
}
|
|
}
|