@nexusbloom/cli 0.9.5 → 0.9.6

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 (4) hide show
  1. package/README.md +45 -12
  2. package/package.json +1 -1
  3. package/src/pipe.js +140 -27
  4. package/src/wizard.js +158 -119
package/README.md CHANGED
@@ -209,18 +209,20 @@ $ nxb pipe --build
209
209
  frequency-analyzer.total_words integer 2
210
210
  ```
211
211
 
212
- When you wire a **collection** (an array or object) into a field, the builder
213
- offers the whole transform catalogue rather than only flagging a type clash —
214
- `len()` on an array, `join(",")`, `keys()`, `pick(field)` and the rest, listed
215
- with what each one does. You can chain them, and they are applied in the order
216
- you chose:
212
+ Every wired field offers the whole transform catalogue, whatever its type, and
213
+ nothing is applied on your behalf — a type clash is flagged on the choice list
214
+ but no transform is chosen for you. So `chars()` on a string and `len()` on an
215
+ array are both one step away, and you can chain as many as you like:
217
216
 
218
217
  ```bash
219
218
  frequency-analyzer → count integer
220
- Apply a transform to count? yes
221
- Transform › len
222
- len length of a string, array or object
223
- frequency-analyzer count=$character_frequency|len()
219
+ count: transform (optional)
220
+ · none — use the value as it came
221
+ len length of a string, array or object
222
+ chars number of characters
223
+ join array to a string, with a separator
224
+ Add another transform to count? yes
225
+ frequency-analyzer count=$character_frequency|len()|int()
224
226
  ```
225
227
 
226
228
  This produces the same `$path|transform()` syntax you can write by hand. Two
@@ -232,9 +234,40 @@ rather than emitting a spec that will not run:
232
234
  from a separator, so it refuses all of them.
233
235
  - **`|` cannot appear in an argument**, because it separates pipeline steps.
234
236
 
235
- A scalar type clash keeps its existing one-tap suggestion (`apply int()?`),
236
- since that is a fix rather than a choice. Run `nxb pipe --transforms` for the
237
- full catalogue with examples.
237
+ Declining the list leaves the value as it came. If that produces a type error,
238
+ `nxb doctor <slug>` and the plan-time check in `--dry-run` name the broken wire
239
+ rather than the pipeline failing mid-run. Run `nxb pipe --transforms` for the
240
+ same catalogue as a plain list.
241
+
242
+ ### Reusing an earlier step's output
243
+
244
+ A bare `$path` always reads the **previous** step. When a pipeline needs two
245
+ values carried forward, add `as=<name>` to the step that produces the one you
246
+ want to keep, then address it by that name from anywhere later:
247
+
248
+ ```bash
249
+ $ nxb pipe 'password-generator length=8 as=pw \
250
+ | lorem-generator count=$pw.password|chars()|str() # use its length here \
251
+ | base64-tool text=$pw.password action=encode # encode the password \
252
+ | base64-tool text=$result action=decode # …and decode it again \
253
+ | echo-tool text=$pw.password' # …and use the original
254
+ ```
255
+
256
+ Without a label that pipeline is impossible: the only way to reach the first
257
+ step's password at the end is to run it a second time.
258
+
259
+ - A label is read from the first path segment, so `$pw.password` and
260
+ `$pw` (the whole output) both work. Transforms chain as usual.
261
+ - Labels are unique across a pipeline; a repeat is an error rather than a
262
+ silent last-one-wins.
263
+ - `as=` only counts as a label in **last** position, so a tool with a real
264
+ input field named `as` is unaffected.
265
+ - Plan-time type checking resolves labels too, so `--dry-run` still catches a
266
+ bad cast on a far connection instead of failing mid-run.
267
+
268
+ The interactive builder offers this: after a step runs it asks whether to keep
269
+ the output, and a kept output then appears in the field picker for every later
270
+ step alongside the previous one.
238
271
 
239
272
  ### Saved pipelines
240
273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexusbloom/cli",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "NexusBloom CLI — run tools and workflows from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/pipe.js CHANGED
@@ -322,6 +322,24 @@ export function parsePipeline(spec) {
322
322
  throw new Error(`Invalid tool slug "${slug}"`);
323
323
  }
324
324
 
325
+ // A trailing `as=<label>` names this step's output so later steps can read
326
+ // it instead of only the immediately previous step. It is recognised only in
327
+ // last position, so a tool with a real field called `as` keeps working.
328
+ let label = null;
329
+ const last = tokens[tokens.length - 1];
330
+ if (last !== undefined && /^as=([a-zA-Z_][a-zA-Z0-9_]*)$/.test(last)) {
331
+ label = last.slice(3);
332
+ tokens.pop();
333
+ } else if (last !== undefined && /^as=/.test(last)) {
334
+ // Trailing `as=` that is not a valid identifier is far more likely a typo
335
+ // than a field the user meant to set, and silently becoming `as=<value>`
336
+ // would produce a confusing "no such field" much later.
337
+ throw new Error(
338
+ `Invalid label "${last.slice(3)}" — a label must start with a letter or _ and ` +
339
+ `contain only letters, digits and _ (e.g. as=pw).`
340
+ );
341
+ }
342
+
325
343
  const inputs = [];
326
344
  for (const token of tokens.slice(1)) {
327
345
  const eq = token.indexOf("=");
@@ -335,13 +353,55 @@ export function parsePipeline(spec) {
335
353
  const exprText = expr.kind === "literal" ? "" : `$${expr.headPath ?? ""}`;
336
354
  inputs.push({ field, expr, exprText });
337
355
  }
338
- steps.push({ slug, inputs, raw: spec });
356
+ steps.push({ slug, inputs, raw: spec, label });
339
357
  }
340
358
 
341
359
  if (!steps.length) throw new Error("Empty pipeline.");
360
+
361
+ const seen = new Map();
362
+ for (const step of steps) {
363
+ if (!step.label) continue;
364
+ const first = seen.get(step.label);
365
+ if (first !== undefined) {
366
+ throw new Error(
367
+ `Duplicate label "${step.label}" on steps ${first + 1} and ${step.slug} — ` +
368
+ `labels name one step's output, so they must be unique.`
369
+ );
370
+ }
371
+ seen.set(step.label, steps.indexOf(step));
372
+ }
373
+
342
374
  return steps;
343
375
  }
344
376
 
377
+ /**
378
+ * Resolve a connection's head path against the labelled outputs in scope.
379
+ *
380
+ * `$x` keeps its meaning — the previous step. `$name.field` reads `field` from
381
+ * whichever step was labelled `as=name`, at any distance, which is what lets a
382
+ * pipeline use an early value again much later.
383
+ *
384
+ * @returns {{label: string|null, upstream: any, accessors: Array}|null} null when
385
+ * the head does not name a label, meaning the caller should use the old
386
+ * previous-step behaviour.
387
+ */
388
+ export function scopeToLabel(accessors, scope) {
389
+ if (!accessors.length || accessors[0].op !== "path") return null;
390
+ const segments = parsePath(accessors[0].path);
391
+ const head = segments[0];
392
+ if (typeof head !== "string" || !scope.has(head)) return null;
393
+
394
+ const rest = segments.slice(1);
395
+ return {
396
+ label: head,
397
+ upstream: scope.get(head),
398
+ accessors: [
399
+ { op: "path", path: rest.length ? formatPath(rest) : "$" },
400
+ ...accessors.slice(1),
401
+ ],
402
+ };
403
+ }
404
+
345
405
  // ─── Plan ────────────────────────────────────────────────────────────────────
346
406
 
347
407
  /**
@@ -355,6 +415,8 @@ export async function buildPlan(steps, getManifest) {
355
415
  const planned = [];
356
416
  const issues = [];
357
417
  const produced = new Map(); // slug -> output field names it declares
418
+ const labelTypes = new Map(); // `as=name` -> that step's output schema
419
+ const labelSlug = new Map(); // `as=name` -> the slug that produced it
358
420
 
359
421
  for (let i = 0; i < steps.length; i++) {
360
422
  const step = steps[i];
@@ -378,6 +440,10 @@ export async function buildPlan(steps, getManifest) {
378
440
  const outProps = info.manifest.output_schema?.properties || {};
379
441
  entry.outputs = Object.keys(outProps);
380
442
  produced.set(step.slug, outProps);
443
+ if (step.label) {
444
+ labelTypes.set(step.label, outProps);
445
+ labelSlug.set(step.label, step.slug);
446
+ }
381
447
 
382
448
  for (const input of step.inputs) {
383
449
  const prop = props[input.field];
@@ -399,37 +465,55 @@ export async function buildPlan(steps, getManifest) {
399
465
  }
400
466
 
401
467
  if (input.expr.kind === "connection") {
468
+ // Where does this connection read from? An `as=` label if the head names
469
+ // one, otherwise the immediately previous step as before.
470
+ const headSegments = parsePath(input.expr.headPath);
471
+ const head = headSegments[0];
472
+ const usesLabel = typeof head === "string" && labelTypes.has(head);
402
473
  const prev = i > 0 ? steps[i - 1] : null;
403
- if (!prev) {
474
+ const sourceSlug = usesLabel ? labelSlug.get(head) : prev?.slug;
475
+ const sourceOut = usesLabel
476
+ ? labelTypes.get(head) || {}
477
+ : (produced.get(prev?.slug) || {});
478
+ // The segments after the label are the real path inside that output.
479
+ const restSegments = usesLabel ? headSegments.slice(1) : headSegments;
480
+
481
+ if (!usesLabel && !prev) {
404
482
  issues.push({ step: i, level: "error", message: `Nothing to read $${input.expr.headPath} from — it is the first step.` });
405
483
  field.verdict = "error";
406
484
  } else {
407
- const prevOut = produced.get(prev.slug) || {};
408
- const headSegments = parsePath(input.expr.headPath);
409
- const leaf = headSegments[headSegments.length - 1];
410
- const prevProp = headSegments.length === 1 ? prevOut[leaf] : undefined;
411
- const prevDeclaresAny = Object.keys(prevOut).length > 0;
412
- const known = headSegments.length === 0 || Boolean(prevProp) || !prevDeclaresAny;
485
+ const leaf = restSegments[restSegments.length - 1];
486
+ const sourceProp = restSegments.length === 1 ? sourceOut[leaf] : undefined;
487
+ const declaresAny = Object.keys(sourceOut).length > 0;
488
+ const known = restSegments.length === 0 || Boolean(sourceProp) || !declaresAny;
413
489
 
414
490
  field.source = {
415
- slug: prev.slug,
416
- path: input.expr.headPath,
417
- pathLabel: formatPath(headSegments),
491
+ slug: sourceSlug,
492
+ label: usesLabel ? head : null,
493
+ path: usesLabel ? formatPath(restSegments) : input.expr.headPath,
494
+ pathLabel: formatPath(restSegments),
418
495
  };
419
- field.sourceTypes = schemaTypes(prevProp);
496
+ field.sourceTypes = schemaTypes(sourceProp);
420
497
  field.sourceKnown = known;
421
-
422
- // Walk the accessors to work out the type that actually arrives.
423
- field.resultTypes = simulateTypes(input.expr.accessors, field.sourceTypes);
498
+ const srcName = usesLabel
499
+ ? `${head}.${formatPath(restSegments) || "(root)"}`
500
+ : `${sourceSlug}.${input.expr.headPath || "(root)"}`;
501
+
502
+ // Walk the accessors to work out the type that actually arrives. The
503
+ // label segment is not part of the data path, so it is dropped first.
504
+ const simAccessors = usesLabel
505
+ ? [{ op: "path", path: formatPath(restSegments) }, ...input.expr.accessors.slice(1)]
506
+ : input.expr.accessors;
507
+ field.resultTypes = simulateTypes(simAccessors, field.sourceTypes);
424
508
  // A path used *after* a transform cannot be typed from the schema.
425
- if (!known || headSegments.length > 1) field.staticallyUnknown = true;
509
+ if (!known || restSegments.length > 1) field.staticallyUnknown = true;
426
510
 
427
511
  if (!known) {
428
512
  field.verdict = "unknown-source";
429
513
  issues.push({
430
514
  step: i,
431
515
  level: "warn",
432
- message: `"${prev.slug}" does not declare output "${input.expr.headPath}" — checked at runtime.`,
516
+ message: `"${sourceSlug}" does not declare output "${formatPath(restSegments)}" — checked at runtime.`,
433
517
  });
434
518
  } else if (field.targetTypes.length && field.resultTypes.length) {
435
519
  const compat = compatibility(field.resultTypes[0], field.targetTypes);
@@ -439,18 +523,18 @@ export async function buildPlan(steps, getManifest) {
439
523
  issues.push({
440
524
  step: i,
441
525
  level: "error",
442
- message: `${prev.slug}.${input.expr.headPath || "(root)"} is ${typeLabel(field.resultTypes)} but ${step.slug}.${input.field} wants ${typeLabel(field.targetTypes)} — add |${compat.suggestion}`,
526
+ message: `${srcName} is ${typeLabel(field.resultTypes)} but ${step.slug}.${input.field} wants ${typeLabel(field.targetTypes)} — add |${compat.suggestion}`,
443
527
  });
444
528
  } else if (compat.level === "lossy") {
445
529
  field.verdict = "lossy";
446
530
  issues.push({
447
531
  step: i,
448
532
  level: "warn",
449
- message: `${prev.slug}.${input.expr.headPath || "(root)"} → ${step.slug}.${input.field} may lose precision (${compat.note}).`,
533
+ message: `${srcName} → ${step.slug}.${input.field} may lose precision (${compat.note}).`,
450
534
  });
451
535
  } else if (compat.level === "invalid") {
452
536
  field.verdict = "error";
453
- issues.push({ step: i, level: "error", message: `${prev.slug}.${input.expr.headPath || "(root)"} → ${step.slug}.${input.field}: ${compat.note}.` });
537
+ issues.push({ step: i, level: "error", message: `${srcName} → ${step.slug}.${input.field}: ${compat.note}.` });
454
538
  } else {
455
539
  field.verdict = "ok";
456
540
  }
@@ -538,6 +622,8 @@ export async function runPipeline({
538
622
  }) {
539
623
  const outputs = new Array(steps.length).fill(undefined);
540
624
  const logs = [];
625
+ // Labelled step outputs, readable from any later step.
626
+ const scope = new Map();
541
627
 
542
628
  for (let i = 0; i < steps.length; i++) {
543
629
  const step = steps[i];
@@ -563,13 +649,24 @@ export async function runPipeline({
563
649
  continue;
564
650
  }
565
651
 
566
- const upstream = outputs[i - 1];
652
+ // An explicit `as=` label wins over the implicit previous step.
653
+ const labelled = scopeToLabel(expr.accessors, scope);
654
+ const upstream = labelled ? labelled.upstream : outputs[i - 1];
567
655
  if (upstream === undefined) {
568
- throw new PipeError(`Step ${i + 1}: ${step.slug} reads $${expr.headPath} but step ${i} has not run.`);
656
+ throw new PipeError(
657
+ labelled
658
+ ? `Step ${i + 1}: ${step.slug} reads $${expr.headPath}, but no step produced "${labelled.label}".`
659
+ : i === 0
660
+ ? `Step 1: ${step.slug} reads $${expr.headPath}, but there is no earlier step. ` +
661
+ `A connection reads the previous step, or a step you labelled with as=<name>.`
662
+ : `Step ${i + 1}: ${step.slug} reads $${expr.headPath} but step ${i} has not run.`
663
+ );
569
664
  }
570
665
 
571
- let value = resolveAccessors(upstream, expr.accessors);
572
- const fromLabel = `${field.source?.slug ?? `step ${i}`}.${expr.headPath || "(root)"}`;
666
+ let value = resolveAccessors(upstream, labelled ? labelled.accessors : expr.accessors);
667
+ const fromLabel = labelled
668
+ ? `${labelled.label}.${formatPath(parsePath(expr.headPath).slice(1)) || "(root)"}`
669
+ : `${field.source?.slug ?? `step ${i}`}.${expr.headPath || "(root)"}`;
573
670
 
574
671
  // A connection that resolves to nothing is always an error: silently
575
672
  // sending `undefined` hides the broken wire behind whatever the tool
@@ -618,6 +715,7 @@ export async function runPipeline({
618
715
 
619
716
  const value = result?.ok === true ? result.result : result;
620
717
  outputs[i] = value;
718
+ if (step.label) scope.set(step.label, value);
621
719
  onStep({ phase: "done", index: i, step, input, output: value, durationMs: result?.durationMs });
622
720
  }
623
721
 
@@ -645,7 +743,11 @@ export function renderPlan(plan) {
645
743
  lines.push(chalk.red(` ✗ ${step.slug} — not found`));
646
744
  continue;
647
745
  }
648
- lines.push(chalk.bold(` ${step.slug}`));
746
+ lines.push(
747
+ step.label
748
+ ? chalk.bold(` ${step.slug}`) + chalk.magenta(` as=${step.label}`)
749
+ : chalk.bold(` ${step.slug}`)
750
+ );
649
751
 
650
752
  for (const f of step.fields) {
651
753
  const target = chalk.cyan(f.field);
@@ -656,11 +758,22 @@ export function renderPlan(plan) {
656
758
  } else if (f.expr.kind === "stdin") {
657
759
  lines.push(` ${target} = ${chalk.magenta("@in")} ${chalk.gray(`(${targetType || "any"})`)}`);
658
760
  } else {
659
- const chain = (f.expr.accessors || [])
761
+ const accessors = f.expr.accessors || [];
762
+ // A labelled source puts the label *and* the rest of the path in the head
763
+ // accessor, so it is displayed the way it was written rather than with the
764
+ // producing slug glued in front.
765
+ const labelled = Boolean(f.source?.label);
766
+ const rest = accessors.slice(labelled ? 1 : 0);
767
+ const chain = rest
660
768
  .map((a) => (a.op === "transform" ? `|${a.name}` : a.path))
661
769
  .join("");
770
+ const head = labelled
771
+ ? `${f.source.label}${f.source.path === "$" ? "" : `.${f.source.path}`}`
772
+ : `${f.source?.slug ?? "?"}`;
662
773
  const arrow = chalk.gray("←");
663
- const src = chalk.dim(`${f.source?.slug ?? "?"}${chain.startsWith(".") || chain.startsWith("|") ? chain : `.${chain}`}`);
774
+ const src = chalk.dim(
775
+ `${head}${labelled ? chain : chain.startsWith(".") || chain.startsWith("|") ? chain : `.${chain}`}`
776
+ );
664
777
  const got = f.resultTypes?.length ? typeLabel(f.resultTypes) : "runtime";
665
778
  const mark =
666
779
  f.verdict === "needs-cast" || f.verdict === "error" ? chalk.red("✗")
package/src/wizard.js CHANGED
@@ -42,10 +42,9 @@ import {
42
42
  formatTransformCall,
43
43
  validateTransformArg,
44
44
  TRANSFORM_DOCS,
45
- TRANSFORMS,
46
45
  MISSING,
47
46
  } from "./values.js";
48
- import { parseValueExpr, resolveAccessors } from "./pipe.js";
47
+ import { parseValueExpr, resolveAccessors, scopeToLabel } from "./pipe.js";
49
48
  import { isInteractive } from "./ui.js";
50
49
  import { renderHeader } from "./ui.js";
51
50
 
@@ -165,7 +164,7 @@ async function promptLiteral(field, prop, P = defaultPrompts) {
165
164
  * Ask how each input field of the next tool is supplied: wired from the
166
165
  * previous output, or a literal.
167
166
  */
168
- async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
167
+ async function wireFields(manifest, sources, P = defaultPrompts) {
169
168
  const props = manifest.input_schema?.properties || {};
170
169
  const required = new Set(manifest.input_schema?.required || []);
171
170
  const assignments = [];
@@ -179,25 +178,30 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
179
178
  name: chalk.gray("· set by hand"),
180
179
  value: { kind: "literal" },
181
180
  },
182
- ...prevFields.map((f) => {
183
- const path = formatPath(f.path);
184
- const from = inferType(f.value);
185
- const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
186
- const flag =
187
- compat.level === "cast" ? chalk.red(" · needs a transform")
188
- : compat.level === "lossy" ? chalk.yellow(" · lossy")
189
- : compat.level === "invalid" ? chalk.red(" · type mismatch")
190
- : "";
191
- return {
192
- name: `${chalk.cyan(path.padEnd(24))} ${chalk.gray(from.padEnd(8))} ${chalk.dim(preview(f.value, 24))}${flag}`,
193
- value: { kind: "wire", path },
194
- };
195
- }),
181
+ ...sources.flatMap((src) =>
182
+ src.fields.map((f) => {
183
+ const path = formatPath(f.path);
184
+ // A kept output is addressed by its label, the previous step by path.
185
+ const wire = src.label ? `${src.label}.${path}` : path;
186
+ const from = inferType(f.value);
187
+ const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
188
+ const flag =
189
+ compat.level === "cast" ? chalk.red(" · needs a transform")
190
+ : compat.level === "lossy" ? chalk.yellow(" · lossy")
191
+ : compat.level === "invalid" ? chalk.red(" · type mismatch")
192
+ : "";
193
+ const tag = src.label ? chalk.magenta(` ${src.label}·`) : "";
194
+ return {
195
+ name: `${chalk.cyan(wire.padEnd(24))} ${chalk.gray(from.padEnd(8))} ${chalk.dim(preview(f.value, 24))}${tag}${flag}`,
196
+ value: { kind: "wire", path: wire, label: src.label || null },
197
+ };
198
+ })
199
+ ),
196
200
  ...(canSkip ? [{ name: chalk.gray("· leave empty"), value: { kind: "skip" } }] : []),
197
201
  ];
198
202
 
199
203
  const choice = await P.select({
200
- message: `${prevSlug} → ${chalk.cyan(field)} ${chalk.gray(typeLabel(targetTypes))}${required.has(field) ? chalk.red(" *") : ""}`,
204
+ message: `${sources[0]?.label || sources[0]?.slug || "?"} → ${chalk.cyan(field)} ${chalk.gray(typeLabel(targetTypes))}${required.has(field) ? chalk.red(" *") : ""}`,
201
205
  choices,
202
206
  pageSize: 14,
203
207
  });
@@ -210,18 +214,18 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
210
214
  continue;
211
215
  }
212
216
 
213
- // A transform was flagged as necessary — offer the suggestion inline.
214
- const source = prevFields.find((f) => formatPath(f.path) === choice.path);
215
- const from = source ? inferType(source.value) : "unknown";
216
- const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
217
- // Offer transforms where one can do something: a type clash makes one
218
- // necessary, and a collection is what `len`/`first`/`join`/`keys` exist for.
219
- // Asking after every wire would be a pointless extra question on the common
220
- // scalar-to-scalar case, where no transform applies anyway.
221
- const worthTransforming = compat.level === "cast" || from === "array" || from === "object";
222
- const chain = worthTransforming
223
- ? await promptTransformChain({ field, targetTypes, compat, from, P })
224
- : "";
217
+ const chosen = sources
218
+ .flatMap((src) => src.fields.map((f) => ({ src, f })))
219
+ .find(({ src, f }) => {
220
+ const path = formatPath(f.path);
221
+ return src.label ? `${src.label}.${path}` === choice.path : path === choice.path;
222
+ });
223
+ const from = chosen ? inferType(chosen.f.value) : "unknown";
224
+ // Any wired value can be reshaped, whatever its type — `len()` on a string
225
+ // is as legitimate as on an array, and the wizard has no business deciding
226
+ // which transforms are worth offering. The type flag on the choice above is
227
+ // the only nudge: it marks a clash without applying anything.
228
+ const chain = await promptTransformChain(field, P);
225
229
 
226
230
  assignments.push({ kind: "wire", text: `${field}=$${choice.path}${chain}` });
227
231
  }
@@ -232,69 +236,68 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
232
236
  /**
233
237
  * Ask which transforms to apply to a wired value.
234
238
  *
235
- * Two things are happening here, in order. First a *required* cast — if the
236
- * source type cannot widen into the field's type, the suggestion is offered
237
- * directly, because without it the pipeline cannot run. Then the whole
238
- * catalogue: any field can be reshaped, not just the ones with a type clash,
239
- * since "how many items came back" is a question `len()` answers and no type
240
- * mismatch hints at.
239
+ * One question, always, with the whole catalogue in it — no type-based
240
+ * narrowing and no suggestion picked on the user's behalf. "How long is this
241
+ * string" is answered by `chars()` and no type mismatch hints at it, so gating
242
+ * the list on whether a cast happens to be needed would hide transforms people
243
+ * reach for. Declining is the first choice, so the common case costs one
244
+ * keystroke.
241
245
  *
242
- * The returned string is appended to `$path`, so it already carries its own
243
- * leading `|`.
246
+ * The returned string is appended to `$path`, so it carries its own `|`.
244
247
  */
245
- async function promptTransformChain({ field, targetTypes, compat, from, P }) {
246
- // A collection feeding a scalar field is reported as a cast, but `first()` is
247
- // a poor guess there — the usual intent is to measure or reshape it (`len`,
248
- // `join`, `pick`). So collections go straight to the catalogue, which offers
249
- // `first` anyway. A genuine scalar clash keeps its original single question:
250
- // one tap to fix a type error beats making everyone walk a picker for it.
251
- if (from === "array" || from === "object") {
252
- let chain = "";
253
- for (;;) {
254
- const more = await P.confirm({
255
- message: chain ? `Add another transform to ${field}?` : `Apply a transform to ${field}?`,
256
- default: false,
257
- });
258
- if (!more) break;
259
- const built = await promptOneTransform(P);
260
- if (!built) break;
261
- chain += built;
262
- }
263
- return chain;
264
- }
248
+ async function promptTransformChain(field, P) {
249
+ const picked = await P.select({
250
+ message: `${field}: transform${chalk.gray(" (optional)")}`,
251
+ source: (term) => transformChoices(term, { withNone: true }),
252
+ pageSize: 15,
253
+ });
254
+
255
+ // `null` is the "use it as it came" row.
256
+ if (!picked) return "";
265
257
 
266
- if (compat.level === "cast" && compat.suggestion) {
267
- const use = await P.confirm({
268
- message: `${field} expects ${typeLabel(targetTypes)} — apply ${chalk.cyan(compat.suggestion)}?`,
269
- default: true,
258
+ let chain = await buildTransform(picked, P);
259
+ for (;;) {
260
+ const more = await P.confirm({
261
+ message: `Add another transform to ${field}?`,
262
+ default: false,
263
+ });
264
+ if (!more) break;
265
+ const next = await P.select({
266
+ message: `${field}: another transform`,
267
+ source: (term) => transformChoices(term, { withNone: true }),
268
+ pageSize: 15,
270
269
  });
271
- if (use) return await buildSuggestedTransform(compat.suggestion, P);
270
+ if (!next) break;
271
+ chain += await buildTransform(next, P);
272
272
  }
273
-
274
- return "";
273
+ return chain;
275
274
  }
276
275
 
277
276
  /**
278
- * Turn a `compatibility()` suggestion into a real transform call.
279
- *
280
- * Suggestions arrive as `int()`, `first()` — but also `pick(field)`, which
281
- * names an argument it cannot fill in. Anything inside the parens is a
282
- * placeholder rather than a value, so it prompts. `default()` is the awkward
283
- * case: the suggestion shows no argument even though the transform needs one,
284
- * so the arity is topped up from `TRANSFORM_ARITY` instead of emitting
285
- * `|default()`, which `parseTransform` rejects.
277
+ * Rows for the searchable transform picker. `withNone` keeps "use as-is" pinned
278
+ * to the top so it survives a search term that matches no transform.
286
279
  */
287
- async function buildSuggestedTransform(suggestion, P) {
288
- const m = String(suggestion).match(/^([a-z_][a-z0-9_]*)(?:\((.*)\))?$/i);
289
- if (!m) return "";
290
- const name = m[1];
291
- if (!Object.hasOwn(TRANSFORMS, name)) return "";
292
- const needsArg = Boolean((m[2] || "").trim()) || transformArgCount(name) > 0;
293
-
294
- if (!needsArg) return formatTransformCall(name);
280
+ function transformChoices(term, { withNone = false } = {}) {
281
+ const skip = withNone
282
+ ? [{ value: null, name: chalk.gray("· none — use the value as it came") }]
283
+ : [];
284
+ const hits = term ? rankTransforms(term) : transformNames();
285
+ return [
286
+ ...skip,
287
+ ...hits.slice(0, 50).map((n) => ({
288
+ value: n,
289
+ name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
290
+ })),
291
+ ];
292
+ }
295
293
 
294
+ /**
295
+ * Build one transform call, prompting for whatever arguments it needs.
296
+ * @returns {Promise<string>} e.g. `|len()` or `|get(0)`
297
+ */
298
+ async function buildTransform(name, P) {
296
299
  const args = [];
297
- for (let i = 0; i < Math.max(1, transformArgCount(name)); i++) {
300
+ for (let i = 0; i < transformArgCount(name); i++) {
298
301
  args.push(
299
302
  await P.input({
300
303
  message: `${name}() argument ${i + 1}`,
@@ -305,34 +308,16 @@ async function buildSuggestedTransform(suggestion, P) {
305
308
  return formatTransformCall(name, args);
306
309
  }
307
310
 
308
- /**
309
- * Pick one transform from the catalogue and ask for whatever it needs.
310
- * @returns {Promise<string>} e.g. `|len()` or `|get(0)`
311
- */
312
- async function promptOneTransform(P) {
313
- const names = transformNames();
314
- const name = await P.select({
315
- message: "Transform",
316
- source: async (term) => {
317
- const hits = rankTransforms(term);
318
- return (term ? hits : names).slice(0, 50).map((n) => ({
319
- value: n,
320
- name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
321
- }));
322
- },
323
- pageSize: 15,
324
- });
325
-
326
- let args = [];
327
- for (let i = 0; i < transformArgCount(name); i++) {
328
- const answer = await P.input({
329
- message: `${name}() argument ${i + 1}`,
330
- validate: (v) => validateTransformArg(v),
331
- });
332
- args.push(answer);
311
+ /** Suggest a label for a step: short, derived from the slug, not already taken. */
312
+ function defaultLabel(slug, taken) {
313
+ const base = String(slug).replace(/[^a-zA-Z0-9_]/g, "").slice(0, 4) || "out";
314
+ const safe = /^[a-zA-Z_]/.test(base) ? base : `_${base}`;
315
+ if (!taken.has(safe)) return safe;
316
+ for (let n = 2; n < 100; n++) {
317
+ const next = `${safe}${n}`;
318
+ if (!taken.has(next)) return next;
333
319
  }
334
-
335
- return formatTransformCall(name, args);
320
+ return safe;
336
321
  }
337
322
 
338
323
  /** Rank transforms by name and description, so "array" finds `join`. */
@@ -349,6 +334,10 @@ function formatLiteral(value) {
349
334
  return JSON.stringify(value);
350
335
  }
351
336
  if (typeof value === "string" && value === "") return '""';
337
+ // An object or array would otherwise land in the spec as "[object Object]",
338
+ // which is valid-looking and parses as neither JSON nor the intended value.
339
+ if (value !== null && typeof value === "object") return JSON.stringify(value);
340
+ if (value === null || value === undefined) return "null";
352
341
  return String(value);
353
342
  }
354
343
 
@@ -379,6 +368,11 @@ export async function buildWizard(opts = {}) {
379
368
 
380
369
  const steps = [];
381
370
  const outputs = [];
371
+ // Outputs the user chose to keep, readable from any later step. `scope` holds
372
+ // the raw output so it can be handed straight to `scopeToLabel`; `kept` keeps
373
+ // the metadata the picker needs to describe it.
374
+ const scope = new Map();
375
+ const kept = [];
382
376
 
383
377
  for (;;) {
384
378
  renderHeader(
@@ -396,11 +390,16 @@ export async function buildWizard(opts = {}) {
396
390
  continue;
397
391
  }
398
392
 
399
- // Wire from the previous step, if there is one.
393
+ // Wire from the previous step and from anything kept earlier.
400
394
  let assignments = [];
401
- if (outputs.length) {
402
- const prev = outputs[outputs.length - 1];
403
- assignments = await wireFields(manifest, prev.slug, prev.fields, P);
395
+ const sources = [
396
+ ...outputs.slice().reverse().map((o) => ({ slug: o.slug, label: null, fields: o.fields })),
397
+ ...kept.filter(
398
+ (k) => !outputs.some((o) => o.slug === k.slug && o.value === k.value)
399
+ ),
400
+ ];
401
+ if (sources.length) {
402
+ assignments = await wireFields(manifest, sources, P);
404
403
  } else {
405
404
  // First step: ask for each field by hand.
406
405
  const props = manifest.input_schema?.properties || {};
@@ -411,13 +410,13 @@ export async function buildWizard(opts = {}) {
411
410
  }
412
411
 
413
412
  const spec = `${tool.slug} ${assignments.map((a) => a.text).join(" ")}`.trim();
414
- steps.push({ slug: tool.slug, spec, assignments });
413
+ steps.push({ slug: tool.slug, spec, assignments, label: null });
415
414
 
416
415
  console.log(chalk.gray(`\n Running ${spec}\n`));
417
416
  const started = Date.now();
418
417
  const result = await execute(
419
418
  tool.slug,
420
- literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined)
419
+ literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined, scope)
421
420
  );
422
421
 
423
422
  if (!result.ok) {
@@ -438,6 +437,37 @@ export async function buildWizard(opts = {}) {
438
437
 
439
438
  outputs.push({ slug: tool.slug, value: result.result, fields });
440
439
 
440
+ // Keeping an output lets a later step read it again, which is the only way to
441
+ // carry two values forward without re-running the step that produced them.
442
+ if (fields.length) {
443
+ const keep = await P.confirm({
444
+ message: `Keep ${tool.slug}'s output for later steps?`,
445
+ default: false,
446
+ });
447
+ if (keep) {
448
+ const label = await P.input({
449
+ message: " Call it",
450
+ default: defaultLabel(tool.slug, scope),
451
+ });
452
+ const name = String(label).trim();
453
+ if (name) {
454
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
455
+ console.log(chalk.yellow(` "${name}" is not a usable label — ignored.`));
456
+ } else if (scope.has(name)) {
457
+ console.log(chalk.yellow(` "${name}" is already used — ignored.`));
458
+ } else {
459
+ const last = steps[steps.length - 1];
460
+ if (last) {
461
+ last.label = name;
462
+ last.spec = `${last.spec} as=${name}`;
463
+ }
464
+ scope.set(name, result.result);
465
+ kept.push({ slug: tool.slug, label: name, fields, value: result.result });
466
+ }
467
+ }
468
+ }
469
+ }
470
+
441
471
  if (fields.length === 0) break;
442
472
  const more = await P.confirm({ message: "Send this output into another tool?", default: steps.length < 2 });
443
473
  if (!more) break;
@@ -479,7 +509,7 @@ export async function buildWizard(opts = {}) {
479
509
  * A reference that resolves to nothing is omitted rather than passed as
480
510
  * `undefined`, so schema validation reports the field by name.
481
511
  */
482
- function literalInput(assignments, upstream) {
512
+ function literalInput(assignments, upstream, scope) {
483
513
  const input = {};
484
514
  for (const a of assignments) {
485
515
  const eq = a.text.indexOf("=");
@@ -488,7 +518,6 @@ function literalInput(assignments, upstream) {
488
518
  const raw = a.text.slice(eq + 1);
489
519
 
490
520
  if (raw.startsWith("$")) {
491
- if (upstream === undefined) continue;
492
521
  let expr;
493
522
  try {
494
523
  expr = parseValueExpr(raw);
@@ -496,9 +525,19 @@ function literalInput(assignments, upstream) {
496
525
  continue;
497
526
  }
498
527
  if (expr.kind !== "connection" || !expr.accessors) continue;
528
+ // A leading segment naming a kept output wins over the previous step,
529
+ // matching how the engine resolves it.
530
+ let source = upstream;
531
+ let accessors = expr.accessors;
532
+ const labelled = scopeToLabel(expr.accessors, scope);
533
+ if (labelled) {
534
+ source = labelled.upstream;
535
+ accessors = labelled.accessors;
536
+ }
537
+ if (source === undefined) continue;
499
538
  let value;
500
539
  try {
501
- value = resolveAccessors(upstream, expr.accessors);
540
+ value = resolveAccessors(source, accessors);
502
541
  } catch {
503
542
  continue;
504
543
  }
@@ -516,4 +555,4 @@ function literalInput(assignments, upstream) {
516
555
  return input;
517
556
  }
518
557
 
519
- export { transformNames, parsePath, rankTransforms };
558
+ export { transformNames, parsePath, rankTransforms, defaultLabel };