@nexusbloom/cli 0.3.4 → 0.8.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, 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,26 +135,32 @@ 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 ──
@@ -154,15 +186,15 @@ program
154
186
  .argument("<slug>", "Tool slug or abbreviation")
155
187
  .action(async (slug, opts, cmd) => {
156
188
  const jsonMode = cmd?.parent?.opts().json || false;
157
- const resolved = await resolveToolSlug(slug);
189
+ const safe = requireSlug(slug, jsonMode);
190
+ if (!safe) return;
158
191
 
192
+ const resolved = await resolveToolSlug(safe);
159
193
  const toolInfo = await fetchToolSource(resolved);
160
194
  const manifest = toolInfo?.manifest;
161
195
 
162
196
  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;
197
+ return reportError(jsonMode, `Tool "${resolved}" not found. Try \`nxb search ${safe}\`.`);
166
198
  }
167
199
 
168
200
  if (jsonMode) return console.log(JSON.stringify(manifest, null, 2));
@@ -173,20 +205,20 @@ program
173
205
  // ─── `nxb transform <slug>` ──────────────────────────────────────────────────
174
206
  program
175
207
  .command("transform")
176
- .description("Pipe stdin through a tool and write result to stdout")
208
+ .description("Pipe stdin through a tool and write the result to stdout")
177
209
  .argument("<slug>", "Tool slug or abbreviation")
178
210
  .option("-k, --key <key>", "API key (or set NEXUSBLOOM_API_KEY env var)")
179
211
  .option("--local", "Execute locally (faster for repeated use)")
180
212
  .action(async (slug, opts, cmd) => {
181
- const resolved = await resolveToolSlug(slug);
182
- const jsonMode = cmd.parent.opts().json;
213
+ const jsonMode = jsonFlag(cmd);
214
+ const safe = requireSlug(slug, jsonMode);
215
+ if (!safe) return;
216
+
217
+ const resolved = await resolveToolSlug(safe);
183
218
 
184
219
  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;
220
+ if (!input || !input.trim()) {
221
+ return reportError(jsonMode, "No input received from stdin. Pipe data into this command.");
190
222
  }
191
223
 
192
224
  let body;
@@ -196,19 +228,123 @@ program
196
228
  body = { input };
197
229
  }
198
230
 
199
- if (opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"))) {
200
- return await runLocal(resolved, body, { jsonMode, slugResolved: true });
231
+ // `transform` exists to be piped, so stdout carries the payload and every
232
+ // decoration goes to stderr.
233
+ const useLocal =
234
+ opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"));
235
+
236
+ const res = useLocal
237
+ ? await runLocal(resolved, body, { jsonMode: false, slugResolved: true, quiet: true })
238
+ : await runRemote(resolved, body, {
239
+ apiKey: opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key"),
240
+ jsonMode: false,
241
+ slugResolved: true,
242
+ quiet: true,
243
+ });
244
+
245
+ if (!res?.ok) {
246
+ return reportError(jsonMode, res?.error || "Transform failed.");
201
247
  }
202
248
 
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."));
249
+ // A string result is emitted verbatim so it can feed another process;
250
+ // anything else is JSON so it can feed another `nxb transform`.
251
+ console.log(typeof res.result === "string" ? res.result : JSON.stringify(res.result, null, 2));
252
+ });
253
+
254
+ // ─── `nxb pipe` ──────────────────────────────────────────────────────────────
255
+ program
256
+ .command("pipe")
257
+ .description("Run a pipeline: connect one tool's output into another's input")
258
+ .argument("[spec]", 'Pipeline string, e.g. "tool-a x=@in | tool-b count=$n|len"')
259
+ .option("--dry-run", "Check the wiring and type compatibility without running")
260
+ .option("--remote", "Run every step against the API instead of locally")
261
+ .option("--transforms", "List the available connection transforms")
262
+ .option("--build", "Build a pipeline interactively")
263
+ .action(async (spec, opts, cmd) => {
264
+ const jsonMode = jsonFlag(cmd);
265
+ if (opts.transforms) return listTransforms(jsonMode);
266
+ if (opts.build || (!spec && isInteractive())) {
267
+ // Building nothing is a failure, not a quiet success — scripts need to
268
+ // be able to detect that no pipeline came out.
269
+ const built = await buildWizard();
270
+ if (!built?.ok) process.exitCode = 1;
271
+ return;
272
+ }
273
+ if (!spec) {
274
+ console.error(chalk.red(" Give a pipeline, or run `nxb pipe --build`."));
207
275
  process.exitCode = 1;
208
276
  return;
209
277
  }
278
+ await pipeAction(spec, { json: jsonMode, dryRun: opts.dryRun, remote: opts.remote });
279
+ });
280
+
281
+ // ─── `nxb pipeline <list|run|delete>` ────────────────────────────────────────
282
+ const savedCmd = program
283
+ .command("pipeline")
284
+ .description("Manage saved pipelines");
210
285
 
211
- await runRemote(resolved, body, { apiKey, jsonMode, slugResolved: true });
286
+ savedCmd
287
+ .command("list")
288
+ .description("List saved pipelines")
289
+ .action((opts, cmd) => {
290
+ const jsonMode = jsonFlag(cmd);
291
+ const items = listPipelines();
292
+ if (jsonMode) return console.log(JSON.stringify({ pipelines: items }, null, 2));
293
+ if (!items.length) {
294
+ console.log(chalk.yellow(" No saved pipelines. Create one with `nxb pipe --build`."));
295
+ return;
296
+ }
297
+ console.log(chalk.bold(`\n ${items.length} saved pipeline${items.length === 1 ? "" : "s"}\n`));
298
+ for (const it of items) {
299
+ console.log(` ${chalk.cyan(it.name.padEnd(20))} ${chalk.dim(`${it.steps} step${it.steps === 1 ? "" : "s"}`)}`);
300
+ if (it.pipeline) console.log(` ${chalk.gray(it.pipeline.slice(0, 92))}`);
301
+ }
302
+ console.log();
303
+ });
304
+
305
+ savedCmd
306
+ .command("run")
307
+ .description("Run a saved pipeline")
308
+ .argument("<name>", "Pipeline name")
309
+ .option("--dry-run", "Check the wiring without running")
310
+ .option("--remote", "Run every step against the API")
311
+ .action(async (name, opts, cmd) => {
312
+ const jsonMode = jsonFlag(cmd);
313
+ const def = loadPipeline(name);
314
+ if (!def?.pipeline) {
315
+ return reportError(jsonMode, `No saved pipeline named "${name}". Try \`nxb pipeline list\`.`);
316
+ }
317
+ await pipeAction(def.pipeline, { json: jsonMode, dryRun: opts.dryRun, remote: opts.remote });
318
+ });
319
+
320
+ savedCmd
321
+ .command("delete")
322
+ .description("Delete a saved pipeline")
323
+ .argument("<name>", "Pipeline name")
324
+ .action((name, _opts, cmd) => {
325
+ const jsonMode = jsonFlag(cmd);
326
+ try {
327
+ if (!deletePipeline(name)) return reportError(jsonMode, `No saved pipeline named "${name}".`);
328
+ console.log(chalk.green(` Deleted "${name}".`));
329
+ } catch (err) {
330
+ return reportError(jsonMode, err.message);
331
+ }
332
+ });
333
+
334
+ // ─── `nxb doctor <slug>` ─────────────────────────────────────────────────────
335
+ program
336
+ .command("doctor")
337
+ .description("Compare a tool's real output against its declared output_schema")
338
+ .argument("<slug>", "Tool slug or abbreviation")
339
+ .action(async (slug, opts, cmd) => {
340
+ const jsonMode = jsonFlag(cmd);
341
+ const safe = requireSlug(slug, jsonMode);
342
+ if (!safe) return;
343
+ const resolved = await resolveToolSlug(safe);
344
+ // `nxb doctor x || exit 1` is the whole point of a drift detector; a
345
+ // finding that exits 0 makes the gate silently useless.
346
+ const verdict = await doctorAction(resolved, { json: jsonMode });
347
+ if (verdict && verdict.ok === false) process.exitCode = 1;
212
348
  });
213
349
 
214
350
  // ─── `nxb abbr [query]` ──────────────────────────────────────────────────────
@@ -216,11 +352,13 @@ program
216
352
  .command("abbr")
217
353
  .description("Show tool abbreviations (paginated)")
218
354
  .argument("[query]", "Search/filter abbreviations")
355
+ .option("-s, --search <query>", "Search/filter abbreviations")
219
356
  .option("--no-interactive", "Disable interactive navigation")
220
357
  .action(async (query, opts, cmd) => {
221
- const jsonMode = cmd.parent.opts().json;
358
+ const jsonMode = jsonFlag(cmd);
222
359
  await abbrAction({
223
360
  query,
361
+ search: opts.search,
224
362
  json: jsonMode,
225
363
  interactive: opts.interactive !== false,
226
364
  });
@@ -234,7 +372,7 @@ const configCmd = program
234
372
  configCmd
235
373
  .command("set")
236
374
  .description("Set a config value (prompts for value if omitted)")
237
- .argument("<key>", "Config key (key, default-tool)")
375
+ .argument("<key>", "Config key (key, supabaseKey, telemetry, toolsDir)")
238
376
  .argument("[value]", "Config value (prompted if not provided)")
239
377
  .action(async (key, value) => {
240
378
  if (!value) {
@@ -245,12 +383,18 @@ configCmd
245
383
  return;
246
384
  }
247
385
  configSet(key, value);
248
- console.log(chalk.green(` Set ${key} to "${value}"`));
386
+ // Never echo a secret back to the terminal / shell history.
387
+ if (isSecretKey(key)) {
388
+ console.log(chalk.green(` Set ${key} (stored, value hidden)`));
389
+ console.log(chalk.dim(` ${CONFIG_PATH}`));
390
+ } else {
391
+ console.log(chalk.green(` Set ${key} to "${value}"`));
392
+ }
249
393
  });
250
394
 
251
395
  configCmd
252
396
  .command("get")
253
- .description("Get a config value")
397
+ .description("Get a config value (secrets redacted)")
254
398
  .argument("[key]", "Config key (omit to show all)")
255
399
  .action((key) => {
256
400
  const store = loadConfig();
@@ -259,256 +403,147 @@ configCmd
259
403
  if (value === undefined) {
260
404
  console.log(chalk.yellow(` ${key} is not set`));
261
405
  } else {
262
- console.log(` ${key}: ${value}`);
406
+ const shown = isSecretKey(key) ? maskSecret(value) : value;
407
+ console.log(` ${key}: ${shown}`);
263
408
  }
264
409
  } 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
- }
410
+ printConfig(store);
272
411
  }
273
412
  });
274
413
 
275
414
  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."));
415
+ .command("unset")
416
+ .description("Remove a config value (e.g. a stored API key)")
417
+ .argument("<key>", "Config key to remove")
418
+ .action((key, opts, cmd) => {
419
+ const jsonMode = jsonFlag(cmd);
420
+ if (!configUnset(key)) {
421
+ return reportError(jsonMode, `"${key}" is not set.`);
422
+ }
423
+ if (jsonMode) {
424
+ console.log(JSON.stringify({ success: true, data: { unset: key } }, null, 2));
282
425
  } else {
283
- Object.entries(store).forEach(([k, v]) => {
284
- console.log(` ${k}: ${v}`);
285
- });
426
+ console.log(chalk.green(` Removed ${key}`));
286
427
  }
287
428
  });
288
429
 
430
+ configCmd
431
+ .command("list")
432
+ .description("List all config values (secrets redacted)")
433
+ .action(() => printConfig(loadConfig()));
434
+
435
+ function maskSecret(value) {
436
+ const s = value == null ? "" : String(value);
437
+ return s.length <= 8 ? "********" : `${s.slice(0, 4)}…${s.slice(-4)}`;
438
+ }
439
+
440
+ function printConfig(store) {
441
+ if (!store || Object.keys(store).length === 0) {
442
+ console.log(chalk.yellow(" No config values set."));
443
+ return;
444
+ }
445
+ const safe = redactedConfig(store);
446
+ for (const [k, v] of Object.entries(safe)) {
447
+ console.log(` ${k}: ${v}`);
448
+ }
449
+ console.log(chalk.dim(`\n Stored at ${CONFIG_PATH}`));
450
+ }
451
+
289
452
  // ─── `nxb cache` ─────────────────────────────────────────────────────────────
290
453
  const cacheCmd = program
291
454
  .command("cache")
292
455
  .description("Manage local tool cache");
293
456
 
457
+ /** Delete everything inside a cache dir (recursively). Returns entries removed. */
458
+ function clearCacheDir(dir) {
459
+ if (!fs.existsSync(dir)) return 0;
460
+ let count = 0;
461
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
462
+ const target = path.join(dir, entry.name);
463
+ try {
464
+ if (entry.isDirectory()) fs.rmSync(target, { recursive: true, force: true });
465
+ else fs.unlinkSync(target);
466
+ count++;
467
+ } catch {
468
+ /* skip anything we cannot remove */
469
+ }
470
+ }
471
+ return count;
472
+ }
473
+
294
474
  cacheCmd
295
475
  .command("clear")
296
476
  .description("Clear the local tool cache")
297
477
  .action(() => {
298
- if (!fs.existsSync(TOOLS_CACHE_DIR)) {
299
- console.log(chalk.yellow(" No cache directory found."));
300
- return;
478
+ const count = clearCacheDir(TOOLS_CACHE_DIR);
479
+ let removedList = 0;
480
+ if (fs.existsSync(TOOLS_LIST_CACHE_PATH)) {
481
+ try {
482
+ fs.unlinkSync(TOOLS_LIST_CACHE_PATH);
483
+ removedList = 1;
484
+ } catch {
485
+ /* best effort */
486
+ }
301
487
  }
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++;
488
+ // Older versions cached inside the installed package directory.
489
+ const legacy = clearCacheDir(LEGACY_CACHE_DIR);
490
+ if (legacy) {
491
+ try {
492
+ fs.rmdirSync(LEGACY_CACHE_DIR);
493
+ } catch {
494
+ /* not empty / not ours — leave it */
495
+ }
307
496
  }
308
- // Also remove tools-list.json
309
- if (fs.existsSync(TOOLS_LIST_CACHE_PATH)) {
310
- fs.unlinkSync(TOOLS_LIST_CACHE_PATH);
497
+ const total = count + removedList;
498
+ if (total === 0 && legacy === 0) {
499
+ console.log(chalk.yellow(" Nothing cached."));
500
+ return;
501
+ }
502
+ console.log(chalk.green(` Cleared ${total} cached item${total !== 1 ? "s" : ""}.`));
503
+ if (legacy) {
504
+ console.log(chalk.dim(` Removed ${legacy} legacy cache entr${legacy === 1 ? "y" : "ies"} from the old package directory.`));
311
505
  }
312
- console.log(chalk.green(` Cleared ${count} cached tool${count !== 1 ? "s" : ""}.`));
313
506
  });
314
507
 
315
508
  cacheCmd
316
509
  .command("list")
317
510
  .description("List cached tools")
318
- .action(() => {
511
+ .option("-s, --search <query>", "Filter cached tools by slug")
512
+ .action((opts) => {
319
513
  if (!fs.existsSync(TOOLS_CACHE_DIR)) {
320
514
  console.log(chalk.yellow(" No cached tools."));
321
515
  return;
322
516
  }
323
- const files = fs.readdirSync(TOOLS_CACHE_DIR);
517
+ const query = (opts.search || "").toLowerCase();
518
+ const files = fs
519
+ .readdirSync(TOOLS_CACHE_DIR)
520
+ .filter((f) => f.endsWith(".json"))
521
+ .map((f) => f.replace(/\.json$/, ""))
522
+ .filter((slug) => !query || slug.toLowerCase().includes(query));
523
+
324
524
  if (files.length === 0) {
325
- console.log(chalk.yellow(" No cached tools."));
525
+ console.log(chalk.yellow(query ? ` No cached tools matching "${opts.search}".` : " No cached tools."));
326
526
  return;
327
527
  }
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`)}`);
528
+ console.log(chalk.bold(`\n ${files.length} cached tool${files.length !== 1 ? "s" : ""}:\n`));
529
+ files.sort().forEach((slug) => {
530
+ const stats = fs.statSync(path.join(TOOLS_CACHE_DIR, `${slug}.json`));
531
+ const age = Math.round((Date.now() - stats.mtimeMs) / 60000);
532
+ const size = (stats.size / 1024).toFixed(1);
533
+ console.log(` ${chalk.cyan(slug.padEnd(30))} ${chalk.dim(`${age}m ago · ${size}kb`)}`);
334
534
  });
335
535
  console.log();
336
536
  });
337
537
 
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"));
538
+ cacheCmd
539
+ .command("path")
540
+ .description("Show where config and cache are stored")
541
+ .action(() => {
542
+ console.log(chalk.bold("\n Paths\n"));
543
+ console.log(` ${chalk.gray("config :")} ${CONFIG_PATH}`);
544
+ console.log(` ${chalk.gray("cache :")} ${CACHE_DIR}`);
545
+ console.log(` ${chalk.gray("tools :")} ${TOOLS_CACHE_DIR}`);
546
+ console.log();
512
547
  });
513
548
 
514
549
  // ─── `nxb json` ─────────────────────────────────────────────────────────────
@@ -520,15 +555,18 @@ program
520
555
  .option("-s, --set <key=value...>", "Set specific field values (e.g. -s count=10 format=csv)")
521
556
  .option("-a, --add <json>", "Merge additional JSON into the output (e.g. -a '{\"count\":20}')")
522
557
  .action(async (opts, cmd) => {
523
- const jsonMode = cmd.parent.opts().json;
558
+ const jsonMode = jsonFlag(cmd);
524
559
 
525
560
  if (opts.template) {
526
- const resolved = await resolveToolSlug(opts.template);
561
+ const safe = requireSlug(opts.template, jsonMode);
562
+ if (!safe) return;
563
+
564
+ const resolved = await resolveToolSlug(safe);
527
565
  const toolInfo = await fetchToolSource(resolved);
528
566
  const manifest = toolInfo?.manifest;
529
567
 
530
568
  if (!manifest) {
531
- console.error(chalk.red(`Tool "${resolved}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
569
+ console.error(chalk.red(`Tool "${resolved}" not found.`));
532
570
  process.exitCode = 1;
533
571
  return;
534
572
  }
@@ -537,6 +575,12 @@ program
537
575
  let result = {};
538
576
 
539
577
  if (opts.interactive) {
578
+ if (!isInteractive()) {
579
+ console.error(chalk.red("Not an interactive terminal — cannot prompt for fields."));
580
+ console.error(chalk.dim(" Drop -i, or use `nxb run` bare to pick fields interactively."));
581
+ process.exitCode = 1;
582
+ return;
583
+ }
540
584
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
541
585
  const ask = (q) => new Promise((r) => rl.question(q, r));
542
586
 
@@ -621,6 +665,12 @@ program
621
665
  // ─── Helpers ─────────────────────────────────────────────────────────────────
622
666
 
623
667
  function promptValue(key) {
668
+ if (!isInteractive()) {
669
+ console.error(chalk.red("Not an interactive terminal — pass the value as an argument."));
670
+ console.error(chalk.dim(` nxb config set ${key} <value>`));
671
+ process.exitCode = 1;
672
+ return Promise.resolve("");
673
+ }
624
674
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
625
675
  return new Promise((resolve) => {
626
676
  rl.question(chalk.cyan(` Enter value for "${key}": `), (answer) => {