forked from nexu-io/open-design
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.ts
More file actions
9977 lines (9524 loc) · 383 KB
/
Copy pathcli.ts
File metadata and controls
9977 lines (9524 loc) · 383 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// @ts-nocheck
import { readFileSync, writeFileSync } from 'node:fs';
import { basename } from 'node:path';
import { runDaemonCliStartup, startDaemonRuntime } from './daemon-startup.js';
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
import { runArtifactsCli } from './artifacts-cli.js';
import { runProjectHandoff } from './handoff-cli.js';
import { runConnectorsToolCli } from './tools-connectors-cli.js';
import { runDesignSystemsToolCli } from './tools-design-systems-cli.js';
import { DESIGN_SYSTEMS_USAGE, isDesignSystemsHelpArg } from './cli-help/index.js';
import { BRAND_USAGE, isBrandHelpArg } from './cli-help/index.js';
import { parseDesignSystemRenameArgs } from './design-systems/rename-args.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
import { splitResearchSubcommand } from './research/cli-args.js';
import { resolveDaemonUrl } from './daemon-url.js';
import { requestJsonIpc } from '@open-design/sidecar';
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
import { EXPORT_FORMATS, EXPORT_IMAGE_FORMATS } from '@open-design/contracts';
import { buildExportCliRequestBody, buildExportCliResultEnvelope, resolveExportCliDeckMode } from './export-cli-request.js';
import { exportRoutePath } from './export-cli-routing.js';
import {
AGENT_SLUGS,
isAgentSlug,
planAgentInstall,
applyJsonInstall,
removeJsonInstall,
} from './mcp-agent-install.js';
const argv = process.argv.slice(2);
const RESUME_CONTINUE_PROMPT =
'The previous turn was interrupted by a transient failure. ' +
'If your last response was cut off, continue it from where you left off ' +
'and keep any work already completed; otherwise complete the original ' +
'request. Inspect the current project files as needed before making ' +
'further changes.';
// ---- 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',
]);
// Hoisted next to MCP_*_FLAGS for the same TDZ reason as the MEDIA flags
// above: `od mcp install <agent>` dispatches through SUBCOMMAND_MAP during
// top-level module evaluation, and runMcpInstall references these `const`
// Sets — defining them next to runMcpInstall lower in the file would hit
// the TDZ.
const MCP_INSTALL_STRING_FLAGS = new Set([
'daemon-url',
'name',
]);
const MCP_INSTALL_CLI_PROBE_FLAG = 'open-design-cli-probe';
const MCP_INSTALL_CLI_PROBE_TOKEN = 'open-design-cli:mcp-install:v1';
const MCP_INSTALL_BOOLEAN_FLAGS = new Set([
'help',
'h',
MCP_INSTALL_CLI_PROBE_FLAG,
'json',
'print',
'dry-run',
'uninstall',
'remove',
]);
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',
'name',
]);
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`.
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']);
// `od library …` (OD Library asset registry). Hoisted so the dispatcher can
// parse flags without hitting a temporal-dead-zone on these sets.
const LIBRARY_ASSET_STRING_FLAGS = new Set([
'daemon-url', 'kind', 'tag', 'source', 'date', 'query', 'project', 'label', 'out', 'dir',
]);
const LIBRARY_ASSET_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const DIAGNOSTICS_STRING_FLAGS = new Set(['daemon-url', 'output']);
const DIAGNOSTICS_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']);
const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const AMR_STRING_FLAGS = new Set(['daemon-url']);
const AMR_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'refresh']);
const PROJECT_STRING_FLAGS = new Set([
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
'pending-prompt', 'project', 'conversation', 'message', 'prompt',
'prompt-file', 'path', 'dir', 'as',
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor',
'title', 'label', 'against', 'seed-from', 'fork-after', 'mode',
'source',
]);
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
// `od templates …` mirrors NewProjectPanel / ExamplesTab. Same surface,
// same /api/templates store. The CLI form is the embeddability contract:
// external agents (hermes-agent, openclaw, ...) can snapshot, list, or
// remove user-saved project templates without going through the web UI.
const TEMPLATES_STRING_FLAGS = new Set([
'daemon-url', 'name', 'description',
]);
const TEMPLATES_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
// `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',
// `od memory profile set` reads structured fields verbatim and/or a prose
// body; `--field "Label=Value"` is repeatable (scanned manually below since
// parseFlags collapses duplicate keys). `--prompt-file <path|->` mirrors the
// long-prose embeddability contract used by `od automation`/`od brand`.
'field', 'prompt-file', 'assertion', 'check', 'rationale',
// `od memory rule suggest` distils annotations into rule proposals: a single
// `--note` plus optional target context, or a `--prompt-file` carrying a JSON
// array of annotations / newline-separated notes.
'note', 'target', 'file', 'current-text',
// `od memory config` toggles accept true|false values (string, not boolean)
// so an agent can set OR clear a hook in one shape: `--profile false`.
'enabled', 'profile', 'rewrite', 'verify', 'extraction',
]);
const MEMORY_BOOLEAN_FLAGS = new Set([
'help', 'h', 'json',
]);
const SHARE_STRING_FLAGS = new Set([
'daemon-url', 'url', 'title', 'text', 'copy-text', 'locale', 'platform',
]);
const SHARE_BOOLEAN_FLAGS = new Set([
'help', 'h', 'json',
]);
// Defined near the top because `runFigma` is reachable through the
// top-of-file SUBCOMMAND_MAP dispatch during module evaluation; a `const`
// further down would still be in TDZ when the handler reads it.
const FIGMA_STRING_FLAGS = new Set([
'daemon-url', 'project', 'file', 'figma-url', 'notes', 'prompt', 'prompt-file',
]);
const FIGMA_BOOLEAN_FLAGS = new Set([
'help', 'h', 'json', 'build',
]);
// `od brand …` mirrors the Brands library + New Brand modal. Same surface,
// same /api/brands store. The CLI form is the embeddability contract: an
// external agent (hermes-agent, openclaw, scripted job) can extract, list,
// inspect, and remove brands headlessly without rendering the web UI.
// Hoisted next to the other dispatch-touched flag sets because runBrand is
// reachable through the top-of-file SUBCOMMAND_MAP dispatch, which runs during
// module evaluation — a const declared further down would still be in TDZ.
const BRAND_STRING_FLAGS = new Set([
'daemon-url', 'prompt-file', 'project', 'locale',
'html-file', 'css-file', 'base-url',
]);
const BRAND_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,
'desktop-auth-pending': 74,
'desktop-import-token-rejected': 75,
};
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,
amr: runAmr,
research: runResearch,
plugin: runPlugin,
ui: runUi,
marketplace: runMarketplace,
share: runShare,
brand: runBrand,
brands: runBrand,
project: runProject,
automation: runAutomation,
automations: runAutomation,
memory: runMemory,
run: runRun,
files: runFiles,
templates: runTemplates,
conversation: runConversation,
chat: runChat,
daemon: runDaemon,
atoms: runAtoms,
skills: runSkills,
'design-systems': runDesignSystems,
craft: runCraft,
diagnostics: runDiagnostics,
export: runExport,
status: runStatus,
version: runVersion,
'whats-new': runWhatsNew,
doctor: runDoctor,
config: runConfig,
library: runLibrary,
figma: runFigma,
};
const EXPORT_STRING_FLAGS = new Set([
'daemon-url', 'project', 'format', 'out', 'output', 'image-format', 'title', 'file',
]);
const EXPORT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'deck', 'page', 'no-deck']);
// EXPORT_FORMATS / EXPORT_IMAGE_FORMATS are the shared contract DTO (single
// source of truth for the web/daemon/CLI export surface), imported above.
function printExportHelp() {
console.log(`Usage:
od export <file> --project <id> --format <fmt> [options]
Programmatic export of an HTML/deck artifact to PDF, image, or PPTX. Runs
entirely from the rendered design (no model/agent calls). Rasterization uses
the desktop runtime's bundled Chromium, so a desktop/packaged runtime must be
reachable; otherwise the command reports that the renderer is unavailable.
Formats: ${EXPORT_FORMATS.join(', ')}
Options:
--project <id> Project id (required)
--format <fmt> One of: ${EXPORT_FORMATS.join(' | ')} (required)
--out <path> Write the file here (defaults to the suggested name)
--image-format <fmt> png | jpeg (for --format image)
--deck Treat the artifact as a multi-slide deck
--page, --no-deck Treat the artifact as a normal scrollable page
--title <title> Title used for metadata / default filename
--json Print a machine-readable result envelope
--daemon-url <url> Override daemon URL
Examples:
od export index.html --project p1 --format pdf --out page.pdf
od export slide.html --project p1 --format image --image-format png --out slide.png
od export deck.html --project p1 --format pptx --out deck.pptx`);
}
async function runExport(args) {
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
printExportHelp();
process.exit(args.length === 0 ? 2 : 0);
}
let flags;
try {
flags = parseFlags(args, { string: EXPORT_STRING_FLAGS, boolean: EXPORT_BOOLEAN_FLAGS });
} catch (err) {
console.error(err.message);
process.exit(2);
}
const pos = positionalArgs(args, EXPORT_STRING_FLAGS);
const file = flags.file || pos[0];
const projectId = flags.project || process.env.OD_PROJECT_ID;
const format = flags.format;
if (!file || !projectId || !format) {
printExportHelp();
process.exit(2);
}
if (!(EXPORT_FORMATS as readonly string[]).includes(format)) {
console.error(`invalid --format: ${format} (expected ${EXPORT_FORMATS.join(' | ')})`);
process.exit(2);
}
if (flags['image-format'] && !(EXPORT_IMAGE_FORMATS as readonly string[]).includes(flags['image-format'])) {
console.error(`invalid --image-format: ${flags['image-format']} (expected ${EXPORT_IMAGE_FORMATS.join(' | ')})`);
process.exit(2);
}
if (flags['image-format'] && format !== 'image') {
console.error('--image-format is only valid with --format image');
process.exit(2);
}
const base = await cliDaemonBaseUrl(flags);
// All three formats rasterize through the desktop screenshot renderer so the
// CLI matches the UI exactly. In particular `pdf` uses `/export/pdf-image`
// (one raster page per deck slide / per viewport for a page) — NOT the generic
// `/export` vector `printToPDF` path, which drops CJK glyphs in the packaged
// runtime and is the bug this feature exists to avoid.
const exportPath = exportRoutePath(format);
let deckMode;
try {
deckMode = resolveExportCliDeckMode({
format,
deck: flags.deck === true,
page: flags.page === true,
noDeck: flags['no-deck'] === true,
});
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
const requestBody = buildExportCliRequestBody({
fileName: file,
format,
deck: deckMode,
...(format === 'image' && flags['image-format'] ? { imageFormat: flags['image-format'] } : {}),
...(flags.title ? { title: flags.title } : {}),
});
let resp;
try {
resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/${exportPath}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(requestBody),
});
} catch (err) {
surfaceFetchError(err, base);
process.exit(3);
}
if (!resp.ok) return structuredHttpFailure(resp);
const buffer = Buffer.from(await resp.arrayBuffer());
let out = flags.out || flags.output;
if (!out) {
const cd = resp.headers.get('content-disposition') || '';
const star = /filename\*=UTF-8''([^;]+)/i.exec(cd);
const plain = /filename="([^"]+)"/i.exec(cd);
if (star && star[1]) {
try { out = decodeURIComponent(star[1]); } catch { out = plain && plain[1] ? plain[1] : null; }
} else if (plain && plain[1]) {
out = plain[1];
}
if (!out) {
const ext = format === 'image'
? (flags['image-format'] === 'jpeg' ? 'jpg' : 'png')
: format === 'pptx' ? 'pptx' : 'pdf';
out = `artifact.${ext}`;
}
}
const { writeFile } = await import('node:fs/promises');
await writeFile(out, buffer);
if (flags.json) {
return process.stdout.write(
JSON.stringify(buildExportCliResultEnvelope({ path: out, bytes: buffer.length, format }), null, 2) + '\n',
);
}
console.log(`wrote ${out} (${buffer.length} bytes)`);
}
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 plugin publish-repo <folder>
Create/update the author's GitHub repo for a local plugin folder.
od plugin open-design-pr <folder>
Push a community-catalog branch and open the Open Design PR form.
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 share <open-design|url> [options]
Build localized social-share targets for the Open Design repo or a
deployed project URL. Use --json for scripted integrations.
od ui <list|show|respond|revoke|prefill> [args]
Read and answer GenUI surfaces (form / choice / confirmation / oauth-prompt) headlessly.
od chat new --project <id> [--seed-from <cid>] [--fork-after <mid>] [--title "<t>"] [--json]
Create a Side Chat: a new conversation that inherits another
conversation's context by copying its messages (--seed-from), optionally
stopping at one message (--fork-after). Mirrors the web chat fork action.
od diagnostics export [<path>] [--json]
Bundle daemon/web/desktop logs, machine info, and recent crash reports
into a zip for support tickets. Same output as Settings → About →
Export diagnostics.
od export <file> --project <id> --format <pdf|image|pptx> [--out <path>]
Programmatically export an HTML/deck artifact to PDF, image, or PPTX
(no model/agent calls). Mirrors the web Download menu; rasterization uses
the desktop runtime's bundled Chromium.
"$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, 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 amr …
// ---------------------------------------------------------------------------
async function runAmr(args) {
const sub = args[0];
if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) {
console.log(`Usage:
od amr status [--refresh] [--json]
Options:
--daemon-url <url> Open Design daemon HTTP base.
--refresh Bypass the daemon's short wallet display cache.
--json Emit raw JSON.`);
process.exit(sub === 'help' || args.includes('--help') || args.includes('-h') ? 0 : 2);
}
const rest = args.slice(1);
const flags = parseFlags(rest, { string: AMR_STRING_FLAGS, boolean: AMR_BOOLEAN_FLAGS });
const base = await cliDaemonBaseUrl(flags);
switch (sub) {
case 'status': {
const query = flags.refresh ? '?refresh=1' : '';
const statusResp = await fetch(`${base}/api/integrations/vela/status`);
if (!statusResp.ok) return structuredHttpFailure(statusResp);
const status = await statusResp.json();
let wallet = null;
if (status?.loggedIn && (!status?.account?.balanceUsd || flags.refresh)) {
const walletResp = await fetch(`${base}/api/integrations/vela/wallet${query}`);
if (walletResp.ok) wallet = await walletResp.json();
else if (flags.refresh && !status?.account?.balanceUsd) return structuredHttpFailure(walletResp);
}
const merged = {
...status,
user: status?.user ?? wallet?.user ?? null,
account:
status?.loggedIn && wallet?.status === 'available'
? {
...(status?.account ?? {}),
balanceUsd: status?.account?.balanceUsd ?? wallet.balanceUsd,
}
: status?.account,
wallet,
};
if (flags.json) return process.stdout.write(JSON.stringify(merged, null, 2) + '\n');
const account = merged?.user?.email ?? merged?.user?.id ?? '-';
console.log(`AMR account\t${account}`);
console.log(`Profile\t${merged?.profile ?? '-'}`);
if (merged?.account?.plan) console.log(`Plan\t${merged.account.plan}`);
if (merged?.account?.balanceUsd) {
console.log(`Wallet balance\t$${merged.account.balanceUsd}`);
if (wallet?.updatedAt || wallet?.fetchedAt) {
console.log(`Updated\t${wallet.updatedAt ?? wallet.fetchedAt}`);
}
console.log(`Source\t${wallet?.source ?? 'status_account'}`);
return;
}
console.log(`Wallet balance\tunavailable`);
console.log(`Status\t${wallet?.status ?? (merged?.loggedIn ? 'logged_in' : 'signed_out')}`);
if (wallet?.error?.message) console.log(`Reason\t${wallet.error.message}`);
return;
}
default:
console.error(`unknown subcommand: od amr ${sub}`);
process.exit(2);
}
}
// ---------------------------------------------------------------------------
// 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;
const token = process.env.OD_TOOL_TOKEN;
if (!projectId && !token) {
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 = token
? `${daemonUrl.replace(/\/$/, '')}/api/tools/media/generate`
: `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
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, { totalBudgetMs: 120_000 });
}
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
const totalBudgetMs = typeof options.totalBudgetMs === 'number' ? options.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),