@tuongaz/seeflow 0.1.57 → 0.1.63

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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/dist/web/assets/{index-CPlccVLi.js → index-DAP_yx-l.js} +354 -354
  3. package/dist/web/assets/{index.es-CYTTDW0Q.js → index.es-2bA-nRVD.js} +1 -1
  4. package/dist/web/assets/{jspdf.es.min-DOaPC0dc.js → jspdf.es.min-C7u0-VKd.js} +3 -3
  5. package/dist/web/index.html +1 -1
  6. package/examples/ecommerce-platform/{.seeflow/flow.json → flow.json} +3 -25
  7. package/examples/ecommerce-platform/{.seeflow/scripts → scripts}/play.ts +1 -1
  8. package/examples/order-pipeline/{.seeflow/flow.json → flow.json} +1 -10
  9. package/package.json +1 -1
  10. package/src/api.ts +83 -55
  11. package/src/cli-helpers.ts +6 -5
  12. package/src/cli-manifest.ts +129 -15
  13. package/src/cli.ts +106 -13
  14. package/src/diagram.ts +0 -1
  15. package/src/file-ref.ts +16 -15
  16. package/src/mcp.ts +96 -16
  17. package/src/merge.ts +0 -1
  18. package/src/node-files.ts +5 -5
  19. package/src/operations.ts +40 -101
  20. package/src/paths.ts +16 -0
  21. package/src/proxy.ts +13 -13
  22. package/src/schema-catalog.ts +114 -0
  23. package/src/schema.ts +110 -133
  24. package/src/server.ts +3 -5
  25. package/src/short-id.ts +24 -0
  26. package/src/status-runner.ts +3 -3
  27. package/src/watcher.ts +15 -27
  28. package/src/sdk-template.ts +0 -37
  29. package/src/sdk-writer.ts +0 -37
  30. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-3zFtHg6ENc/detail.md +0 -0
  31. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-5F424NWbEu/detail.md +0 -0
  32. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-CbwYqb7NfB/detail.md +0 -0
  33. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-XwygzfKPZ5/view.html +0 -0
  34. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-fkptXw7uvs/detail.md +0 -0
  35. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-kwBY8YPmYM/detail.md +0 -0
  36. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-mPqan8rFYN/detail.md +0 -0
  37. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-yKrg9DV5fJ/detail.md +0 -0
  38. /package/examples/ecommerce-platform/{.seeflow/style.json → style.json} +0 -0
  39. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-GXTKUcE3ye/detail.md +0 -0
  40. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-XKIyds0TDg/detail.md +0 -0
  41. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-YOYiHJpY0i/detail.md +0 -0
  42. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-zUIH7WFnhK/detail.md +0 -0
  43. /package/examples/order-pipeline/{.seeflow/scripts → scripts}/play.ts +0 -0
  44. /package/examples/order-pipeline/{.seeflow/style.json → style.json} +0 -0
@@ -134,7 +134,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
134
134
  synopsis: 'seeflow register [--path <dir>] [--flow <file>]',
135
135
  description:
136
136
  'Register a demo repo with the studio. Reads <repoPath>/<flow> (defaulting ' +
137
- 'to ./.seeflow/flow.json), validates the schema, and writes an entry to ' +
137
+ 'to ./flow.json), validates the schema, and writes an entry to ' +
138
138
  '~/.seeflow/registry.json. Alias of flows:register.',
139
139
  category: 'flows',
140
140
  args: [],
@@ -143,12 +143,12 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
143
143
  {
144
144
  name: 'flow',
145
145
  valuePlaceholder: '<file>',
146
- description: 'Path to flow.json relative to repo root (default: .seeflow/flow.json)',
146
+ description: 'Path to flow.json relative to repo root (default: flow.json)',
147
147
  },
148
148
  ],
149
149
  outputs: {
150
- okExample: { id: 'abc12345', slug: 'checkout', sdk: { outcome: 'skipped', filePath: null } },
151
- errorKinds: ['fileNotFound', 'badJson', 'badSchema', 'sdkWriteFailed'],
150
+ okExample: { id: 'abc12345', slug: 'checkout' },
151
+ errorKinds: ['fileNotFound', 'badJson', 'badSchema'],
152
152
  },
153
153
  requiresStudio: false,
154
154
  examples: ['seeflow register', 'seeflow register --path ./my-app'],
@@ -164,13 +164,13 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
164
164
  {
165
165
  name: 'flow',
166
166
  valuePlaceholder: '<file>',
167
- description: 'Path to flow.json relative to repo root (default: .seeflow/flow.json)',
167
+ description: 'Path to flow.json relative to repo root (default: flow.json)',
168
168
  },
169
169
  ],
170
170
  body: { schemaRef: 'RegisterBody' },
171
171
  outputs: {
172
- okExample: { id: 'abc12345', slug: 'checkout', sdk: { outcome: 'skipped', filePath: null } },
173
- errorKinds: ['fileNotFound', 'badJson', 'badSchema', 'sdkWriteFailed'],
172
+ okExample: { id: 'abc12345', slug: 'checkout' },
173
+ errorKinds: ['fileNotFound', 'badJson', 'badSchema'],
174
174
  },
175
175
  requiresStudio: false,
176
176
  examples: ['seeflow flows:register --path ./my-app'],
@@ -303,22 +303,36 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
303
303
  // ---- project -----------------------------------------------------------
304
304
  {
305
305
  name: 'projects:create',
306
- synopsis: 'seeflow projects:create --name <name>',
306
+ synopsis: 'seeflow projects:create --path <dir> --name <name> [--description <text>]',
307
307
  description:
308
- 'Scaffold a new project under ~/.seeflow/<slug>/ with an empty flow.json and ' +
309
- 'register it. The flow id is returned for follow-up writes.',
308
+ 'Scaffold a new project at <path> with an empty flow.json and register it. ' +
309
+ 'Errors if <path>/flow.json already exists use flows:register for ' +
310
+ 'an existing project.',
310
311
  category: 'project',
311
312
  args: [],
312
313
  flags: [
314
+ {
315
+ name: 'path',
316
+ valuePlaceholder: '<dir>',
317
+ description: 'Project folder (created if it does not exist)',
318
+ required: true,
319
+ },
313
320
  { name: 'name', valuePlaceholder: '<name>', description: 'Project name', required: true },
321
+ {
322
+ name: 'description',
323
+ valuePlaceholder: '<text>',
324
+ description: 'Optional human description, written into flow.json',
325
+ },
314
326
  ],
315
327
  body: { schemaRef: 'CreateProjectBody' },
316
328
  outputs: {
317
- okExample: { id: 'abc12345', slug: 'checkout', scaffolded: true },
318
- errorKinds: ['scaffoldFailed'],
329
+ okExample: { id: 'abc12345', slug: 'checkout' },
330
+ errorKinds: ['alreadyExists', 'scaffoldFailed'],
319
331
  },
320
332
  requiresStudio: false,
321
- examples: ['seeflow projects:create --name "Checkout"'],
333
+ examples: [
334
+ 'seeflow projects:create --path ./checkout --name "Checkout" --description "Cart + payments flow"',
335
+ ],
322
336
  },
323
337
  // ---- nodes -------------------------------------------------------------
324
338
  {
@@ -331,7 +345,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
331
345
  body: {
332
346
  example: {
333
347
  type: 'stateNode',
334
- data: { name: 'hello', kind: 'state', stateSource: { kind: 'request' } },
348
+ data: { name: 'hello', stateSource: { kind: 'request' } },
335
349
  },
336
350
  },
337
351
  outputs: {
@@ -514,7 +528,64 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
514
528
  ],
515
529
  outputs: { okExample: { ok: true } },
516
530
  requiresStudio: false,
517
- examples: ['seeflow validate --file .seeflow/flow.json'],
531
+ examples: ['seeflow validate --file flow.json'],
532
+ },
533
+ {
534
+ name: 'ids',
535
+ synopsis: 'seeflow ids <type> <count>',
536
+ description:
537
+ 'Generate <count> canonical short ids of the given <type>, one per line ' +
538
+ 'on stdout. <type> must be `node` (emits `node-<10 base62 chars>`) or ' +
539
+ '`connector` (emits `conn-<10 base62 chars>`). <count> must be an ' +
540
+ 'integer in [1, 100]. Uses the same alphabet, length, and rejection-' +
541
+ 'sampling logic as the canvas / server / upload regex, so skill-minted ' +
542
+ 'ids match every other id producer in the studio. Pure compute — no ' +
543
+ 'studio required. Call once per type (i.e. one call for nodes, one for ' +
544
+ 'connectors) when seeding a flow.json.',
545
+ category: 'meta',
546
+ args: [
547
+ {
548
+ name: 'type',
549
+ required: true,
550
+ description: "Id kind to generate: 'node' (→ `node-…`) or 'connector' (→ `conn-…`)",
551
+ },
552
+ {
553
+ name: 'count',
554
+ required: true,
555
+ description: 'How many ids to print (integer, 1..100)',
556
+ },
557
+ ],
558
+ flags: [],
559
+ outputKind: 'text',
560
+ outputs: {},
561
+ requiresStudio: false,
562
+ examples: ['seeflow ids node 10', 'seeflow ids connector 5'],
563
+ },
564
+ {
565
+ name: 'schema',
566
+ synopsis: 'seeflow schema [<category>]',
567
+ description:
568
+ 'Introspect the SeeFlow flow.json / style.json schemas at runtime. Call ' +
569
+ 'without arguments to list the five categories (flow, node, connector, ' +
570
+ 'action, style); call with a category name to get its full JSON Schema(s) ' +
571
+ '(Draft-07) plus a `notes` array of cross-field invariants the schema ' +
572
+ "can't express. Use this before authoring any flow.json write — never " +
573
+ 'memorise field shapes.',
574
+ category: 'meta',
575
+ args: [
576
+ {
577
+ name: 'category',
578
+ required: false,
579
+ description: 'One of: flow, node, connector, action, style',
580
+ },
581
+ ],
582
+ flags: [],
583
+ outputs: {
584
+ okExample: { categories: [{ name: 'flow', description: 'Top-level flow.json envelope.' }] },
585
+ errorKinds: ['notFound'],
586
+ },
587
+ requiresStudio: false,
588
+ examples: ['seeflow schema', 'seeflow schema node', 'seeflow schema connector'],
518
589
  },
519
590
  // ---- live --------------------------------------------------------------
520
591
  {
@@ -536,6 +607,49 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
536
607
  requiresStudio: true,
537
608
  examples: ['seeflow e2e abc12345'],
538
609
  },
610
+ {
611
+ name: 'emit',
612
+ synopsis:
613
+ 'seeflow emit <flowId> <nodeId> <status> [--run-id <id>] [--payload <json>] [--studio-url <url>]',
614
+ description:
615
+ 'Broadcast a node-status event to the studio (status: running | done | error). ' +
616
+ 'User apps shell out to this command instead of importing an in-repo helper; ' +
617
+ "the studio re-broadcasts the event on the flow's SSE stream.",
618
+ category: 'live',
619
+ args: [
620
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
621
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
622
+ { name: 'status', required: true, description: 'Status: running | done | error' },
623
+ ],
624
+ flags: [
625
+ {
626
+ name: 'run-id',
627
+ valuePlaceholder: '<id>',
628
+ description: 'Correlate events emitted within a single run',
629
+ },
630
+ {
631
+ name: 'payload',
632
+ valuePlaceholder: '<json>',
633
+ description: 'JSON string merged into the event payload',
634
+ },
635
+ {
636
+ name: 'studio-url',
637
+ valuePlaceholder: '<url>',
638
+ description: 'Override studio base URL (skips auto-start; targets the URL as-is)',
639
+ },
640
+ { name: 'no-start', description: 'Fail if the studio is not already running' },
641
+ ],
642
+ outputs: {
643
+ okExample: { ok: true },
644
+ errorKinds: [],
645
+ },
646
+ requiresStudio: true,
647
+ examples: [
648
+ 'seeflow emit abc12345 api-charge done',
649
+ 'seeflow emit abc12345 api-charge error --payload \'{"code":402}\'',
650
+ 'seeflow emit abc12345 api-charge running --run-id run-9b3 --studio-url http://localhost:4321',
651
+ ],
652
+ },
539
653
  ];
540
654
 
541
655
  function resolveSchemaRef(ref: string): unknown {
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  NodePatchBodySchema,
13
13
  ReorderBodySchema,
14
14
  } from './operations.ts';
15
- import { seeflowHome } from './paths.ts';
15
+ import { PROJECT_FLOW_FILENAME, seeflowHome } from './paths.ts';
16
16
  import { defaultProcessSpawner } from './process-spawner.ts';
17
17
  import { type Registry, createRegistry } from './registry.ts';
18
18
  import {
@@ -28,9 +28,10 @@ import {
28
28
  } from './runtime.ts';
29
29
  import { FlowSchema } from './schema.ts';
30
30
  import { serve } from './server.ts';
31
+ import { MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
31
32
  import { createStatusRunner } from './status-runner.ts';
32
33
 
33
- const DEFAULT_FLOW_PATH = '.seeflow/flow.json';
34
+ const DEFAULT_FLOW_PATH = PROJECT_FLOW_FILENAME;
34
35
  const HEALTH_TIMEOUT_MS = 10_000;
35
36
  const HEALTH_POLL_INTERVAL_MS = 150;
36
37
  const STOP_TIMEOUT_MS = 5_000;
@@ -157,8 +158,14 @@ if (argv.includes('--version') || argv.includes('-v')) {
157
158
  await runConnectorsDelete();
158
159
  } else if (sub === 'validate') {
159
160
  await runValidate();
161
+ } else if (sub === 'schema') {
162
+ await runSchema();
163
+ } else if (sub === 'ids') {
164
+ await runIds();
160
165
  } else if (sub === 'e2e') {
161
166
  await runE2e();
167
+ } else if (sub === 'emit') {
168
+ await runEmit();
162
169
  } else {
163
170
  console.error(`Unknown subcommand: ${sub}`);
164
171
  printHelp();
@@ -178,7 +185,7 @@ Commands (work without a running studio):
178
185
  stop Stop a background studio instance
179
186
  register Register a demo repo, writing to ~/.seeflow/registry.json (alias of flows:register)
180
187
  flows:register Register a demo repo
181
- projects:create Create a new project (--name <name>)
188
+ projects:create Create a new project (--path <dir> --name <name> [--description <text>])
182
189
  flows:list List registered flows
183
190
  flows:summary List registered flows (id + name + description only)
184
191
  flows:get <id> Get flow details
@@ -196,9 +203,18 @@ Commands (work without a running studio):
196
203
  connectors:patch <id> <connId> Patch a connector (--json/--file/--stdin)
197
204
  connectors:delete <id> <connId> Delete a connector
198
205
  validate Schema-validate a flow.json (--file <file> [--style <file>])
206
+ schema [<category>] Get the flow.json schema. No arg → category index;
207
+ category arg → full JSON Schema(s) for that category
208
+ ids <type> <count> Print <count> short ids of the given <type>, one per
209
+ line. <type> is 'node' (-> 'node-...') or 'connector'
210
+ (-> 'conn-...'). <count> is 1..100. Call once per type
211
+ when seeding a flow.json (e.g. 'ids node 10', then
212
+ 'ids connector 12').
199
213
 
200
214
  Commands (require a running studio):
201
215
  flows:play <id> <n> Trigger a play on node <n>
216
+ emit <id> <n> <st> Broadcast a status event for node <n> (st: running|done|error)
217
+ [--run-id <id>] [--payload <json>] [--studio-url <url>]
202
218
  e2e <id> End-to-end validate a registered flow (--skip-nodes a,b)
203
219
 
204
220
  Meta:
@@ -223,14 +239,14 @@ Options (start):
223
239
  Options (register):
224
240
  --path <dir> Path to repo root (default: current directory)
225
241
  --flow <file> Path to flow JSON, relative to repo root
226
- (default: .seeflow/flow.json)
242
+ (default: flow.json)
227
243
 
228
244
  Examples:
229
245
  npx -y @tuongaz/seeflow@latest
230
246
  npx -y @tuongaz/seeflow@latest --port 8080
231
247
  npx -y @tuongaz/seeflow@latest start --foreground
232
248
  npx -y @tuongaz/seeflow@latest register --path ./my-app
233
- npx -y @tuongaz/seeflow@latest projects:create --name "Checkout"
249
+ npx -y @tuongaz/seeflow@latest projects:create --path ./checkout --name "Checkout"
234
250
  npx -y @tuongaz/seeflow@latest flows:list
235
251
  npx -y @tuongaz/seeflow@latest stop
236
252
  `.trim(),
@@ -251,6 +267,7 @@ async function runHelp() {
251
267
  }
252
268
 
253
269
  async function runStart() {
270
+ mkdirSync(seeflowHome(), { recursive: true });
254
271
  const config = readConfig();
255
272
  const portArg = flagValue('port');
256
273
  // --port wins; otherwise always fall back to the schema default (not the
@@ -316,7 +333,7 @@ async function seedExamples(registry: Registry) {
316
333
 
317
334
  async function seedExample(registry: Registry, exampleName: string) {
318
335
  const destDir = join(seeflowHome(), exampleName);
319
- const flowPath = '.seeflow/flow.json';
336
+ const flowPath = PROJECT_FLOW_FILENAME;
320
337
 
321
338
  // Always sync from source so that schema changes and example updates are
322
339
  // reflected on every startup, even when the dest directory already exists.
@@ -410,6 +427,25 @@ function reportDaemonFailure(logPath: string | undefined) {
410
427
  console.error(tail || '(log is empty — daemon exited before writing anything)');
411
428
  }
412
429
 
430
+ async function runIds() {
431
+ const typeArg = argv[1];
432
+ if (!typeArg || typeArg.startsWith('--')) {
433
+ printError("Missing required positional argument: type (expected 'node' or 'connector')");
434
+ }
435
+ if (!isIdType(typeArg)) {
436
+ printError(`Invalid type: ${typeArg} (expected 'node' or 'connector')`);
437
+ }
438
+ const rawCount = argv[2];
439
+ if (!rawCount || rawCount.startsWith('--')) {
440
+ printError(`Missing required positional argument: count (integer 1..${MAX_ID_COUNT})`);
441
+ }
442
+ const count = Number.parseInt(rawCount as string, 10);
443
+ if (!Number.isFinite(count) || count < 1 || count > MAX_ID_COUNT) {
444
+ printError(`Invalid count: ${rawCount} (expected an integer in [1, ${MAX_ID_COUNT}])`);
445
+ }
446
+ for (const id of generateIds(typeArg, count)) process.stdout.write(`${id}\n`);
447
+ }
448
+
413
449
  async function printVersion() {
414
450
  const pkgPath = join(import.meta.dir, '../package.json');
415
451
  const pkg = (await Bun.file(pkgPath).json()) as { version?: string };
@@ -524,12 +560,6 @@ async function runRegister() {
524
560
  } else {
525
561
  console.log(`Registered "${parsed.data.name}" (slug: ${body.slug})`);
526
562
  }
527
-
528
- if (body.sdk?.outcome === 'written') {
529
- console.log(`Wrote ${body.sdk.filePath} (event-bound state node detected)`);
530
- } else if (body.sdk?.outcome === 'present') {
531
- console.log(`SDK helper already present at ${body.sdk.filePath} (skipped)`);
532
- }
533
563
  }
534
564
 
535
565
  async function ensureStudioRunning(url: string, port: number, noStart: boolean) {
@@ -581,10 +611,18 @@ async function waitForHealth(url: string, timeoutMs: number): Promise<boolean> {
581
611
  // ---- HTTP-passthrough subcommands ----------------------------------------
582
612
 
583
613
  async function runProjectsCreate() {
614
+ const rawPath = flagValue('path');
615
+ if (!rawPath) printError('Missing required flag: --path');
584
616
  const name = flagValue('name');
585
617
  if (!name) printError('Missing required flag: --name');
618
+ const description = flagValue('description');
619
+
586
620
  const ops = createCliOperations();
587
- const result = await ops.createProject({ name: name as string });
621
+ const result = await ops.createProject({
622
+ path: resolve(rawPath as string),
623
+ name: name as string,
624
+ ...(description !== undefined ? { description } : {}),
625
+ });
588
626
  printOutcome(result);
589
627
  }
590
628
 
@@ -646,6 +684,45 @@ async function runFlowsPlay() {
646
684
  printOk(out);
647
685
  }
648
686
 
687
+ async function runEmit() {
688
+ const flowId = requireArg(1, '<flowId>');
689
+ const nodeId = requireArg(2, '<nodeId>');
690
+ const status = requireArg(3, '<status>');
691
+ if (status !== 'running' && status !== 'done' && status !== 'error') {
692
+ printError(`Invalid status: ${status} (expected running | done | error)`);
693
+ }
694
+
695
+ const runId = flagValue('run-id');
696
+ const rawPayload = flagValue('payload');
697
+ let payload: unknown;
698
+ if (rawPayload !== undefined) {
699
+ try {
700
+ payload = JSON.parse(rawPayload);
701
+ } catch (err) {
702
+ const message = err instanceof Error ? err.message : String(err);
703
+ printError(`--payload must be valid JSON: ${message}`);
704
+ }
705
+ }
706
+
707
+ // Explicit --studio-url targets a specific instance; skip auto-start since
708
+ // the caller is asserting where the studio lives.
709
+ const studioUrlFlag = flagValue('studio-url');
710
+ let url: string;
711
+ if (studioUrlFlag) {
712
+ url = studioUrlFlag.replace(/\/+$/, '');
713
+ } else {
714
+ ({ url } = await studioUrlOrDie(hasFlag('no-start')));
715
+ }
716
+
717
+ const body: Record<string, unknown> = { flowId, nodeId, status };
718
+ if (runId !== undefined) body.runId = runId;
719
+ if (payload !== undefined) body.payload = payload;
720
+
721
+ const res = await postJson(`${url}/api/emit`, body);
722
+ const out = (await handleResponse(res)) as object;
723
+ printOk(out);
724
+ }
725
+
649
726
  async function runNodesAdd() {
650
727
  const flowId = requireArg(1, '<flowId>');
651
728
  const body = await bodyFromFlags();
@@ -800,6 +877,22 @@ async function runValidate() {
800
877
  printOk(body);
801
878
  }
802
879
 
880
+ async function runSchema() {
881
+ const category = argv[1] && !argv[1].startsWith('--') ? argv[1] : undefined;
882
+ const { listSchemaCategories, getSchemaCategory } = await import('./schema-catalog.ts');
883
+ if (!category) {
884
+ printOk({ categories: listSchemaCategories() });
885
+ }
886
+ const payload = getSchemaCategory(category as string);
887
+ if (!payload) {
888
+ const available = listSchemaCategories().map((c) => c.name);
889
+ const message = `unknown schema category: ${category}`;
890
+ process.stderr.write(`${JSON.stringify({ error: message, code: 'notFound', available })}\n`);
891
+ process.exit(3);
892
+ }
893
+ printOk({ name: category, schemas: payload.schemas, notes: payload.notes });
894
+ }
895
+
803
896
  async function runE2e() {
804
897
  const flowId = requireArg(1, '<flowId>');
805
898
  const skipNodesRaw = flagValue('skip-nodes');
package/src/diagram.ts CHANGED
@@ -225,7 +225,6 @@ const normalizeConnectors = (
225
225
  const key = [
226
226
  source,
227
227
  target,
228
- String(raw.kind ?? ''),
229
228
  String(raw.sourceHandle ?? ''),
230
229
  String(raw.targetHandle ?? ''),
231
230
  ].join('\t');
package/src/file-ref.ts CHANGED
@@ -19,24 +19,25 @@ const looksLikeFlowNode = (obj: Record<string, unknown>): obj is { id: string; d
19
19
 
20
20
  /**
21
21
  * Resolve every `file://<relative-path>` string in `raw` by reading the file
22
- * under `<seeflowRoot>/nodes/<nodeId>/` (node-relative) and substituting its
22
+ * under `<projectRoot>/nodes/<nodeId>/` (node-relative) and substituting its
23
23
  * UTF-8 content. Strings outside any enclosing flow node are treated as
24
24
  * invalid — every supported file:// ref currently lives inside `node.data`.
25
25
  *
26
- * Returns the mutated tree plus the sorted, de-duplicated list of seeflow-root-relative
27
- * paths that resolved cleanly (the watcher tracks these for live reload, so the
28
- * external contract uses `nodes/<id>/<file>` even though the source string is short).
26
+ * Returns the mutated tree plus the sorted, de-duplicated list of project-
27
+ * root-relative paths that resolved cleanly (the watcher tracks these for
28
+ * live reload, so the external contract uses `nodes/<id>/<file>` even though
29
+ * the source string is short).
29
30
  */
30
31
  export function resolveFileRefs(
31
32
  raw: unknown,
32
- seeflowRoot: string,
33
+ projectRoot: string,
33
34
  ): { resolved: unknown; refs: string[] } {
34
35
  const refs = new Set<string>();
35
- let seeflowRealRoot: string;
36
+ let projectRealRoot: string;
36
37
  try {
37
- seeflowRealRoot = existsSync(seeflowRoot) ? realpathSync(seeflowRoot) : seeflowRoot;
38
+ projectRealRoot = existsSync(projectRoot) ? realpathSync(projectRoot) : projectRoot;
38
39
  } catch {
39
- seeflowRealRoot = seeflowRoot;
40
+ projectRealRoot = projectRoot;
40
41
  }
41
42
 
42
43
  const resolveString = (s: string, nodeId: string | null): string => {
@@ -45,26 +46,26 @@ export function resolveFileRefs(
45
46
  if (!isCleanRelativePath(relPath)) return invalidMarker(relPath);
46
47
  if (nodeId === null) return invalidMarker(relPath);
47
48
 
48
- const seeflowRelPath = `nodes/${nodeId}/${relPath}`;
49
- const abs = join(seeflowRoot, seeflowRelPath);
50
- if (!existsSync(abs)) return missingMarker(seeflowRelPath);
49
+ const projectRelPath = `nodes/${nodeId}/${relPath}`;
50
+ const abs = join(projectRoot, projectRelPath);
51
+ if (!existsSync(abs)) return missingMarker(projectRelPath);
51
52
 
52
53
  // Symlink-escape defense: resolve realpath and confirm it stays inside root.
53
54
  let realAbs: string;
54
55
  try {
55
56
  realAbs = realpathSync(abs);
56
57
  } catch {
57
- return missingMarker(seeflowRelPath);
58
+ return missingMarker(projectRelPath);
58
59
  }
59
- const rel = relative(seeflowRealRoot, realAbs);
60
+ const rel = relative(projectRealRoot, realAbs);
60
61
  if (rel.startsWith('..') || isAbsolute(rel)) return invalidMarker(relPath);
61
62
 
62
63
  try {
63
64
  const content = readFileSync(realAbs, 'utf8');
64
- refs.add(seeflowRelPath);
65
+ refs.add(projectRelPath);
65
66
  return content;
66
67
  } catch {
67
- return missingMarker(seeflowRelPath);
68
+ return missingMarker(projectRelPath);
68
69
  }
69
70
  };
70
71
 
package/src/mcp.ts CHANGED
@@ -20,13 +20,13 @@ import {
20
20
  flowBulkNonEmpty,
21
21
  } from './operations.ts';
22
22
  import type { Registry } from './registry.ts';
23
+ import { getSchemaCategory, listSchemaCategories, schemaCategoryNames } from './schema-catalog.ts';
24
+ import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
23
25
  import type { FlowWatcher } from './watcher.ts';
24
26
 
25
27
  export interface CreateMcpServerOptions {
26
28
  registry: Registry;
27
29
  watcher?: FlowWatcher;
28
- /** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
29
- projectBaseDir?: string;
30
30
  }
31
31
 
32
32
  // Tools are pushed into this in-memory list inside `createMcpServer`. Each
@@ -193,6 +193,94 @@ const buildTools = (ops: Operations): McpTool[] => [
193
193
  return okResult(result.data);
194
194
  },
195
195
  },
196
+ {
197
+ name: 'seeflow_schema',
198
+ description:
199
+ 'Get the SeeFlow flow.json schema. Call with no args for a category index; ' +
200
+ "call with `name` for one category's full JSON Schemas. Use this to learn " +
201
+ 'what a node, connector, action, or flow envelope looks like before authoring ' +
202
+ 'writes. Categories: `flow`, `node`, `connector`, `action`, `style`.',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ name: {
207
+ type: 'string',
208
+ description: 'Optional category name. Omit for the index.',
209
+ },
210
+ },
211
+ additionalProperties: false,
212
+ },
213
+ handler: async (args) => {
214
+ const name =
215
+ args && typeof args === 'object' && !Array.isArray(args)
216
+ ? (args as { name?: unknown }).name
217
+ : undefined;
218
+ if (name === undefined || name === null || name === '') {
219
+ return okResult({ categories: listSchemaCategories() });
220
+ }
221
+ if (typeof name !== 'string') {
222
+ return errorResult('Invalid arguments: `name` must be a string when present');
223
+ }
224
+ const payload = getSchemaCategory(name);
225
+ if (!payload) {
226
+ return errorResult(
227
+ `unknown schema category: ${name} (available: ${schemaCategoryNames().join(', ')})`,
228
+ );
229
+ }
230
+ return okResult({ name, schemas: payload.schemas, notes: payload.notes });
231
+ },
232
+ },
233
+ {
234
+ name: 'seeflow_ids',
235
+ description:
236
+ 'Batch-mint canonical short ids. `type` is `node` (emits `node-<10 base62 chars>`) ' +
237
+ 'or `connector` (emits `conn-<10 base62 chars>`); `count` is an integer in ' +
238
+ '[1, 100]. Pure compute — no flow side effects, no studio state read. Use ' +
239
+ 'before authoring a flow.json so minted ids match every other id producer ' +
240
+ 'in the studio (canvas, server, upload regex). Call once per type when ' +
241
+ 'seeding a flow (one call for nodes, one for connectors).',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ type: {
246
+ type: 'string',
247
+ enum: [...ID_TYPES],
248
+ description: "Id kind: 'node' (→ `node-…`) or 'connector' (→ `conn-…`)",
249
+ },
250
+ count: {
251
+ type: 'integer',
252
+ minimum: 1,
253
+ maximum: MAX_ID_COUNT,
254
+ description: `How many ids to mint (1..${MAX_ID_COUNT})`,
255
+ },
256
+ },
257
+ required: ['type', 'count'],
258
+ additionalProperties: false,
259
+ },
260
+ handler: async (args) => {
261
+ const body =
262
+ args && typeof args === 'object' && !Array.isArray(args)
263
+ ? (args as { type?: unknown; count?: unknown })
264
+ : {};
265
+ const { type, count } = body;
266
+ if (!isIdType(type)) {
267
+ return errorResult(
268
+ `invalid type: ${String(type)} (expected one of: ${ID_TYPES.join(', ')})`,
269
+ );
270
+ }
271
+ if (
272
+ typeof count !== 'number' ||
273
+ !Number.isInteger(count) ||
274
+ count < 1 ||
275
+ count > MAX_ID_COUNT
276
+ ) {
277
+ return errorResult(
278
+ `invalid count: ${String(count)} (expected an integer in [1, ${MAX_ID_COUNT}])`,
279
+ );
280
+ }
281
+ return okResult({ ids: generateIds(type, count) });
282
+ },
283
+ },
196
284
  {
197
285
  name: 'validate_seeflow',
198
286
  description:
@@ -330,8 +418,6 @@ const buildTools = (ops: Operations): McpTool[] => [
330
418
  return errorResult(
331
419
  `Flow file failed schema validation: ${JSON.stringify(result.issues)}`,
332
420
  );
333
- case 'sdkWriteFailed':
334
- return errorResult(`Failed to write SDK helper: ${result.message}`);
335
421
  }
336
422
  },
337
423
  },
@@ -353,7 +439,8 @@ const buildTools = (ops: Operations): McpTool[] => [
353
439
  },
354
440
  {
355
441
  name: 'seeflow_create_project',
356
- description: 'Create a new SeeFlow project folder, or register an existing one.',
442
+ description:
443
+ 'Scaffold a new SeeFlow project at the given path. Errors if a project already exists there.',
357
444
  inputSchema: inputSchemaFromZod(CreateProjectBodySchema),
358
445
  handler: async (args) => {
359
446
  const parsed = CreateProjectBodySchema.safeParse(args);
@@ -364,23 +451,17 @@ const buildTools = (ops: Operations): McpTool[] => [
364
451
  switch (result.kind) {
365
452
  case 'ok':
366
453
  return okResult(result.data);
367
- case 'badJson':
368
- return errorResult(`Existing demo file is not valid JSON: ${result.detail}`);
369
- case 'badSchema':
370
- return errorResult(
371
- `Existing demo file failed schema validation: ${JSON.stringify(result.issues)}`,
372
- );
454
+ case 'alreadyExists':
455
+ return errorResult(`Project already exists at ${result.path}`);
373
456
  case 'scaffoldFailed':
374
457
  return errorResult(`Failed to scaffold project: ${result.message}`);
375
- case 'sdkWriteFailed':
376
- return errorResult(`Failed to write SDK helper: ${result.message}`);
377
458
  }
378
459
  },
379
460
  },
380
461
  {
381
462
  name: 'seeflow_add_node',
382
463
  description:
383
- 'Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on htmlNode) are auto-externalized to <project>/.seeflow/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.',
464
+ 'Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on htmlNode) are auto-externalized to <project>/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.',
384
465
  inputSchema: inputSchemaFromZod(AddNodeInputSchema),
385
466
  handler: async (args) => {
386
467
  const parsed = AddNodeInputSchema.safeParse(args);
@@ -504,7 +585,7 @@ const buildTools = (ops: Operations): McpTool[] => [
504
585
  {
505
586
  name: 'seeflow_patch_node',
506
587
  description:
507
- 'Update fields on an existing node (position, name, description, detail, icon, colors, border, font, shape, dimensions, autoSize, plus iconNode-only color/strokeWidth/alt). Setting detail (every node) or html (htmlNode) writes the content to <project>/.seeflow/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.',
588
+ 'Update fields on an existing node (position, name, description, detail, icon, colors, border, font, shape, dimensions, autoSize, plus iconNode-only color/strokeWidth/alt). Setting detail (every node) or html (htmlNode) writes the content to <project>/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.',
508
589
  inputSchema: inputSchemaFromZod(PatchNodeInputSchema),
509
590
  handler: async (args) => {
510
591
  const parsed = PatchNodeInputSchema.safeParse(args);
@@ -672,7 +753,6 @@ export function createMcpServer(options: CreateMcpServerOptions): Server {
672
753
  const ops = createOperations({
673
754
  registry: options.registry,
674
755
  watcher: options.watcher,
675
- projectBaseDir: options.projectBaseDir,
676
756
  });
677
757
  const tools = buildTools(ops);
678
758