@nexusbloom/cli 0.2.12 → 0.3.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
@@ -4,88 +4,46 @@
4
4
  * NexusBloom CLI (nxb)
5
5
  *
6
6
  * Usage:
7
- * nxb list — List available tools
7
+ * nxb list — List available tools (interactive pagination)
8
+ * nxb list -s <query> — Filter + paginate
8
9
  * nxb search <query> — Search tools
9
- * nxb run <slug> [args...] — Execute a tool (remote API)
10
- * nxb run <slug> --input '{}' — Execute with inline JSON input
11
- * nxb run <slug> --file input.json — Execute with file input
12
- * nxb run <slug> --local — Execute coreLogic locally (cached)
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
13
15
  * nxb info <slug> — Show tool schema
14
- * nxb transform <slug> — Pipe stdin through a tool
15
- * nxb config set <key> [value] — Set config (interactive if value omitted)
16
- * nxb config get [key] — Get config
17
- * nxb cache clear — Clear local tool cache
16
+ * nxb abbr — Show tool abbreviations (paginated)
17
+ * nxb transform <slug> — Pipe stdin through a tool
18
+ * nxb config set <key> [value] — Set config (interactive if value omitted)
19
+ * nxb config get [key] — Get config
20
+ * nxb cache clear — Clear local tool cache
21
+ * nxb cache list — List cached tools
22
+ * nxb create — Scaffold a new tool locally
23
+ * nxb json --template <slug> — Create JSON input for tools
18
24
  */
19
25
 
20
26
  import { program } from "commander";
21
27
  import chalk from "chalk";
22
- import { createRequire } from "module";
23
28
  import fs from "fs";
24
29
  import path from "path";
25
- import { fileURLToPath } from "url";
26
30
  import readline from "readline";
27
- import QRCode from "qrcode";
31
+ import { createRequire } from "module";
28
32
 
29
33
  const require = createRequire(import.meta.url);
30
34
  const { version } = require("../package.json");
31
35
 
32
- const API_BASE = process.env.NEXUSBLOOM_API_URL || "https://www.nexusbloom.dev";
33
-
34
- // ─── Simple file-based config (avoids `conf` dependency issues) ───────────
35
- const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(require("os").homedir(), ".config"), "nexusbloom");
36
- const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
37
-
38
- function ensureConfigDir() {
39
- if (!fs.existsSync(CONFIG_DIR)) {
40
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
41
- }
42
- }
43
-
44
- function loadConfig() {
45
- try {
46
- if (fs.existsSync(CONFIG_PATH)) {
47
- return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
48
- }
49
- } catch { /* ignore corrupt config */ }
50
- return {};
51
- }
52
-
53
- function saveConfig(store) {
54
- ensureConfigDir();
55
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2), "utf-8");
56
- }
57
-
58
- function configGet(key) {
59
- const store = loadConfig();
60
- if (key) return store[key];
61
- return store;
62
- }
63
-
64
- function configSet(key, value) {
65
- const store = loadConfig();
66
- store[key] = value;
67
- saveConfig(store);
68
- }
69
-
70
- function promptValue(key) {
71
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
72
- return new Promise((resolve) => {
73
- rl.question(chalk.cyan(` Enter value for "${key}": `), (answer) => {
74
- rl.close();
75
- resolve(answer.trim());
76
- });
77
- });
78
- }
79
-
80
- // ─── Cache directory for local tool execution ────────────────────────────────
81
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
82
- const CACHE_DIR = path.resolve(__dirname, "../.cache/tools");
83
-
84
- function ensureCacheDir() {
85
- if (!fs.existsSync(CACHE_DIR)) {
86
- fs.mkdirSync(CACHE_DIR, { recursive: true });
87
- }
88
- }
36
+ // ─── Local modules ──────────────────────────────────────────────────────────
37
+ import { API_BASE } from "./tools.js";
38
+ import {
39
+ loadConfig, saveConfig, configGet, configSet,
40
+ fetchTools, fetchToolSource, resolveToolSlug,
41
+ generateAbbreviations, readStdin,
42
+ } from "./tools.js";
43
+ import { runLocal, runRemote, buildInputBody, runInteractive } from "./run.js";
44
+ import listAction from "./list.js";
45
+ import abbrAction from "./abbr.js";
46
+ import { TOOLS_CACHE_DIR, TOOLS_LIST_CACHE_PATH } from "./paths.js";
89
47
 
90
48
  // ─── Program ─────────────────────────────────────────────────────────────────
91
49
  program
@@ -94,61 +52,42 @@ program
94
52
  .version(version)
95
53
  .option("--json", "Output in JSON format");
96
54
 
97
- // ─── `nxb list` — list available tools ───────────────────────────────────────
55
+ // ─── `nxb list` ──────────────────────────────────────────────────────────────
98
56
  program
99
57
  .command("list")
100
- .description("List available tools")
101
- .option("-s, --search <query>", "Search tools by name or description")
58
+ .description("List available tools (interactive pagination)")
59
+ .option("-s, --search <query>", "Search tools by name, slug, category, or tags")
60
+ .option("--no-interactive", "Disable interactive navigation")
102
61
  .action(async (opts, cmd) => {
103
- const jsonMode = cmd.parent.opts().json || opts.json;
104
- let tools = await fetchToolsWithFallback() || [];
105
- // Merge in local tools from filesystem
106
- const localTools = scanLocalTools();
107
- const localSlugs = new Set(localTools.map(t => t.slug));
108
- tools = [...localTools, ...tools.filter(t => !localSlugs.has(t.slug))];
109
- if (tools.length === 0) {
110
- if (jsonMode) return console.log(JSON.stringify({ tools: [] }));
111
- console.log(chalk.yellow("No tools found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
112
- return;
113
- }
114
- let filtered = tools;
115
- if (opts.search) {
116
- const q = opts.search.toLowerCase();
117
- filtered = tools.filter(t =>
118
- (t.slug || "").includes(q) ||
119
- (t.name || "").toLowerCase().includes(q) ||
120
- (t.description || "").toLowerCase().includes(q) ||
121
- (t.category || "").includes(q)
122
- );
123
- }
124
- if (jsonMode) return console.log(JSON.stringify({ tools: filtered }, null, 2));
125
- console.log(chalk.bold(`\n ${filtered.length} tool${filtered.length > 1 ? "s" : ""} available\n`));
126
- filtered.forEach((t) => {
127
- const typeBadge = t.type === "community" ? chalk.dim("[community]") : t.type === "local" ? chalk.cyan("[local]") : "";
128
- console.log(` ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim((t.description || "").slice(0, 50))} ${typeBadge}`);
62
+ const jsonMode = cmd.parent.opts().json;
63
+ await listAction({
64
+ search: opts.search,
65
+ json: jsonMode,
66
+ interactive: opts.interactive !== false,
129
67
  });
130
- console.log();
131
68
  });
132
69
 
133
- // ─── `nxb search <query>` — search tools ─────────────────────────────────────
70
+ // ─── `nxb search <query>` ────────────────────────────────────────────────────
134
71
  program
135
72
  .command("search")
136
73
  .description("Search for tools")
137
74
  .argument("<query>", "Search query")
138
75
  .action(async (query, opts, cmd) => {
139
- const jsonMode = (cmd?.parent?.opts().json) || false;
140
- const tools = await fetchToolsWithFallback();
76
+ const jsonMode = cmd?.parent?.opts().json || false;
77
+ const tools = await fetchTools();
141
78
  if (!tools || tools.length === 0) {
142
79
  if (jsonMode) return console.log(JSON.stringify({ tools: [], query }));
143
80
  console.log(chalk.yellow(`No tools found matching "${query}".`));
144
81
  return;
145
82
  }
146
83
  const q = query.toLowerCase();
147
- const filtered = tools.filter(t =>
148
- (t.slug || "").includes(q) ||
149
- (t.name || "").toLowerCase().includes(q) ||
150
- (t.description || "").toLowerCase().includes(q) ||
151
- (t.category || "").includes(q)
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))
152
91
  );
153
92
  if (filtered.length === 0) {
154
93
  if (jsonMode) return console.log(JSON.stringify({ tools: [], query }));
@@ -156,18 +95,19 @@ program
156
95
  return;
157
96
  }
158
97
  if (jsonMode) return console.log(JSON.stringify({ tools: filtered, query }, null, 2));
98
+
159
99
  console.log(chalk.bold(`\n ${filtered.length} result${filtered.length > 1 ? "s" : ""} for "${query}"\n`));
160
100
  filtered.forEach((t) => {
161
- console.log(` ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim((t.description || "").slice(0, 60))}`);
101
+ console.log(` ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim((t.short_description || t.description || "").slice(0, 60))}`);
162
102
  });
163
103
  console.log();
164
104
  });
165
105
 
166
- // ─── `nxb run <slug>` — execute a tool ───────────────────────────────────────
106
+ // ─── `nxb run [slug] [args...]` ──────────────────────────────────────────────
167
107
  program
168
108
  .command("run")
169
- .description("Execute a tool")
170
- .argument("<slug>", "Tool slug")
109
+ .description("Execute a tool (interactive if no slug is given)")
110
+ .argument("[slug]", "Tool slug or abbreviation")
171
111
  .argument("[args...]", "Key=value arguments (e.g. domain=example.com)")
172
112
  .option("-k, --key <key>", "API key (or set NEXUSBLOOM_API_KEY env var)")
173
113
  .option("-i, --input <json>", "Inline JSON input or @- for stdin (e.g. '{\"domain\":\"example.com\"}' or @-)")
@@ -176,356 +116,61 @@ program
176
116
  .action(async (slug, args, opts, cmd) => {
177
117
  const jsonMode = cmd.parent.opts().json;
178
118
 
179
- // ── Resolve abbreviation ────────────────────────────────────────────
180
- const allTools = await fetchToolsWithFallback();
119
+ // ── Interactive mode: no slug provided ──
120
+ if (!slug) {
121
+ return await runInteractive({ json: jsonMode });
122
+ }
123
+
124
+ // ── Resolve abbreviation ──
125
+ const allTools = await fetchTools();
181
126
  const slugs = (allTools || []).map((t) => t.slug);
182
127
  const abbrMap = generateAbbreviations(slugs);
128
+ let resolved = slug;
183
129
  if (abbrMap[slug] && slugs.includes(abbrMap[slug])) {
184
- if (!jsonMode) console.log(chalk.dim(` Resolved: ${slug} → ${abbrMap[slug]}`));
185
- slug = abbrMap[slug];
130
+ resolved = abbrMap[slug];
131
+ if (!jsonMode) console.log(chalk.dim(` Resolved: ${slug} → ${resolved}`));
186
132
  }
187
133
 
188
- // ── Build input body ────────────────────────────────────────────────
189
- let body = {};
134
+ // ── Build input body ──
135
+ const body = await buildInputBody(opts, args);
136
+ if (body === null) return;
190
137
 
191
- // 0. Detect stdin pipe (auto-detect or explicit @-)
192
- const isPiped = opts.input === "@-" || opts.input === "@stdin" || (!opts.input && !opts.file && !process.stdin.isTTY);
193
- if (isPiped) {
194
- try {
195
- const stdin = await readStdin();
196
- if (stdin && stdin.trim()) {
197
- body = JSON.parse(stdin);
198
- }
199
- } catch {
200
- if (jsonMode) return console.log(JSON.stringify({ error: "Invalid JSON from stdin" }));
201
- console.error(chalk.red("Error: Invalid JSON received from stdin"));
202
- process.exit(1);
203
- }
204
- }
205
-
206
- // 1. --input flag takes highest priority (skip if @- was used)
207
- if (opts.input && opts.input !== "@-" && opts.input !== "@stdin") {
208
- try {
209
- body = JSON.parse(opts.input);
210
- } catch {
211
- if (jsonMode) return console.log(JSON.stringify({ error: "Invalid JSON in --input flag" }));
212
- console.error(chalk.red("Error: --input must be valid JSON"));
213
- process.exit(1);
214
- }
215
- }
216
-
217
- // 2. --file flag reads from a file
218
- if (opts.file) {
219
- try {
220
- const fileContent = fs.readFileSync(path.resolve(opts.file), "utf-8");
221
- const fileData = JSON.parse(fileContent);
222
- // Merge file data, with --input taking precedence
223
- body = { ...fileData, ...body };
224
- } catch (err) {
225
- if (jsonMode) return console.log(JSON.stringify({ error: `Failed to read --file: ${err.message}` }));
226
- console.error(chalk.red(`Error reading file: ${err.message}`));
227
- process.exit(1);
228
- }
229
- }
230
-
231
- // 3. Key=value args (lowest priority, merged in)
232
- if (!opts.input && !opts.file) {
233
- (args || []).forEach((arg) => {
234
- const [k, ...v] = arg.split("=");
235
- body[k] = v.join("=");
236
- });
237
- }
238
-
239
- // ── Local execution mode ────────────────────────────────────────────
240
- if (opts.local) {
241
- return await executeLocal(slug, body, jsonMode);
242
- }
243
-
244
- // ── Remote API execution ────────────────────────────────────────────
245
- const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
246
- if (!apiKey) {
247
- // No API key — try local mode automatically
248
- return await executeLocal(slug, body, jsonMode);
138
+ // ── Local vs remote ──
139
+ if (opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"))) {
140
+ return await runLocal(resolved, body, { jsonMode, slugResolved: true });
249
141
  }
250
142
 
251
- try {
252
- const res = await fetch(`${API_BASE}/api/run/${slug}`, {
253
- method: "POST",
254
- headers: {
255
- "Content-Type": "application/json",
256
- Authorization: `Bearer ${apiKey}`,
257
- },
258
- body: JSON.stringify(body),
259
- });
260
-
261
- if (!res.ok && !res.headers.get("content-type")?.includes("json")) {
262
- // API returned non-JSON (likely not deployed) — fall back to local
263
- if (!jsonMode) console.log(chalk.dim(` API not deployed, falling back to local execution...`));
264
- return await executeLocal(slug, body, jsonMode);
265
- }
266
-
267
- const data = await res.json();
268
- if (data.success) {
269
- if (jsonMode) return console.log(JSON.stringify({ success: true, data: data.data }, null, 2));
270
- console.log(chalk.green("\n Result:"));
271
- // Try to render QR code if the output is SVG-based
272
- if (data.data && typeof data.data === "object" && data.data.svg) {
273
- await renderQRCode(body.data || body.text || JSON.stringify(body), slug);
274
- }
275
- console.log(` ${JSON.stringify(data.data, null, 2)}`);
276
- } else {
277
- if (jsonMode) return console.log(JSON.stringify({ success: false, error: data.error || "Unknown error" }, null, 2));
278
- console.error(chalk.red(`\n Error: ${data.error || "Unknown error"}`));
279
- process.exit(1);
280
- }
281
- } catch (err) {
282
- // Network error — fall back to local
283
- if (!jsonMode) console.log(chalk.dim(` API unreachable, falling back to local execution...`));
284
- return await executeLocal(slug, body, jsonMode);
285
- }
143
+ return await runRemote(resolved, body, {
144
+ apiKey: opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key"),
145
+ jsonMode,
146
+ slugResolved: true,
147
+ });
286
148
  });
287
149
 
288
- // ─── Local execution helper ──────────────────────────────────────────────────
289
- async function executeLocal(slug, input, jsonMode) {
290
- ensureCacheDir();
291
- const cacheFile = path.join(CACHE_DIR, `${slug}.json`);
292
-
293
- // Try loading from local filesystem first (fastest, most up-to-date)
294
- let manifest, coreLogicSource;
295
- const localToolPath = findLocalTool(slug);
296
- if (localToolPath) {
297
- if (!jsonMode) console.log(chalk.dim(` Using local tool: ${slug}`));
298
- const source = fs.readFileSync(localToolPath, "utf-8");
299
- const manifestMatch = source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});/);
300
- if (manifestMatch) {
301
- try {
302
- manifest = new Function("return " + manifestMatch[1])();
303
- } catch { /* will fall through to cache/API */ }
304
- }
305
- coreLogicSource = source;
306
- // Save to cache for next time
307
- if (manifest) {
308
- fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource, cachedAt: new Date().toISOString() }), "utf-8");
309
- }
310
- }
311
-
312
- // Try loading from cache second
313
- if (!manifest || !coreLogicSource) {
314
- if (fs.existsSync(cacheFile)) {
315
- try {
316
- const cached = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
317
- manifest = cached.manifest;
318
- coreLogicSource = cached.coreLogicSource;
319
- if (!jsonMode) console.log(chalk.dim(` Using cached tool: ${slug}`));
320
- } catch {
321
- // Cache corrupted
322
- }
323
- }
324
- }
325
-
326
- // Fetch from API or Supabase if still not found
327
- if (!manifest || !coreLogicSource) {
328
- if (!jsonMode) console.log(chalk.dim(` Fetching tool: ${slug}...`));
329
-
330
- // Try API first
331
- let apiSuccess = false;
332
- try {
333
- const res = await fetch(`${API_BASE}/api/run/${slug}`);
334
- if (res.ok) {
335
- const data = await res.json();
336
- manifest = data.data?.manifest || data.manifest || data;
337
-
338
- const sourceRes = await fetch(`${API_BASE}/api/run/${slug}?source=true`);
339
- if (sourceRes.ok) {
340
- const sourceData = await sourceRes.json();
341
- coreLogicSource = sourceData.data?.v2_source;
342
- }
343
- apiSuccess = true;
344
- }
345
- } catch {
346
- // API failed, try Supabase fallback
347
- }
348
-
349
- // Fallback: query Supabase directly via shared helper
350
- if (!apiSuccess) {
351
- try {
352
- const toolInfo = await fetchToolFromSupabase(slug);
353
- if (toolInfo) {
354
- manifest = toolInfo.manifest;
355
- coreLogicSource = toolInfo.coreLogicSource;
356
- apiSuccess = true;
357
- }
358
- } catch {
359
- // Supabase fallback also failed
360
- }
361
- }
362
-
363
- if (!apiSuccess) {
364
- if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${slug}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or run \`nxb config set supabaseKey <your-anon-key>\` for offline mode.` }));
365
- console.error(chalk.red(`Tool "${slug}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or run \`nxb config set supabaseKey <your-anon-key>\` for offline mode.`));
366
- process.exit(1);
367
- }
368
-
369
- // Cache it
370
- if (manifest) {
371
- fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource, cachedAt: new Date().toISOString() }), "utf-8");
372
- }
373
- }
374
-
375
- if (!coreLogicSource) {
376
- // Fallback: try to extract coreLogic from the manifest itself
377
- if (jsonMode) return console.log(JSON.stringify({ error: "No v2 source available for local execution. Set NEXUSBLOOM_SUPABASE_ANON_KEY or run `nxb config set supabaseKey <your-key>`." }));
378
- console.error(chalk.yellow("No v2 source available for local execution. Set NEXUSBLOOM_SUPABASE_ANON_KEY or run `nxb config set supabaseKey <your-key>`."));
379
- process.exit(1);
380
- }
381
-
382
- // Execute coreLogic locally
383
- try {
384
- // Strip imports and exports, then wrap the full module scope in an IIFE
385
- // that returns coreLogic — this preserves module-scoped helpers (e.g. generateField)
386
- const cleanSource = coreLogicSource
387
- .replace(/^import\s+.*?;?\s*$/gm, "") // Remove import statements
388
- .replace(/^export\s+/gm, ""); // Remove export keywords
389
- const wrappedSource = `(function() {\n${cleanSource}\nreturn coreLogic;\n})()`;
390
- const coreLogicFn = new Function("return " + wrappedSource)();
391
- const isAsync = /export\s+async\s+function\s+coreLogic/.test(coreLogicSource);
392
-
393
- // Validate input against manifest
394
- const schema = manifest.input_schema || {};
395
- const required = schema.required || [];
396
- const missing = required.filter((k) => input[k] === undefined || input[k] === null);
397
- if (missing.length > 0) {
398
- if (jsonMode) return console.log(JSON.stringify({ error: `Missing required fields: ${missing.join(", ")}` }));
399
- console.error(chalk.red(`Missing required fields: ${missing.join(", ")}`));
400
- process.exit(1);
401
- }
402
-
403
- // Apply defaults
404
- if (schema.properties) {
405
- for (const [key, prop] of Object.entries(schema.properties)) {
406
- if (input[key] === undefined && prop.default !== undefined) {
407
- input[key] = prop.default;
408
- }
409
- }
410
- }
411
-
412
- // Create and execute the function
413
- const timeoutMs = manifest.timeout_ms || 5000;
414
- let result;
415
-
416
- if (isAsync) {
417
- result = await Promise.race([
418
- coreLogicFn(input),
419
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)),
420
- ]);
421
- } else {
422
- result = coreLogicFn(input);
423
- }
424
-
425
- // Fire-and-forget: track the run for total runs counting and api_usage
426
- const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
427
- const trackHeaders = { "Content-Type": "application/json" };
428
- if (apiKey) trackHeaders["Authorization"] = `Bearer ${apiKey}`;
429
- fetch(`${API_BASE}/api/track-run`, {
430
- method: "POST",
431
- headers: trackHeaders,
432
- body: JSON.stringify({ slug, source: "cli" }),
433
- }).catch(() => {});
434
-
435
- if (jsonMode) return console.log(JSON.stringify({ success: true, data: result }, null, 2));
436
- console.log(chalk.green("\n Result (local):"));
437
- console.log(` ${JSON.stringify(result, null, 2)}`);
438
- } catch (err) {
439
- if (jsonMode) return console.log(JSON.stringify({ error: err.message }));
440
- console.error(chalk.red(`\n Local execution error: ${err.message}`));
441
- process.exit(1);
442
- }
443
- }
444
-
445
- // ─── `nxb info <slug>` — show tool schema ────────────────────────────────────
150
+ // ─── `nxb info <slug>` ───────────────────────────────────────────────────────
446
151
  program
447
152
  .command("info")
448
153
  .description("Show tool schema and details")
449
154
  .argument("<slug>", "Tool slug or abbreviation")
450
155
  .action(async (slug, opts, cmd) => {
451
- const jsonMode = (cmd?.parent?.opts().json) || false;
452
-
453
- // Resolve abbreviation
454
- const allTools = await fetchToolsWithFallback();
455
- const slugs = (allTools || []).map((t) => t.slug);
456
- const abbrMap = generateAbbreviations(slugs);
457
- if (abbrMap[slug] && slugs.includes(abbrMap[slug])) {
458
- if (!jsonMode) console.log(chalk.dim(` Resolved: ${slug} → ${abbrMap[slug]}`));
459
- slug = abbrMap[slug];
460
- }
461
-
462
- // Try to get manifest — API first, then Supabase fallback
463
- let manifest = null;
464
- try {
465
- const res = await fetch(`${API_BASE}/api/run/${slug}`);
466
- if (res.ok) {
467
- const data = await res.json();
468
- manifest = data.data?.manifest || data.manifest || data;
469
- }
470
- } catch { /* API failed */ }
156
+ const jsonMode = cmd?.parent?.opts().json || false;
157
+ const resolved = await resolveToolSlug(slug);
471
158
 
472
- if (!manifest) {
473
- // Try Supabase fallback
474
- try {
475
- const toolInfo = await fetchToolFromSupabase(slug);
476
- if (toolInfo) manifest = toolInfo.manifest;
477
- } catch { /* fallback failed */ }
478
- }
479
-
480
- // Fallback: check local filesystem
481
- if (!manifest) {
482
- const localPath = findLocalTool(slug);
483
- if (localPath) {
484
- const source = fs.readFileSync(localPath, "utf-8");
485
- const match = source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});/);
486
- if (match) {
487
- try { manifest = new Function("return " + match[1])(); } catch {}
488
- }
489
- }
490
- }
159
+ const toolInfo = await fetchToolSource(resolved);
160
+ const manifest = toolInfo?.manifest;
491
161
 
492
162
  if (!manifest) {
493
- if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${slug}" not found.` }));
494
- console.error(chalk.red(`Tool "${slug}" not found.`));
163
+ if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${resolved}" not found.` }));
164
+ console.error(chalk.red(`Tool "${resolved}" not found.`));
495
165
  return;
496
166
  }
497
167
 
498
168
  if (jsonMode) return console.log(JSON.stringify(manifest, null, 2));
499
- const toolName = manifest.name || manifest.slug || slug;
500
- console.log(chalk.bold(`\n ${toolName}`));
501
- console.log(` ${chalk.dim(manifest.short_description || "")}`);
502
- console.log(` Version: ${chalk.cyan(manifest.version || "1.0.0")}`);
503
- console.log(` Runtime: ${chalk.cyan(manifest.runtime || "browser")}`);
504
- console.log(` Pricing: ${chalk.green("free")}`);
505
- if (manifest.input_schema?.properties) {
506
- console.log(chalk.bold(`\n Input Parameters:`));
507
- const props = manifest.input_schema.properties;
508
- const required = manifest.input_schema.required || [];
509
- for (const [name, prop] of Object.entries(props)) {
510
- const req = required.includes(name) ? chalk.red(" *required") : "";
511
- const def = prop.default !== undefined ? chalk.dim(` [default: ${JSON.stringify(prop.default)}]`) : "";
512
- console.log(` ${chalk.cyan(name)} (${prop.type})${req}${def}`);
513
- if (prop.description) console.log(` ${chalk.dim(prop.description)}`);
514
- if (prop.enum && prop.enum.length > 0) {
515
- console.log(` ${chalk.dim("Allowed values:")} ${chalk.cyan(prop.enum.map(v => JSON.stringify(v)).join(", "))}`);
516
- }
517
- }
518
- }
519
- if (manifest.output_schema?.properties) {
520
- console.log(chalk.bold(`\n Output:`));
521
- for (const [name, prop] of Object.entries(manifest.output_schema.properties)) {
522
- console.log(` ${chalk.cyan(name)} (${prop.type})`);
523
- }
524
- }
525
- console.log();
169
+
170
+ renderToolInfo(manifest, resolved);
526
171
  });
527
172
 
528
- // ─── `nxb transform <slug>` — pipe stdin through a tool ──────────────────────
173
+ // ─── `nxb transform <slug>` ──────────────────────────────────────────────────
529
174
  program
530
175
  .command("transform")
531
176
  .description("Pipe stdin through a tool and write result to stdout")
@@ -533,17 +178,15 @@ program
533
178
  .option("-k, --key <key>", "API key (or set NEXUSBLOOM_API_KEY env var)")
534
179
  .option("--local", "Execute locally (faster for repeated use)")
535
180
  .action(async (slug, opts, cmd) => {
536
- // Resolve abbreviation
537
- slug = await resolveToolSlug(slug);
538
-
181
+ const resolved = await resolveToolSlug(slug);
539
182
  const jsonMode = cmd.parent.opts().json;
540
- const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
541
183
 
542
184
  const input = await readStdin();
543
185
  if (!input) {
544
186
  if (jsonMode) return console.log(JSON.stringify({ error: "No input received from stdin" }));
545
187
  console.error(chalk.red("No input received from stdin. Pipe data into this command."));
546
- process.exit(1);
188
+ process.exitCode = 1;
189
+ return;
547
190
  }
548
191
 
549
192
  let body;
@@ -553,46 +196,37 @@ program
553
196
  body = { input };
554
197
  }
555
198
 
556
- if (opts.local) {
557
- return await executeLocal(slug, body, jsonMode);
199
+ if (opts.local || (!opts.key && !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key"))) {
200
+ return await runLocal(resolved, body, { jsonMode, slugResolved: true });
558
201
  }
559
202
 
203
+ const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
560
204
  if (!apiKey) {
561
205
  if (jsonMode) return console.log(JSON.stringify({ error: "API key required" }));
562
206
  console.error(chalk.red("API key required. Set NEXUSBLOOM_API_KEY, pass --key, or use --local mode."));
563
- process.exit(1);
207
+ process.exitCode = 1;
208
+ return;
564
209
  }
565
210
 
566
- try {
567
- const res = await fetch(`${API_BASE}/api/run/${slug}`, {
568
- method: "POST",
569
- headers: {
570
- "Content-Type": "application/json",
571
- Authorization: `Bearer ${apiKey}`,
572
- },
573
- body: JSON.stringify(body),
574
- });
575
- if (!res.ok && !res.headers.get("content-type")?.includes("json")) {
576
- if (!jsonMode) console.log(chalk.dim(` API not deployed, falling back to local execution...`));
577
- return await executeLocal(slug, body, jsonMode);
578
- }
579
- const data = await res.json();
580
- if (data.success) {
581
- const output = typeof data.data === "string" ? data.data : JSON.stringify(data.data, null, 2);
582
- if (jsonMode) return console.log(JSON.stringify({ success: true, data: data.data }, null, 2));
583
- process.stdout.write(output + "\n");
584
- } else {
585
- if (jsonMode) return console.log(JSON.stringify({ success: false, error: data.error || "Unknown error" }, null, 2));
586
- console.error(chalk.red(`\n Error: ${data.error || "Unknown error"}`));
587
- process.exit(1);
588
- }
589
- } catch (err) {
590
- if (!jsonMode) console.log(chalk.dim(` API unreachable, falling back to local execution...`));
591
- return await executeLocal(slug, body, jsonMode);
592
- }
211
+ await runRemote(resolved, body, { apiKey, jsonMode, slugResolved: true });
593
212
  });
594
213
 
595
- // ─── `nxb config` — manage configuration ─────────────────────────────────────
214
+ // ─── `nxb abbr [query]` ──────────────────────────────────────────────────────
215
+ program
216
+ .command("abbr")
217
+ .description("Show tool abbreviations (paginated)")
218
+ .argument("[query]", "Search/filter abbreviations")
219
+ .option("--no-interactive", "Disable interactive navigation")
220
+ .action(async (query, opts, cmd) => {
221
+ const jsonMode = cmd.parent.opts().json;
222
+ await abbrAction({
223
+ query,
224
+ json: jsonMode,
225
+ interactive: opts.interactive !== false,
226
+ });
227
+ });
228
+
229
+ // ─── `nxb config` ────────────────────────────────────────────────────────────
596
230
  const configCmd = program
597
231
  .command("config")
598
232
  .description("Manage CLI configuration");
@@ -652,7 +286,7 @@ configCmd
652
286
  }
653
287
  });
654
288
 
655
- // ─── `nxb cache` — manage local tool cache ─────────────────────────────────
289
+ // ─── `nxb cache` ─────────────────────────────────────────────────────────────
656
290
  const cacheCmd = program
657
291
  .command("cache")
658
292
  .description("Manage local tool cache");
@@ -661,13 +295,20 @@ cacheCmd
661
295
  .command("clear")
662
296
  .description("Clear the local tool cache")
663
297
  .action(() => {
664
- ensureCacheDir();
665
- const files = fs.readdirSync(CACHE_DIR);
298
+ if (!fs.existsSync(TOOLS_CACHE_DIR)) {
299
+ console.log(chalk.yellow(" No cache directory found."));
300
+ return;
301
+ }
302
+ const files = fs.readdirSync(TOOLS_CACHE_DIR);
666
303
  let count = 0;
667
304
  for (const file of files) {
668
- fs.unlinkSync(path.join(CACHE_DIR, file));
305
+ fs.unlinkSync(path.join(TOOLS_CACHE_DIR, file));
669
306
  count++;
670
307
  }
308
+ // Also remove tools-list.json
309
+ if (fs.existsSync(TOOLS_LIST_CACHE_PATH)) {
310
+ fs.unlinkSync(TOOLS_LIST_CACHE_PATH);
311
+ }
671
312
  console.log(chalk.green(` Cleared ${count} cached tool${count !== 1 ? "s" : ""}.`));
672
313
  });
673
314
 
@@ -675,8 +316,11 @@ cacheCmd
675
316
  .command("list")
676
317
  .description("List cached tools")
677
318
  .action(() => {
678
- ensureCacheDir();
679
- const files = fs.readdirSync(CACHE_DIR);
319
+ if (!fs.existsSync(TOOLS_CACHE_DIR)) {
320
+ console.log(chalk.yellow(" No cached tools."));
321
+ return;
322
+ }
323
+ const files = fs.readdirSync(TOOLS_CACHE_DIR);
680
324
  if (files.length === 0) {
681
325
  console.log(chalk.yellow(" No cached tools."));
682
326
  return;
@@ -684,461 +328,14 @@ cacheCmd
684
328
  console.log(chalk.bold(`\n ${files.length} cached tool${files.length > 1 ? "s" : ""}:\n`));
685
329
  files.forEach((f) => {
686
330
  const slug = f.replace(/\.json$/, "");
687
- const stats = fs.statSync(path.join(CACHE_DIR, f));
331
+ const stats = fs.statSync(path.join(TOOLS_CACHE_DIR, f));
688
332
  const age = Math.round((Date.now() - stats.mtimeMs) / 1000 / 60);
689
333
  console.log(` ${chalk.cyan(slug.padEnd(30))} ${chalk.dim(`${age}m ago`)}`);
690
334
  });
691
335
  console.log();
692
336
  });
693
337
 
694
- // ─── Abbreviation generator ──────────────────────────────────────────────────
695
-
696
- function generateAbbreviations(slugs) {
697
- const abbrMap = {};
698
- const used = new Set();
699
-
700
- for (const slug of slugs) {
701
- // Try first letter of each word
702
- const parts = slug.split("-");
703
- let abbr = parts.map((p) => p[0]).join("").toLowerCase();
704
-
705
- // If taken, try longer combinations
706
- if (used.has(abbr) || abbr.length < 2) {
707
- for (let len = 2; len <= slug.length; len++) {
708
- abbr = slug.slice(0, len);
709
- if (!used.has(abbr)) break;
710
- }
711
- }
712
-
713
- used.add(abbr);
714
- abbrMap[slug] = abbr;
715
- abbrMap[abbr] = slug;
716
- }
717
-
718
- return abbrMap;
719
- }
720
-
721
- // ─── `nxb abbr` — show tool abbreviations ────────────────────────────────────
722
-
723
- program
724
- .command("abbr")
725
- .description("Show tool abbreviations")
726
- .argument("[query]", "Search/filter abbreviations (e.g. 'ip' shows all IP-related tools)")
727
- .action(async (query, opts, cmd) => {
728
- const tools = await fetchToolsWithFallback();
729
- if (!tools || tools.length === 0) {
730
- console.log(chalk.yellow(" No tools available. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
731
- return;
732
- }
733
- const slugs = tools.map((t) => t.slug);
734
- const abbrMap = generateAbbreviations(slugs);
735
-
736
- // Filter by query if provided
737
- let filtered = slugs;
738
- if (query) {
739
- const q = query.toLowerCase();
740
- filtered = slugs.filter(
741
- (slug) => slug.includes(q) || abbrMap[slug].includes(q) || (tools || []).find(t => t.slug === slug)?.name?.toLowerCase().includes(q)
742
- );
743
- if (filtered.length === 0) {
744
- console.log(chalk.yellow(` No abbreviations matching "${query}"`));
745
- return;
746
- }
747
- }
748
-
749
- console.log(chalk.bold(`\n Tool Abbreviations${query ? ` matching "${query}"` : ""}:\n`));
750
- filtered.forEach((slug) => {
751
- const tool = (tools || []).find(t => t.slug === slug);
752
- const name = tool?.name || slug;
753
- console.log(` ${chalk.cyan(abbrMap[slug].padEnd(15))} → ${chalk.dim(slug.padEnd(30))} ${chalk.gray(name)}`);
754
- });
755
- console.log();
756
- });
757
-
758
- // ─── Shared abbreviation resolver ─────────────────────────────────────────────
759
- // Extracts the abbr resolution logic so all commands that accept slugs can use it.
760
-
761
- async function resolveToolSlug(slug) {
762
- const allTools = await fetchToolsWithFallback();
763
- if (!allTools) return slug;
764
- const slugs = allTools.map((t) => t.slug);
765
- const abbrMap = generateAbbreviations(slugs);
766
- if (abbrMap[slug] && slugs.includes(abbrMap[slug])) {
767
- return abbrMap[slug];
768
- }
769
- return slug;
770
- }
771
-
772
- // ─── `nxb json` — create JSON input for tools ───────────────────────────────
773
-
774
- program
775
- .command("json")
776
- .description("Create JSON input for tools (pipe into nxb run)")
777
- .option("-t, --template <slug>", "Generate JSON template for a specific tool")
778
- .option("-i, --interactive", "Interactive mode - prompts for each field")
779
- .option("-s, --set <key=value...>", "Set specific field values (e.g. -s count=10 format=csv)")
780
- .option("-a, --add <json>", "Merge additional JSON into the output (e.g. -a '{\"count\":20}')")
781
- .action(async (opts, cmd) => {
782
- const jsonMode = cmd.parent.opts().json;
783
-
784
- if (opts.template) {
785
- // Resolve abbreviation
786
- opts.template = await resolveToolSlug(opts.template);
787
-
788
- // Fetch tool schema — try API first, then Supabase
789
- let manifest = null;
790
- try {
791
- const res = await fetch(`${API_BASE}/api/run/${opts.template}`);
792
- if (res.ok) {
793
- const data = await res.json();
794
- manifest = data.data?.manifest || data.manifest || data;
795
- }
796
- } catch { /* API failed */ }
797
-
798
- if (!manifest) {
799
- const toolInfo = await fetchToolFromSupabase(opts.template);
800
- if (toolInfo) manifest = toolInfo.manifest;
801
- }
802
-
803
- if (!manifest) {
804
- console.error(chalk.red(`Tool "${opts.template}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
805
- process.exit(1);
806
- }
807
- const schema = manifest.input_schema || {};
808
-
809
- let result = {};
810
-
811
- if (opts.interactive) {
812
- // Interactive mode
813
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
814
- const ask = (q) => new Promise((r) => rl.question(q, r));
815
-
816
- if (schema.properties) {
817
- for (const [key, prop] of Object.entries(schema.properties)) {
818
- const required = schema.required?.includes(key);
819
- const hint = prop.description ? ` (${prop.description})` : "";
820
- const def = prop.default !== undefined ? ` [${JSON.stringify(prop.default)}]` : "";
821
- const enumInfo = prop.enum && prop.enum.length > 0
822
- ? ` {${prop.enum.map(v => JSON.stringify(v)).join("|")}}`
823
- : "";
824
- const answer = await ask(chalk.cyan(` ${key}${hint}${enumInfo}${def}: `));
825
- const val = answer || prop.default;
826
- if (val !== undefined && val !== "") {
827
- result[key] = prop.type === "number" || prop.type === "integer" ? Number(val) : val;
828
- }
829
- }
830
- }
831
- rl.close();
832
- } else {
833
- // Generate template with defaults
834
- if (schema.properties) {
835
- for (const [key, prop] of Object.entries(schema.properties)) {
836
- if (prop.default !== undefined) {
837
- result[key] = prop.default;
838
- } else if (prop.type === "string") {
839
- result[key] = prop.enum ? prop.enum[0] : "";
840
- } else if (prop.type === "number" || prop.type === "integer") {
841
- result[key] = 0;
842
- } else if (prop.type === "boolean") {
843
- result[key] = false;
844
- } else if (prop.type === "array") {
845
- result[key] = [];
846
- } else if (prop.type === "object") {
847
- result[key] = {};
848
- }
849
- }
850
- }
851
- }
852
-
853
- // ── Apply --set overrides ──────────────────────────────────────
854
- if (opts.set) {
855
- const sets = Array.isArray(opts.set) ? opts.set : [opts.set];
856
- for (const pair of sets) {
857
- const eqIdx = pair.indexOf("=");
858
- if (eqIdx > 0) {
859
- const k = pair.slice(0, eqIdx);
860
- let v = pair.slice(eqIdx + 1);
861
- // Try parsing as number or boolean
862
- if (v === "true") v = true;
863
- else if (v === "false") v = false;
864
- else if (!isNaN(v) && v.trim() !== "") v = Number(v);
865
- result[k] = v;
866
- }
867
- }
868
- }
869
-
870
- // ── Apply --add merge ──────────────────────────────────────────
871
- if (opts.add) {
872
- try {
873
- const extra = typeof opts.add === "string" ? JSON.parse(opts.add) : opts.add;
874
- result = { ...result, ...extra };
875
- } catch {
876
- console.error(chalk.red("Error: --add must be valid JSON"));
877
- process.exit(1);
878
- }
879
- }
880
-
881
- const output = JSON.stringify(result, null, jsonMode ? 2 : 0);
882
- if (jsonMode) return console.log(output);
883
- console.log(output);
884
- } else {
885
- console.log(chalk.yellow("Usage:"));
886
- console.log(chalk.dim(" nxb json --template <slug> Generate JSON template with defaults"));
887
- console.log(chalk.dim(" nxb json --template <slug> -i Interactive mode (prompts for fields)"));
888
- console.log(chalk.dim(" nxb json --template <slug> -s count=10 Set specific field values"));
889
- console.log(chalk.dim(" nxb json --template <slug> -a '{\"x\":1}' Merge additional JSON"));
890
- console.log(chalk.dim(""));
891
- console.log(chalk.dim(" Examples:"));
892
- console.log(chalk.dim(" nxb json --template data-generator | nxb run data-generator --input @-"));
893
- console.log(chalk.dim(" nxb run data-generator --input \"$(nxb json --template dg -s count=5)\""));
894
- console.log(chalk.dim(" nxb json --template color-palette -s color=#4A5D23 --json"));
895
- }
896
- });
897
-
898
- // ─── Supabase Helpers ─────────────────────────────────────────────────────────
899
-
900
- const SUPABASE_URL_DEFAULT = "https://dhxaqatmuprehrkszxzs.supabase.co";
901
- const SUPABASE_ANON_KEY_DEFAULT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRoeGFxYXRtdXByZWhya3N6eHpzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxNjIzNTQsImV4cCI6MjA4ODczODM1NH0.pq7DxW66ETQt6QlIAu2WiLO08tfA7JHQVPd2SvvA3sM";
902
-
903
- function getSupabaseKey() {
904
- return process.env.NEXUSBLOOM_SUPABASE_ANON_KEY
905
- || configGet("supabaseKey")
906
- || SUPABASE_ANON_KEY_DEFAULT;
907
- }
908
-
909
- function getSupabaseUrl() {
910
- return process.env.NEXUSBLOOM_SUPABASE_URL
911
- || SUPABASE_URL_DEFAULT;
912
- }
913
-
914
- function supabaseHeaders() {
915
- const key = getSupabaseKey();
916
- return { apikey: key, Authorization: `Bearer ${key}` };
917
- }
918
-
919
- /**
920
- * Fetch tool list from Supabase directly (fallback when API is down).
921
- * Returns array of {slug, name, short_description, category, runtime, version, icon} or null.
922
- */
923
- async function fetchToolsFromSupabase() {
924
- const headers = supabaseHeaders();
925
- const supabaseUrl = getSupabaseUrl();
926
- try {
927
- const res = await fetch(
928
- `${supabaseUrl}/rest/v1/tools?select=slug,name,short_description,tool_type,runtime,version,icon&published=eq.true&order=name.asc`,
929
- { headers }
930
- );
931
- if (!res.ok) return null;
932
- const tools = await res.json();
933
- if (!Array.isArray(tools)) return null;
934
-
935
- // Also fetch community tools
936
- const communityRes = await fetch(
937
- `${supabaseUrl}/rest/v1/community_tools?select=slug,name,short_description,category,version,icon,author_id&status=eq.published&order=name.asc`,
938
- { headers }
939
- );
940
- const communityTools = communityRes.ok ? (await communityRes.json()) : [];
941
-
942
- const allTools = [
943
- ...tools.map(t => ({
944
- slug: t.slug,
945
- name: t.name,
946
- description: t.short_description || "",
947
- category: t.tool_type || "uncategorized",
948
- runtime: t.runtime || "both",
949
- version: t.version || "1.0.0",
950
- icon: t.icon || "🔧",
951
- type: "builtin",
952
- })),
953
- ...communityTools.map(t => ({
954
- slug: t.slug,
955
- name: t.name,
956
- description: t.short_description || "",
957
- category: t.category || "uncategorized",
958
- runtime: "both",
959
- version: t.version || "1.0.0",
960
- icon: t.icon || "🛠️",
961
- type: "community",
962
- author_id: t.author_id,
963
- })),
964
- ];
965
-
966
- return allTools;
967
- } catch {
968
- return null;
969
- }
970
- }
971
-
972
- /**
973
- * Fetch a single tool's v2_source from Supabase (fallback).
974
- * Returns {manifest, coreLogicSource, v2Source} or null.
975
- */
976
- async function fetchToolFromSupabase(slug) {
977
- const headers = supabaseHeaders();
978
- const supabaseUrl = getSupabaseUrl();
979
- try {
980
- // Try builtin tools first
981
- const toolRes = await fetch(
982
- `${supabaseUrl}/rest/v1/tools?slug=eq.${slug}&select=id&published=eq.true`,
983
- { headers }
984
- );
985
- let tools = await toolRes.json();
986
- let toolId = tools?.[0]?.id;
987
- let toolType = "builtin";
988
-
989
- // Fall back to community tools
990
- if (!toolId) {
991
- const comRes = await fetch(
992
- `${supabaseUrl}/rest/v1/community_tools?slug=eq.${slug}&select=id&status=eq.published`,
993
- { headers }
994
- );
995
- const comTools = await comRes.json();
996
- toolId = comTools?.[0]?.id;
997
- toolType = "community";
998
- }
999
-
1000
- if (!toolId) return null;
1001
-
1002
- const versionRes = await fetch(
1003
- `${supabaseUrl}/rest/v1/tool_versions?tool_id=eq.${toolId}&tool_type=eq.${toolType}&status=eq.published&select=v2_source&order=created_at.desc&limit=1`,
1004
- { headers }
1005
- );
1006
- const versions = await versionRes.json();
1007
- const v2Source = versions?.[0]?.v2_source;
1008
- if (!v2Source) return null;
1009
-
1010
- const manifestMatch = v2Source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});\s*\n/);
1011
- let manifest = null;
1012
- if (manifestMatch) {
1013
- try {
1014
- manifest = new Function("return " + manifestMatch[1])();
1015
- } catch {
1016
- try {
1017
- manifest = JSON.parse(manifestMatch[1].replace(/(\w+):/g, '"$1":').replace(/'/g, '"'));
1018
- } catch {
1019
- manifest = eval("(" + manifestMatch[1] + ")");
1020
- }
1021
- }
1022
- }
1023
- return { manifest, coreLogicSource: v2Source, v2Source };
1024
- } catch {
1025
- return null;
1026
- }
1027
- }
1028
-
1029
- /**
1030
- * Try API first, fall back to Supabase. Returns array or null.
1031
- */
1032
- async function fetchToolsWithFallback() {
1033
- // Try API first
1034
- try {
1035
- const url = new URL("/api/tools", API_BASE);
1036
- const res = await fetch(url);
1037
- const ct = res.headers.get("content-type") || "";
1038
- if (ct.includes("json")) {
1039
- const { tools } = await res.json();
1040
- if (Array.isArray(tools) && tools.length > 0) return tools;
1041
- }
1042
- } catch { /* API failed */ }
1043
-
1044
- // Fall back to Supabase
1045
- const supabaseTools = await fetchToolsFromSupabase();
1046
- if (supabaseTools) return supabaseTools;
1047
- return null;
1048
- }
1049
-
1050
- // ─── Helpers ─────────────────────────────────────────────────────────────────
1051
-
1052
- /**
1053
- * Search the local filesystem for a tool v2.js file.
1054
- * Checks common locations relative to CWD.
1055
- */
1056
- function findLocalTool(slug) {
1057
- const cwd = process.cwd();
1058
- const candidates = [
1059
- path.join(cwd, "src", "tools", slug, "v2.js"),
1060
- path.join(cwd, "tools", slug, "v2.js"),
1061
- path.join(cwd, slug, "v2.js"),
1062
- path.join(cwd, "apps", "main-site", "frontend", "src", "tools", slug, "v2.js"),
1063
- path.join(cwd, "..", "apps", "main-site", "frontend", "src", "tools", slug, "v2.js"),
1064
- ];
1065
- try {
1066
- const config = loadConfig();
1067
- const toolsDir = config.toolsDir;
1068
- if (toolsDir && !candidates.some(c => c.includes(toolsDir))) {
1069
- candidates.unshift(path.join(cwd, toolsDir, slug, "v2.js"));
1070
- }
1071
- } catch {}
1072
- for (const candidate of candidates) {
1073
- if (fs.existsSync(candidate)) return candidate;
1074
- }
1075
- return null;
1076
- }
1077
-
1078
- /**
1079
- * Scan the local filesystem for all tool v2.js files.
1080
- * Returns array of { slug, name, description, type: "local" }.
1081
- */
1082
- function scanLocalTools() {
1083
- const cwd = process.cwd();
1084
- const searchDirs = [
1085
- path.join(cwd, "src", "tools"),
1086
- path.join(cwd, "tools"),
1087
- path.join(cwd, "apps", "main-site", "frontend", "src", "tools"),
1088
- ];
1089
- const tools = [];
1090
- for (const dir of searchDirs) {
1091
- if (!fs.existsSync(dir)) continue;
1092
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1093
- for (const entry of entries) {
1094
- if (!entry.isDirectory()) continue;
1095
- const v2Path = path.join(dir, entry.name, "v2.js");
1096
- if (!fs.existsSync(v2Path)) continue;
1097
- const source = fs.readFileSync(v2Path, "utf-8");
1098
- const match = source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});/);
1099
- if (!match) continue;
1100
- try {
1101
- const m = new Function("return " + match[1])();
1102
- tools.push({
1103
- slug: m.slug || entry.name,
1104
- name: m.name || entry.name,
1105
- description: m.short_description || "",
1106
- category: m.category || "uncategorized",
1107
- runtime: m.runtime || "both",
1108
- version: m.version || "1.0.0",
1109
- icon: m.icon || "🔧",
1110
- type: "local",
1111
- });
1112
- } catch {}
1113
- }
1114
- }
1115
- return tools;
1116
- }
1117
-
1118
- function readStdin() {
1119
- return new Promise((resolve) => {
1120
- const chunks = [];
1121
- process.stdin.setEncoding("utf8");
1122
- process.stdin.on("data", (chunk) => chunks.push(chunk));
1123
- process.stdin.on("end", () => resolve(chunks.join("")));
1124
- if (process.stdin.isTTY) resolve("");
1125
- });
1126
- }
1127
-
1128
- // ─── QR Code Terminal Renderer ────────────────────────────────────────────
1129
-
1130
- async function renderQRCode(data, slug) {
1131
- try {
1132
- const qr = await QRCode.toString(data, { type: "terminal", small: true });
1133
- console.log(chalk.cyan("\n QR Code:"));
1134
- console.log(qr);
1135
- console.log(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
1136
- } catch {
1137
- // Silently skip if QR rendering fails
1138
- }
1139
- }
1140
-
1141
- // ─── `nxb create` — scaffold a new tool locally ──────────────────────────
338
+ // ─── `nxb create` ────────────────────────────────────────────────────────────
1142
339
  const VALID_RUNTIMES = ["browser", "node", "both"];
1143
340
  const VALID_CATEGORIES = [
1144
341
  "text", "image", "data", "network", "security", "development",
@@ -1155,13 +352,9 @@ program
1155
352
  .option("--runtime <runtime>", `Runtime (${VALID_RUNTIMES.join(", ")})`)
1156
353
  .option("--dir <dir>", "Target directory (default: ./src/tools/<slug>)")
1157
354
  .action(async (options) => {
1158
- const rl = readline.createInterface({
1159
- input: process.stdin,
1160
- output: process.stdout,
1161
- });
355
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1162
356
  const ask = (q) => new Promise((r) => rl.question(q, r));
1163
357
 
1164
- // Gather inputs
1165
358
  let slug = options.slug;
1166
359
  while (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
1167
360
  slug = (await ask(chalk.cyan("Tool slug (e.g. my-tool): "))).trim().toLowerCase();
@@ -1207,7 +400,6 @@ program
1207
400
 
1208
401
  rl.close();
1209
402
 
1210
- // Generate v2.js source
1211
403
  const pascalName = slug
1212
404
  .split("-")
1213
405
  .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
@@ -1288,27 +480,24 @@ export default function ${pascalName}Tool({ onResult }) {
1288
480
  }
1289
481
  `;
1290
482
 
1291
- // Determine target directory
1292
483
  const targetDir = options.dir || path.join(process.cwd(), "src/tools", slug);
1293
484
  const v2Path = path.join(targetDir, "v2.js");
1294
485
  const indexPath = path.join(targetDir, "index.js");
1295
486
 
1296
- // Check for existing files
1297
487
  if (fs.existsSync(v2Path) || fs.existsSync(indexPath)) {
1298
- const overwrite = (await new Promise((r) => {
488
+ const overwrite = await new Promise((r) => {
1299
489
  const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
1300
490
  rl2.question(chalk.yellow(`Tool "${slug}" already exists. Overwrite? (y/N): `), (a) => {
1301
491
  rl2.close();
1302
492
  r(a.trim().toLowerCase() === "y");
1303
493
  });
1304
- }));
494
+ });
1305
495
  if (!overwrite) {
1306
496
  console.log(chalk.dim("Aborted."));
1307
497
  return;
1308
498
  }
1309
499
  }
1310
500
 
1311
- // Write files
1312
501
  fs.mkdirSync(targetDir, { recursive: true });
1313
502
  fs.writeFileSync(v2Path, v2Source, "utf-8");
1314
503
  fs.writeFileSync(indexPath, indexSource, "utf-8");
@@ -1322,4 +511,153 @@ export default function ${pascalName}Tool({ onResult }) {
1322
511
  console.log(chalk.dim(" 3. Publish from the VS Code extension or API\n"));
1323
512
  });
1324
513
 
1325
- program.parse(process.argv);
514
+ // ─── `nxb json` ─────────────────────────────────────────────────────────────
515
+ program
516
+ .command("json")
517
+ .description("Create JSON input for tools (pipe into nxb run)")
518
+ .option("-t, --template <slug>", "Generate JSON template for a specific tool")
519
+ .option("-i, --interactive", "Interactive mode - prompts for each field")
520
+ .option("-s, --set <key=value...>", "Set specific field values (e.g. -s count=10 format=csv)")
521
+ .option("-a, --add <json>", "Merge additional JSON into the output (e.g. -a '{\"count\":20}')")
522
+ .action(async (opts, cmd) => {
523
+ const jsonMode = cmd.parent.opts().json;
524
+
525
+ if (opts.template) {
526
+ const resolved = await resolveToolSlug(opts.template);
527
+ const toolInfo = await fetchToolSource(resolved);
528
+ const manifest = toolInfo?.manifest;
529
+
530
+ if (!manifest) {
531
+ console.error(chalk.red(`Tool "${resolved}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
532
+ process.exitCode = 1;
533
+ return;
534
+ }
535
+
536
+ const schema = manifest.input_schema || {};
537
+ let result = {};
538
+
539
+ if (opts.interactive) {
540
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
541
+ const ask = (q) => new Promise((r) => rl.question(q, r));
542
+
543
+ if (schema.properties) {
544
+ for (const [key, prop] of Object.entries(schema.properties)) {
545
+ const required = schema.required?.includes(key);
546
+ const hint = prop.description ? ` (${prop.description})` : "";
547
+ const def = prop.default !== undefined ? ` [${JSON.stringify(prop.default)}]` : "";
548
+ const enumInfo = prop.enum && prop.enum.length > 0
549
+ ? ` {${prop.enum.map((v) => JSON.stringify(v)).join("|")}}`
550
+ : "";
551
+ const answer = await ask(chalk.cyan(` ${key}${hint}${enumInfo}${def}: `));
552
+ const val = answer || prop.default;
553
+ if (val !== undefined && val !== "") {
554
+ result[key] = prop.type === "number" || prop.type === "integer" ? Number(val) : val;
555
+ }
556
+ }
557
+ }
558
+ rl.close();
559
+ } else {
560
+ if (schema.properties) {
561
+ for (const [key, prop] of Object.entries(schema.properties)) {
562
+ if (prop.default !== undefined) {
563
+ result[key] = prop.default;
564
+ } else if (prop.type === "string") {
565
+ result[key] = prop.enum ? prop.enum[0] : "";
566
+ } else if (prop.type === "number" || prop.type === "integer") {
567
+ result[key] = 0;
568
+ } else if (prop.type === "boolean") {
569
+ result[key] = false;
570
+ } else if (prop.type === "array") {
571
+ result[key] = [];
572
+ } else if (prop.type === "object") {
573
+ result[key] = {};
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ if (opts.set) {
580
+ const sets = Array.isArray(opts.set) ? opts.set : [opts.set];
581
+ for (const pair of sets) {
582
+ const eqIdx = pair.indexOf("=");
583
+ if (eqIdx > 0) {
584
+ const k = pair.slice(0, eqIdx);
585
+ let v = pair.slice(eqIdx + 1);
586
+ if (v === "true") v = true;
587
+ else if (v === "false") v = false;
588
+ else if (!isNaN(v) && v.trim() !== "") v = Number(v);
589
+ result[k] = v;
590
+ }
591
+ }
592
+ }
593
+
594
+ if (opts.add) {
595
+ try {
596
+ const extra = typeof opts.add === "string" ? JSON.parse(opts.add) : opts.add;
597
+ result = { ...result, ...extra };
598
+ } catch {
599
+ console.error(chalk.red("Error: --add must be valid JSON"));
600
+ process.exitCode = 1;
601
+ }
602
+ }
603
+
604
+ const output = JSON.stringify(result, null, jsonMode ? 2 : 0);
605
+ if (jsonMode) return console.log(output);
606
+ console.log(output);
607
+ } else {
608
+ console.log(chalk.yellow("Usage:"));
609
+ console.log(chalk.dim(" nxb json --template <slug> Generate JSON template with defaults"));
610
+ console.log(chalk.dim(" nxb json --template <slug> -i Interactive mode (prompts for fields)"));
611
+ console.log(chalk.dim(" nxb json --template <slug> -s count=10 Set specific field values"));
612
+ console.log(chalk.dim(" nxb json --template <slug> -a '{\"x\":1}' Merge additional JSON"));
613
+ console.log(chalk.dim(""));
614
+ console.log(chalk.dim(" Examples:"));
615
+ console.log(chalk.dim(" nxb json --template data-generator | nxb run data-generator --input @-"));
616
+ console.log(chalk.dim(" nxb run data-generator --input \"$(nxb json --template dg -s count=5)\""));
617
+ console.log(chalk.dim(" nxb json --template color-palette -s color=#4A5D23 --json"));
618
+ }
619
+ });
620
+
621
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
622
+
623
+ function promptValue(key) {
624
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
625
+ return new Promise((resolve) => {
626
+ rl.question(chalk.cyan(` Enter value for "${key}": `), (answer) => {
627
+ rl.close();
628
+ resolve(answer.trim());
629
+ });
630
+ });
631
+ }
632
+
633
+ function renderToolInfo(manifest, resolved) {
634
+ const toolName = manifest.name || manifest.slug || resolved;
635
+ console.log(chalk.bold(`\n ${toolName}`));
636
+ console.log(` ${chalk.dim(manifest.short_description || "")}`);
637
+ console.log(` Version: ${chalk.cyan(manifest.version || "1.0.0")}`);
638
+ console.log(` Runtime: ${chalk.cyan(manifest.runtime || "browser")}`);
639
+ console.log(` Pricing: ${chalk.green("free")}`);
640
+ if (manifest.input_schema?.properties) {
641
+ console.log(chalk.bold(`\n Input Parameters:`));
642
+ const props = manifest.input_schema.properties;
643
+ const required = manifest.input_schema.required || [];
644
+ for (const [name, prop] of Object.entries(props)) {
645
+ const req = required.includes(name) ? chalk.red(" *required") : "";
646
+ const def = prop.default !== undefined ? chalk.dim(` [default: ${JSON.stringify(prop.default)}]`) : "";
647
+ console.log(` ${chalk.cyan(name)} (${prop.type})${req}${def}`);
648
+ if (prop.description) console.log(` ${chalk.dim(prop.description)}`);
649
+ if (prop.enum && prop.enum.length > 0) {
650
+ console.log(` ${chalk.dim("Allowed values:")} ${chalk.cyan(prop.enum.map((v) => JSON.stringify(v)).join(", "))}`);
651
+ }
652
+ }
653
+ }
654
+ if (manifest.output_schema?.properties) {
655
+ console.log(chalk.bold(`\n Output:`));
656
+ for (const [name, prop] of Object.entries(manifest.output_schema.properties)) {
657
+ console.log(` ${chalk.cyan(name)} (${prop.type})`);
658
+ }
659
+ }
660
+ console.log();
661
+ }
662
+
663
+ program.parse(process.argv);