@nexusbloom/cli 0.9.3 → 0.9.5

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,6 +209,33 @@ $ 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:
217
+
218
+ ```bash
219
+ 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()
224
+ ```
225
+
226
+ This produces the same `$path|transform()` syntax you can write by hand. Two
227
+ constraints come from the parser, and the builder refuses bad input up front
228
+ rather than emitting a spec that will not run:
229
+
230
+ - **No spaces anywhere in a connection.** `$items|join(", ")` is rejected — use
231
+ `join(",")`. The parser cannot distinguish a space inside a quoted argument
232
+ from a separator, so it refuses all of them.
233
+ - **`|` cannot appear in an argument**, because it separates pipeline steps.
234
+
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.
238
+
212
239
  ### Saved pipelines
213
240
 
214
241
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexusbloom/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "NexusBloom CLI — run tools and workflows from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/abbr.js CHANGED
@@ -1,13 +1,7 @@
1
1
  /**
2
2
  * abbr.js — `nxb abbr` command.
3
3
  *
4
- * Paginated abbreviation view.
5
- *
6
- * nxb abbr — interactive pagination (20/page)
7
- * nxb abbr ip — filter by query (matches slug, abbr, or name)
8
- * nxb abbr -s ip — same, explicit flag
9
- * nxb abbr --json — raw JSON
10
- * nxb abbr --no-interactive— single dump
4
+ * Paginated abbreviation view. Flags are declared in index.js.
11
5
  */
12
6
 
13
7
  import chalk from "chalk";
package/src/commands.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  runPipeline,
24
24
  } from "./pipe.js";
25
25
  import { inferType, schemaTypes, typeLabel } from "./types.js";
26
- import { MISSING, getPath, preview, transformNames, parsePath, formatPath } from "./values.js";
26
+ import { MISSING, getPath, preview, transformNames, parsePath, formatPath, TRANSFORM_GROUPS } from "./values.js";
27
27
  import { buildWizard } from "./wizard.js";
28
28
 
29
29
  /** Shared manifest/tool lookup used by the plan, the runner and the wizard. */
@@ -150,17 +150,13 @@ export function listTransforms(jsonMode) {
150
150
  return;
151
151
  }
152
152
  console.log(chalk.bold("\n Connection transforms\n"));
153
- const rows = [
154
- ["len / count / words / lines / chars", "size of a string, array or object"],
155
- ["first / last / get(i) / slice(a,b)", "reduce an array to a single value"],
156
- ["join(sep) / split(sep) / reverse / sort(key)", "reshape collections"],
157
- ["keys / values / pick(field)", "reach into an object"],
158
- ["upper / lower / trim / replace(a,b) / pad(n)", "edit text"],
159
- ["int() / float() / num / str / bool()", "change type — required for casts"],
160
- ["json / unjson", "serialise / parse"],
161
- ["default(v)", "supply a value when the path is missing"],
162
- ];
163
- for (const [a, b] of rows) console.log(` ${chalk.cyan(a.padEnd(42))} ${chalk.dim(b)}`);
153
+ // Rendered from the shared catalogue so the list and the wizard's picker can
154
+ // never describe the same transform differently. The column is sized to the
155
+ // longest heading, which is wider than a fixed pad and left the docs ragged.
156
+ const width = Math.max(...TRANSFORM_GROUPS.map((g) => g.label.length)) + 2;
157
+ for (const group of TRANSFORM_GROUPS) {
158
+ console.log(` ${chalk.cyan(group.label.padEnd(width))} ${chalk.dim(group.doc)}`);
159
+ }
164
160
  console.log(chalk.dim("\n Use: key=$path|transform (no spaces around |)\n"));
165
161
  return { ok: true };
166
162
  }
package/src/index.js CHANGED
@@ -1,27 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * NexusBloom CLI (nxb)
4
+ * NexusBloom CLI (nxb) — command wiring.
5
5
  *
6
- * Usage:
7
- * nxb list — List available tools (interactive pagination)
8
- * nxb list -s <query> — Filter + paginate
9
- * nxb search <query> — Search tools
10
- * nxb run <slug> [args...] — Execute a tool (remote API)
11
- * nxb run <slug> --input '{}' — Execute with inline JSON input
12
- * nxb run <slug> --file input.json — Execute with file input
13
- * nxb run <slug> --local — Execute coreLogic locally (cached)
14
- * nxb run — Interactive: select tool → pick inputs → auto-build command
15
- * nxb run -s <query> — Filter the tool picker (non-TTY: print the list)
16
- * nxb info <slug> — Show tool schema
17
- * nxb abbr [-s <query>] — Show tool abbreviations (paginated)
18
- * nxb transform <slug> — Pipe stdin through a tool
19
- * nxb config set <key> [value] — Set config (interactive if value omitted)
20
- * nxb config get [key] — Get config (secrets redacted)
21
- * nxb cache clear — Clear local tool cache
22
- * nxb cache list — List cached tools
23
- * nxb cache path — Print where config/cache live
24
- * nxb json --template <slug> — Create JSON input for tools
6
+ * Commands are declared with commander below, and `--help` is generated from
7
+ * those declarations. A hand-maintained usage list in this header was always
8
+ * going to drift from the code, so there isn't one; README.md carries the
9
+ * task-oriented examples.
25
10
  */
26
11
 
27
12
  import { program } from "commander";
package/src/list.js CHANGED
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * list.js — `nxb list` command.
3
3
  *
4
- * Paginated tool listing. Long lists are always paired with a search option.
5
- *
6
- * nxb list — interactive pagination (20/page)
7
- * nxb list -s ssl — filter + paginate
8
- * nxb list --json — raw JSON
9
- * nxb list --no-interactive — single dump
4
+ * Paginated tool listing. Long lists are always paired with a search option;
5
+ * `--json` and the other flags are declared in index.js, which is also where
6
+ * `--help` reads them from.
10
7
  */
11
8
 
12
9
  import chalk from "chalk";
package/src/run.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * run.js — `nxb run` command.
3
3
  *
4
4
  * Two modes:
5
- * 1. Direct: nxb run <slug> [key=value ...] [--input json] [--file path] [--local]
6
- * 2. Interactive: nxb run (no slug) → select tool → pick inputs → auto-build & execute
5
+ * 1. Direct: nxb run <slug> [key=value ...] [--input json] [--file path] [--local]
6
+ * 2. Interactive: nxb run → select tool → pick inputs → auto-build & execute
7
7
  *
8
- * Imports execution logic from the original monolithic index.js but adds
9
- * inquirer-driven field prompting for the interactive path.
8
+ * The interactive path is only entered when no slug and no input were supplied,
9
+ * otherwise a scripted call would block on a prompt it never sees.
10
10
  */
11
11
 
12
12
  import fs from "fs";
package/src/tools.js CHANGED
@@ -1,29 +1,16 @@
1
1
  /**
2
- * tools.js — Centralised tool catalogue for the nxb CLI.
2
+ * tools.js — Tool catalogue resolution and config access.
3
3
  *
4
4
  * Resolution order for the *tool list*:
5
- * 1. Cached snapshot (`.cache/tools-list.json`) — always written on a
6
- * successful fetch so subsequent `list` / `abbr` calls are offline-fast.
7
- * 2. Live API (`/api/tools`)
5
+ * 1. Cached snapshot (`$XDG_CACHE_HOME/nexusbloom/tools-list.json`) — written
6
+ * on every successful fetch, so `list` / `abbr` stay fast and work offline
7
+ * 2. Live API (`/api/tools`)
8
8
  * 3. Supabase fallback (`/rest/v1/tools` + `/rest/v1/community_tools`)
9
- * 4. Local filesystem scan (`src/tools/<slug>/v2.js`)
9
+ * 4. Local filesystem scan — see `LOCAL_TOOL_DIRS` for the search roots
10
10
  *
11
- * Resolution order for a *single tool's source* (for `--local` execution):
12
- * 1. Local filesystem → 2. cache (`tools/<slug>.json`) → 3. API/Supabase
13
- *
14
- * Exports:
15
- * loadConfig / saveConfig / configGet / configSet / redactedConfig
16
- * fetchTools() — resolves list (cache-first on network failure)
17
- * refreshTools() — force a fresh fetch + persist to cache
18
- * fetchToolSource(slug) — { manifest, coreLogicSource }
19
- * filterTools / rankTools — shared search used by list / search / abbr / run
20
- * isValidSlug / normalizeSlug
21
- * scanLocalTools()
22
- * findLocalTool(slug)
23
- * generateAbbreviations(slugs)
24
- * resolveToolSlug(slug, tools?)
25
- * getSupabaseKey / getSupabaseUrl / supabaseHeaders
26
- * API_BASE
11
+ * Resolution order for a *single tool's source* (for `--local` execution) is
12
+ * the mirror image: local filesystem, then cache, then API/Supabase. Local wins
13
+ * because a developer running `--local` is iterating on that file right now.
27
14
  */
28
15
 
29
16
  import fs from "fs";
@@ -446,7 +433,6 @@ export async function fetchToolSource(rawSlug) {
446
433
  const source = fs.readFileSync(localPath, "utf-8");
447
434
  const manifest = extractManifest(source, { trusted: true });
448
435
  if (manifest) {
449
- // Refresh cache from local source
450
436
  fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource: source, cachedAt: new Date().toISOString() }), "utf-8");
451
437
  return { manifest, coreLogicSource: source, source: "local" };
452
438
  }
package/src/values.js CHANGED
@@ -223,6 +223,127 @@ export function transformNames() {
223
223
  return Object.keys(TRANSFORMS).sort();
224
224
  }
225
225
 
226
+ /**
227
+ * One-line description per transform, for the wizard's picker.
228
+ *
229
+ * These live beside the implementations rather than in the UI so that adding a
230
+ * transform and describing it are the same edit — a picker entry with no
231
+ * description reads as a mystery, and a description with no entry is worse.
232
+ */
233
+ export const TRANSFORM_DOCS = {
234
+ len: "length of a string, array or object",
235
+ count: "same as len — size of an array or string",
236
+ words: "number of whitespace-separated words",
237
+ lines: "number of lines",
238
+ chars: "number of characters",
239
+ first: "first element of an array",
240
+ last: "last element of an array",
241
+ get: "element at an index",
242
+ slice: "a range of an array or string",
243
+ join: "array to a string, with a separator",
244
+ keys: "an object's keys, as an array",
245
+ values: "an object's values, as an array",
246
+ reverse: "reverse an array",
247
+ sort: "sort an array, optionally by field",
248
+ upper: "UPPERCASE",
249
+ lower: "lowercase",
250
+ trim: "strip surrounding whitespace",
251
+ replace: "replace every occurrence of a substring",
252
+ pad: "pad the start to a width",
253
+ split: "string to an array on a separator",
254
+ str: "to string",
255
+ int: "to integer",
256
+ float: "to number",
257
+ num: "to number",
258
+ bool: "to true/false",
259
+ json: "serialise to a JSON string",
260
+ unjson: "parse a JSON string",
261
+ pick: "one field out of an object",
262
+ default: "value to use when the path is missing",
263
+ };
264
+
265
+ /**
266
+ * The catalogue grouped by purpose, for `nxb pipe --transforms`.
267
+ *
268
+ * `label` is the heading as a user reads it — arg hints and all — so it is
269
+ * written out rather than derived from `TRANSFORM_ARITY` (which would render
270
+ * `get()` where the heading wants `get(i)`).
271
+ */
272
+ export const TRANSFORM_GROUPS = [
273
+ { label: "len / count / words / lines / chars", doc: "size of a string, array or object",
274
+ names: ["len", "count", "words", "lines", "chars"] },
275
+ { label: "first / last / get(i) / slice(a,b)", doc: "reduce an array to a single value",
276
+ names: ["first", "last", "get", "slice"] },
277
+ { label: "join(sep) / split(sep) / reverse / sort(key)", doc: "reshape collections",
278
+ names: ["join", "split", "reverse", "sort"] },
279
+ { label: "keys / values / pick(field)", doc: "reach into an object",
280
+ names: ["keys", "values", "pick"] },
281
+ { label: "upper / lower / trim / replace(a,b) / pad(n)", doc: "edit text",
282
+ names: ["upper", "lower", "trim", "replace", "pad"] },
283
+ { label: "int() / float() / num / str / bool()", doc: "change type — required for casts",
284
+ names: ["int", "float", "num", "str", "bool"] },
285
+ { label: "json / unjson", doc: "serialise / parse",
286
+ names: ["json", "unjson"] },
287
+ { label: "default(v)", doc: "supply a value when the path is missing",
288
+ names: ["default"] },
289
+ ];
290
+
291
+ /**
292
+ * How many arguments a transform takes, excluding the value itself.
293
+ * @returns {number} 0 for transforms callable with no argument
294
+ */
295
+ export function transformArgCount(name) {
296
+ const arity = TRANSFORM_ARITY[name];
297
+ return arity === undefined ? 0 : Math.max(0, arity - 1);
298
+ }
299
+
300
+ /**
301
+ * Render one literal as it must appear inside a pipeline spec.
302
+ *
303
+ * Bare words are left alone (`hello`, `2024-01-01`); anything that could be
304
+ * misread — a space, a comma, a quote — is JSON-quoted so `parseValueExpr`'s
305
+ * quote-aware splitter sees it as one argument.
306
+ */
307
+ export function formatArg(value) {
308
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
309
+ if (value === null || value === undefined) return "";
310
+ const s = String(value);
311
+ if (s === "") return '""';
312
+ if (/^[\w.@/+-]+$/.test(s)) return s;
313
+ return JSON.stringify(s);
314
+ }
315
+
316
+ /**
317
+ * Render a transform call: `len()`, `get(0)`, `join(",")`.
318
+ *
319
+ * Always emits parens, even with no arguments, so the result reads as a call
320
+ * and round-trips through `parseTransform` unchanged.
321
+ */
322
+ export function formatTransformCall(name, args = []) {
323
+ return `|${name}(${(args || []).map(formatArg).join(",")})`;
324
+ }
325
+
326
+ /**
327
+ * Reject an argument the parser cannot accept.
328
+ *
329
+ * `parseValueExpr` rejects whitespace anywhere in an expression — it cannot
330
+ * tell a separator from a space inside a quoted argument, so `$x|join(", ")` is
331
+ * refused. Better to say so at the prompt than to emit a spec that fails to
332
+ * parse after the pipeline is already built.
333
+ *
334
+ * @returns {string|null} an error message, or null when the arg is usable
335
+ */
336
+ export function validateTransformArg(raw) {
337
+ const s = String(raw ?? "");
338
+ if (/\s/.test(s)) {
339
+ return "arguments cannot contain spaces — the pipeline syntax has none. Try a symbol like \"-\" or \"%20\".";
340
+ }
341
+ if (s.includes("|")) {
342
+ return "arguments cannot contain \"|\" — it separates steps in a pipeline.";
343
+ }
344
+ return null;
345
+ }
346
+
226
347
  /**
227
348
  * Apply a chain of transforms to a value.
228
349
  * @param {any} value
package/src/wizard.js CHANGED
@@ -33,7 +33,18 @@ 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, MISSING } from "./values.js";
36
+ import {
37
+ formatPath,
38
+ parsePath,
39
+ preview,
40
+ transformNames,
41
+ transformArgCount,
42
+ formatTransformCall,
43
+ validateTransformArg,
44
+ TRANSFORM_DOCS,
45
+ TRANSFORMS,
46
+ MISSING,
47
+ } from "./values.js";
37
48
  import { parseValueExpr, resolveAccessors } from "./pipe.js";
38
49
  import { isInteractive } from "./ui.js";
39
50
  import { renderHeader } from "./ui.js";
@@ -203,20 +214,134 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
203
214
  const source = prevFields.find((f) => formatPath(f.path) === choice.path);
204
215
  const from = source ? inferType(source.value) : "unknown";
205
216
  const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
206
- let transform = "";
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
+ : "";
225
+
226
+ assignments.push({ kind: "wire", text: `${field}=$${choice.path}${chain}` });
227
+ }
228
+
229
+ return assignments;
230
+ }
207
231
 
208
- if (compat.level === "cast" && compat.suggestion) {
209
- const use = await P.confirm({
210
- message: `${field} expects ${typeLabel(targetTypes)} — apply ${chalk.cyan(compat.suggestion)}?`,
211
- default: true,
232
+ /**
233
+ * Ask which transforms to apply to a wired value.
234
+ *
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.
241
+ *
242
+ * The returned string is appended to `$path`, so it already carries its own
243
+ * leading `|`.
244
+ */
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,
212
257
  });
213
- if (use) transform = `|${compat.suggestion}`;
258
+ if (!more) break;
259
+ const built = await promptOneTransform(P);
260
+ if (!built) break;
261
+ chain += built;
214
262
  }
263
+ return chain;
264
+ }
215
265
 
216
- assignments.push({ kind: "wire", text: `${field}=$${choice.path}${transform}` });
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,
270
+ });
271
+ if (use) return await buildSuggestedTransform(compat.suggestion, P);
217
272
  }
218
273
 
219
- return assignments;
274
+ return "";
275
+ }
276
+
277
+ /**
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.
286
+ */
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);
295
+
296
+ const args = [];
297
+ for (let i = 0; i < Math.max(1, transformArgCount(name)); i++) {
298
+ args.push(
299
+ await P.input({
300
+ message: `${name}() argument ${i + 1}`,
301
+ validate: (v) => validateTransformArg(v),
302
+ })
303
+ );
304
+ }
305
+ return formatTransformCall(name, args);
306
+ }
307
+
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);
333
+ }
334
+
335
+ return formatTransformCall(name, args);
336
+ }
337
+
338
+ /** Rank transforms by name and description, so "array" finds `join`. */
339
+ function rankTransforms(term) {
340
+ if (!term) return [];
341
+ const t = String(term).toLowerCase();
342
+ return transformNames().filter(
343
+ (n) => n.includes(t) || (TRANSFORM_DOCS[n] || "").toLowerCase().includes(t)
344
+ );
220
345
  }
221
346
 
222
347
  function formatLiteral(value) {
@@ -391,4 +516,4 @@ function literalInput(assignments, upstream) {
391
516
  return input;
392
517
  }
393
518
 
394
- export { transformNames, parsePath };
519
+ export { transformNames, parsePath, rankTransforms };