@tuongaz/seeflow 0.1.47 → 0.1.52

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.
@@ -0,0 +1,772 @@
1
+ // Machine-readable command catalogue. Powers `seeflow help` and
2
+ // `seeflow help <command>` so AI agents and downstream tools can discover
3
+ // every subcommand without scraping the human help text.
4
+
5
+ import { zodToJsonSchema } from 'zod-to-json-schema';
6
+ import { EXIT_CODE_BY_KIND, exitCodeForKind } from './cli-helpers.ts';
7
+ import {
8
+ ConnectorPatchBodySchema,
9
+ ConnectorsBulkBodySchema,
10
+ CreateProjectBodySchema,
11
+ NodePatchBodySchema,
12
+ NodesBulkBodySchema,
13
+ PositionBodySchema,
14
+ RegisterBodySchema,
15
+ ReorderBodySchema,
16
+ } from './operations.ts';
17
+
18
+ export interface CommandFlag {
19
+ /** Flag name without the leading `--`. */
20
+ name: string;
21
+ /** Placeholder shown in synopsis (e.g. `<n>`, `<path>`, `<JSON>`). */
22
+ valuePlaceholder?: string;
23
+ description: string;
24
+ required?: boolean;
25
+ }
26
+
27
+ export interface CommandArg {
28
+ /** Positional argument name (no angle brackets). */
29
+ name: string;
30
+ required: boolean;
31
+ description: string;
32
+ }
33
+
34
+ export type CommandCategory =
35
+ | 'lifecycle'
36
+ | 'flows'
37
+ | 'nodes'
38
+ | 'connectors'
39
+ | 'project'
40
+ | 'live'
41
+ | 'meta';
42
+
43
+ export type CommandOutputKind = 'json' | 'text' | 'stream';
44
+
45
+ export interface CommandManifestEntry {
46
+ name: string;
47
+ synopsis: string;
48
+ description: string;
49
+ category: CommandCategory;
50
+ args: CommandArg[];
51
+ flags: CommandFlag[];
52
+ body?: {
53
+ /** Name of a Zod schema exported from operations.ts (resolved to JSON
54
+ * Schema in the rendered JSON output). */
55
+ schemaRef?: string;
56
+ /** Concrete example body the caller can copy. */
57
+ example?: unknown;
58
+ };
59
+ /** Shape of stdout. Default 'json' (envelope {ok:true,...}). 'text' for
60
+ * human-readable lifecycle output. 'stream' for SSE-driven runs. */
61
+ outputKind?: CommandOutputKind;
62
+ outputs: {
63
+ okExample?: unknown;
64
+ errorKinds?: string[];
65
+ };
66
+ requiresStudio: boolean;
67
+ examples: string[];
68
+ }
69
+
70
+ const BODY_FLAGS: CommandFlag[] = [
71
+ { name: 'json', valuePlaceholder: '<JSON>', description: 'Inline JSON body' },
72
+ { name: 'file', valuePlaceholder: '<path>', description: 'Read JSON body from file' },
73
+ { name: 'stdin', description: 'Read JSON body from stdin' },
74
+ ];
75
+
76
+ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
77
+ // ---- lifecycle ---------------------------------------------------------
78
+ {
79
+ name: 'start',
80
+ synopsis: 'seeflow start [--port <n>] [--foreground] [--debug]',
81
+ description: 'Start the SeeFlow Studio server. Default when no command is given.',
82
+ category: 'lifecycle',
83
+ args: [],
84
+ flags: [
85
+ { name: 'port', valuePlaceholder: '<n>', description: 'Listen on port n (default: 4321)' },
86
+ { name: 'foreground', description: 'Run attached to the terminal (default: background)' },
87
+ { name: 'debug', description: 'Verbose logs + pipe daemon output to ~/.seeflow/seeflow.log' },
88
+ ],
89
+ outputKind: 'text',
90
+ outputs: { okExample: { url: 'http://localhost:4321', port: 4321, pid: 12345 } },
91
+ requiresStudio: false,
92
+ examples: ['seeflow start', 'seeflow start --port 8080 --foreground'],
93
+ },
94
+ {
95
+ name: 'stop',
96
+ synopsis: 'seeflow stop',
97
+ description: 'Stop a background studio instance (no-op if none is running).',
98
+ category: 'lifecycle',
99
+ args: [],
100
+ flags: [],
101
+ outputKind: 'text',
102
+ outputs: { okExample: { stopped: true, pid: 12345 } },
103
+ requiresStudio: false,
104
+ examples: ['seeflow stop'],
105
+ },
106
+ // ---- meta --------------------------------------------------------------
107
+ {
108
+ name: 'version',
109
+ synopsis: 'seeflow version',
110
+ description: 'Print the CLI version.',
111
+ category: 'meta',
112
+ args: [],
113
+ flags: [],
114
+ outputs: { okExample: { version: '0.1.47' } },
115
+ requiresStudio: false,
116
+ examples: ['seeflow version'],
117
+ },
118
+ {
119
+ name: 'help',
120
+ synopsis: 'seeflow help [<command>]',
121
+ description:
122
+ 'Show CLI help. With no args lists every command grouped by category. ' +
123
+ "With a command name shows that command's synopsis, flags, body schema, " +
124
+ 'output shape, error kinds, and examples.',
125
+ category: 'meta',
126
+ args: [{ name: 'command', required: false, description: 'Name of a command to drill into' }],
127
+ flags: [],
128
+ outputs: {},
129
+ requiresStudio: false,
130
+ examples: ['seeflow help', 'seeflow help nodes:add'],
131
+ },
132
+ // ---- flows -------------------------------------------------------------
133
+ {
134
+ name: 'register',
135
+ synopsis: 'seeflow register [--path <dir>] [--flow <file>]',
136
+ description:
137
+ 'Register a demo repo with the studio. Reads <repoPath>/<flow> (defaulting ' +
138
+ 'to ./.seeflow/flow.json), validates the schema, and writes an entry to ' +
139
+ '~/.seeflow/registry.json. Alias of flows:register.',
140
+ category: 'flows',
141
+ args: [],
142
+ flags: [
143
+ { name: 'path', valuePlaceholder: '<dir>', description: 'Path to repo root (default: cwd)' },
144
+ {
145
+ name: 'flow',
146
+ valuePlaceholder: '<file>',
147
+ description: 'Path to flow.json relative to repo root (default: .seeflow/flow.json)',
148
+ },
149
+ ],
150
+ outputs: {
151
+ okExample: { id: 'abc12345', slug: 'checkout', sdk: { outcome: 'skipped', filePath: null } },
152
+ errorKinds: ['fileNotFound', 'badJson', 'badSchema', 'sdkWriteFailed'],
153
+ },
154
+ requiresStudio: false,
155
+ examples: ['seeflow register', 'seeflow register --path ./my-app'],
156
+ },
157
+ {
158
+ name: 'flows:register',
159
+ synopsis: 'seeflow flows:register [--path <dir>] [--flow <file>]',
160
+ description: 'Register a demo repo. Identical behaviour to `register`.',
161
+ category: 'flows',
162
+ args: [],
163
+ flags: [
164
+ { name: 'path', valuePlaceholder: '<dir>', description: 'Path to repo root (default: cwd)' },
165
+ {
166
+ name: 'flow',
167
+ valuePlaceholder: '<file>',
168
+ description: 'Path to flow.json relative to repo root (default: .seeflow/flow.json)',
169
+ },
170
+ ],
171
+ body: { schemaRef: 'RegisterBody' },
172
+ outputs: {
173
+ okExample: { id: 'abc12345', slug: 'checkout', sdk: { outcome: 'skipped', filePath: null } },
174
+ errorKinds: ['fileNotFound', 'badJson', 'badSchema', 'sdkWriteFailed'],
175
+ },
176
+ requiresStudio: false,
177
+ examples: ['seeflow flows:register --path ./my-app'],
178
+ },
179
+ {
180
+ name: 'flows:list',
181
+ synopsis: 'seeflow flows:list',
182
+ description: 'List every registered flow with id, slug, name, repoPath, and valid flag.',
183
+ category: 'flows',
184
+ args: [],
185
+ flags: [],
186
+ outputs: { okExample: { flows: [{ id: 'abc12345', slug: 'checkout', name: 'Checkout' }] } },
187
+ requiresStudio: false,
188
+ examples: ['seeflow flows:list'],
189
+ },
190
+ {
191
+ name: 'flows:summary',
192
+ synopsis: 'seeflow flows:summary',
193
+ description:
194
+ 'Cheap discovery — returns { id, name, description } only. Pair with flows:get ' +
195
+ 'or flows:graph to drill into one flow.',
196
+ category: 'flows',
197
+ args: [],
198
+ flags: [],
199
+ outputs: { okExample: { flows: [{ id: 'abc12345', name: 'Checkout' }] } },
200
+ requiresStudio: false,
201
+ examples: ['seeflow flows:summary'],
202
+ },
203
+ {
204
+ name: 'flows:get',
205
+ synopsis: 'seeflow flows:get <flowId>',
206
+ description: 'Get the full merged flow definition and on-disk state for one flow.',
207
+ category: 'flows',
208
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
209
+ flags: [],
210
+ outputs: { errorKinds: ['notFound', 'fileNotFound'] },
211
+ requiresStudio: false,
212
+ examples: ['seeflow flows:get abc12345'],
213
+ },
214
+ {
215
+ name: 'flows:graph',
216
+ synopsis: 'seeflow flows:graph <flowId>',
217
+ description:
218
+ 'Get nodes + connectors for one flow without inlining per-node file-backed ' +
219
+ 'content (detail.md, view.html). Cheap topology read.',
220
+ category: 'flows',
221
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
222
+ flags: [],
223
+ outputs: { errorKinds: ['notFound', 'fileNotFound', 'badJson', 'badSchema'] },
224
+ requiresStudio: false,
225
+ examples: ['seeflow flows:graph abc12345'],
226
+ },
227
+ {
228
+ name: 'flows:delete',
229
+ synopsis: 'seeflow flows:delete <flowId>',
230
+ description: 'Unregister a flow from the studio (the on-disk file is left untouched).',
231
+ category: 'flows',
232
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
233
+ flags: [],
234
+ outputs: { okExample: { ok: true }, errorKinds: ['notFound'] },
235
+ requiresStudio: false,
236
+ examples: ['seeflow flows:delete abc12345'],
237
+ },
238
+ {
239
+ name: 'flows:layout',
240
+ synopsis: 'seeflow flows:layout <flowId> [--json | --file | --stdin]',
241
+ description:
242
+ 'Compute an ELK layout for the flow and write style.json next to flow.json. ' +
243
+ 'Body is optional — `{ options? }` shape. Empty body uses defaults.',
244
+ category: 'flows',
245
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
246
+ flags: BODY_FLAGS,
247
+ body: { example: { options: { 'elk.direction': 'RIGHT' } } },
248
+ outputs: {
249
+ okExample: { ok: true },
250
+ errorKinds: ['flowNotFound', 'fileNotFound', 'badJson', 'badSchema', 'writeFailed'],
251
+ },
252
+ requiresStudio: false,
253
+ examples: ['seeflow flows:layout abc12345'],
254
+ },
255
+ {
256
+ name: 'flows:play',
257
+ synopsis: 'seeflow flows:play <flowId> <nodeId>',
258
+ description: 'Trigger a play action on one node. Requires a running studio.',
259
+ category: 'live',
260
+ args: [
261
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
262
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
263
+ ],
264
+ flags: [{ name: 'no-start', description: 'Fail if the studio is not already running' }],
265
+ outputKind: 'stream',
266
+ outputs: {},
267
+ requiresStudio: true,
268
+ examples: ['seeflow flows:play abc12345 api-checkout'],
269
+ },
270
+ // ---- project -----------------------------------------------------------
271
+ {
272
+ name: 'projects:create',
273
+ synopsis: 'seeflow projects:create --name <name>',
274
+ description:
275
+ 'Scaffold a new project under ~/.seeflow/<slug>/ with an empty flow.json and ' +
276
+ 'register it. The flow id is returned for follow-up writes.',
277
+ category: 'project',
278
+ args: [],
279
+ flags: [
280
+ { name: 'name', valuePlaceholder: '<name>', description: 'Project name', required: true },
281
+ ],
282
+ body: { schemaRef: 'CreateProjectBody' },
283
+ outputs: {
284
+ okExample: { id: 'abc12345', slug: 'checkout', scaffolded: true },
285
+ errorKinds: ['scaffoldFailed'],
286
+ },
287
+ requiresStudio: false,
288
+ examples: ['seeflow projects:create --name "Checkout"'],
289
+ },
290
+ // ---- nodes -------------------------------------------------------------
291
+ {
292
+ name: 'nodes:add',
293
+ synopsis: 'seeflow nodes:add <flowId> [--json | --file | --stdin]',
294
+ description: 'Add a single node to a flow. Body is the node object (auto-id if omitted).',
295
+ category: 'nodes',
296
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
297
+ flags: BODY_FLAGS,
298
+ body: {
299
+ example: {
300
+ type: 'stateNode',
301
+ data: { name: 'hello', kind: 'state', stateSource: { kind: 'request' } },
302
+ },
303
+ },
304
+ outputs: {
305
+ okExample: { id: 'node-abc' },
306
+ errorKinds: [
307
+ 'flowNotFound',
308
+ 'fileNotFound',
309
+ 'badJson',
310
+ 'badSchema',
311
+ 'idAlreadyExists',
312
+ 'writeFailed',
313
+ ],
314
+ },
315
+ requiresStudio: false,
316
+ examples: [
317
+ 'seeflow nodes:add abc12345 --json \'{"type":"shapeNode","data":{"shape":"rectangle"}}\'',
318
+ ],
319
+ },
320
+ {
321
+ name: 'nodes:add-bulk',
322
+ synopsis: 'seeflow nodes:add-bulk <flowId> [--json | --file | --stdin]',
323
+ description:
324
+ 'Add up to 100 nodes in one transactional write. Body shape: ' +
325
+ '`{ nodes: Node[] }`. Any duplicate id rolls back the whole batch.',
326
+ category: 'nodes',
327
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
328
+ flags: BODY_FLAGS,
329
+ body: { schemaRef: 'NodesBulkBody' },
330
+ outputs: {
331
+ okExample: { ids: ['a', 'b'] },
332
+ errorKinds: ['flowNotFound', 'fileNotFound', 'badSchema', 'duplicateIdInBatch'],
333
+ },
334
+ requiresStudio: false,
335
+ examples: [
336
+ 'seeflow nodes:add-bulk abc12345 --json \'{"nodes":[{"id":"a","type":"shapeNode","data":{"shape":"rectangle"}}]}\'',
337
+ ],
338
+ },
339
+ {
340
+ name: 'nodes:get',
341
+ synopsis: 'seeflow nodes:get <flowId> <nodeId>',
342
+ description:
343
+ 'Get one node with its file-backed content (detail.md, view.html) inlined. ' +
344
+ 'Use after flows:graph to drill in.',
345
+ category: 'nodes',
346
+ args: [
347
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
348
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
349
+ ],
350
+ flags: [],
351
+ outputs: { errorKinds: ['notFound', 'fileNotFound', 'unknownNode', 'badJson', 'badSchema'] },
352
+ requiresStudio: false,
353
+ examples: ['seeflow nodes:get abc12345 api-checkout'],
354
+ },
355
+ {
356
+ name: 'nodes:patch',
357
+ synopsis: 'seeflow nodes:patch <flowId> <nodeId> [--json | --file | --stdin]',
358
+ description: 'Patch fields on an existing node. Validates the partial against NodePatchBody.',
359
+ category: 'nodes',
360
+ args: [
361
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
362
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
363
+ ],
364
+ flags: BODY_FLAGS,
365
+ body: { schemaRef: 'NodePatchBody' },
366
+ outputs: {
367
+ errorKinds: ['flowNotFound', 'fileNotFound', 'unknownNode', 'badSchema', 'writeFailed'],
368
+ },
369
+ requiresStudio: false,
370
+ examples: ['seeflow nodes:patch abc12345 api-checkout --json \'{"data":{"name":"renamed"}}\''],
371
+ },
372
+ {
373
+ name: 'nodes:move',
374
+ synopsis: 'seeflow nodes:move <flowId> <nodeId> --x <n> --y <n>',
375
+ description: 'Set the node position in style.json (does not touch flow.json).',
376
+ category: 'nodes',
377
+ args: [
378
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
379
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
380
+ ],
381
+ flags: [
382
+ { name: 'x', valuePlaceholder: '<n>', description: 'X coordinate', required: true },
383
+ { name: 'y', valuePlaceholder: '<n>', description: 'Y coordinate', required: true },
384
+ ],
385
+ body: { schemaRef: 'PositionBody' },
386
+ outputs: { errorKinds: ['flowNotFound', 'fileNotFound', 'unknownNode', 'writeFailed'] },
387
+ requiresStudio: false,
388
+ examples: ['seeflow nodes:move abc12345 api-checkout --x 250 --y 320'],
389
+ },
390
+ {
391
+ name: 'nodes:reorder',
392
+ synopsis:
393
+ 'seeflow nodes:reorder <flowId> <nodeId> --op forward|backward|toFront|toBack|toIndex [--index <n>]',
394
+ description: "Reorder a node's z-position within the flow.",
395
+ category: 'nodes',
396
+ args: [
397
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
398
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
399
+ ],
400
+ flags: [
401
+ {
402
+ name: 'op',
403
+ valuePlaceholder: '<op>',
404
+ description: 'forward | backward | toFront | toBack | toIndex',
405
+ required: true,
406
+ },
407
+ { name: 'index', valuePlaceholder: '<n>', description: 'Required when --op toIndex' },
408
+ ],
409
+ body: { schemaRef: 'ReorderBody' },
410
+ outputs: { errorKinds: ['flowNotFound', 'fileNotFound', 'unknownNode', 'writeFailed'] },
411
+ requiresStudio: false,
412
+ examples: [
413
+ 'seeflow nodes:reorder abc12345 api-checkout --op forward',
414
+ 'seeflow nodes:reorder abc12345 api-checkout --op toIndex --index 0',
415
+ ],
416
+ },
417
+ {
418
+ name: 'nodes:delete',
419
+ synopsis: 'seeflow nodes:delete <flowId> <nodeId>',
420
+ description: 'Delete a node and any connectors that reference it.',
421
+ category: 'nodes',
422
+ args: [
423
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
424
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
425
+ ],
426
+ flags: [],
427
+ outputs: {
428
+ okExample: { ok: true, removedConnectors: 0 },
429
+ errorKinds: ['flowNotFound', 'unknownNode'],
430
+ },
431
+ requiresStudio: false,
432
+ examples: ['seeflow nodes:delete abc12345 api-checkout'],
433
+ },
434
+ // ---- connectors --------------------------------------------------------
435
+ {
436
+ name: 'connectors:add',
437
+ synopsis: 'seeflow connectors:add <flowId> [--json | --file | --stdin]',
438
+ description: 'Add a connector. Body is the connector object (source/target required).',
439
+ category: 'connectors',
440
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
441
+ flags: BODY_FLAGS,
442
+ body: {
443
+ example: { source: { nodeId: 'a' }, target: { nodeId: 'b' } },
444
+ },
445
+ outputs: {
446
+ okExample: { id: 'conn-abc' },
447
+ errorKinds: ['flowNotFound', 'badSchema', 'idAlreadyExists', 'writeFailed'],
448
+ },
449
+ requiresStudio: false,
450
+ examples: [
451
+ 'seeflow connectors:add abc12345 --json \'{"source":{"nodeId":"a"},"target":{"nodeId":"b"}}\'',
452
+ ],
453
+ },
454
+ {
455
+ name: 'connectors:add-bulk',
456
+ synopsis: 'seeflow connectors:add-bulk <flowId> [--json | --file | --stdin]',
457
+ description: 'Add up to 100 connectors transactionally. Body: `{ connectors: Connector[] }`.',
458
+ category: 'connectors',
459
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
460
+ flags: BODY_FLAGS,
461
+ body: { schemaRef: 'ConnectorsBulkBody' },
462
+ outputs: { errorKinds: ['flowNotFound', 'badSchema', 'duplicateIdInBatch'] },
463
+ requiresStudio: false,
464
+ examples: ['seeflow connectors:add-bulk abc12345 --file connectors.json'],
465
+ },
466
+ {
467
+ name: 'connectors:patch',
468
+ synopsis: 'seeflow connectors:patch <flowId> <connectorId> [--json | --file | --stdin]',
469
+ description: 'Patch fields on an existing connector.',
470
+ category: 'connectors',
471
+ args: [
472
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
473
+ { name: 'connectorId', required: true, description: 'Connector id in the flow' },
474
+ ],
475
+ flags: BODY_FLAGS,
476
+ body: { schemaRef: 'ConnectorPatchBody' },
477
+ outputs: { errorKinds: ['flowNotFound', 'unknownConnector', 'badSchema', 'writeFailed'] },
478
+ requiresStudio: false,
479
+ examples: ['seeflow connectors:patch abc12345 conn-1 --json \'{"label":"new"}\''],
480
+ },
481
+ {
482
+ name: 'connectors:delete',
483
+ synopsis: 'seeflow connectors:delete <flowId> <connectorId>',
484
+ description: 'Delete a connector.',
485
+ category: 'connectors',
486
+ args: [
487
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
488
+ { name: 'connectorId', required: true, description: 'Connector id in the flow' },
489
+ ],
490
+ flags: [],
491
+ outputs: { okExample: { ok: true }, errorKinds: ['flowNotFound', 'unknownConnector'] },
492
+ requiresStudio: false,
493
+ examples: ['seeflow connectors:delete abc12345 conn-1'],
494
+ },
495
+ // ---- validate ----------------------------------------------------------
496
+ {
497
+ name: 'validate',
498
+ synopsis: 'seeflow validate --file <flow.json> [--style <style.json>]',
499
+ description:
500
+ 'Schema-validate a flow.json (and optional style.json) without registering. ' +
501
+ 'Pure compute — no registry side-effects, no file:// resolution.',
502
+ category: 'meta',
503
+ args: [],
504
+ flags: [
505
+ {
506
+ name: 'file',
507
+ valuePlaceholder: '<flow.json>',
508
+ description: 'Flow file to validate',
509
+ required: true,
510
+ },
511
+ { name: 'style', valuePlaceholder: '<style.json>', description: 'Optional style file' },
512
+ ],
513
+ outputs: { okExample: { ok: true } },
514
+ requiresStudio: false,
515
+ examples: ['seeflow validate --file .seeflow/flow.json'],
516
+ },
517
+ // ---- live --------------------------------------------------------------
518
+ {
519
+ name: 'e2e',
520
+ synopsis: 'seeflow e2e <flowId> [--skip-nodes a,b]',
521
+ description: 'End-to-end validate a registered flow. Requires a running studio.',
522
+ category: 'live',
523
+ args: [{ name: 'flowId', required: true, description: 'Flow id or slug' }],
524
+ flags: [
525
+ {
526
+ name: 'skip-nodes',
527
+ valuePlaceholder: '<a,b>',
528
+ description: 'Comma-separated node ids to skip',
529
+ },
530
+ { name: 'no-start', description: 'Fail if the studio is not already running' },
531
+ ],
532
+ outputKind: 'stream',
533
+ outputs: {},
534
+ requiresStudio: true,
535
+ examples: ['seeflow e2e abc12345'],
536
+ },
537
+ ];
538
+
539
+ function resolveSchemaRef(ref: string): unknown {
540
+ switch (ref) {
541
+ case 'NodePatchBody':
542
+ return zodToJsonSchema(NodePatchBodySchema, { $refStrategy: 'none' });
543
+ case 'ConnectorPatchBody':
544
+ return zodToJsonSchema(ConnectorPatchBodySchema, { $refStrategy: 'none' });
545
+ case 'NodesBulkBody':
546
+ return zodToJsonSchema(NodesBulkBodySchema, { $refStrategy: 'none' });
547
+ case 'ConnectorsBulkBody':
548
+ return zodToJsonSchema(ConnectorsBulkBodySchema, { $refStrategy: 'none' });
549
+ case 'CreateProjectBody':
550
+ return zodToJsonSchema(CreateProjectBodySchema, { $refStrategy: 'none' });
551
+ case 'RegisterBody':
552
+ return zodToJsonSchema(RegisterBodySchema, { $refStrategy: 'none' });
553
+ case 'PositionBody':
554
+ return zodToJsonSchema(PositionBodySchema, { $refStrategy: 'none' });
555
+ case 'ReorderBody':
556
+ return zodToJsonSchema(ReorderBodySchema, { $refStrategy: 'none' });
557
+ default:
558
+ return undefined;
559
+ }
560
+ }
561
+
562
+ export function renderCommandHelp(name: string): string {
563
+ const entry = COMMAND_MANIFEST.find((e) => e.name === name);
564
+ if (!entry) throw new Error(`Unknown command: ${name}`);
565
+
566
+ const lines: string[] = [];
567
+ lines.push(`# ${entry.name}`, '');
568
+ lines.push(entry.description, '');
569
+
570
+ lines.push('## Synopsis', ` ${entry.synopsis}`, '');
571
+
572
+ if (entry.args.length > 0) {
573
+ lines.push('## Arguments');
574
+ for (const a of entry.args) {
575
+ const req = a.required ? '(required)' : '(optional)';
576
+ lines.push(` <${a.name}> ${req} — ${a.description}`);
577
+ }
578
+ lines.push('');
579
+ }
580
+
581
+ if (entry.flags.length > 0) {
582
+ lines.push('## Flags');
583
+ for (const f of entry.flags) {
584
+ const value = f.valuePlaceholder ? ` ${f.valuePlaceholder}` : '';
585
+ const req = f.required ? '(required)' : '(optional)';
586
+ lines.push(` --${f.name}${value} ${req} — ${f.description}`);
587
+ }
588
+ lines.push('');
589
+ }
590
+
591
+ if (entry.body) {
592
+ lines.push('## Input (body)');
593
+ if (entry.body.schemaRef) {
594
+ const schema = resolveSchemaRef(entry.body.schemaRef);
595
+ if (schema !== undefined) {
596
+ lines.push('Schema (JSON Schema, resolved from Zod):', '');
597
+ lines.push(indent(JSON.stringify(schema, null, 2), ' '));
598
+ lines.push('');
599
+ }
600
+ }
601
+ if (entry.body.example !== undefined) {
602
+ lines.push('Example body:', '');
603
+ lines.push(indent(JSON.stringify(entry.body.example, null, 2), ' '));
604
+ lines.push('');
605
+ }
606
+ }
607
+
608
+ lines.push('## Output');
609
+ lines.push(...renderOutputSection(entry));
610
+ lines.push('');
611
+
612
+ if (entry.examples.length > 0) {
613
+ lines.push('## Examples');
614
+ for (const ex of entry.examples) lines.push(` ${ex}`);
615
+ lines.push('');
616
+ }
617
+
618
+ lines.push(`Requires studio running: ${entry.requiresStudio ? 'yes' : 'no'}`);
619
+ return lines.join('\n');
620
+ }
621
+
622
+ function indent(text: string, prefix: string): string {
623
+ return text
624
+ .split('\n')
625
+ .map((l) => `${prefix}${l}`)
626
+ .join('\n');
627
+ }
628
+
629
+ function renderOutputSection(entry: CommandManifestEntry): string[] {
630
+ const kind = entry.outputKind ?? 'json';
631
+ if (kind === 'text') return renderOutputText(entry);
632
+ if (kind === 'stream') return renderOutputStream(entry);
633
+ return renderOutputJson(entry);
634
+ }
635
+
636
+ function renderOutputJson(entry: CommandManifestEntry): string[] {
637
+ const out: string[] = [];
638
+ out.push('On success (stdout, exit 0):', '');
639
+ if (entry.outputs.okExample !== undefined) {
640
+ const merged = { ok: true, ...(entry.outputs.okExample as object) };
641
+ out.push(indent(JSON.stringify(merged, null, 2), ' '));
642
+ } else {
643
+ out.push(' { "ok": true }');
644
+ }
645
+ out.push('');
646
+ out.push('On error (stderr, non-zero exit):', '');
647
+ out.push(' { "error": "<message>", "code": "<kind>" }', '');
648
+ const kinds = entry.outputs.errorKinds ?? [];
649
+ if (kinds.length > 0) {
650
+ out.push('Error kinds for this command:');
651
+ for (const group of groupKindsByExitCode(kinds)) {
652
+ for (const k of group.kinds) {
653
+ out.push(` ${k} → exit ${group.code}`);
654
+ }
655
+ }
656
+ }
657
+ return out;
658
+ }
659
+
660
+ function renderOutputText(entry: CommandManifestEntry): string[] {
661
+ const out: string[] = [];
662
+ out.push('Prints human-readable status to stdout (no JSON envelope).');
663
+ out.push('Exit 0 on success, non-zero on failure.', '');
664
+ if (entry.name === 'start') {
665
+ out.push('Example stdout:');
666
+ out.push(' SeeFlow Studio listening on http://localhost:4321');
667
+ out.push(' SeeFlow Studio started in background on http://localhost:4321 (pid 12345)');
668
+ } else if (entry.name === 'stop') {
669
+ out.push('Example stdout:');
670
+ out.push(' Stopped studio (pid 12345).');
671
+ out.push(' No studio running (no pid file at ~/.seeflow/seeflow.pid).');
672
+ }
673
+ return out;
674
+ }
675
+ function renderOutputStream(entry: CommandManifestEntry): string[] {
676
+ const out: string[] = [];
677
+ out.push('Streams progress events to stdout until the run completes.');
678
+ out.push('Exit 0 on success, non-zero on failure.');
679
+ if (entry.name === 'flows:play') {
680
+ out.push('');
681
+ out.push("Triggers the node's play action and prints status updates as the studio drives it.");
682
+ } else if (entry.name === 'e2e') {
683
+ out.push('');
684
+ out.push(
685
+ 'Walks every node in topological order, prints per-node status, exits non-zero on the first failure.',
686
+ );
687
+ }
688
+ return out;
689
+ }
690
+
691
+ /**
692
+ * Bucket a list of error kinds by their runtime exit code, preserving the
693
+ * caller's order within each bucket. Shared between the global preamble
694
+ * (which passes all known kinds) and the per-command output section (which
695
+ * passes only the kinds that command can emit). Both render via the same
696
+ * formatter so output style cannot drift.
697
+ */
698
+ function groupKindsByExitCode(kinds: string[]): Array<{ code: number; kinds: string[] }> {
699
+ const byCode = new Map<number, string[]>();
700
+ for (const kind of kinds) {
701
+ const code = exitCodeForKind(kind);
702
+ const arr = byCode.get(code) ?? [];
703
+ arr.push(kind);
704
+ byCode.set(code, arr);
705
+ }
706
+ return [...byCode.entries()].sort(([a], [b]) => a - b).map(([code, k]) => ({ code, kinds: k }));
707
+ }
708
+
709
+ function renderExitCodeTable(): string {
710
+ // Derive the ordered set of unique exit codes from the runtime map, skipping
711
+ // 1 (the catch-all is rendered as a final literal line). This keeps the
712
+ // preamble future-proof if a new exit code is added to EXIT_CODE_BY_KIND.
713
+ const codes = [...new Set(Object.values(EXIT_CODE_BY_KIND))].sort((a, b) => a - b);
714
+ const groups = groupKindsByExitCode(Object.keys(EXIT_CODE_BY_KIND));
715
+ const byCode = new Map(groups.map((g) => [g.code, g.kinds]));
716
+ const lines: string[] = ['Exit codes:'];
717
+ for (const code of codes) {
718
+ if (code === 1) continue;
719
+ const kinds = byCode.get(code);
720
+ if (!kinds) continue;
721
+ lines.push(` ${kinds.join(', ')} — exit ${code}`);
722
+ }
723
+ lines.push(' anything else — exit 1');
724
+ return lines.join('\n');
725
+ }
726
+
727
+ export function renderCommandList(): string {
728
+ // Stable category order for predictable output regardless of manifest order.
729
+ const order: CommandCategory[] = [
730
+ 'lifecycle',
731
+ 'flows',
732
+ 'nodes',
733
+ 'connectors',
734
+ 'project',
735
+ 'live',
736
+ 'meta',
737
+ ];
738
+ const byCategory = new Map<CommandCategory, CommandManifestEntry[]>();
739
+ for (const entry of COMMAND_MANIFEST) {
740
+ const arr = byCategory.get(entry.category) ?? [];
741
+ arr.push(entry);
742
+ byCategory.set(entry.category, arr);
743
+ }
744
+ const lines: string[] = [];
745
+ lines.push('seeflow — local studio for file-defined interactive demos');
746
+ lines.push('');
747
+ lines.push('Run `seeflow help <command>` for full detail on any command below.');
748
+ lines.push('');
749
+ lines.push('## Calling convention');
750
+ lines.push(
751
+ " Body-bearing commands accept JSON via exactly one of: --json '<inline>' | --file <path> | --stdin",
752
+ );
753
+ lines.push(' On success: stdout = {"ok": true, ...payload}; exit 0.');
754
+ lines.push(' On error: stderr = {"error": "<msg>", "code": "<kind>"}; non-zero exit.');
755
+ lines.push('');
756
+ lines.push(renderExitCodeTable());
757
+ lines.push('');
758
+ for (const category of order) {
759
+ const entries = byCategory.get(category);
760
+ if (!entries || entries.length === 0) continue;
761
+ lines.push(`## ${category}`);
762
+ for (const e of entries) {
763
+ const liveMarker = e.requiresStudio ? ' (requires running studio)' : '';
764
+ lines.push(` ${e.name} — ${e.description.split('\n')[0]}${liveMarker}`);
765
+ // Include the synopsis so flags (like `--foreground` on start) are
766
+ // discoverable from the top-level listing without a drill-in.
767
+ lines.push(` ${e.synopsis}`);
768
+ }
769
+ lines.push('');
770
+ }
771
+ return lines.join('\n');
772
+ }