@nexusbloom/cli 0.2.11 → 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/list.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * list.js — `nxb list` command.
3
+ *
4
+ * Lists available tools in a paginated, table-style view with icons,
5
+ * categories, and type badges. Supports:
6
+ *
7
+ * nxb list — interactive pagination (10/page, prev/next)
8
+ * nxb list -s ssl — filter + still paginated
9
+ * nxb list --json — raw JSON
10
+ * nxb list --no-interactive— non-interactive single dump
11
+ */
12
+
13
+ import chalk from "chalk";
14
+ import { fetchTools, resolveToolSlug, generateAbbreviations } from "./tools.js";
15
+ import { renderHeader, renderTable, paginate, paginateInteractive, typeBadge, categoryBadge, PAGE_SIZE, renderPageInfo } from "./ui.js";
16
+
17
+ /**
18
+ * Render a single page of tools as a table.
19
+ */
20
+ function renderToolsPage(result) {
21
+ if (result.total === 0) {
22
+ console.log(chalk.yellow(" No tools found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
23
+ return;
24
+ }
25
+
26
+ const rows = result.items.map((t, i) => ({
27
+ "#": chalk.gray(result.startIndex + i + 1),
28
+ name: `${t.icon || "🔧"} ${t.name || t.slug}`,
29
+ slug: chalk.cyan(t.slug || ""),
30
+ cat: categoryBadge(t.category || "uncategorized"),
31
+ type: typeBadge(t),
32
+ tags: t.tags && t.tags.length ? chalk.gray(t.tags.slice(0, 3).join(",")) : "",
33
+ }));
34
+
35
+ const cols = [
36
+ { key: "#", header: "#", width: 4 },
37
+ { key: "name", header: "Tool", width: 32 },
38
+ { key: "slug", header: "Slug", width: 26 },
39
+ { key: "cat", header: "Category", width: 14, align: "center" },
40
+ { key: "type", header: "Type", width: 12, align: "center" },
41
+ { key: "tags", header: "Tags", width: 20 },
42
+ ];
43
+
44
+ renderTable(cols, rows, { headColor: "bold" });
45
+ console.log();
46
+ renderPageInfo(result);
47
+ }
48
+
49
+ /**
50
+ * The `nxb list` action.
51
+ */
52
+ export async function listAction(opts = {}) {
53
+ const { search, json: jsonMode, interactive = true, force = false, offline = false } = opts;
54
+
55
+ let tools = await fetchTools({ force, offline });
56
+ let filtered = tools;
57
+
58
+ if (search) {
59
+ const q = search.toLowerCase();
60
+ filtered = tools.filter(
61
+ (t) =>
62
+ (t.slug || "").includes(q) ||
63
+ (t.name || "").toLowerCase().includes(q) ||
64
+ (t.short_description || t.description || "").toLowerCase().includes(q) ||
65
+ (t.category || "").includes(q) ||
66
+ (t.tags || []).some((tg) => tg.toLowerCase().includes(q))
67
+ );
68
+ }
69
+
70
+ // ── JSON mode: dump everything, no pagination ──
71
+ if (jsonMode) {
72
+ console.log(JSON.stringify({ tools: filtered }, null, 2));
73
+ return;
74
+ }
75
+
76
+ // ── Non-interactive: single-page dump ──
77
+ if (!interactive) {
78
+ renderHeader("NexusBloom Tools", { subtitle: `${filtered.length} tool${filtered.length === 1 ? "" : "s"} available` });
79
+ if (filtered.length === 0) return;
80
+ const result = paginate(filtered, filtered.length, 1);
81
+ renderToolsPage(result);
82
+ return;
83
+ }
84
+
85
+ // ── Interactive: paginated with live navigation ──
86
+ const abbrMap = generateAbbreviations(filtered.map((t) => t.slug));
87
+ const abbrCount = Object.keys(abbrMap).filter((k) => !(k in filtered.map((t) => t.slug))).length;
88
+
89
+ renderHeader("NexusBloom Tools", {
90
+ subtitle: `${filtered.length} tool${filtered.length === 1 ? "" : "s"} available · page 1 of ${Math.ceil(filtered.length / PAGE_SIZE) || 1}`,
91
+ });
92
+
93
+ await paginateInteractive(
94
+ filtered,
95
+ PAGE_SIZE,
96
+ (result) => {
97
+ console.clear();
98
+ renderHeader("NexusBloom Tools", {
99
+ subtitle: `${result.total} tool${result.total === 1 ? "" : "s"} · page ${chalk.cyan(result.page)} of ${chalk.cyan(result.totalPages)}`,
100
+ });
101
+ renderToolsPage(result);
102
+ },
103
+ {}
104
+ );
105
+ }
106
+
107
+ export default listAction;
package/src/paths.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * paths.js — Centralised filesystem paths for the nxb CLI.
3
+ *
4
+ * Keeps config + cache locations in one place so every module can share them
5
+ * without duplicating the XDG / home-directory logic.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import os from "os";
11
+ import { fileURLToPath } from "url";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ // ─── Config (nxb config) ──────────────────────────────────────────────────────
16
+ export const CONFIG_DIR = path.join(
17
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
18
+ "nexusbloom"
19
+ );
20
+ export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
21
+
22
+ // ─── Tool list cache (offline tool catalogue) ─────────────────────────────────
23
+ // Stored under ~/.cache/nexusbloom/tools.json — a flat snapshot of every tool
24
+ // so `nxb list` / `nxb abbr` work without a network round-trip.
25
+ export const CACHE_DIR = path.resolve(__dirname, "../.cache");
26
+ export const TOOLS_CACHE_DIR = path.join(CACHE_DIR, "tools");
27
+ export const TOOLS_LIST_CACHE_PATH = path.join(CACHE_DIR, "tools-list.json");
28
+
29
+ // ─── Local tool source cache (for --local execution) ──────────────────────────
30
+ export const TOOL_SOURCE_CACHE_DIR = TOOLS_CACHE_DIR;
31
+
32
+ export function ensureDir(dir) {
33
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
34
+ }
35
+
36
+ export function ensureConfigDir() {
37
+ ensureDir(CONFIG_DIR);
38
+ }
39
+
40
+ export function ensureCacheDir() {
41
+ ensureDir(TOOLS_CACHE_DIR);
42
+ }
package/src/run.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * run.js — `nxb run` command.
3
+ *
4
+ * Two modes:
5
+ * 1. Direct: nxb run <slug> [key=value ...] [--input json] [--file path] [--local]
6
+ * 2. Interactive: nxb run (no slug) → select tool → pick inputs → auto-build & execute
7
+ *
8
+ * Imports execution logic from the original monolithic index.js but adds
9
+ * inquirer-driven field prompting for the interactive path.
10
+ */
11
+
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import chalk from "chalk";
15
+ import ora from "ora";
16
+ import QRCode from "qrcode";
17
+
18
+ import {
19
+ fetchTools,
20
+ fetchToolSource,
21
+ resolveToolSlug,
22
+ configGet,
23
+ API_BASE,
24
+ readStdin,
25
+ } from "./tools.js";
26
+ import {
27
+ renderHeader,
28
+ renderBox,
29
+ typeBadge,
30
+ promptSelect,
31
+ promptInput,
32
+ promptConfirm,
33
+ promptNumber,
34
+ } from "./ui.js";
35
+
36
+ // ─── Remote execution ────────────────────────────────────────────────────────
37
+
38
+ export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved }) {
39
+ const key = apiKey || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
40
+ if (!key) {
41
+ if (!jsonMode) console.log(chalk.dim(" No API key — falling back to local execution..."));
42
+ return runLocal(slug, body, { jsonMode, slugResolved });
43
+ }
44
+
45
+ const spinner = !jsonMode ? ora(chalk.gray(` Running ${slug}...`))`.start()` : null;
46
+
47
+ try {
48
+ const res = await fetch(`${API_BASE}/api/run/${slug}`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
51
+ body: JSON.stringify(body),
52
+ });
53
+
54
+ if (!res.ok && !res.headers.get("content-type")?.includes("json")) {
55
+ if (spinner) spinner.succeed(chalk.dim(" API not deployed — falling back to local"));
56
+ return runLocal(slug, body, { jsonMode, slugResolved });
57
+ }
58
+
59
+ const data = await res.json();
60
+ if (spinner) {
61
+ if (data.success) spinner.succeed(chalk.green(` ${slug} completed`));
62
+ else spinner.fail(chalk.red(` ${slug} failed`));
63
+ }
64
+
65
+ if (data.success) {
66
+ if (data.data && typeof data.data === "object" && data.data.svg) {
67
+ await renderQRCode(body.data || body.text || JSON.stringify(body), slug);
68
+ }
69
+ if (jsonMode) return console.log(JSON.stringify({ success: true, data: data.data }, null, 2));
70
+ console.log(chalk.green("\n Result:"));
71
+ console.log(` ${JSON.stringify(data.data, null, 2)}`);
72
+ return;
73
+ }
74
+
75
+ if (jsonMode) return console.log(JSON.stringify({ success: false, error: data.error || "Unknown error" }, null, 2));
76
+ console.error(chalk.red(`\n Error: ${data.error || "Unknown error"}`));
77
+ process.exitCode = 1;
78
+ } catch (err) {
79
+ if (spinner) spinner.fail(chalk.dim(" API unreachable — falling back to local"));
80
+ return runLocal(slug, body, { jsonMode, slugResolved });
81
+ }
82
+ }
83
+
84
+ // ─── Local execution ────────────────────────────────────────────────────────
85
+
86
+ export async function runLocal(slug, input, { jsonMode, slugResolved, source: src } = {}) {
87
+ const toolInfo = await fetchToolSource(slug);
88
+ if (!toolInfo) {
89
+ if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${slug}" not found.` }));
90
+ console.error(chalk.red(`Tool "${slug}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+
95
+ const { manifest, coreLogicSource } = toolInfo;
96
+ if (!coreLogicSource) {
97
+ if (jsonMode)
98
+ return console.log(JSON.stringify({ error: "No v2 source available for local execution." }));
99
+ console.error(chalk.yellow("No v2 source available for local execution. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+
104
+ if (!jsonMode) console.log(chalk.dim(` Using ${slugResolved ? "local" : src || "cached"} tool: ${slug}`));
105
+
106
+ // Strip imports/exports, wrap in IIFE that returns coreLogic
107
+ const cleanSource = coreLogicSource
108
+ .replace(/^import\s+.*?;?\s*$/gm, "")
109
+ .replace(/^export\s+/gm, "");
110
+ const wrappedSource = `(function() {\n${cleanSource}\nreturn coreLogic;\n})()`;
111
+ const coreLogicFn = new Function("return " + wrappedSource)();
112
+ const isAsync = /export\s+async\s+function\s+coreLogic/.test(coreLogicSource);
113
+
114
+ const schema = manifest.input_schema || {};
115
+ const required = schema.required || [];
116
+ const missing = required.filter((k) => input[k] === undefined || input[k] === null);
117
+ if (missing.length > 0) {
118
+ if (jsonMode) return console.log(JSON.stringify({ error: `Missing required fields: ${missing.join(", ")}` }));
119
+ console.error(chalk.red(`Missing required fields: ${missing.join(", ")}`));
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+
124
+ // Apply defaults
125
+ if (schema.properties) {
126
+ for (const [key, prop] of Object.entries(schema.properties)) {
127
+ if (input[key] === undefined && prop.default !== undefined) {
128
+ input[key] = prop.default;
129
+ }
130
+ }
131
+ }
132
+
133
+ const timeoutMs = manifest.timeout_ms || 5000;
134
+ const spinner = !jsonMode ? ora(chalk.gray(` Executing ${slug} (local)`)).start() : null;
135
+ let result;
136
+
137
+ try {
138
+ if (isAsync) {
139
+ result = await Promise.race([
140
+ coreLogicFn(input),
141
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)),
142
+ ]);
143
+ } else {
144
+ result = coreLogicFn(input);
145
+ }
146
+ if (spinner) spinner.succeed(chalk.green(` ${slug} completed`));
147
+
148
+ // Fire-and-forget telemetry
149
+ const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
150
+ const trackHeaders = { "Content-Type": "application/json" };
151
+ if (apiKey) trackHeaders["Authorization"] = `Bearer ${apiKey}`;
152
+ fetch(`${API_BASE}/api/track-run`, { method: "POST", headers: trackHeaders, body: JSON.stringify({ slug, source: "cli" }) }).catch(() => {});
153
+
154
+ if (jsonMode) return console.log(JSON.stringify({ success: true, data: result }, null, 2));
155
+ console.log(chalk.green("\n Result (local):"));
156
+ console.log(` ${JSON.stringify(result, null, 2)}`);
157
+ } catch (err) {
158
+ if (spinner) spinner.fail(chalk.red(` Execution error`));
159
+ if (jsonMode) return console.log(JSON.stringify({ error: err.message }));
160
+ console.error(chalk.red(`\n Local execution error: ${err.message}`));
161
+ process.exitCode = 1;
162
+ }
163
+ }
164
+
165
+ // ─── Input building ─────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Build the input body from key=value args, --input, --file, and stdin.
169
+ * Priority: --input > --file > stdin > key=value args.
170
+ */
171
+ export async function buildInputBody(opts, args) {
172
+ const { input: inputFlag, file } = opts;
173
+ let body = {};
174
+
175
+ // stdin detection (auto or explicit @-)
176
+ const isPiped = inputFlag === "@-" || inputFlag === "@stdin" || (!inputFlag && !file && !process.stdin.isTTY);
177
+ if (isPiped) {
178
+ try {
179
+ const stdin = await readStdin();
180
+ if (stdin && stdin.trim()) body = JSON.parse(stdin);
181
+ } catch {
182
+ console.error(chalk.red("Error: Invalid JSON received from stdin"));
183
+ process.exitCode = 1;
184
+ return null;
185
+ }
186
+ }
187
+
188
+ // --input flag (highest priority after stdin)
189
+ if (inputFlag && inputFlag !== "@-" && inputFlag !== "@stdin") {
190
+ try {
191
+ body = JSON.parse(inputFlag);
192
+ } catch {
193
+ console.error(chalk.red("Error: --input must be valid JSON"));
194
+ process.exitCode = 1;
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // --file
200
+ if (file) {
201
+ try {
202
+ const fileContent = fs.readFileSync(path.resolve(file), "utf-8");
203
+ body = { ...JSON.parse(fileContent), ...body };
204
+ } catch (err) {
205
+ console.error(chalk.red(`Error reading file: ${err.message}`));
206
+ process.exitCode = 1;
207
+ return null;
208
+ }
209
+ }
210
+
211
+ // key=value args (lowest priority)
212
+ if (!inputFlag && !file) {
213
+ (args || []).forEach((arg) => {
214
+ const [k, ...v] = arg.split("=");
215
+ body[k] = v.join("=");
216
+ });
217
+ }
218
+
219
+ return body;
220
+ }
221
+
222
+ // ─── QR code renderer ────────────────────────────────────────────────────────
223
+
224
+ async function renderQRCode(data, slug) {
225
+ try {
226
+ const qr = await QRCode.toString(data, { type: "terminal", small: true });
227
+ console.log(chalk.cyan("\n QR Code:"));
228
+ console.log(qr);
229
+ console.log(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
230
+ } catch {
231
+ /* silently skip */
232
+ }
233
+ }
234
+
235
+ // ─── Interactive runner ────────────────────────────────────────────────────
236
+
237
+ /**
238
+ * `nxb run` with no slug: interactively select a tool and its inputs,
239
+ * then auto-build & execute the exact command.
240
+ */
241
+ export async function runInteractive(opts = {}) {
242
+ const { json: jsonMode } = opts;
243
+ const tools = await fetchTools({ offline: true });
244
+
245
+ if (!tools || tools.length === 0) {
246
+ console.log(chalk.yellow(" No tools available. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
247
+ return;
248
+ }
249
+
250
+ renderHeader("NexusBloom — Interactive Runner", { subtitle: "Select a tool to run" });
251
+
252
+ // 1. Select a tool
253
+ const choices = tools.map((t) => ({
254
+ name: `${t.icon || "🔧"} ${t.name || t.slug} ${typeBadge(t)}`,
255
+ value: t,
256
+ description: t.short_description || t.description || "",
257
+ }));
258
+
259
+ const selected = await promptSelect("Choose a tool:", choices, { pageSize: 15 });
260
+ if (!selected) return;
261
+
262
+ const slug = selected.slug;
263
+ const resolved = await resolveToolSlug(slug, tools);
264
+
265
+ // 2. Fetch manifest / schema
266
+ const toolInfo = await fetchToolSource(resolved);
267
+ if (!toolInfo?.manifest) {
268
+ console.log(chalk.yellow(` No schema available for "${resolved}". Falling back to raw input.`));
269
+ await runCommand(resolved, {}, { ...opts, jsonMode });
270
+ return;
271
+ }
272
+
273
+ const { manifest } = toolInfo;
274
+ const schema = manifest.input_schema || { type: "object", properties: {} };
275
+
276
+ renderHeader("Configure Inputs", { subtitle: manifest.short_description || manifest.name });
277
+
278
+ // 3. Prompt for each field
279
+ const body = {};
280
+ const props = schema.properties || {};
281
+ const required = schema.required || [];
282
+
283
+ for (const [key, prop] of Object.entries(props)) {
284
+ const req = required.includes(key);
285
+ const hint = prop.description || "";
286
+ const suffix = req ? chalk.red(" *required") : chalk.dim(" (optional)");
287
+ const label = `${key}${suffix}${hint ? chalk.dim(` — ${hint}`) : ""}`;
288
+
289
+ const val = await promptField(key, prop);
290
+ if (val !== undefined) body[key] = val;
291
+ }
292
+
293
+ // Allow extra fields
294
+ const extra = await promptConfirm("Add a custom field?", { default: false });
295
+ if (extra) {
296
+ const k = await promptInput(" Field name:");
297
+ if (k) {
298
+ const v = await promptInput(` Value for "${k}":`);
299
+ if (v !== undefined) body[k] = v;
300
+ }
301
+ }
302
+
303
+ // 4. Auto-build the command
304
+ const inputJson = JSON.stringify(body);
305
+ const command = `nxb run ${resolved} --input '${inputJson}'`;
306
+ const isLocal = !process.env.NEXUSBLOOM_API_KEY && !configGet("apiKey") && !configGet("key");
307
+ const fullCommand = `${command}${isLocal ? " --local" : ""}`;
308
+
309
+ renderBox("Generated Command", [
310
+ chalk.green(fullCommand),
311
+ "",
312
+ chalk.dim(`Tool: ${manifest.name} (${resolved})`),
313
+ chalk.dim(`Input: ${inputJson}`),
314
+ ], { width: 78 });
315
+
316
+ // 5. Confirm & execute
317
+ const runIt = await promptConfirm("Execute now?", { default: true });
318
+ if (!runIt) {
319
+ console.log(chalk.dim(" Command copied to clipboard (if available):"));
320
+ console.log(chalk.gray(` ${fullCommand}`));
321
+ return;
322
+ }
323
+
324
+ // Execute directly via the same path
325
+ await runCommand(resolved, body, { ...opts, jsonMode, resolved });
326
+ }
327
+
328
+ /** Prompt for a single schema field based on its type. */
329
+ async function promptField(key, prop) {
330
+ const { type, enum: enumVals, default: def } = prop;
331
+
332
+ // Enum → select
333
+ if (enumVals && Array.isArray(enumVals) && enumVals.length > 0) {
334
+ const choices = enumVals.map((v) => ({
335
+ name: String(v),
336
+ value: v,
337
+ ...(v === def && { checked: true }),
338
+ }));
339
+ if (def !== undefined) choices.unshift({ name: chalk.gray("(default)"), value: def });
340
+ return promptSelect(`${key}:`, choices);
341
+ }
342
+
343
+ // Number / integer
344
+ if (type === "number" || type === "integer") {
345
+ return promptNumber(`${key}:`, { min: prop.minimum, max: prop.maximum, default: def });
346
+ }
347
+
348
+ // Boolean
349
+ if (type === "boolean") {
350
+ return promptConfirm(`${key}:`, { default: def ?? true });
351
+ }
352
+
353
+ // Object / array → JSON string input
354
+ if (type === "object" || type === "array") {
355
+ const defaultStr = def !== undefined ? JSON.stringify(def) : [];
356
+ const raw = await promptInput(`${key}: (JSON)`, { defaultValue: defaultStr });
357
+ if (!raw) return def;
358
+ try {
359
+ return JSON.parse(raw);
360
+ } catch {
361
+ console.log(chalk.yellow(" Invalid JSON — using raw string"));
362
+ return raw;
363
+ }
364
+ }
365
+
366
+ // Default: string input
367
+ return promptInput(`${key}:`, { defaultValue: def });
368
+ }
369
+
370
+ /** Execute a resolved slug+body, choosing local vs remote. */
371
+ async function runCommand(slug, body, opts = {}) {
372
+ const { json: jsonMode, resolved, local } = opts;
373
+ const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
374
+
375
+ if (local || !apiKey) {
376
+ await runLocal(slug, body, { jsonMode, slugResolved: resolved });
377
+ return;
378
+ }
379
+ await runRemote(slug, body, { apiKey, jsonMode, slugResolved: resolved });
380
+ }