@nexusbloom/cli 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.7",
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/values.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
 
15
15
  /** Distinguishes "the field is absent" from "the field holds undefined". */
16
+ import { createHash, randomUUID } from "crypto";
17
+
16
18
  export const MISSING = Symbol("missing");
17
19
 
18
20
  /**
@@ -96,6 +98,40 @@ export function formatPath(segments) {
96
98
  return out;
97
99
  }
98
100
 
101
+ // ─── Helpers for the computed transforms ─────────────────────────────────────
102
+
103
+ /**
104
+ * Compare loosely enough that `eq(200)` matches a JSON number but `eq("200")`
105
+ * also matches the string a tool happened to return, while still refusing to
106
+ * claim `[1]` equals `[1]`.
107
+ */
108
+ function coerceLike(other, sample) {
109
+ if (typeof other === "string" && typeof sample === "number") {
110
+ const n = Number(other);
111
+ return isNaN(n) ? other : n;
112
+ }
113
+ return other;
114
+ }
115
+
116
+ /**
117
+ * Equality that forgives a number arriving as text, without extending that
118
+ * forgiveness to containers. Comparing by string would make two separate
119
+ * `[1]`s equal and two `{a:1}`s equal, which is never what "are these the same"
120
+ * should answer.
121
+ */
122
+ function looseEqual(a, b) {
123
+ if (a === b) return true;
124
+ const bothPrimitives =
125
+ (a === null || typeof a !== "object") && (b === null || typeof b !== "object");
126
+ if (!bothPrimitives) return false;
127
+ return String(a) === String(b);
128
+ }
129
+
130
+ /** Stable hash of a stringified value, for change detection and cache keys. */
131
+ function digest(value, algorithm) {
132
+ return createHash(algorithm).update(toStr(value), "utf8").digest("hex");
133
+ }
134
+
99
135
  // ─── Coercion helpers ────────────────────────────────────────────────────────
100
136
 
101
137
  const toStr = (v) => {
@@ -204,6 +240,212 @@ export const TRANSFORMS = {
204
240
  return r;
205
241
  },
206
242
  default: (v, fallback) => (v === MISSING || v === undefined || v === null || v === "" ? fallback : v),
243
+
244
+ // — arithmetic —
245
+ //
246
+ // The tools in a catalogue rarely line up numerically: one returns a count, the
247
+ // next wants a word count. These fill the gap without a dedicated tool, which is
248
+ // the difference between "add a tool" and "compose what exists".
249
+ //
250
+ // A wrong input type returns the value untouched rather than throwing or
251
+ // coercing, so a transform never silently invents a number.
252
+ add: (v, n) => (typeof v === "number" && isFinite(v) ? v + Number(n) : v),
253
+ sub: (v, n) => (typeof v === "number" && isFinite(v) ? v - Number(n) : v),
254
+ mul: (v, n) => (typeof v === "number" && isFinite(v) ? v * Number(n) : v),
255
+ div: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v / Number(n) : v),
256
+ mod: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v % Number(n) : v),
257
+ inc: (v) => (typeof v === "number" && isFinite(v) ? v + 1 : v),
258
+ dec: (v) => (typeof v === "number" && isFinite(v) ? v - 1 : v),
259
+ neg: (v) => (typeof v === "number" && isFinite(v) ? -v : v),
260
+ abs: (v) => (typeof v === "number" && isFinite(v) ? Math.abs(v) : v),
261
+ pow: (v, n) => (typeof v === "number" && isFinite(v) ? v ** Number(n) : v),
262
+ round: (v, p = 0) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(Number(p))) : v),
263
+ floor: (v) => (typeof v === "number" && isFinite(v) ? Math.floor(v) : v),
264
+ ceil: (v) => (typeof v === "number" && isFinite(v) ? Math.ceil(v) : v),
265
+ clamp: (v, lo, hi) =>
266
+ typeof v === "number" && isFinite(v)
267
+ ? Math.min(Math.max(v, Number(lo)), Number(hi))
268
+ : v,
269
+
270
+ // Aggregate — the whole input array collapses to one number.
271
+ sum: (v) => (Array.isArray(v) ? v.filter((n) => typeof n === "number").reduce((a, b) => a + b, 0) : v),
272
+ avg: (v) => {
273
+ if (!Array.isArray(v)) return v;
274
+ const nums = v.filter((n) => typeof n === "number");
275
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : MISSING;
276
+ },
277
+ min: (v) => (Array.isArray(v) && v.length ? Math.min(...v.filter((n) => typeof n === "number")) : v),
278
+ max: (v) => (Array.isArray(v) && v.length ? Math.max(...v.filter((n) => typeof n === "number")) : v),
279
+ // Sum one field across an array of objects — the common "total of the column".
280
+ sumBy: (v, key) => {
281
+ if (!Array.isArray(v)) return v;
282
+ return v
283
+ .map((r) => getPath(r, key))
284
+ .filter((n) => typeof n === "number")
285
+ .reduce((a, b) => a + b, 0);
286
+ },
287
+
288
+ // — comparison —
289
+ //
290
+ // These return real booleans, so they are the input to `then()` and to any tool
291
+ // taking a boolean. A non-matching value is `false`, never MISSING.
292
+ eq: (v, other) => looseEqual(v, other),
293
+ ne: (v, other) => !looseEqual(v, other),
294
+ gt: (v, n) => typeof v === "number" && isFinite(v) && v > Number(n),
295
+ gte: (v, n) => typeof v === "number" && isFinite(v) && v >= Number(n),
296
+ lt: (v, n) => typeof v === "number" && isFinite(v) && v < Number(n),
297
+ lte: (v, n) => typeof v === "number" && isFinite(v) && v <= Number(n),
298
+ between: (v, lo, hi) => typeof v === "number" && isFinite(v) && v >= Number(lo) && v <= Number(hi),
299
+ includes: (v, needle) =>
300
+ typeof v === "string"
301
+ ? v.includes(needle)
302
+ : Array.isArray(v)
303
+ ? v.some((x) => x === needle)
304
+ : false,
305
+ startsWith: (v, s) => typeof v === "string" && v.startsWith(s),
306
+ endsWith: (v, s) => typeof v === "string" && v.endsWith(s),
307
+ // Regex source only — a pattern with spaces or flags cannot be expressed, and a
308
+ // bad pattern is the user's typo rather than something to guess at.
309
+ matches: (v, re) => {
310
+ if (typeof v !== "string" || !re) return false;
311
+ try {
312
+ return new RegExp(String(re)).test(v);
313
+ } catch {
314
+ return false;
315
+ }
316
+ },
317
+ isEmpty: (v) =>
318
+ v === MISSING || v === undefined || v === null || v === "" ||
319
+ (Array.isArray(v) && v.length === 0) ||
320
+ (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0),
321
+
322
+ // — branching —
323
+ //
324
+ // `then(cond, whenTrue, whenFalse)` is what a linear pipeline was missing: pick
325
+ // between two values on a condition, without a step to do it. `coalesce` is the
326
+ // safe default chain that does not need a condition at all.
327
+ then: (v, cond, whenTrue, whenFalse) => {
328
+ const test = typeof cond === "boolean" ? cond : v === true;
329
+ return test ? whenTrue : whenFalse === undefined ? MISSING : whenFalse;
330
+ },
331
+ coalesce: (v, ...fallbacks) => {
332
+ if (v !== MISSING && v !== undefined && v !== null && v !== "") return v;
333
+ for (const f of fallbacks) {
334
+ if (f !== MISSING && f !== undefined && f !== null && f !== "") return f;
335
+ }
336
+ return MISSING;
337
+ },
338
+
339
+ // — arrays —
340
+ //
341
+ // `pluck` is the one that unlocks the most: it turns an array of records into a
342
+ // flat array of one field, which everything above (`len`, `sort`, `sumBy`,
343
+ // `join`) can then work on.
344
+ pluck: (v, key) =>
345
+ Array.isArray(v) ? v.map((row) => getPath(row, key)) : v,
346
+ unique: (v) => (Array.isArray(v) ? [...new Set(v)] : v),
347
+ compact: (v) =>
348
+ Array.isArray(v) ? v.filter((x) => x !== undefined && x !== null && x !== "") : v,
349
+ flatten: (v) => (Array.isArray(v) ? v.flat(Infinity) : v),
350
+ chunk: (v, n) => {
351
+ if (!Array.isArray(v)) return v;
352
+ const size = Math.max(1, Number(n) || 1);
353
+ const out = [];
354
+ for (let i = 0; i < v.length; i += size) out.push(v.slice(i, i + size));
355
+ return out;
356
+ },
357
+ without: (v, needle) => (Array.isArray(v) ? v.filter((x) => x !== needle) : v),
358
+ // Keep array elements whose field passes a comparison: where("status","eq",200).
359
+ where: (v, key, op, operand) => {
360
+ if (!Array.isArray(v)) return v;
361
+ const test = (row) => {
362
+ const got = getPath(row, key);
363
+ switch (op) {
364
+ case "eq": return looseEqual(got, operand);
365
+ case "ne": return !looseEqual(got, operand);
366
+ case "gt": return typeof got === "number" && got > Number(operand);
367
+ case "gte": return typeof got === "number" && got >= Number(operand);
368
+ case "lt": return typeof got === "number" && got < Number(operand);
369
+ case "lte": return typeof got === "number" && got <= Number(operand);
370
+ case "contains": return String(got ?? "").includes(String(operand));
371
+ case "exists": return got !== MISSING && got !== undefined && got !== null;
372
+ default: return false;
373
+ }
374
+ };
375
+ return v.filter(test);
376
+ },
377
+
378
+ // — naming and shape —
379
+ title: (v) => toStr(v).replace(/\b\w/g, (c) => c.toUpperCase()),
380
+ camel: (v) => toStr(v).replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")).replace(/^(.)/, (c) => c.toLowerCase()),
381
+ snake: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase(),
382
+ kebab: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(),
383
+ slugify: (v) =>
384
+ toStr(v)
385
+ .toLowerCase()
386
+ .normalize("NFKD")
387
+ .replace(/[\u0300-\u036f]/g, "")
388
+ .replace(/[^a-z0-9]+/g, "-")
389
+ .replace(/^-+|-+$/g, ""),
390
+ truncate: (v, n, ellipsis = "…") => {
391
+ const s = toStr(v);
392
+ const max = Math.max(0, Number(n) || 0);
393
+ if (s.length <= max) return s;
394
+ // Prefer a word boundary, but only if one is close enough that truncating to
395
+ // it does not throw away most of what was asked for.
396
+ const hard = s.slice(0, max);
397
+ const space = hard.lastIndexOf(" ");
398
+ const cut = space > max * 0.6 ? hard.slice(0, space).replace(/\s+$/, "") : hard;
399
+ return cut + ellipsis;
400
+ },
401
+ repeat: (v, n) => toStr(v).repeat(Math.max(0, Number(n) || 0)),
402
+ // Show the first and last few characters, e.g. mask(4) → "jTI…Nj".
403
+ mask: (v, n = 3) => {
404
+ const s = toStr(v);
405
+ const keep = Math.max(0, Number(n) || 0);
406
+ if (s.length <= keep * 2) return s;
407
+ return `${s.slice(0, keep)}${"…".repeat(1)}${s.slice(-keep)}`;
408
+ },
409
+
410
+ // — encoding and identity —
411
+ //
412
+ // Hashing here is for change detection and cache keys, never for passwords.
413
+ md5: (v) => digest(v, "md5"),
414
+ sha1: (v) => digest(v, "sha1"),
415
+ sha256: (v) => digest(v, "sha256"),
416
+ base64: (v) => Buffer.from(toStr(v), "utf8").toString("base64"),
417
+ unbase64: (v) => {
418
+ const text = toStr(v).trim();
419
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text)) return MISSING;
420
+ try {
421
+ const bytes = Buffer.from(text, "base64");
422
+ // Buffer.from silently drops anything outside the alphabet, so the only
423
+ // way to notice the input was never base64 is to re-encode and compare.
424
+ // Garbage bytes are worse than an honest MISSING.
425
+ const reencoded = bytes.toString("base64").replace(/=+$/, "");
426
+ if (reencoded !== text.replace(/=+$/, "")) return MISSING;
427
+ return bytes.toString("utf8");
428
+ } catch {
429
+ return MISSING;
430
+ }
431
+ },
432
+ urlEncode: (v) => encodeURIComponent(toStr(v)),
433
+ urlDecode: (v) => {
434
+ try {
435
+ return decodeURIComponent(toStr(v));
436
+ } catch {
437
+ return MISSING;
438
+ }
439
+ },
440
+ uuid: () => randomUUID(),
441
+
442
+ // — introspection —
443
+ type: (v) => (v === MISSING ? "missing" : Array.isArray(v) ? "array" : v === null ? "null" : typeof v),
444
+ isArray: (v) => Array.isArray(v),
445
+ isString: (v) => typeof v === "string",
446
+ isNumber: (v) => typeof v === "number" && isFinite(v),
447
+ isObject: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
448
+ isBool: (v) => typeof v === "boolean",
207
449
  };
208
450
 
209
451
  /** Transforms that are no-ops on their argument order, used for plan hints. */
@@ -217,6 +459,20 @@ export const TRANSFORM_ARITY = {
217
459
  sort: 2,
218
460
  pick: 2,
219
461
  default: 2,
462
+
463
+ add: 2, sub: 2, mul: 2, div: 2, mod: 2, pow: 2,
464
+ round: 2, clamp: 3, sumBy: 2,
465
+ eq: 2, ne: 2, gt: 2, gte: 2, lt: 2, lte: 2,
466
+ between: 3, includes: 2, startsWith: 2, endsWith: 2, matches: 2,
467
+ then: 4,
468
+ // Arity is how many arguments the *builder* asks for, which is one less than
469
+ // the argument count of the hand-written form for a variadic transform.
470
+ // coalesce takes any number of fallbacks; prompting for a fixed count would
471
+ // understate it, so the builder asks for one and a written pipeline may list
472
+ // as many as it likes.
473
+ coalesce: 2,
474
+ pluck: 2, chunk: 2, without: 2, where: 4,
475
+ truncate: 3, repeat: 2, mask: 2,
220
476
  };
221
477
 
222
478
  export function transformNames() {
@@ -260,6 +516,71 @@ export const TRANSFORM_DOCS = {
260
516
  unjson: "parse a JSON string",
261
517
  pick: "one field out of an object",
262
518
  default: "value to use when the path is missing",
519
+ // The catalogue is grouped by purpose; `nxb pipe --transforms` and the
520
+ // builder's picker both read from here, so a transform and its description
521
+ // are always edited together.
522
+ add: "add a number",
523
+ sub: "subtract a number",
524
+ mul: "multiply by a number",
525
+ div: "divide by a number",
526
+ mod: "remainder after division",
527
+ pow: "raise to a power",
528
+ inc: "add one",
529
+ dec: "subtract one",
530
+ neg: "negate",
531
+ abs: "absolute value",
532
+ round: "round to N decimal places",
533
+ floor: "round down to an integer",
534
+ ceil: "round up to an integer",
535
+ clamp: "keep a number between a low and a high",
536
+ sum: "add up every number in an array",
537
+ avg: "average of every number in an array",
538
+ min: "smallest number in an array",
539
+ max: "largest number in an array",
540
+ sumBy: "add up one field across an array of objects",
541
+ eq: "is it equal to a value",
542
+ ne: "is it different from a value",
543
+ gt: "is it greater than a number",
544
+ gte: "is it at least a number",
545
+ lt: "is it less than a number",
546
+ lte: "is it at most a number",
547
+ between: "is a number within a low and a high",
548
+ includes: "does a string or array contain a value",
549
+ startsWith: "does a string start with text",
550
+ endsWith: "does a string end with text",
551
+ matches: "does a string match a regular expression",
552
+ isEmpty: "is the value empty — blank, [], or {}",
553
+ then: "choose between two values on a condition",
554
+ coalesce: "first value that is not empty",
555
+ pluck: "pull one field out of every object in an array",
556
+ unique: "remove duplicates from an array",
557
+ compact: "drop empty values from an array",
558
+ flatten: "flatten a nested array",
559
+ chunk: "split an array into fixed-size groups",
560
+ without: "remove every occurrence of a value",
561
+ where: "keep array items whose field passes a test",
562
+ title: "Title Case",
563
+ camel: "camelCase",
564
+ snake: "snake_case",
565
+ kebab: "kebab-case",
566
+ slugify: "url-safe slug",
567
+ truncate: "shorten to N characters",
568
+ repeat: "repeat a string N times",
569
+ mask: "hide the middle, e.g. jTI…Nj",
570
+ md5: "MD5 hex digest",
571
+ sha1: "SHA-1 hex digest",
572
+ sha256: "SHA-256 hex digest",
573
+ base64: "Base64-encode text",
574
+ unbase64: "decode Base64 text",
575
+ urlEncode: "percent-encode for a URL",
576
+ urlDecode: "decode percent-encoding",
577
+ uuid: "a random UUID",
578
+ type: "the value's type as text",
579
+ isArray: "is it an array",
580
+ isString: "is it text",
581
+ isNumber: "is it a number",
582
+ isObject: "is it an object",
583
+ isBool: "is it true or false",
263
584
  };
264
585
 
265
586
  /**
@@ -284,8 +605,32 @@ export const TRANSFORM_GROUPS = [
284
605
  names: ["int", "float", "num", "str", "bool"] },
285
606
  { label: "json / unjson", doc: "serialise / parse",
286
607
  names: ["json", "unjson"] },
287
- { label: "default(v)", doc: "supply a value when the path is missing",
288
- names: ["default"] },
608
+ { label: "default(v) / coalesce(a,b,…) / then(cond,a,b)", doc: "supply a value when one is missing, or choose on a condition",
609
+ names: ["default", "coalesce", "then"] },
610
+ { label: "eq / ne / gt / gte / lt / lte / between(a,b)", doc: "compare — the result is true or false",
611
+ names: ["eq", "ne", "gt", "gte", "lt", "lte", "between"] },
612
+ { label: "includes / startsWith / endsWith / matches(re) / isEmpty", doc: "test text and arrays",
613
+ names: ["includes", "startsWith", "endsWith", "matches", "isEmpty"] },
614
+ { label: "pluck(field) / where(field,op,value)", doc: "select out of an array of records",
615
+ names: ["pluck", "where"] },
616
+ { label: "unique / compact / flatten / chunk(n) / without(v)", doc: "reshape an array",
617
+ names: ["unique", "compact", "flatten", "chunk", "without"] },
618
+ { label: "add(n) / sub(n) / mul(n) / div(n) / mod(n) / pow(n)", doc: "arithmetic on a number",
619
+ names: ["add", "sub", "mul", "div", "mod", "pow"] },
620
+ { label: "inc / dec / neg / abs / round(p) / floor / ceil / clamp(lo,hi)", doc: "adjust a number",
621
+ names: ["inc", "dec", "neg", "abs", "round", "floor", "ceil", "clamp"] },
622
+ { label: "sum / avg / min / max / sumBy(field)", doc: "collapse an array to one number",
623
+ names: ["sum", "avg", "min", "max", "sumBy"] },
624
+ { label: "title / camel / snake / kebab / slugify", doc: "change a string's shape",
625
+ names: ["title", "camel", "snake", "kebab", "slugify"] },
626
+ { label: "truncate(n) / repeat(n) / mask(n)", doc: "shorten, repeat or hide part of a string",
627
+ names: ["truncate", "repeat", "mask"] },
628
+ { label: "md5 / sha1 / sha256 / base64 / unbase64", doc: "digest or encode — for fingerprints, not secrets",
629
+ names: ["md5", "sha1", "sha256", "base64", "unbase64"] },
630
+ { label: "urlEncode / urlDecode / uuid", doc: "URL and identity helpers",
631
+ names: ["urlEncode", "urlDecode", "uuid"] },
632
+ { label: "type / isArray / isString / isNumber / isObject / isBool", doc: "what is this value",
633
+ names: ["type", "isArray", "isString", "isNumber", "isObject", "isBool"] },
289
634
  ];
290
635
 
291
636
  /**
@@ -350,14 +695,18 @@ export function validateTransformArg(raw) {
350
695
  * @param {Array<{name: string, args: any[]}>} chain
351
696
  * @returns {any|typeof MISSING}
352
697
  */
698
+ /** Transforms permitted to replace a missing value with a fallback. */
699
+ const FALLBACK_TRANSFORMS = new Set(["default", "coalesce"]);
700
+
353
701
  export function applyTransforms(value, chain) {
354
702
  let cur = value;
355
703
  for (const step of chain) {
356
704
  const fn = TRANSFORMS[step.name];
357
705
  if (!fn) throw new Error(`Unknown transform "${step.name}". Run \`nxb pipe --transforms\`.`);
358
- // A missing value stays missing unless the transform can supply one —
359
- // only `default()` is allowed to invent a value.
360
- if (cur === MISSING && step.name !== "default") return MISSING;
706
+ // A missing value stays missing unless the transform is explicitly allowed to
707
+ // supply a fallback — `default(v)` and `coalesce(…)`. Anything else would
708
+ // be inventing a value the tool never produced.
709
+ if (cur === MISSING && !FALLBACK_TRANSFORMS.has(step.name)) return MISSING;
361
710
  // A step may legitimately take no argument; treat a missing `args` as
362
711
  // empty rather than failing to iterate undefined.
363
712
  cur = fn(cur, ...(step.args || []));
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,71 @@ 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.search({
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.search({
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
+ // No cap: this is a searchable list, so it paginates and scrolls. Truncating
288
+ // here silently hid every transform past the cut-off, which is unreachable
289
+ // by typing the name you can see.
290
+ ...hits.map((n) => ({
291
+ value: n,
292
+ name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
293
+ })),
294
+ ];
295
+ }
295
296
 
297
+ /**
298
+ * Build one transform call, prompting for whatever arguments it needs.
299
+ * @returns {Promise<string>} e.g. `|len()` or `|get(0)`
300
+ */
301
+ async function buildTransform(name, P) {
296
302
  const args = [];
297
- for (let i = 0; i < Math.max(1, transformArgCount(name)); i++) {
303
+ for (let i = 0; i < transformArgCount(name); i++) {
298
304
  args.push(
299
305
  await P.input({
300
306
  message: `${name}() argument ${i + 1}`,
@@ -305,34 +311,16 @@ async function buildSuggestedTransform(suggestion, P) {
305
311
  return formatTransformCall(name, args);
306
312
  }
307
313
 
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);
314
+ /** Suggest a label for a step: short, derived from the slug, not already taken. */
315
+ function defaultLabel(slug, taken) {
316
+ const base = String(slug).replace(/[^a-zA-Z0-9_]/g, "").slice(0, 4) || "out";
317
+ const safe = /^[a-zA-Z_]/.test(base) ? base : `_${base}`;
318
+ if (!taken.has(safe)) return safe;
319
+ for (let n = 2; n < 100; n++) {
320
+ const next = `${safe}${n}`;
321
+ if (!taken.has(next)) return next;
333
322
  }
334
-
335
- return formatTransformCall(name, args);
323
+ return safe;
336
324
  }
337
325
 
338
326
  /** Rank transforms by name and description, so "array" finds `join`. */
@@ -349,6 +337,10 @@ function formatLiteral(value) {
349
337
  return JSON.stringify(value);
350
338
  }
351
339
  if (typeof value === "string" && value === "") return '""';
340
+ // An object or array would otherwise land in the spec as "[object Object]",
341
+ // which is valid-looking and parses as neither JSON nor the intended value.
342
+ if (value !== null && typeof value === "object") return JSON.stringify(value);
343
+ if (value === null || value === undefined) return "null";
352
344
  return String(value);
353
345
  }
354
346
 
@@ -379,6 +371,11 @@ export async function buildWizard(opts = {}) {
379
371
 
380
372
  const steps = [];
381
373
  const outputs = [];
374
+ // Outputs the user chose to keep, readable from any later step. `scope` holds
375
+ // the raw output so it can be handed straight to `scopeToLabel`; `kept` keeps
376
+ // the metadata the picker needs to describe it.
377
+ const scope = new Map();
378
+ const kept = [];
382
379
 
383
380
  for (;;) {
384
381
  renderHeader(
@@ -396,11 +393,16 @@ export async function buildWizard(opts = {}) {
396
393
  continue;
397
394
  }
398
395
 
399
- // Wire from the previous step, if there is one.
396
+ // Wire from the previous step and from anything kept earlier.
400
397
  let assignments = [];
401
- if (outputs.length) {
402
- const prev = outputs[outputs.length - 1];
403
- assignments = await wireFields(manifest, prev.slug, prev.fields, P);
398
+ const sources = [
399
+ ...outputs.slice().reverse().map((o) => ({ slug: o.slug, label: null, fields: o.fields })),
400
+ ...kept.filter(
401
+ (k) => !outputs.some((o) => o.slug === k.slug && o.value === k.value)
402
+ ),
403
+ ];
404
+ if (sources.length) {
405
+ assignments = await wireFields(manifest, sources, P);
404
406
  } else {
405
407
  // First step: ask for each field by hand.
406
408
  const props = manifest.input_schema?.properties || {};
@@ -411,13 +413,13 @@ export async function buildWizard(opts = {}) {
411
413
  }
412
414
 
413
415
  const spec = `${tool.slug} ${assignments.map((a) => a.text).join(" ")}`.trim();
414
- steps.push({ slug: tool.slug, spec, assignments });
416
+ steps.push({ slug: tool.slug, spec, assignments, label: null });
415
417
 
416
418
  console.log(chalk.gray(`\n Running ${spec}\n`));
417
419
  const started = Date.now();
418
420
  const result = await execute(
419
421
  tool.slug,
420
- literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined)
422
+ literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined, scope)
421
423
  );
422
424
 
423
425
  if (!result.ok) {
@@ -438,6 +440,37 @@ export async function buildWizard(opts = {}) {
438
440
 
439
441
  outputs.push({ slug: tool.slug, value: result.result, fields });
440
442
 
443
+ // Keeping an output lets a later step read it again, which is the only way to
444
+ // carry two values forward without re-running the step that produced them.
445
+ if (fields.length) {
446
+ const keep = await P.confirm({
447
+ message: `Keep ${tool.slug}'s output for later steps?`,
448
+ default: false,
449
+ });
450
+ if (keep) {
451
+ const label = await P.input({
452
+ message: " Call it",
453
+ default: defaultLabel(tool.slug, scope),
454
+ });
455
+ const name = String(label).trim();
456
+ if (name) {
457
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
458
+ console.log(chalk.yellow(` "${name}" is not a usable label — ignored.`));
459
+ } else if (scope.has(name)) {
460
+ console.log(chalk.yellow(` "${name}" is already used — ignored.`));
461
+ } else {
462
+ const last = steps[steps.length - 1];
463
+ if (last) {
464
+ last.label = name;
465
+ last.spec = `${last.spec} as=${name}`;
466
+ }
467
+ scope.set(name, result.result);
468
+ kept.push({ slug: tool.slug, label: name, fields, value: result.result });
469
+ }
470
+ }
471
+ }
472
+ }
473
+
441
474
  if (fields.length === 0) break;
442
475
  const more = await P.confirm({ message: "Send this output into another tool?", default: steps.length < 2 });
443
476
  if (!more) break;
@@ -479,7 +512,7 @@ export async function buildWizard(opts = {}) {
479
512
  * A reference that resolves to nothing is omitted rather than passed as
480
513
  * `undefined`, so schema validation reports the field by name.
481
514
  */
482
- function literalInput(assignments, upstream) {
515
+ function literalInput(assignments, upstream, scope) {
483
516
  const input = {};
484
517
  for (const a of assignments) {
485
518
  const eq = a.text.indexOf("=");
@@ -488,7 +521,6 @@ function literalInput(assignments, upstream) {
488
521
  const raw = a.text.slice(eq + 1);
489
522
 
490
523
  if (raw.startsWith("$")) {
491
- if (upstream === undefined) continue;
492
524
  let expr;
493
525
  try {
494
526
  expr = parseValueExpr(raw);
@@ -496,9 +528,19 @@ function literalInput(assignments, upstream) {
496
528
  continue;
497
529
  }
498
530
  if (expr.kind !== "connection" || !expr.accessors) continue;
531
+ // A leading segment naming a kept output wins over the previous step,
532
+ // matching how the engine resolves it.
533
+ let source = upstream;
534
+ let accessors = expr.accessors;
535
+ const labelled = scopeToLabel(expr.accessors, scope);
536
+ if (labelled) {
537
+ source = labelled.upstream;
538
+ accessors = labelled.accessors;
539
+ }
540
+ if (source === undefined) continue;
499
541
  let value;
500
542
  try {
501
- value = resolveAccessors(upstream, expr.accessors);
543
+ value = resolveAccessors(source, accessors);
502
544
  } catch {
503
545
  continue;
504
546
  }
@@ -516,4 +558,4 @@ function literalInput(assignments, upstream) {
516
558
  return input;
517
559
  }
518
560
 
519
- export { transformNames, parsePath, rankTransforms };
561
+ export { transformNames, parsePath, rankTransforms, defaultLabel };