bytespost-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,747 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * canvas-bridge CLI (SLICE 1 + 2).
4
+ *
5
+ * canvas-bridge serve start the daemon (blocks)
6
+ * canvas-bridge health GET /v1/health, print JSON
7
+ * canvas-bridge ls GET /v1/tabs
8
+ * canvas-bridge screenshot -o <file> GET /v1/screenshot, write PNG
9
+ * canvas-bridge draw <json|file> POST /v1/draw, print {nodeIds}
10
+ * canvas-bridge draw rect --x ... POST /v1/draw with a typed spec
11
+ *
12
+ * `draw` takes either an inline JSON string (a `{nodes:[...]}` body, or a bare
13
+ * array of specs) or a path to a .json file containing the same.
14
+ *
15
+ * Talks to the daemon over plain localhost HTTP. Intentionally tiny.
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import { readFile, writeFile } from 'node:fs/promises';
19
+ import { resolvePort, startDaemon } from './daemon.js';
20
+ import { COMMAND_SPECS, GLOBAL_FLAGS, findCommandSpec, getTopLevelCommands, } from './commandRegistry.js';
21
+ const HOST = '127.0.0.1';
22
+ function baseUrl() {
23
+ return `http://${HOST}:${resolvePort()}`;
24
+ }
25
+ function fail(message) {
26
+ console.error(`canvas-bridge: ${message}`);
27
+ process.exit(1);
28
+ }
29
+ const BOOLEAN_FLAGS = new Set(['help', 'json', 'dry-run', 'yes']);
30
+ const AI_MODES = new Set(['plan', 'auto', 'explain']);
31
+ function isFlagValueToken(token) {
32
+ return token !== '-o' && !token.startsWith('--');
33
+ }
34
+ function parseArgs(args) {
35
+ const positionals = [];
36
+ const flags = {};
37
+ let trailing = [];
38
+ for (let i = 0; i < args.length; i += 1) {
39
+ const arg = args[i];
40
+ if (arg === '--') {
41
+ trailing = args.slice(i + 1);
42
+ positionals.push(...trailing);
43
+ break;
44
+ }
45
+ if (arg.startsWith('--')) {
46
+ const eq = arg.indexOf('=');
47
+ if (eq !== -1) {
48
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
49
+ continue;
50
+ }
51
+ const key = arg.slice(2);
52
+ const next = args[i + 1];
53
+ if (BOOLEAN_FLAGS.has(key)) {
54
+ flags[key] = true;
55
+ continue;
56
+ }
57
+ if (key === 'ai') {
58
+ if (next && AI_MODES.has(next)) {
59
+ flags[key] = next;
60
+ i += 1;
61
+ }
62
+ else {
63
+ flags[key] = true;
64
+ }
65
+ continue;
66
+ }
67
+ if (next && isFlagValueToken(next)) {
68
+ flags[key] = next;
69
+ i += 1;
70
+ }
71
+ else {
72
+ flags[key] = true;
73
+ }
74
+ continue;
75
+ }
76
+ if (arg === '-h') {
77
+ flags.help = true;
78
+ continue;
79
+ }
80
+ if (arg === '-o' && i + 1 < args.length) {
81
+ flags.output = args[i + 1];
82
+ i += 1;
83
+ continue;
84
+ }
85
+ positionals.push(arg);
86
+ }
87
+ return { positionals, flags, trailing };
88
+ }
89
+ function flagString(flags, name) {
90
+ const value = flags[name];
91
+ return typeof value === 'string' ? value : undefined;
92
+ }
93
+ function flagNumber(flags, name, required = false) {
94
+ const raw = flagString(flags, name);
95
+ if (raw === undefined) {
96
+ if (required)
97
+ fail(`--${name} is required`);
98
+ return undefined;
99
+ }
100
+ const value = Number(raw);
101
+ if (!Number.isFinite(value)) {
102
+ fail(`--${name} must be a number`);
103
+ }
104
+ return value;
105
+ }
106
+ function withTab(path, flags) {
107
+ const tab = flagString(flags, 'tab');
108
+ if (!tab) {
109
+ return path;
110
+ }
111
+ const join = path.includes('?') ? '&' : '?';
112
+ return `${path}${join}tab=${encodeURIComponent(tab)}`;
113
+ }
114
+ async function requestText(path, init) {
115
+ const res = await requestRaw(path, init);
116
+ const text = await res.text();
117
+ return { res, text };
118
+ }
119
+ async function requestRaw(path, init) {
120
+ let res;
121
+ try {
122
+ res = await fetch(`${baseUrl()}${path}`, init);
123
+ }
124
+ catch (error) {
125
+ fail(`cannot reach daemon at ${baseUrl()} (is it running? \`canvas-bridge serve\`) — ${error instanceof Error ? error.message : String(error)}`);
126
+ }
127
+ return res;
128
+ }
129
+ function printJsonOrText(text, json) {
130
+ if (json) {
131
+ console.log(text);
132
+ return;
133
+ }
134
+ const parsed = JSON.parse(text);
135
+ console.log(formatHuman(parsed));
136
+ }
137
+ function formatHuman(value) {
138
+ if (value &&
139
+ typeof value === 'object' &&
140
+ Array.isArray(value.tabs)) {
141
+ const tabs = value.tabs;
142
+ const rows = tabs.map((tab) => [
143
+ String(tab.id ?? ''),
144
+ String(tab.app ?? ''),
145
+ String(tab.document?.name ?? tab.title ?? ''),
146
+ String(tab.canvasType ?? ''),
147
+ tab.ready === false ? 'no' : 'yes',
148
+ String(tab.url ?? ''),
149
+ ]);
150
+ return [
151
+ ['TAB ID', 'APP', 'PROJECT', 'TYPE', 'READY', 'URL'].join('\t'),
152
+ ...rows.map((row) => row.join('\t')),
153
+ ].join('\n');
154
+ }
155
+ return JSON.stringify(value, null, 2);
156
+ }
157
+ function commandPositionals(parsed) {
158
+ if (parsed.trailing.length === 0) {
159
+ return parsed.positionals;
160
+ }
161
+ return parsed.positionals.slice(0, -parsed.trailing.length);
162
+ }
163
+ function isHelpRequested(parsed) {
164
+ return parsed.flags.help === true;
165
+ }
166
+ function isAiRequested(parsed) {
167
+ return parsed.flags.ai !== undefined;
168
+ }
169
+ function normalizeAiMode(value) {
170
+ if (value === undefined || value === true) {
171
+ return 'plan';
172
+ }
173
+ if (value === 'plan' || value === 'auto' || value === 'explain') {
174
+ return value;
175
+ }
176
+ fail(`--ai must be one of: plan, auto, explain`);
177
+ }
178
+ function printHelp(positionals, json) {
179
+ const { spec } = findCommandSpec(positionals);
180
+ if (json) {
181
+ console.log(JSON.stringify(spec ? serializeCommandHelp(spec) : serializeRootHelp(positionals), null, 2));
182
+ return;
183
+ }
184
+ console.log(spec ? renderCommandHelp(spec) : renderRootHelp(positionals));
185
+ }
186
+ function serializeRootHelp(prefix = []) {
187
+ const prefixText = prefix.join(' ');
188
+ const commands = prefix.length === 0
189
+ ? getTopLevelCommands()
190
+ : COMMAND_SPECS
191
+ .filter((spec) => prefix.every((part, index) => spec.path[index] === part))
192
+ .map((spec) => ({ name: spec.path.slice(prefix.length).join(' '), summary: spec.summary }))
193
+ .filter((entry) => entry.name.length > 0);
194
+ return {
195
+ command: prefixText ? `canvas ${prefixText}` : 'canvas',
196
+ summary: prefixText ? 'Canvas command group.' : 'AI-native local canvas control plane.',
197
+ usage: prefixText ? `canvas ${prefixText} <command> [flags]` : 'canvas <command> [flags]',
198
+ commands,
199
+ globalFlags: GLOBAL_FLAGS,
200
+ };
201
+ }
202
+ function serializeCommandHelp(spec) {
203
+ return {
204
+ command: `canvas ${spec.path.join(' ')}`,
205
+ summary: spec.summary,
206
+ description: spec.description,
207
+ usage: spec.usage,
208
+ args: spec.args ?? [],
209
+ flags: [...(spec.flags ?? []), ...GLOBAL_FLAGS],
210
+ examples: spec.examples ?? [],
211
+ output: spec.output ?? null,
212
+ ai: spec.ai ?? { supported: false },
213
+ };
214
+ }
215
+ function renderRootHelp(prefix = []) {
216
+ const help = serializeRootHelp(prefix);
217
+ const commands = help.commands ?? [];
218
+ return [
219
+ `${help.command}`,
220
+ '',
221
+ String(help.summary),
222
+ '',
223
+ 'Usage:',
224
+ ` ${help.usage}`,
225
+ '',
226
+ 'Commands:',
227
+ ...commands.map((command) => ` ${command.name.padEnd(18)} ${command.summary}`),
228
+ '',
229
+ 'Global flags:',
230
+ ...GLOBAL_FLAGS.map(formatFlagHelp),
231
+ '',
232
+ 'Examples:',
233
+ ' canvas ls --json',
234
+ ' canvas draw rect --tab tab_abc123 --x 100 --y 100 --w 240 --h 120',
235
+ ' canvas draw rect --tab tab_abc123 --ai --prompt-file ./instruction.txt --json',
236
+ '',
237
+ 'Use `canvas <command> --help` or `canvas <command> -h` for details.',
238
+ ].join('\n');
239
+ }
240
+ function renderCommandHelp(spec) {
241
+ const lines = [
242
+ `canvas ${spec.path.join(' ')}`,
243
+ '',
244
+ spec.description,
245
+ '',
246
+ 'Usage:',
247
+ ` ${spec.usage}`,
248
+ ];
249
+ if (spec.args?.length) {
250
+ lines.push('', 'Arguments:', ...spec.args.map((arg) => ` <${arg.name}>${arg.required ? ' (required)' : ''} ${arg.description}`));
251
+ }
252
+ const flags = spec.flags ?? [];
253
+ if (flags.length) {
254
+ lines.push('', 'Command flags:', ...flags.map(formatFlagHelp));
255
+ }
256
+ lines.push('', 'Global flags:', ...GLOBAL_FLAGS.map(formatFlagHelp));
257
+ if (spec.examples?.length) {
258
+ lines.push('', 'Examples:', ...spec.examples.map((example) => ` ${example.command}${example.description ? ` # ${example.description}` : ''}`));
259
+ }
260
+ if (spec.output) {
261
+ lines.push('', 'Output:', ` ${spec.output}`);
262
+ }
263
+ if (spec.ai?.supported) {
264
+ lines.push('', 'AI:', ' Add --ai to compile the command, explicit flags, and prompt text into an internal plan.', ' Long prompts: use --prompt-file <path>, --prompt <text>, stdin with --prompt-file -, or trailing text after --.', ` Context: ${spec.ai.context.join(', ') || 'none'}.`, ' Images are not supported in v1.');
265
+ }
266
+ return lines.join('\n');
267
+ }
268
+ function formatFlagHelp(flag) {
269
+ const names = [`--${flag.name}${flag.valueName && flag.type !== 'boolean' ? ` <${flag.valueName}>` : ''}`];
270
+ if (flag.alias) {
271
+ names.push(`-${flag.alias}${flag.valueName && flag.type !== 'boolean' ? ` <${flag.valueName}>` : ''}`);
272
+ }
273
+ const required = flag.required === true ? ' required.' : flag.required === 'when-multiple-tabs' ? ' required with multiple tabs.' : '';
274
+ return ` ${names.join(', ').padEnd(30)} ${flag.description}${required}`;
275
+ }
276
+ async function readAiPrompt(parsed) {
277
+ const parts = [];
278
+ const sources = [];
279
+ const inline = flagString(parsed.flags, 'prompt');
280
+ if (inline !== undefined) {
281
+ parts.push(inline);
282
+ sources.push('--prompt');
283
+ }
284
+ const file = flagString(parsed.flags, 'prompt-file');
285
+ if (file !== undefined) {
286
+ if (isUnsupportedImagePromptFile(file)) {
287
+ fail('image prompt files are not supported in --ai v1; pass a UTF-8 text file instead');
288
+ }
289
+ const text = file === '-' ? await readStdin() : await readFile(file, 'utf8');
290
+ parts.push(text);
291
+ sources.push(file === '-' ? 'stdin' : `file:${file}`);
292
+ }
293
+ if (parsed.trailing.length > 0) {
294
+ parts.push(parsed.trailing.join(' '));
295
+ sources.push('--');
296
+ }
297
+ return { text: parts.join('\n\n'), sources };
298
+ }
299
+ function isUnsupportedImagePromptFile(path) {
300
+ return /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i.test(path);
301
+ }
302
+ async function readStdin() {
303
+ const chunks = [];
304
+ for await (const chunk of process.stdin) {
305
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
306
+ }
307
+ return Buffer.concat(chunks).toString('utf8');
308
+ }
309
+ function summarizePrompt(prompt) {
310
+ const sha256 = createHash('sha256').update(prompt.text).digest('hex');
311
+ return {
312
+ sources: prompt.sources,
313
+ charCount: prompt.text.length,
314
+ sha256,
315
+ preview: prompt.text.length > 240 ? `${prompt.text.slice(0, 240)}...` : prompt.text,
316
+ };
317
+ }
318
+ function commandFlagsForPlan(flags, spec) {
319
+ const globalNames = new Set(GLOBAL_FLAGS.map((flag) => flag.name));
320
+ const flagSpecs = new Map((spec.flags ?? []).map((flag) => [flag.name, flag]));
321
+ const result = {};
322
+ for (const [key, value] of Object.entries(flags)) {
323
+ if (globalNames.has(key)) {
324
+ continue;
325
+ }
326
+ const flagSpec = flagSpecs.get(key);
327
+ if (flagSpec?.type === 'number' && typeof value === 'string') {
328
+ const parsed = Number(value);
329
+ result[key] = Number.isFinite(parsed) ? parsed : value;
330
+ continue;
331
+ }
332
+ if (flagSpec?.type === 'json' && typeof value === 'string') {
333
+ try {
334
+ result[key] = JSON.parse(value);
335
+ }
336
+ catch {
337
+ result[key] = value;
338
+ }
339
+ continue;
340
+ }
341
+ result[key] = value;
342
+ }
343
+ return result;
344
+ }
345
+ async function cmdAiPlan(parsed) {
346
+ const positionals = commandPositionals(parsed);
347
+ const { spec, consumed } = findCommandSpec(positionals);
348
+ if (!spec) {
349
+ fail(`unknown command for --ai: ${positionals.join(' ') || '(none)'}`);
350
+ }
351
+ if (!spec.ai?.supported) {
352
+ fail(`--ai is not supported for canvas ${spec.path.join(' ')}`);
353
+ }
354
+ const mode = normalizeAiMode(parsed.flags.ai);
355
+ const prompt = await readAiPrompt(parsed);
356
+ const commandArgs = positionals.slice(consumed);
357
+ const commandFlags = commandFlagsForPlan(parsed.flags, spec);
358
+ const plan = {
359
+ ok: true,
360
+ command: `canvas ${spec.path.join(' ')}`,
361
+ ai: {
362
+ mode,
363
+ planner: 'local-command-plan-v1',
364
+ imageInputSupported: false,
365
+ prompt: summarizePrompt(prompt),
366
+ context: {
367
+ requested: spec.ai.context,
368
+ tab: typeof commandFlags.tab === 'string' ? commandFlags.tab : null,
369
+ },
370
+ },
371
+ plan: {
372
+ version: 1,
373
+ risk: spec.ai.safety,
374
+ internalCommands: [
375
+ {
376
+ op: spec.ai.operation,
377
+ args: commandArgs,
378
+ flags: commandFlags,
379
+ promptRef: prompt.text.length > 0 ? 'ai.prompt' : null,
380
+ },
381
+ ],
382
+ constraints: {
383
+ explicitFlagsAreHardConstraints: true,
384
+ imagesSupported: false,
385
+ },
386
+ },
387
+ execution: {
388
+ status: 'planned_only',
389
+ reason: mode === 'auto'
390
+ ? 'Remote paid AI execution is not wired in v1; this command produced the internal plan.'
391
+ : 'Plan mode does not mutate the canvas.',
392
+ },
393
+ };
394
+ if (parsed.flags.json === true) {
395
+ console.log(JSON.stringify(plan, null, 2));
396
+ return;
397
+ }
398
+ console.log(renderAiPlanHuman(plan));
399
+ }
400
+ function renderAiPlanHuman(plan) {
401
+ return [
402
+ `AI plan: ${plan.command}`,
403
+ '',
404
+ `Mode: ${plan.ai.mode}`,
405
+ `Risk: ${plan.plan.risk}`,
406
+ `Prompt: ${String(plan.ai.prompt.charCount)} chars from ${plan.ai.prompt.sources.join(', ') || 'none'}`,
407
+ `Context: ${plan.ai.context.requested.join(', ') || 'none'}${plan.ai.context.tab ? ` (tab ${plan.ai.context.tab})` : ''}`,
408
+ '',
409
+ 'Internal commands:',
410
+ ...plan.plan.internalCommands.map((command, index) => ` ${index + 1}. ${command.op} ${JSON.stringify({ args: command.args, flags: command.flags })}`),
411
+ '',
412
+ `${plan.execution.status}: ${plan.execution.reason}`,
413
+ 'Use --json for a stable machine-readable plan.',
414
+ ].join('\n');
415
+ }
416
+ async function cmdServe() {
417
+ const handle = await startDaemon();
418
+ const shutdown = () => {
419
+ void handle.close().then(() => process.exit(0));
420
+ };
421
+ process.on('SIGINT', shutdown);
422
+ process.on('SIGTERM', shutdown);
423
+ // Keep the process alive; startDaemon's listeners hold it open.
424
+ }
425
+ async function cmdHealth() {
426
+ const { res, text } = await requestText('/v1/health');
427
+ console.log(text);
428
+ if (!res.ok) {
429
+ process.exit(1);
430
+ }
431
+ }
432
+ async function cmdLs(args) {
433
+ const parsed = parseArgs(args);
434
+ const { res, text } = await requestText('/v1/tabs');
435
+ printJsonOrText(text, parsed.flags.json === true);
436
+ if (!res.ok) {
437
+ process.exit(1);
438
+ }
439
+ }
440
+ async function cmdScreenshot(args) {
441
+ const parsed = parseArgs(args);
442
+ const out = flagString(parsed.flags, 'output');
443
+ if (!out) {
444
+ fail('screenshot requires -o <file>');
445
+ }
446
+ const res = await requestRaw(withTab('/v1/screenshot', parsed.flags));
447
+ if (!res.ok) {
448
+ const detail = await res.text().catch(() => '');
449
+ fail(`screenshot failed: HTTP ${res.status} ${detail}`);
450
+ }
451
+ const buf = Buffer.from(await res.arrayBuffer());
452
+ await writeFile(out, buf);
453
+ console.log(`wrote ${buf.byteLength} bytes -> ${out}`);
454
+ }
455
+ async function cmdDraw(args) {
456
+ const parsedArgs = parseArgs(args);
457
+ const [kindOrPayload] = parsedArgs.positionals;
458
+ if (kindOrPayload && ['rect', 'text', 'frame', 'line'].includes(kindOrPayload)) {
459
+ await cmdDrawTyped(kindOrPayload, parsedArgs.flags);
460
+ return;
461
+ }
462
+ const arg = kindOrPayload;
463
+ if (!arg) {
464
+ fail('draw requires a JSON string or a path to a .json file');
465
+ }
466
+ // Inline JSON if it looks like JSON; otherwise treat it as a file path.
467
+ const looksInline = /^\s*[[{]/.test(arg);
468
+ let text;
469
+ if (looksInline) {
470
+ text = arg;
471
+ }
472
+ else {
473
+ try {
474
+ text = await readFile(arg, 'utf8');
475
+ }
476
+ catch (error) {
477
+ fail(`cannot read draw spec file ${arg}: ${error instanceof Error ? error.message : String(error)}`);
478
+ }
479
+ }
480
+ let parsed;
481
+ try {
482
+ parsed = JSON.parse(text);
483
+ }
484
+ catch (error) {
485
+ fail(`draw spec is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
486
+ }
487
+ // Accept either a full {nodes:[...]} body or a bare array of specs.
488
+ const payload = Array.isArray(parsed) ? { nodes: parsed } : parsed;
489
+ const { res, text: body } = await requestText(withTab('/v1/draw', parsedArgs.flags), {
490
+ method: 'POST',
491
+ headers: { 'content-type': 'application/json' },
492
+ body: JSON.stringify(payload),
493
+ });
494
+ console.log(body);
495
+ if (!res.ok) {
496
+ process.exit(1);
497
+ }
498
+ }
499
+ async function cmdDrawTyped(kind, flags) {
500
+ const node = buildDrawSpec(kind, flags);
501
+ const { res, text } = await requestText(withTab('/v1/draw', flags), {
502
+ method: 'POST',
503
+ headers: { 'content-type': 'application/json' },
504
+ body: JSON.stringify({ nodes: [node] }),
505
+ });
506
+ if (flags.json === true) {
507
+ console.log(text);
508
+ }
509
+ else {
510
+ printJsonOrText(text, false);
511
+ }
512
+ if (!res.ok) {
513
+ process.exit(1);
514
+ }
515
+ }
516
+ function buildDrawSpec(kind, flags) {
517
+ if (kind === 'rect' || kind === 'frame') {
518
+ return compact({
519
+ type: kind,
520
+ x: flagNumber(flags, 'x', true),
521
+ y: flagNumber(flags, 'y', true),
522
+ w: flagNumber(flags, 'w', true),
523
+ h: flagNumber(flags, 'h', true),
524
+ fill: flagString(flags, 'fill'),
525
+ stroke: flagString(flags, 'stroke'),
526
+ strokeWidth: flagNumber(flags, 'stroke-width'),
527
+ radius: flagNumber(flags, 'radius'),
528
+ name: flagString(flags, 'name'),
529
+ });
530
+ }
531
+ if (kind === 'text') {
532
+ return compact({
533
+ type: 'text',
534
+ x: flagNumber(flags, 'x', true),
535
+ y: flagNumber(flags, 'y', true),
536
+ text: flagString(flags, 'text') ?? '',
537
+ fontSize: flagNumber(flags, 'font-size'),
538
+ color: flagString(flags, 'color'),
539
+ w: flagNumber(flags, 'w'),
540
+ name: flagString(flags, 'name'),
541
+ });
542
+ }
543
+ if (kind === 'line') {
544
+ return compact({
545
+ type: 'line',
546
+ x1: flagNumber(flags, 'x1', true),
547
+ y1: flagNumber(flags, 'y1', true),
548
+ x2: flagNumber(flags, 'x2', true),
549
+ y2: flagNumber(flags, 'y2', true),
550
+ stroke: flagString(flags, 'stroke'),
551
+ width: flagNumber(flags, 'width'),
552
+ name: flagString(flags, 'name'),
553
+ });
554
+ }
555
+ fail(`unsupported draw type ${kind}`);
556
+ }
557
+ async function cmdTab(args) {
558
+ const parsed = parseArgs(args);
559
+ const [subcommand, tabId] = parsed.positionals;
560
+ if (subcommand !== 'info' || !tabId) {
561
+ fail('usage: canvas-bridge tab info <tab> [--json]');
562
+ }
563
+ const { res, text } = await requestText(`/v1/tabs/${encodeURIComponent(tabId)}`);
564
+ printJsonOrText(text, parsed.flags.json === true);
565
+ if (!res.ok)
566
+ process.exit(1);
567
+ }
568
+ async function cmdDocument(args) {
569
+ const parsed = parseArgs(args);
570
+ const [subcommand] = parsed.positionals;
571
+ const path = subcommand === 'nodes' ? '/v1/document/nodes' : subcommand === 'get' ? '/v1/document' : null;
572
+ if (!path) {
573
+ fail('usage: canvas-bridge document <get|nodes> --tab <tab> [--json]');
574
+ }
575
+ const { res, text } = await requestText(withTab(path, parsed.flags));
576
+ printJsonOrText(text, parsed.flags.json === true);
577
+ if (!res.ok)
578
+ process.exit(1);
579
+ }
580
+ async function cmdNode(args) {
581
+ const parsed = parseArgs(args);
582
+ const [subcommand, nodeId] = parsed.positionals;
583
+ if (!nodeId || !['get', 'delete', 'update'].includes(subcommand ?? '')) {
584
+ fail('usage: canvas-bridge node <get|delete|update> <nodeId> --tab <tab>');
585
+ }
586
+ const path = withTab(`/v1/node/${encodeURIComponent(nodeId)}`, parsed.flags);
587
+ if (subcommand === 'get' || subcommand === 'delete') {
588
+ const { res, text } = await requestText(path, { method: subcommand === 'delete' ? 'DELETE' : 'GET' });
589
+ printJsonOrText(text, parsed.flags.json === true);
590
+ if (!res.ok)
591
+ process.exit(1);
592
+ return;
593
+ }
594
+ const patchText = flagString(parsed.flags, 'patch') ?? flagString(parsed.flags, 'data');
595
+ if (!patchText) {
596
+ fail('node update requires --patch JSON');
597
+ }
598
+ const { res, text } = await requestText(path, {
599
+ method: 'PATCH',
600
+ headers: { 'content-type': 'application/json' },
601
+ body: patchText,
602
+ });
603
+ printJsonOrText(text, parsed.flags.json === true);
604
+ if (!res.ok)
605
+ process.exit(1);
606
+ }
607
+ async function cmdSelect(args) {
608
+ const parsed = parseArgs(args);
609
+ const [subcommand, nodeId] = parsed.positionals;
610
+ if (subcommand === 'clear') {
611
+ const { res, text } = await requestText(withTab('/v1/select/clear', parsed.flags), { method: 'POST' });
612
+ printJsonOrText(text, parsed.flags.json === true);
613
+ if (!res.ok)
614
+ process.exit(1);
615
+ return;
616
+ }
617
+ if (subcommand !== 'set' || !nodeId) {
618
+ fail('usage: canvas-bridge select <set nodeId|clear> --tab <tab>');
619
+ }
620
+ const { res, text } = await requestText(withTab('/v1/select', parsed.flags), {
621
+ method: 'POST',
622
+ headers: { 'content-type': 'application/json' },
623
+ body: JSON.stringify({ nodeId }),
624
+ });
625
+ printJsonOrText(text, parsed.flags.json === true);
626
+ if (!res.ok)
627
+ process.exit(1);
628
+ }
629
+ async function cmdViewport(args) {
630
+ const parsed = parseArgs(args);
631
+ const [subcommand] = parsed.positionals;
632
+ if (subcommand === 'screenshot') {
633
+ await cmdScreenshot(args.slice(1));
634
+ return;
635
+ }
636
+ const path = subcommand === 'fit' ? '/v1/viewport/fit' : subcommand === 'zoom' ? '/v1/viewport/zoom' : null;
637
+ if (!path) {
638
+ fail('usage: canvas-bridge viewport <screenshot|fit|zoom> --tab <tab>');
639
+ }
640
+ const body = subcommand === 'zoom'
641
+ ? JSON.stringify({ zoom: flagNumber(parsed.flags, 'to', true) })
642
+ : '{}';
643
+ const { res, text } = await requestText(withTab(path, parsed.flags), {
644
+ method: 'POST',
645
+ headers: { 'content-type': 'application/json' },
646
+ body,
647
+ });
648
+ printJsonOrText(text, parsed.flags.json === true);
649
+ if (!res.ok)
650
+ process.exit(1);
651
+ }
652
+ async function cmdBuilding(args) {
653
+ const parsed = parseArgs(args);
654
+ const [buildingType] = parsed.positionals;
655
+ if (!buildingType) {
656
+ fail('usage: canvas-bridge building <wall|room|slab|roof|door|window|zone|furniture> --tab <tab> [flags]');
657
+ }
658
+ const input = compact({
659
+ startX: flagNumber(parsed.flags, 'start-x'),
660
+ startZ: flagNumber(parsed.flags, 'start-z'),
661
+ endX: flagNumber(parsed.flags, 'end-x'),
662
+ endZ: flagNumber(parsed.flags, 'end-z'),
663
+ x: flagNumber(parsed.flags, 'x'),
664
+ y: flagNumber(parsed.flags, 'y'),
665
+ z: flagNumber(parsed.flags, 'z'),
666
+ width: flagNumber(parsed.flags, 'w') ?? flagNumber(parsed.flags, 'width'),
667
+ depth: flagNumber(parsed.flags, 'd') ?? flagNumber(parsed.flags, 'depth'),
668
+ label: flagString(parsed.flags, 'label'),
669
+ type: flagString(parsed.flags, 'type'),
670
+ color: flagString(parsed.flags, 'color'),
671
+ });
672
+ const { res, text } = await requestText(withTab('/v1/building', parsed.flags), {
673
+ method: 'POST',
674
+ headers: { 'content-type': 'application/json' },
675
+ body: JSON.stringify({ buildingType, input }),
676
+ });
677
+ printJsonOrText(text, parsed.flags.json === true);
678
+ if (!res.ok)
679
+ process.exit(1);
680
+ }
681
+ function compact(value) {
682
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
683
+ }
684
+ async function main() {
685
+ const rawArgs = process.argv.slice(2);
686
+ const parsed = parseArgs(rawArgs);
687
+ const positionals = commandPositionals(parsed);
688
+ if (isHelpRequested(parsed) || positionals.length === 0) {
689
+ printHelp(positionals, parsed.flags.json === true);
690
+ return;
691
+ }
692
+ if (isAiRequested(parsed)) {
693
+ await cmdAiPlan(parsed);
694
+ return;
695
+ }
696
+ const [command] = positionals;
697
+ const commandIndex = rawArgs.findIndex((arg) => arg === command);
698
+ const rest = commandIndex === -1
699
+ ? rawArgs.slice(1)
700
+ : [...rawArgs.slice(0, commandIndex), ...rawArgs.slice(commandIndex + 1)];
701
+ switch (command) {
702
+ case 'serve':
703
+ case 'start':
704
+ await cmdServe();
705
+ return;
706
+ case 'mcp': {
707
+ const { runMcpServer } = await import('./mcp.js');
708
+ await runMcpServer();
709
+ return;
710
+ }
711
+ case 'health':
712
+ case 'status':
713
+ await cmdHealth();
714
+ return;
715
+ case 'ls':
716
+ await cmdLs(rest);
717
+ return;
718
+ case 'tab':
719
+ await cmdTab(rest);
720
+ return;
721
+ case 'screenshot':
722
+ await cmdScreenshot(rest);
723
+ return;
724
+ case 'draw':
725
+ await cmdDraw(rest);
726
+ return;
727
+ case 'document':
728
+ await cmdDocument(rest);
729
+ return;
730
+ case 'node':
731
+ await cmdNode(rest);
732
+ return;
733
+ case 'select':
734
+ await cmdSelect(rest);
735
+ return;
736
+ case 'viewport':
737
+ await cmdViewport(rest);
738
+ return;
739
+ case 'building':
740
+ await cmdBuilding(rest);
741
+ return;
742
+ default:
743
+ console.error('usage: canvas <serve|start|mcp|health|status|ls|tab|document|node|select|viewport|building|screenshot|draw>');
744
+ process.exit(command ? 1 : 0);
745
+ }
746
+ }
747
+ void main();