@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.
- package/README.md +45 -12
- package/package.json +1 -1
- package/src/pipe.js +140 -27
- 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
|
-
|
|
213
|
-
|
|
214
|
-
`
|
|
215
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
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
|
-
|
|
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
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
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:
|
|
416
|
-
|
|
417
|
-
|
|
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(
|
|
496
|
+
field.sourceTypes = schemaTypes(sourceProp);
|
|
420
497
|
field.sourceKnown = known;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 ||
|
|
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: `"${
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
...
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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: `${
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
243
|
-
* leading `|`.
|
|
246
|
+
* The returned string is appended to `$path`, so it carries its own `|`.
|
|
244
247
|
*/
|
|
245
|
-
async function promptTransformChain(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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 <
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
393
|
+
// Wire from the previous step and from anything kept earlier.
|
|
400
394
|
let assignments = [];
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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(
|
|
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 };
|