@nexusbloom/cli 0.8.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexusbloom/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "NexusBloom CLI — run tools and workflows from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,4 +30,4 @@
30
30
  "test:watch": "node --test --watch --import ./test/setup.mjs test/*.test.js",
31
31
  "lint": "node --check src/index.js"
32
32
  }
33
- }
33
+ }
package/src/index.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  fetchTools, fetchToolSource, resolveToolSlug,
41
41
  generateAbbreviations, normalizeSlug, readStdin,
42
42
  } from "./tools.js";
43
- import { runLocal, runRemote, buildInputBody, runInteractive, reportError } from "./run.js";
43
+ import { runLocal, runRemote, buildInputBody, runInteractive, runInteractiveFor, isExitPromptError, reportError } from "./run.js";
44
44
  import listAction from "./list.js";
45
45
  import { pipeAction, listTransforms, doctorAction } from "./commands.js";
46
46
  import { buildWizard } from "./wizard.js";
@@ -167,6 +167,21 @@ program
167
167
  const body = await buildInputBody(opts, args);
168
168
  if (body === null) return;
169
169
 
170
+ // ── No input at all: configure the tool interactively ──
171
+ // `nxb run text-statistics` with nothing to send used to fail schema
172
+ // validation ("Missing required fields: text"), which is a dead end for a
173
+ // human at a terminal. Ask for the fields instead. Only when the body is
174
+ // genuinely empty: any explicit input (--input/--file, key=value, piped
175
+ // stdin) means the caller is scripting, and a prompt there would hang CI.
176
+ if (Object.keys(body).length === 0 && isInteractive()) {
177
+ return await runInteractiveFor(resolved, {
178
+ json: jsonMode,
179
+ tools: allTools,
180
+ local: Boolean(opts.local),
181
+ key: opts.key,
182
+ });
183
+ }
184
+
170
185
  // ── Local vs remote ──
171
186
  if (opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"))) {
172
187
  return await runLocal(resolved, body, { jsonMode, slugResolved: true });
@@ -710,4 +725,11 @@ function renderToolInfo(manifest, resolved) {
710
725
  console.log();
711
726
  }
712
727
 
713
- program.parse(process.argv);
728
+ // `parse` does not await the async action handlers, so a Ctrl+C inside an
729
+ // inquirer prompt surfaces here as a rejected promise rather than a thrown
730
+ // error. Catching it turns a stack trace into a clean, conventional exit.
731
+ program.parseAsync(process.argv).catch((err) => {
732
+ if (!isExitPromptError(err)) throw err;
733
+ process.stderr.write("\n Cancelled.\n");
734
+ process.exit(130);
735
+ });
package/src/pipe.js CHANGED
@@ -135,7 +135,7 @@ function tokenize(spec) {
135
135
  * @returns {{kind:"stdin"|"connection"|"literal", headPath?: string,
136
136
  * accessors?: Array, chain?: Array, literal?: any}}
137
137
  */
138
- function parseValueExpr(expr) {
138
+ export function parseValueExpr(expr) {
139
139
  const raw = String(expr ?? "");
140
140
 
141
141
  if (raw === "@in" || raw === "@stdin") return { kind: "stdin" };
package/src/run.js CHANGED
@@ -200,6 +200,21 @@ export async function runLocal(slug, input, { jsonMode, slugResolved, quiet } =
200
200
  return { ok: true, result: res.result, durationMs: res.durationMs };
201
201
  }
202
202
 
203
+ /**
204
+ * True when an error is inquirer's "the user pressed Ctrl+C" signal.
205
+ *
206
+ * @inquirer/core rejects with an `ExitPromptError` from inside a readline
207
+ * `SIGINT` handler. Because the CLI parses with `program.parse()` — which does
208
+ * not await the async action handlers — that rejection used to escape as an
209
+ * unhandled rejection and dump an inquirer stack trace over the user's
210
+ * terminal. The class is not exported from `@inquirer/prompts`, and importing
211
+ * `@inquirer/core` directly would tie us to a transitive dependency, so it is
212
+ * matched by name.
213
+ */
214
+ export function isExitPromptError(err) {
215
+ return Boolean(err) && err.name === "ExitPromptError";
216
+ }
217
+
203
218
  /** Print an error on stderr (or as JSON) and set a non-zero exit code. */
204
219
  export function reportError(jsonMode, message) {
205
220
  if (jsonMode) {
@@ -365,15 +380,30 @@ export async function runInteractive(opts = {}) {
365
380
  const selected = await promptSelect("Choose a tool:", choices, { pageSize: 15 });
366
381
  if (!selected) return;
367
382
 
368
- const slug = selected.slug;
369
- const resolved = await resolveToolSlug(slug, tools);
383
+ return await runInteractiveFor(selected.slug, { ...opts, json: jsonMode, tools });
384
+ }
385
+
386
+ /**
387
+ * Interactive configuration for a slug that is already known.
388
+ *
389
+ * Shared by the tool picker above and by `nxb run <slug>` with no input of any
390
+ * kind: a bare `nxb run text-statistics` has nothing to send, so asking for the
391
+ * fields is the useful behaviour rather than failing schema validation with
392
+ * "Missing required fields: text".
393
+ *
394
+ * Returns the executed slug, or undefined when the user backs out or the tool
395
+ * has no schema to configure.
396
+ */
397
+ export async function runInteractiveFor(slug, opts = {}) {
398
+ const { json: jsonMode, tools: knownTools } = opts;
399
+ const resolved = await resolveToolSlug(slug, knownTools);
370
400
 
371
401
  // 2. Fetch manifest / schema
372
402
  const toolInfo = await fetchToolSource(resolved);
373
403
  if (!toolInfo?.manifest) {
374
404
  console.log(chalk.yellow(` No schema available for "${resolved}". Falling back to raw input.`));
375
405
  await runCommand(resolved, {}, { ...opts, json: jsonMode });
376
- return;
406
+ return resolved;
377
407
  }
378
408
 
379
409
  const { manifest } = toolInfo;
@@ -419,10 +449,11 @@ export async function runInteractive(opts = {}) {
419
449
  if (!runIt) {
420
450
  console.log(chalk.dim(" Command copied to clipboard (if available):"));
421
451
  console.log(chalk.gray(` ${fullCommand}`));
422
- return;
452
+ return resolved;
423
453
  }
424
454
 
425
455
  await runCommand(resolved, body, { ...opts, json: jsonMode, resolved });
456
+ return resolved;
426
457
  }
427
458
 
428
459
  /** Prompt for a single schema field based on its type. */
package/src/wizard.js CHANGED
@@ -33,7 +33,8 @@ import { execInSandbox } from "./sandbox.js";
33
33
  import { configGet } from "./tools.js";
34
34
  import { runRemote } from "./run.js";
35
35
  import { inferType, schemaTypes, typeLabel, compatibility } from "./types.js";
36
- import { formatPath, parsePath, preview, transformNames } from "./values.js";
36
+ import { formatPath, parsePath, preview, transformNames, MISSING } from "./values.js";
37
+ import { parseValueExpr, resolveAccessors } from "./pipe.js";
37
38
  import { isInteractive } from "./ui.js";
38
39
  import { renderHeader } from "./ui.js";
39
40
 
@@ -283,7 +284,10 @@ export async function buildWizard(opts = {}) {
283
284
 
284
285
  console.log(chalk.gray(`\n Running ${spec}\n`));
285
286
  const started = Date.now();
286
- const result = await execute(tool.slug, literalInput(assignments));
287
+ const result = await execute(
288
+ tool.slug,
289
+ literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined)
290
+ );
287
291
 
288
292
  if (!result.ok) {
289
293
  console.log(chalk.red(` ✗ ${result.error}`));
@@ -328,15 +332,51 @@ export async function buildWizard(opts = {}) {
328
332
  return { ok: true, spec: pipeline };
329
333
  }
330
334
 
331
- /** Build the input object for the trial run from literal assignments. */
332
- function literalInput(assignments) {
335
+ /**
336
+ * Build the input object for a step's trial run.
337
+ *
338
+ * Wired fields (`$path`, `$path|transform`) are resolved against the previous
339
+ * step's real output, using the *same* parser and resolver the pipeline engine
340
+ * uses at run time. That matters twice over:
341
+ *
342
+ * - correctness: a step whose required input is wired previously ran with that
343
+ * input simply absent, so `password-generator → base64-tool` failed with
344
+ * "text must be a non-empty string" no matter what the user chose;
345
+ * - consistency: the trial the wizard shows is the run the replay command
346
+ * will later reproduce, because both go through `parseValueExpr` +
347
+ * `resolveAccessors` rather than a second, drifting implementation.
348
+ *
349
+ * A reference that resolves to nothing is omitted rather than passed as
350
+ * `undefined`, so schema validation reports the field by name.
351
+ */
352
+ function literalInput(assignments, upstream) {
333
353
  const input = {};
334
354
  for (const a of assignments) {
335
355
  const eq = a.text.indexOf("=");
336
356
  if (eq === -1) continue;
337
357
  const key = a.text.slice(0, eq);
338
358
  const raw = a.text.slice(eq + 1);
339
- if (raw.startsWith("$")) continue; // wired fields are not needed for a first run
359
+
360
+ if (raw.startsWith("$")) {
361
+ if (upstream === undefined) continue;
362
+ let expr;
363
+ try {
364
+ expr = parseValueExpr(raw);
365
+ } catch {
366
+ continue;
367
+ }
368
+ if (expr.kind !== "connection" || !expr.accessors) continue;
369
+ let value;
370
+ try {
371
+ value = resolveAccessors(upstream, expr.accessors);
372
+ } catch {
373
+ continue;
374
+ }
375
+ if (value === MISSING || value === undefined) continue;
376
+ input[key] = value;
377
+ continue;
378
+ }
379
+
340
380
  try {
341
381
  input[key] = JSON.parse(raw);
342
382
  } catch {