@nexusbloom/cli 0.3.4 → 0.9.0

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/src/index.js CHANGED
@@ -12,14 +12,15 @@
12
12
  * nxb run <slug> --file input.json — Execute with file input
13
13
  * nxb run <slug> --local — Execute coreLogic locally (cached)
14
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)
15
16
  * nxb info <slug> — Show tool schema
16
- * nxb abbr — Show tool abbreviations (paginated)
17
+ * nxb abbr [-s <query>] — Show tool abbreviations (paginated)
17
18
  * nxb transform <slug> — Pipe stdin through a tool
18
19
  * nxb config set <key> [value] — Set config (interactive if value omitted)
19
- * nxb config get [key] — Get config
20
+ * nxb config get [key] — Get config (secrets redacted)
20
21
  * nxb cache clear — Clear local tool cache
21
22
  * nxb cache list — List cached tools
22
- * nxb create — Scaffold a new tool locally
23
+ * nxb cache path — Print where config/cache live
23
24
  * nxb json --template <slug> — Create JSON input for tools
24
25
  */
25
26
 
@@ -34,16 +35,58 @@ const require = createRequire(import.meta.url);
34
35
  const { version } = require("../package.json");
35
36
 
36
37
  // ─── Local modules ──────────────────────────────────────────────────────────
37
- import { API_BASE } from "./tools.js";
38
38
  import {
39
- loadConfig, saveConfig, configGet, configSet,
39
+ loadConfig, configGet, configSet, configUnset, redactedConfig, isSecretKey,
40
40
  fetchTools, fetchToolSource, resolveToolSlug,
41
- generateAbbreviations, readStdin,
41
+ generateAbbreviations, normalizeSlug, readStdin,
42
42
  } from "./tools.js";
43
- import { runLocal, runRemote, buildInputBody, runInteractive } from "./run.js";
43
+ import { runLocal, runRemote, buildInputBody, runInteractive, runInteractiveFor, isExitPromptError, reportError } from "./run.js";
44
44
  import listAction from "./list.js";
45
+ import { pipeAction, listTransforms, doctorAction } from "./commands.js";
46
+ import { buildWizard } from "./wizard.js";
47
+ import { listPipelines, loadPipeline, deletePipeline } from "./pipelines.js";
45
48
  import abbrAction from "./abbr.js";
46
- import { TOOLS_CACHE_DIR, TOOLS_LIST_CACHE_PATH } from "./paths.js";
49
+ import { isInteractive } from "./ui.js";
50
+ import {
51
+ CACHE_DIR,
52
+ CONFIG_PATH,
53
+ LEGACY_CACHE_DIR,
54
+ TOOLS_CACHE_DIR,
55
+ TOOLS_LIST_CACHE_PATH,
56
+ } from "./paths.js";
57
+
58
+ /**
59
+ * Was `--json` requested?
60
+ *
61
+ * A subcommand's immediate parent is not always the program: for
62
+ * `nxb pipeline list` it is the `pipeline` command, which has no `json` option
63
+ * of its own. Walking to the root is what makes the global flag mean the same
64
+ * thing at every depth.
65
+ */
66
+ function jsonFlag(cmd) {
67
+ let c = cmd;
68
+ while (c) {
69
+ if (typeof c.opts === "function") {
70
+ const v = c.opts().json;
71
+ if (v !== undefined) return v;
72
+ }
73
+ c = c.parent;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /** Guard: slugs become cache filenames and query strings, so reject oddities. */
79
+ function requireSlug(slug, jsonMode) {
80
+ const safe = normalizeSlug(slug);
81
+ if (safe) return safe;
82
+ const msg = `Invalid tool slug: "${slug}". Use letters, digits, dots, dashes and underscores.`;
83
+ // Same envelope as every other failure, so a script keying off `success`
84
+ // does not see `undefined` here and nowhere else.
85
+ if (jsonMode) console.log(JSON.stringify({ success: false, error: msg }));
86
+ else console.error(chalk.red(msg));
87
+ process.exitCode = 1;
88
+ return null;
89
+ }
47
90
 
48
91
  // ─── Program ─────────────────────────────────────────────────────────────────
49
92
  program
@@ -58,12 +101,16 @@ program
58
101
  .description("List available tools (interactive pagination)")
59
102
  .option("-s, --search <query>", "Search tools by name, slug, category, or tags")
60
103
  .option("--no-interactive", "Disable interactive navigation")
104
+ .option("-r, --refresh", "Bypass the local cache and re-fetch the catalogue")
105
+ .option("--offline", "Use only the local cache (no network)")
61
106
  .action(async (opts, cmd) => {
62
- const jsonMode = cmd.parent.opts().json;
107
+ const jsonMode = jsonFlag(cmd);
63
108
  await listAction({
64
109
  search: opts.search,
65
110
  json: jsonMode,
66
111
  interactive: opts.interactive !== false,
112
+ force: Boolean(opts.refresh),
113
+ offline: Boolean(opts.offline),
67
114
  });
68
115
  });
69
116
 
@@ -72,35 +119,14 @@ program
72
119
  .command("search")
73
120
  .description("Search for tools")
74
121
  .argument("<query>", "Search query")
122
+ .option("--no-interactive", "Disable interactive navigation")
75
123
  .action(async (query, opts, cmd) => {
76
124
  const jsonMode = cmd?.parent?.opts().json || false;
77
- const tools = await fetchTools();
78
- if (!tools || tools.length === 0) {
79
- if (jsonMode) return console.log(JSON.stringify({ tools: [], query }));
80
- console.log(chalk.yellow(`No tools found matching "${query}".`));
81
- return;
82
- }
83
- const q = query.toLowerCase();
84
- const filtered = tools.filter(
85
- (t) =>
86
- (t.slug || "").includes(q) ||
87
- (t.name || "").toLowerCase().includes(q) ||
88
- (t.short_description || t.description || "").toLowerCase().includes(q) ||
89
- (t.category || "").includes(q) ||
90
- (t.tags || []).some((tg) => tg.toLowerCase().includes(q))
91
- );
92
- if (filtered.length === 0) {
93
- if (jsonMode) return console.log(JSON.stringify({ tools: [], query }));
94
- console.log(chalk.yellow(`No tools matching "${query}".`));
95
- return;
96
- }
97
- if (jsonMode) return console.log(JSON.stringify({ tools: filtered, query }, null, 2));
98
-
99
- console.log(chalk.bold(`\n ${filtered.length} result${filtered.length > 1 ? "s" : ""} for "${query}"\n`));
100
- filtered.forEach((t) => {
101
- console.log(` ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim((t.short_description || t.description || "").slice(0, 60))}`);
125
+ await listAction({
126
+ search: query,
127
+ json: jsonMode,
128
+ interactive: opts.interactive !== false,
102
129
  });
103
- console.log();
104
130
  });
105
131
 
106
132
  // ─── `nxb run [slug] [args...]` ──────────────────────────────────────────────
@@ -109,32 +135,53 @@ program
109
135
  .description("Execute a tool (interactive if no slug is given)")
110
136
  .argument("[slug]", "Tool slug or abbreviation")
111
137
  .argument("[args...]", "Key=value arguments (e.g. domain=example.com)")
138
+ .option("-s, --search <query>", "Filter tools by name, slug, category, or tags")
112
139
  .option("-k, --key <key>", "API key (or set NEXUSBLOOM_API_KEY env var)")
113
140
  .option("-i, --input <json>", "Inline JSON input or @- for stdin (e.g. '{\"domain\":\"example.com\"}' or @-)")
114
141
  .option("-f, --file <path>", "Read input from a JSON file")
115
142
  .option("--local", "Execute coreLogic locally (fetches tool source, caches it)")
116
143
  .action(async (slug, args, opts, cmd) => {
117
- const jsonMode = cmd.parent.opts().json;
144
+ const jsonMode = jsonFlag(cmd);
118
145
 
119
- // ── Interactive mode: no slug provided ──
146
+ // ── Interactive / list mode: no slug provided ──
120
147
  if (!slug) {
121
- return await runInteractive({ json: jsonMode });
148
+ return await runInteractive({ json: jsonMode, search: opts.search });
122
149
  }
123
150
 
151
+ const safeSlug = requireSlug(slug, jsonMode);
152
+ if (!safeSlug) return;
153
+
124
154
  // ── Resolve abbreviation ──
125
155
  const allTools = await fetchTools();
126
156
  const slugs = (allTools || []).map((t) => t.slug);
127
157
  const abbrMap = generateAbbreviations(slugs);
128
- let resolved = slug;
129
- if (abbrMap[slug] && slugs.includes(abbrMap[slug])) {
130
- resolved = abbrMap[slug];
131
- if (!jsonMode) console.log(chalk.dim(` Resolved: ${slug} → ${resolved}`));
158
+ let resolved = safeSlug;
159
+ if (abbrMap[safeSlug] && slugs.includes(abbrMap[safeSlug])) {
160
+ resolved = abbrMap[safeSlug];
161
+ // The result itself goes to stderr, so the notice does too — otherwise
162
+ // `nxb run <abbr> ... > out.json` writes prose into the JSON.
163
+ if (!jsonMode) console.error(chalk.dim(` Resolved: ${safeSlug} → ${resolved}`));
132
164
  }
133
165
 
134
166
  // ── Build input body ──
135
167
  const body = await buildInputBody(opts, args);
136
168
  if (body === null) return;
137
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
+
138
185
  // ── Local vs remote ──
139
186
  if (opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"))) {
140
187
  return await runLocal(resolved, body, { jsonMode, slugResolved: true });
@@ -154,15 +201,15 @@ program
154
201
  .argument("<slug>", "Tool slug or abbreviation")
155
202
  .action(async (slug, opts, cmd) => {
156
203
  const jsonMode = cmd?.parent?.opts().json || false;
157
- const resolved = await resolveToolSlug(slug);
204
+ const safe = requireSlug(slug, jsonMode);
205
+ if (!safe) return;
158
206
 
207
+ const resolved = await resolveToolSlug(safe);
159
208
  const toolInfo = await fetchToolSource(resolved);
160
209
  const manifest = toolInfo?.manifest;
161
210
 
162
211
  if (!manifest) {
163
- if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${resolved}" not found.` }));
164
- console.error(chalk.red(`Tool "${resolved}" not found.`));
165
- return;
212
+ return reportError(jsonMode, `Tool "${resolved}" not found. Try \`nxb search ${safe}\`.`);
166
213
  }
167
214
 
168
215
  if (jsonMode) return console.log(JSON.stringify(manifest, null, 2));
@@ -173,20 +220,20 @@ program
173
220
  // ─── `nxb transform <slug>` ──────────────────────────────────────────────────
174
221
  program
175
222
  .command("transform")
176
- .description("Pipe stdin through a tool and write result to stdout")
223
+ .description("Pipe stdin through a tool and write the result to stdout")
177
224
  .argument("<slug>", "Tool slug or abbreviation")
178
225
  .option("-k, --key <key>", "API key (or set NEXUSBLOOM_API_KEY env var)")
179
226
  .option("--local", "Execute locally (faster for repeated use)")
180
227
  .action(async (slug, opts, cmd) => {
181
- const resolved = await resolveToolSlug(slug);
182
- const jsonMode = cmd.parent.opts().json;
228
+ const jsonMode = jsonFlag(cmd);
229
+ const safe = requireSlug(slug, jsonMode);
230
+ if (!safe) return;
231
+
232
+ const resolved = await resolveToolSlug(safe);
183
233
 
184
234
  const input = await readStdin();
185
- if (!input) {
186
- if (jsonMode) return console.log(JSON.stringify({ error: "No input received from stdin" }));
187
- console.error(chalk.red("No input received from stdin. Pipe data into this command."));
188
- process.exitCode = 1;
189
- return;
235
+ if (!input || !input.trim()) {
236
+ return reportError(jsonMode, "No input received from stdin. Pipe data into this command.");
190
237
  }
191
238
 
192
239
  let body;
@@ -196,19 +243,123 @@ program
196
243
  body = { input };
197
244
  }
198
245
 
199
- if (opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"))) {
200
- return await runLocal(resolved, body, { jsonMode, slugResolved: true });
246
+ // `transform` exists to be piped, so stdout carries the payload and every
247
+ // decoration goes to stderr.
248
+ const useLocal =
249
+ opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"));
250
+
251
+ const res = useLocal
252
+ ? await runLocal(resolved, body, { jsonMode: false, slugResolved: true, quiet: true })
253
+ : await runRemote(resolved, body, {
254
+ apiKey: opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key"),
255
+ jsonMode: false,
256
+ slugResolved: true,
257
+ quiet: true,
258
+ });
259
+
260
+ if (!res?.ok) {
261
+ return reportError(jsonMode, res?.error || "Transform failed.");
201
262
  }
202
263
 
203
- const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
204
- if (!apiKey) {
205
- if (jsonMode) return console.log(JSON.stringify({ error: "API key required" }));
206
- console.error(chalk.red("API key required. Set NEXUSBLOOM_API_KEY, pass --key, or use --local mode."));
264
+ // A string result is emitted verbatim so it can feed another process;
265
+ // anything else is JSON so it can feed another `nxb transform`.
266
+ console.log(typeof res.result === "string" ? res.result : JSON.stringify(res.result, null, 2));
267
+ });
268
+
269
+ // ─── `nxb pipe` ──────────────────────────────────────────────────────────────
270
+ program
271
+ .command("pipe")
272
+ .description("Run a pipeline: connect one tool's output into another's input")
273
+ .argument("[spec]", 'Pipeline string, e.g. "tool-a x=@in | tool-b count=$n|len"')
274
+ .option("--dry-run", "Check the wiring and type compatibility without running")
275
+ .option("--remote", "Run every step against the API instead of locally")
276
+ .option("--transforms", "List the available connection transforms")
277
+ .option("--build", "Build a pipeline interactively")
278
+ .action(async (spec, opts, cmd) => {
279
+ const jsonMode = jsonFlag(cmd);
280
+ if (opts.transforms) return listTransforms(jsonMode);
281
+ if (opts.build || (!spec && isInteractive())) {
282
+ // Building nothing is a failure, not a quiet success — scripts need to
283
+ // be able to detect that no pipeline came out.
284
+ const built = await buildWizard();
285
+ if (!built?.ok) process.exitCode = 1;
286
+ return;
287
+ }
288
+ if (!spec) {
289
+ console.error(chalk.red(" Give a pipeline, or run `nxb pipe --build`."));
207
290
  process.exitCode = 1;
208
291
  return;
209
292
  }
293
+ await pipeAction(spec, { json: jsonMode, dryRun: opts.dryRun, remote: opts.remote });
294
+ });
295
+
296
+ // ─── `nxb pipeline <list|run|delete>` ────────────────────────────────────────
297
+ const savedCmd = program
298
+ .command("pipeline")
299
+ .description("Manage saved pipelines");
300
+
301
+ savedCmd
302
+ .command("list")
303
+ .description("List saved pipelines")
304
+ .action((opts, cmd) => {
305
+ const jsonMode = jsonFlag(cmd);
306
+ const items = listPipelines();
307
+ if (jsonMode) return console.log(JSON.stringify({ pipelines: items }, null, 2));
308
+ if (!items.length) {
309
+ console.log(chalk.yellow(" No saved pipelines. Create one with `nxb pipe --build`."));
310
+ return;
311
+ }
312
+ console.log(chalk.bold(`\n ${items.length} saved pipeline${items.length === 1 ? "" : "s"}\n`));
313
+ for (const it of items) {
314
+ console.log(` ${chalk.cyan(it.name.padEnd(20))} ${chalk.dim(`${it.steps} step${it.steps === 1 ? "" : "s"}`)}`);
315
+ if (it.pipeline) console.log(` ${chalk.gray(it.pipeline.slice(0, 92))}`);
316
+ }
317
+ console.log();
318
+ });
319
+
320
+ savedCmd
321
+ .command("run")
322
+ .description("Run a saved pipeline")
323
+ .argument("<name>", "Pipeline name")
324
+ .option("--dry-run", "Check the wiring without running")
325
+ .option("--remote", "Run every step against the API")
326
+ .action(async (name, opts, cmd) => {
327
+ const jsonMode = jsonFlag(cmd);
328
+ const def = loadPipeline(name);
329
+ if (!def?.pipeline) {
330
+ return reportError(jsonMode, `No saved pipeline named "${name}". Try \`nxb pipeline list\`.`);
331
+ }
332
+ await pipeAction(def.pipeline, { json: jsonMode, dryRun: opts.dryRun, remote: opts.remote });
333
+ });
334
+
335
+ savedCmd
336
+ .command("delete")
337
+ .description("Delete a saved pipeline")
338
+ .argument("<name>", "Pipeline name")
339
+ .action((name, _opts, cmd) => {
340
+ const jsonMode = jsonFlag(cmd);
341
+ try {
342
+ if (!deletePipeline(name)) return reportError(jsonMode, `No saved pipeline named "${name}".`);
343
+ console.log(chalk.green(` Deleted "${name}".`));
344
+ } catch (err) {
345
+ return reportError(jsonMode, err.message);
346
+ }
347
+ });
210
348
 
211
- await runRemote(resolved, body, { apiKey, jsonMode, slugResolved: true });
349
+ // ─── `nxb doctor <slug>` ─────────────────────────────────────────────────────
350
+ program
351
+ .command("doctor")
352
+ .description("Compare a tool's real output against its declared output_schema")
353
+ .argument("<slug>", "Tool slug or abbreviation")
354
+ .action(async (slug, opts, cmd) => {
355
+ const jsonMode = jsonFlag(cmd);
356
+ const safe = requireSlug(slug, jsonMode);
357
+ if (!safe) return;
358
+ const resolved = await resolveToolSlug(safe);
359
+ // `nxb doctor x || exit 1` is the whole point of a drift detector; a
360
+ // finding that exits 0 makes the gate silently useless.
361
+ const verdict = await doctorAction(resolved, { json: jsonMode });
362
+ if (verdict && verdict.ok === false) process.exitCode = 1;
212
363
  });
213
364
 
214
365
  // ─── `nxb abbr [query]` ──────────────────────────────────────────────────────
@@ -216,11 +367,13 @@ program
216
367
  .command("abbr")
217
368
  .description("Show tool abbreviations (paginated)")
218
369
  .argument("[query]", "Search/filter abbreviations")
370
+ .option("-s, --search <query>", "Search/filter abbreviations")
219
371
  .option("--no-interactive", "Disable interactive navigation")
220
372
  .action(async (query, opts, cmd) => {
221
- const jsonMode = cmd.parent.opts().json;
373
+ const jsonMode = jsonFlag(cmd);
222
374
  await abbrAction({
223
375
  query,
376
+ search: opts.search,
224
377
  json: jsonMode,
225
378
  interactive: opts.interactive !== false,
226
379
  });
@@ -234,7 +387,7 @@ const configCmd = program
234
387
  configCmd
235
388
  .command("set")
236
389
  .description("Set a config value (prompts for value if omitted)")
237
- .argument("<key>", "Config key (key, default-tool)")
390
+ .argument("<key>", "Config key (key, supabaseKey, telemetry, toolsDir)")
238
391
  .argument("[value]", "Config value (prompted if not provided)")
239
392
  .action(async (key, value) => {
240
393
  if (!value) {
@@ -245,12 +398,18 @@ configCmd
245
398
  return;
246
399
  }
247
400
  configSet(key, value);
248
- console.log(chalk.green(` Set ${key} to "${value}"`));
401
+ // Never echo a secret back to the terminal / shell history.
402
+ if (isSecretKey(key)) {
403
+ console.log(chalk.green(` Set ${key} (stored, value hidden)`));
404
+ console.log(chalk.dim(` ${CONFIG_PATH}`));
405
+ } else {
406
+ console.log(chalk.green(` Set ${key} to "${value}"`));
407
+ }
249
408
  });
250
409
 
251
410
  configCmd
252
411
  .command("get")
253
- .description("Get a config value")
412
+ .description("Get a config value (secrets redacted)")
254
413
  .argument("[key]", "Config key (omit to show all)")
255
414
  .action((key) => {
256
415
  const store = loadConfig();
@@ -259,256 +418,147 @@ configCmd
259
418
  if (value === undefined) {
260
419
  console.log(chalk.yellow(` ${key} is not set`));
261
420
  } else {
262
- console.log(` ${key}: ${value}`);
421
+ const shown = isSecretKey(key) ? maskSecret(value) : value;
422
+ console.log(` ${key}: ${shown}`);
263
423
  }
264
424
  } else {
265
- if (Object.keys(store).length === 0) {
266
- console.log(chalk.yellow(" No config values set."));
267
- } else {
268
- Object.entries(store).forEach(([k, v]) => {
269
- console.log(` ${k}: ${v}`);
270
- });
271
- }
425
+ printConfig(store);
272
426
  }
273
427
  });
274
428
 
275
429
  configCmd
276
- .command("list")
277
- .description("List all config values (alias for config get)")
278
- .action(() => {
279
- const store = loadConfig();
280
- if (Object.keys(store).length === 0) {
281
- console.log(chalk.yellow(" No config values set."));
430
+ .command("unset")
431
+ .description("Remove a config value (e.g. a stored API key)")
432
+ .argument("<key>", "Config key to remove")
433
+ .action((key, opts, cmd) => {
434
+ const jsonMode = jsonFlag(cmd);
435
+ if (!configUnset(key)) {
436
+ return reportError(jsonMode, `"${key}" is not set.`);
437
+ }
438
+ if (jsonMode) {
439
+ console.log(JSON.stringify({ success: true, data: { unset: key } }, null, 2));
282
440
  } else {
283
- Object.entries(store).forEach(([k, v]) => {
284
- console.log(` ${k}: ${v}`);
285
- });
441
+ console.log(chalk.green(` Removed ${key}`));
286
442
  }
287
443
  });
288
444
 
445
+ configCmd
446
+ .command("list")
447
+ .description("List all config values (secrets redacted)")
448
+ .action(() => printConfig(loadConfig()));
449
+
450
+ function maskSecret(value) {
451
+ const s = value == null ? "" : String(value);
452
+ return s.length <= 8 ? "********" : `${s.slice(0, 4)}…${s.slice(-4)}`;
453
+ }
454
+
455
+ function printConfig(store) {
456
+ if (!store || Object.keys(store).length === 0) {
457
+ console.log(chalk.yellow(" No config values set."));
458
+ return;
459
+ }
460
+ const safe = redactedConfig(store);
461
+ for (const [k, v] of Object.entries(safe)) {
462
+ console.log(` ${k}: ${v}`);
463
+ }
464
+ console.log(chalk.dim(`\n Stored at ${CONFIG_PATH}`));
465
+ }
466
+
289
467
  // ─── `nxb cache` ─────────────────────────────────────────────────────────────
290
468
  const cacheCmd = program
291
469
  .command("cache")
292
470
  .description("Manage local tool cache");
293
471
 
472
+ /** Delete everything inside a cache dir (recursively). Returns entries removed. */
473
+ function clearCacheDir(dir) {
474
+ if (!fs.existsSync(dir)) return 0;
475
+ let count = 0;
476
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
477
+ const target = path.join(dir, entry.name);
478
+ try {
479
+ if (entry.isDirectory()) fs.rmSync(target, { recursive: true, force: true });
480
+ else fs.unlinkSync(target);
481
+ count++;
482
+ } catch {
483
+ /* skip anything we cannot remove */
484
+ }
485
+ }
486
+ return count;
487
+ }
488
+
294
489
  cacheCmd
295
490
  .command("clear")
296
491
  .description("Clear the local tool cache")
297
492
  .action(() => {
298
- if (!fs.existsSync(TOOLS_CACHE_DIR)) {
299
- console.log(chalk.yellow(" No cache directory found."));
300
- return;
493
+ const count = clearCacheDir(TOOLS_CACHE_DIR);
494
+ let removedList = 0;
495
+ if (fs.existsSync(TOOLS_LIST_CACHE_PATH)) {
496
+ try {
497
+ fs.unlinkSync(TOOLS_LIST_CACHE_PATH);
498
+ removedList = 1;
499
+ } catch {
500
+ /* best effort */
501
+ }
301
502
  }
302
- const files = fs.readdirSync(TOOLS_CACHE_DIR);
303
- let count = 0;
304
- for (const file of files) {
305
- fs.unlinkSync(path.join(TOOLS_CACHE_DIR, file));
306
- count++;
503
+ // Older versions cached inside the installed package directory.
504
+ const legacy = clearCacheDir(LEGACY_CACHE_DIR);
505
+ if (legacy) {
506
+ try {
507
+ fs.rmdirSync(LEGACY_CACHE_DIR);
508
+ } catch {
509
+ /* not empty / not ours — leave it */
510
+ }
307
511
  }
308
- // Also remove tools-list.json
309
- if (fs.existsSync(TOOLS_LIST_CACHE_PATH)) {
310
- fs.unlinkSync(TOOLS_LIST_CACHE_PATH);
512
+ const total = count + removedList;
513
+ if (total === 0 && legacy === 0) {
514
+ console.log(chalk.yellow(" Nothing cached."));
515
+ return;
516
+ }
517
+ console.log(chalk.green(` Cleared ${total} cached item${total !== 1 ? "s" : ""}.`));
518
+ if (legacy) {
519
+ console.log(chalk.dim(` Removed ${legacy} legacy cache entr${legacy === 1 ? "y" : "ies"} from the old package directory.`));
311
520
  }
312
- console.log(chalk.green(` Cleared ${count} cached tool${count !== 1 ? "s" : ""}.`));
313
521
  });
314
522
 
315
523
  cacheCmd
316
524
  .command("list")
317
525
  .description("List cached tools")
318
- .action(() => {
526
+ .option("-s, --search <query>", "Filter cached tools by slug")
527
+ .action((opts) => {
319
528
  if (!fs.existsSync(TOOLS_CACHE_DIR)) {
320
529
  console.log(chalk.yellow(" No cached tools."));
321
530
  return;
322
531
  }
323
- const files = fs.readdirSync(TOOLS_CACHE_DIR);
532
+ const query = (opts.search || "").toLowerCase();
533
+ const files = fs
534
+ .readdirSync(TOOLS_CACHE_DIR)
535
+ .filter((f) => f.endsWith(".json"))
536
+ .map((f) => f.replace(/\.json$/, ""))
537
+ .filter((slug) => !query || slug.toLowerCase().includes(query));
538
+
324
539
  if (files.length === 0) {
325
- console.log(chalk.yellow(" No cached tools."));
540
+ console.log(chalk.yellow(query ? ` No cached tools matching "${opts.search}".` : " No cached tools."));
326
541
  return;
327
542
  }
328
- console.log(chalk.bold(`\n ${files.length} cached tool${files.length > 1 ? "s" : ""}:\n`));
329
- files.forEach((f) => {
330
- const slug = f.replace(/\.json$/, "");
331
- const stats = fs.statSync(path.join(TOOLS_CACHE_DIR, f));
332
- const age = Math.round((Date.now() - stats.mtimeMs) / 1000 / 60);
333
- console.log(` ${chalk.cyan(slug.padEnd(30))} ${chalk.dim(`${age}m ago`)}`);
543
+ console.log(chalk.bold(`\n ${files.length} cached tool${files.length !== 1 ? "s" : ""}:\n`));
544
+ files.sort().forEach((slug) => {
545
+ const stats = fs.statSync(path.join(TOOLS_CACHE_DIR, `${slug}.json`));
546
+ const age = Math.round((Date.now() - stats.mtimeMs) / 60000);
547
+ const size = (stats.size / 1024).toFixed(1);
548
+ console.log(` ${chalk.cyan(slug.padEnd(30))} ${chalk.dim(`${age}m ago · ${size}kb`)}`);
334
549
  });
335
550
  console.log();
336
551
  });
337
552
 
338
- // ─── `nxb create` ────────────────────────────────────────────────────────────
339
- const VALID_RUNTIMES = ["browser", "node", "both"];
340
- const VALID_CATEGORIES = [
341
- "text", "image", "data", "network", "security", "development",
342
- "education", "business", "utility", "math", "game", "Other",
343
- ];
344
-
345
- program
346
- .command("create")
347
- .description("Scaffold a new tool locally")
348
- .option("--slug <slug>", "Tool slug (lowercase, hyphenated)")
349
- .option("--name <name>", "Display name")
350
- .option("--desc <desc>", "Short description")
351
- .option("--category <category>", `Category (${VALID_CATEGORIES.join(", ")})`)
352
- .option("--runtime <runtime>", `Runtime (${VALID_RUNTIMES.join(", ")})`)
353
- .option("--dir <dir>", "Target directory (default: ./src/tools/<slug>)")
354
- .action(async (options) => {
355
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
356
- const ask = (q) => new Promise((r) => rl.question(q, r));
357
-
358
- let slug = options.slug;
359
- while (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
360
- slug = (await ask(chalk.cyan("Tool slug (e.g. my-tool): "))).trim().toLowerCase();
361
- if (!slug) { console.log(chalk.red("Slug is required.")); continue; }
362
- if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
363
- console.log(chalk.red("Slug must be lowercase, hyphenated (e.g. my-tool)."));
364
- slug = null;
365
- }
366
- }
367
-
368
- let name = options.name;
369
- while (!name || name.length < 2) {
370
- name = (await ask(chalk.cyan("Display name: "))).trim();
371
- if (!name) console.log(chalk.red("Name is required."));
372
- }
373
-
374
- let desc = options.desc;
375
- while (!desc) {
376
- desc = (await ask(chalk.cyan("Short description: "))).trim();
377
- if (!desc) console.log(chalk.red("Description is required."));
378
- }
379
-
380
- let category = options.category;
381
- while (!category || !VALID_CATEGORIES.includes(category)) {
382
- const catList = VALID_CATEGORIES.join(", ");
383
- category = (await ask(chalk.cyan("Category (" + catList + "): "))).trim().toLowerCase();
384
- if (category === "other") category = "Other";
385
- if (!VALID_CATEGORIES.includes(category)) {
386
- console.log(chalk.red("Invalid category. Choose from: " + catList));
387
- category = null;
388
- }
389
- }
390
-
391
- let runtime = options.runtime;
392
- while (!runtime || !VALID_RUNTIMES.includes(runtime)) {
393
- const rtList = VALID_RUNTIMES.join(", ");
394
- runtime = (await ask(chalk.cyan("Runtime (" + rtList + "): "))).trim().toLowerCase();
395
- if (!VALID_RUNTIMES.includes(runtime)) {
396
- console.log(chalk.red("Invalid runtime. Choose from: " + rtList));
397
- runtime = null;
398
- }
399
- }
400
-
401
- rl.close();
402
-
403
- const pascalName = slug
404
- .split("-")
405
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
406
- .join("");
407
-
408
- const v2Source = `/**
409
- * ${name} — ${desc}
410
- * Auto-generated by nxb create
411
- */
412
-
413
- export const MANIFEST = {
414
- slug: "${slug}",
415
- name: "${name}",
416
- short_description: "${desc}",
417
- icon: "🛠️",
418
- category: "${category}",
419
- tags: [],
420
- runtime: "${runtime}",
421
- pricing: "free",
422
- version: "1.0.0",
423
- timeout_ms: 5000,
424
- input_schema: {
425
- type: "object",
426
- properties: {
427
- input: {
428
- type: "string",
429
- description: "Input text to process",
430
- },
431
- },
432
- required: ["input"],
433
- },
434
- output_schema: {
435
- type: "object",
436
- properties: {
437
- result: {
438
- type: "string",
439
- description: "Processed output",
440
- },
441
- },
442
- },
443
- };
444
-
445
- export async function coreLogic(input) {
446
- // Replace with your tool's logic
447
- return { result: \`Processed: \${input.input}\` };
448
- }
449
- `;
450
-
451
- const indexSource = `import React from "react";
452
- import { MANIFEST } from "./v2";
453
-
454
- export default function ${pascalName}Tool({ onResult }) {
455
- const [input, setInput] = React.useState("");
456
- const [result, setResult] = React.useState(null);
457
-
458
- const handleRun = async () => {
459
- try {
460
- const { coreLogic } = await import("./v2");
461
- const output = await coreLogic({ input });
462
- setResult(output);
463
- if (onResult) onResult(output);
464
- } catch (err) {
465
- setResult({ error: err.message });
466
- }
467
- };
468
-
469
- return (
470
- <div className="nexusbloom-tool">
471
- <h2 className="text-xl font-bold mb-4">{MANIFEST.name}</h2>
472
- <p className="text-gray-600 dark:text-gray-400 mb-4">{MANIFEST.short_description}</p>
473
- <textarea className="w-full p-2 border rounded" rows={4} value={input}
474
- onChange={(e) => setInput(e.target.value)} placeholder="Enter input..." />
475
- <button className="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
476
- onClick={handleRun}>Run</button>
477
- {result && <pre className="mt-4 p-3 bg-gray-100 rounded overflow-auto">{JSON.stringify(result, null, 2)}</pre>}
478
- </div>
479
- );
480
- }
481
- `;
482
-
483
- const targetDir = options.dir || path.join(process.cwd(), "src/tools", slug);
484
- const v2Path = path.join(targetDir, "v2.js");
485
- const indexPath = path.join(targetDir, "index.js");
486
-
487
- if (fs.existsSync(v2Path) || fs.existsSync(indexPath)) {
488
- const overwrite = await new Promise((r) => {
489
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
490
- rl2.question(chalk.yellow(`Tool "${slug}" already exists. Overwrite? (y/N): `), (a) => {
491
- rl2.close();
492
- r(a.trim().toLowerCase() === "y");
493
- });
494
- });
495
- if (!overwrite) {
496
- console.log(chalk.dim("Aborted."));
497
- return;
498
- }
499
- }
500
-
501
- fs.mkdirSync(targetDir, { recursive: true });
502
- fs.writeFileSync(v2Path, v2Source, "utf-8");
503
- fs.writeFileSync(indexPath, indexSource, "utf-8");
504
-
505
- console.log(chalk.green(`\n ✓ Tool "${name}" created successfully!\n`));
506
- console.log(chalk.dim(` ${v2Path}`));
507
- console.log(chalk.dim(` ${indexPath}\n`));
508
- console.log(chalk.dim(" Next steps:"));
509
- console.log(chalk.dim(" 1. Edit v2.js to implement your tool logic"));
510
- console.log(chalk.dim(" 2. Run nxb run --local <slug> to test locally"));
511
- console.log(chalk.dim(" 3. Publish from the VS Code extension or API\n"));
553
+ cacheCmd
554
+ .command("path")
555
+ .description("Show where config and cache are stored")
556
+ .action(() => {
557
+ console.log(chalk.bold("\n Paths\n"));
558
+ console.log(` ${chalk.gray("config :")} ${CONFIG_PATH}`);
559
+ console.log(` ${chalk.gray("cache :")} ${CACHE_DIR}`);
560
+ console.log(` ${chalk.gray("tools :")} ${TOOLS_CACHE_DIR}`);
561
+ console.log();
512
562
  });
513
563
 
514
564
  // ─── `nxb json` ─────────────────────────────────────────────────────────────
@@ -520,15 +570,18 @@ program
520
570
  .option("-s, --set <key=value...>", "Set specific field values (e.g. -s count=10 format=csv)")
521
571
  .option("-a, --add <json>", "Merge additional JSON into the output (e.g. -a '{\"count\":20}')")
522
572
  .action(async (opts, cmd) => {
523
- const jsonMode = cmd.parent.opts().json;
573
+ const jsonMode = jsonFlag(cmd);
524
574
 
525
575
  if (opts.template) {
526
- const resolved = await resolveToolSlug(opts.template);
576
+ const safe = requireSlug(opts.template, jsonMode);
577
+ if (!safe) return;
578
+
579
+ const resolved = await resolveToolSlug(safe);
527
580
  const toolInfo = await fetchToolSource(resolved);
528
581
  const manifest = toolInfo?.manifest;
529
582
 
530
583
  if (!manifest) {
531
- console.error(chalk.red(`Tool "${resolved}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
584
+ console.error(chalk.red(`Tool "${resolved}" not found.`));
532
585
  process.exitCode = 1;
533
586
  return;
534
587
  }
@@ -537,6 +590,12 @@ program
537
590
  let result = {};
538
591
 
539
592
  if (opts.interactive) {
593
+ if (!isInteractive()) {
594
+ console.error(chalk.red("Not an interactive terminal — cannot prompt for fields."));
595
+ console.error(chalk.dim(" Drop -i, or use `nxb run` bare to pick fields interactively."));
596
+ process.exitCode = 1;
597
+ return;
598
+ }
540
599
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
541
600
  const ask = (q) => new Promise((r) => rl.question(q, r));
542
601
 
@@ -621,6 +680,12 @@ program
621
680
  // ─── Helpers ─────────────────────────────────────────────────────────────────
622
681
 
623
682
  function promptValue(key) {
683
+ if (!isInteractive()) {
684
+ console.error(chalk.red("Not an interactive terminal — pass the value as an argument."));
685
+ console.error(chalk.dim(` nxb config set ${key} <value>`));
686
+ process.exitCode = 1;
687
+ return Promise.resolve("");
688
+ }
624
689
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
625
690
  return new Promise((resolve) => {
626
691
  rl.question(chalk.cyan(` Enter value for "${key}": `), (answer) => {
@@ -660,4 +725,11 @@ function renderToolInfo(manifest, resolved) {
660
725
  console.log();
661
726
  }
662
727
 
663
- 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
+ });