@nexusbloom/cli 0.3.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * pipelines.js — Named pipelines stored as plain files.
3
+ *
4
+ * Deliberately not a database: pipelines are small, shareable, diffable text,
5
+ * so they live in the user's config directory where git and rsync reach them.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+
11
+ import { PIPELINES_DIR, ensurePipelinesDir } from "./paths.js";
12
+
13
+ const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
14
+
15
+ export function pipelinePath(name) {
16
+ // Require a string outright rather than coercing: `["ok"]` and `123` would
17
+ // otherwise pass the charset check by way of String(), so the guard would be
18
+ // a string check pretending to be a type check. Coercion is not exploitable
19
+ // here — the coerced value is what gets validated *and* what builds the path
20
+ // — but accepting a non-string here means a caller's type mistake surfaces as
21
+ // a confusingly-named file instead of an error.
22
+ if (typeof name !== "string" || !SAFE_NAME.test(name)) {
23
+ throw new Error(`Invalid pipeline name "${String(name)}". Use letters, digits, dots, dashes and underscores.`);
24
+ }
25
+ return path.join(PIPELINES_DIR, `${name}.json`);
26
+ }
27
+
28
+ /** Write a pipeline definition. Returns the file path. */
29
+ export function savePipeline(name, definition) {
30
+ ensurePipelinesDir();
31
+ const file = pipelinePath(name);
32
+ const record = { name, ...definition, savedAt: new Date().toISOString() };
33
+ fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
34
+ return file;
35
+ }
36
+
37
+ export function loadPipeline(name) {
38
+ const file = pipelinePath(name);
39
+ if (!fs.existsSync(file)) return null;
40
+ try {
41
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function listPipelines() {
48
+ if (!fs.existsSync(PIPELINES_DIR)) return [];
49
+ return fs
50
+ .readdirSync(PIPELINES_DIR)
51
+ .filter((f) => f.endsWith(".json"))
52
+ .map((f) => f.replace(/\.json$/, ""))
53
+ // The directory is shared with anything the user drops there. A stray
54
+ // `.json` whose stem is not a legal name (a dotfile, a partial download)
55
+ // must be skipped, not allowed to make `nxb pipeline list` throw.
56
+ .filter((name) => {
57
+ try {
58
+ pipelinePath(name);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ })
64
+ .map((name) => {
65
+ const def = loadPipeline(name);
66
+ return { name, pipeline: def?.pipeline || "", steps: def?.steps?.length || 0, savedAt: def?.savedAt };
67
+ })
68
+ .sort((a, b) => a.name.localeCompare(b.name));
69
+ }
70
+
71
+ export function deletePipeline(name) {
72
+ const file = pipelinePath(name);
73
+ if (!fs.existsSync(file)) return false;
74
+ fs.unlinkSync(file);
75
+ return true;
76
+ }
package/src/run.js CHANGED
@@ -14,10 +14,14 @@ import path from "path";
14
14
  import chalk from "chalk";
15
15
  import QRCode from "qrcode";
16
16
 
17
+ import { execInSandbox } from "./sandbox.js";
18
+ import { validateInput } from "./types.js";
19
+
17
20
  import {
18
21
  fetchTools,
19
22
  fetchToolSource,
20
23
  resolveToolSlug,
24
+ filterTools,
21
25
  configGet,
22
26
  API_BASE,
23
27
  readStdin,
@@ -30,18 +34,43 @@ import {
30
34
  promptInput,
31
35
  promptConfirm,
32
36
  promptNumber,
37
+ isInteractive,
38
+ PAGE_SIZE,
33
39
  } from "./ui.js";
34
40
 
41
+ // ─── Telemetry ────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Fire-and-forget run counter. Opt out with `nxb config set telemetry false`
45
+ * or NEXUSBLOOM_TELEMETRY=false. Only the slug is ever sent — never the input
46
+ * payload, the API key, or the result.
47
+ */
48
+ function trackRun(slug, source) {
49
+ const enabled = process.env.NEXUSBLOOM_TELEMETRY || configGet("telemetry");
50
+ if (String(enabled).toLowerCase() === "false") return;
51
+ const headers = { "Content-Type": "application/json" };
52
+ const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
53
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
54
+ // AbortSignal keeps the fire-and-forget request from holding the event
55
+ // loop open — an unbounded pending fetch delays process exit.
56
+ fetch(`${API_BASE}/api/track-run`, {
57
+ method: "POST",
58
+ headers,
59
+ body: JSON.stringify({ slug, source }),
60
+ signal: AbortSignal.timeout(1500),
61
+ }).catch(() => {});
62
+ }
63
+
35
64
  // ─── Remote execution ────────────────────────────────────────────────────────
36
65
 
37
- export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved }) {
66
+ export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved, quiet } = {}) {
38
67
  const key = apiKey || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
39
68
  if (!key) {
40
- if (!jsonMode) console.log(chalk.dim(" No API key — falling back to local execution..."));
41
- return runLocal(slug, body, { jsonMode, slugResolved });
69
+ if (!quiet) log(chalk.dim(" No API key — falling back to local execution..."));
70
+ return runLocal(slug, body, { jsonMode, slugResolved, quiet });
42
71
  }
43
72
 
44
- if (!jsonMode) console.log(chalk.dim(` Running ${slug}...`));
73
+ if (!quiet) log(chalk.dim(` Running ${slug} remotely...`));
45
74
 
46
75
  try {
47
76
  const res = await fetch(`${API_BASE}/api/run/${slug}`, {
@@ -51,74 +80,80 @@ export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved })
51
80
  });
52
81
 
53
82
  if (!res.ok && !res.headers.get("content-type")?.includes("json")) {
54
- if (!jsonMode) console.log(chalk.dim(" API not deployed — falling back to local"));
55
- return runLocal(slug, body, { jsonMode, slugResolved });
83
+ if (!quiet) log(chalk.dim(" API not deployed — falling back to local"));
84
+ return runLocal(slug, body, { jsonMode, slugResolved, quiet });
56
85
  }
57
86
 
58
87
  const data = await res.json();
59
88
 
60
- if (data.success) {
61
- if (!jsonMode) console.log(chalk.green(` ✓ ${slug} completed`));
62
- if (data.data && typeof data.data === "object" && data.data.svg) {
63
- await renderQRCode(body.data || body.text || JSON.stringify(body), slug);
64
- }
65
- if (jsonMode) return console.log(JSON.stringify({ success: true, data: data.data }, null, 2));
66
- console.log(chalk.green("\n Result:"));
67
- console.log(` ${JSON.stringify(data.data, null, 2)}`);
68
- return;
89
+ if (!data.success) {
90
+ return reportError(jsonMode, data.error || "Unknown error");
69
91
  }
70
92
 
71
- if (!jsonMode) console.log(chalk.red(` ✗ ${slug} failed`));
72
- if (jsonMode) return console.log(JSON.stringify({ success: false, error: data.error || "Unknown error" }, null, 2));
73
- console.error(chalk.red(`\n Error: ${data.error || "Unknown error"}`));
74
- process.exitCode = 1;
75
- } catch (err) {
76
- if (!jsonMode) console.log(chalk.dim(" API unreachable — falling back to local"));
77
- return runLocal(slug, body, { jsonMode, slugResolved });
93
+ const result = data.data;
94
+ if (!quiet) log(chalk.green(` ✓ ${slug} completed`));
95
+ if (result && typeof result === "object" && result.svg) {
96
+ await renderQRCode(result.data || result.text || JSON.stringify(result), slug);
97
+ }
98
+
99
+ trackRun(slug, "remote");
100
+
101
+ if (jsonMode) {
102
+ console.log(JSON.stringify({ success: true, data: result }, null, 2));
103
+ } else {
104
+ log(chalk.green("\n Result:"));
105
+ log(` ${JSON.stringify(result, null, 2)}`);
106
+ }
107
+ return { ok: true, result };
108
+ } catch {
109
+ if (!quiet) log(chalk.dim(" API unreachable — falling back to local"));
110
+ return runLocal(slug, body, { jsonMode, slugResolved, quiet });
78
111
  }
79
112
  }
80
113
 
81
114
  // ─── Local execution ────────────────────────────────────────────────────────
82
115
 
83
- export async function runLocal(slug, input, { jsonMode, slugResolved, source: src } = {}) {
116
+ export async function runLocal(slug, input, { jsonMode, slugResolved, quiet } = {}) {
84
117
  const toolInfo = await fetchToolSource(slug);
85
118
  if (!toolInfo) {
86
- if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${slug}" not found.` }));
87
- console.error(chalk.red(`Tool "${slug}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
88
- process.exitCode = 1;
89
- return;
119
+ return reportError(jsonMode, `Tool "${slug}" not found. Run \`nxb search ${slug}\` to see available tools.`);
90
120
  }
91
121
 
92
122
  const { manifest, coreLogicSource } = toolInfo;
123
+ // Where the code came from: "local" | "cache" | "api" | "supabase".
124
+ const src = toolInfo.source;
125
+
93
126
  if (!coreLogicSource) {
94
- if (jsonMode)
95
- return console.log(JSON.stringify({ error: "No v2 source available for local execution." }));
96
- console.error(chalk.yellow("No v2 source available for local execution. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
97
- process.exitCode = 1;
98
- return;
127
+ return reportError(
128
+ jsonMode,
129
+ `No runnable source published for "${slug}" — try \`nxb run ${slug}\` without --local.`
130
+ );
99
131
  }
100
132
 
101
- if (!jsonMode) console.log(chalk.dim(` Using ${slugResolved ? "local" : src || "cached"} tool: ${slug}`));
102
-
103
- // Strip imports/exports, wrap in IIFE that returns coreLogic
104
- const cleanSource = coreLogicSource
105
- .replace(/^import\s+.*?;?\s*$/gm, "")
106
- .replace(/^export\s+/gm, "");
107
- const wrappedSource = `(function() {\n${cleanSource}\nreturn coreLogic;\n})()`;
108
- const coreLogicFn = new Function("return " + wrappedSource)();
109
- const isAsync = /export\s+async\s+function\s+coreLogic/.test(coreLogicSource);
133
+ // All human-facing output goes to stderr so `--json` and `transform` piping
134
+ // get clean stdout.
135
+ if (!quiet) {
136
+ log(` Using ${slugResolved ? "local" : src || "cached"} tool: ${slug}`);
137
+ if (src && src !== "local") {
138
+ log(
139
+ chalk.yellow(
140
+ ` ⚠ About to run ${src === "cache" ? "previously downloaded" : "downloaded"} code for "${slug}" on this machine.`
141
+ )
142
+ );
143
+ }
144
+ }
110
145
 
111
146
  const schema = manifest.input_schema || {};
112
147
  const required = schema.required || [];
113
148
  const missing = required.filter((k) => input[k] === undefined || input[k] === null);
114
149
  if (missing.length > 0) {
115
- if (jsonMode) return console.log(JSON.stringify({ error: `Missing required fields: ${missing.join(", ")}` }));
116
- console.error(chalk.red(`Missing required fields: ${missing.join(", ")}`));
117
- process.exitCode = 1;
118
- return;
150
+ const message = `Missing required fields: ${missing.join(", ")}`;
151
+ // A quiet caller reports this itself; doing it here too would print the
152
+ // identical line twice in a row.
153
+ if (quiet) return { ok: false, error: message };
154
+ return reportError(jsonMode, message);
119
155
  }
120
156
 
121
- // Apply defaults
122
157
  if (schema.properties) {
123
158
  for (const [key, prop] of Object.entries(schema.properties)) {
124
159
  if (input[key] === undefined && prop.default !== undefined) {
@@ -127,36 +162,73 @@ export async function runLocal(slug, input, { jsonMode, slugResolved, source: sr
127
162
  }
128
163
  }
129
164
 
165
+ // Type-check against the declared schema before spending a process on it.
166
+ const typeProblems = validateInput(input, schema);
167
+ if (typeProblems.length) {
168
+ const detail = typeProblems
169
+ .map((p) => `${p.field}: ${p.error}${p.suggestion ? ` (${p.suggestion})` : ""}`)
170
+ .join("; ");
171
+ return reportError(jsonMode, `Invalid input for "${slug}" — ${detail}`);
172
+ }
173
+
130
174
  const timeoutMs = manifest.timeout_ms || 5000;
131
- let result;
175
+ if (!quiet) log(chalk.dim(` Executing ${slug} in sandbox (${timeoutMs}ms budget)...`));
132
176
 
133
- try {
134
- if (!jsonMode) console.log(chalk.dim(` Executing ${slug} (local)...`));
135
- if (isAsync) {
136
- result = await Promise.race([
137
- coreLogicFn(input),
138
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)),
139
- ]);
177
+ // Execution happens in a child process: a tool that loops forever, allocates
178
+ // without bound, or calls process.exit() cannot take the CLI with it.
179
+ const res = await execInSandbox({ source: coreLogicSource, input, timeoutMs });
180
+
181
+ if (!res.ok) {
182
+ return reportError(jsonMode, `Execution failed for "${slug}": ${res.error}`);
183
+ }
184
+
185
+ if (!quiet) log(chalk.green(` ✓ ${slug} completed`));
186
+ trackRun(slug, "cli");
187
+
188
+ // `quiet` means the caller renders the result itself. `transform` relies on
189
+ // this: it owns stdout, because its whole purpose is to be piped. Printing
190
+ // here as well would put the payload on stderr and stdout both, and a
191
+ // `transform | jq` would see it twice.
192
+ if (!quiet) {
193
+ if (jsonMode) {
194
+ console.log(JSON.stringify({ success: true, data: res.result }, null, 2));
140
195
  } else {
141
- result = coreLogicFn(input);
196
+ log(chalk.green("\n Result (local):"));
197
+ log(` ${JSON.stringify(res.result, null, 2)}`);
142
198
  }
143
- if (!jsonMode) console.log(chalk.green(` ✓ ${slug} completed`));
144
-
145
- // Fire-and-forget telemetry
146
- const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
147
- const trackHeaders = { "Content-Type": "application/json" };
148
- if (apiKey) trackHeaders["Authorization"] = `Bearer ${apiKey}`;
149
- fetch(`${API_BASE}/api/track-run`, { method: "POST", headers: trackHeaders, body: JSON.stringify({ slug, source: "cli" }) }).catch(() => {});
150
-
151
- if (jsonMode) return console.log(JSON.stringify({ success: true, data: result }, null, 2));
152
- console.log(chalk.green("\n Result (local):"));
153
- console.log(` ${JSON.stringify(result, null, 2)}`);
154
- } catch (err) {
155
- if (!jsonMode) console.log(chalk.red(` ✗ Execution error`));
156
- if (jsonMode) return console.log(JSON.stringify({ error: err.message }));
157
- console.error(chalk.red(`\n Local execution error: ${err.message}`));
158
- process.exitCode = 1;
159
199
  }
200
+ return { ok: true, result: res.result, durationMs: res.durationMs };
201
+ }
202
+
203
+ /**
204
+ * True when an error is inquirer's "the user pressed Ctrl+C" signal.
205
+ *
206
+ * @inquirer/core rejects with an `ExitPromptError` from inside a readline
207
+ * `SIGINT` handler. Because the CLI parses with `program.parse()` — which does
208
+ * not await the async action handlers — that rejection used to escape as an
209
+ * unhandled rejection and dump an inquirer stack trace over the user's
210
+ * terminal. The class is not exported from `@inquirer/prompts`, and importing
211
+ * `@inquirer/core` directly would tie us to a transitive dependency, so it is
212
+ * matched by name.
213
+ */
214
+ export function isExitPromptError(err) {
215
+ return Boolean(err) && err.name === "ExitPromptError";
216
+ }
217
+
218
+ /** Print an error on stderr (or as JSON) and set a non-zero exit code. */
219
+ export function reportError(jsonMode, message) {
220
+ if (jsonMode) {
221
+ console.log(JSON.stringify({ success: false, error: message }));
222
+ } else {
223
+ console.error(chalk.red(` ✗ ${message}`));
224
+ }
225
+ process.exitCode = 1;
226
+ return { ok: false, error: message };
227
+ }
228
+
229
+ /** Progress output belongs on stderr so stdout stays machine-readable. */
230
+ function log(line) {
231
+ console.error(line);
160
232
  }
161
233
 
162
234
  // ─── Input building ─────────────────────────────────────────────────────────
@@ -221,9 +293,9 @@ export async function buildInputBody(opts, args) {
221
293
  async function renderQRCode(data, slug) {
222
294
  try {
223
295
  const qr = await QRCode.toString(data, { type: "terminal", small: true });
224
- console.log(chalk.cyan("\n QR Code:"));
225
- console.log(qr);
226
- console.log(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
296
+ console.error(chalk.cyan("\n QR Code:"));
297
+ console.error(qr);
298
+ console.error(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
227
299
  } catch {
228
300
  /* silently skip */
229
301
  }
@@ -234,20 +306,72 @@ async function renderQRCode(data, slug) {
234
306
  /**
235
307
  * `nxb run` with no slug: interactively select a tool and its inputs,
236
308
  * then auto-build & execute the exact command.
309
+ *
310
+ * With `-s/--search`, the list is filtered first (search matches slug, name,
311
+ * description, category and abbreviation). Without a TTY there is nothing to
312
+ * prompt with, so the tool list is printed as a plain searchable dump and the
313
+ * command exits — it never hangs a pipe or a CI job.
237
314
  */
238
315
  export async function runInteractive(opts = {}) {
239
- const { json: jsonMode } = opts;
240
- const tools = await fetchTools({ offline: true });
316
+ const { json: jsonMode, search } = opts;
317
+ const tools = await fetchTools({ offline: false });
241
318
 
242
319
  if (!tools || tools.length === 0) {
243
- console.log(chalk.yellow(" No tools available. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
320
+ console.log(chalk.yellow(" No tools available. Check your connection, or run `nxb cache list`."));
321
+ return;
322
+ }
323
+
324
+ // ── Non-interactive: print the (optionally filtered) list and stop ──
325
+ if (!isInteractive()) {
326
+ const filtered = search ? filterTools(tools, search) : tools;
327
+
328
+ if (jsonMode) {
329
+ console.log(JSON.stringify({ tools: filtered, ...(search ? { query: search } : {}) }, null, 2));
330
+ return;
331
+ }
332
+
333
+ if (filtered.length === 0) {
334
+ console.log(chalk.yellow(` No tools matching "${search}".`));
335
+ return;
336
+ }
337
+
338
+ console.log(chalk.bold(`\n Available tools (${filtered.length})${search ? ` matching "${search}"` : ""}\n`));
339
+ for (const t of filtered) {
340
+ const desc = (t.short_description || t.description || "").slice(0, 60);
341
+ console.log(` ${(t.icon || "🔧")} ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim(desc)}`);
342
+ }
343
+ console.log(chalk.dim(`\n Run a tool with: nxb run <slug> [key=value ...]`));
344
+ console.log(chalk.dim(` Narrow the list: nxb run -s <query> · nxb search <query>`));
345
+ console.log();
244
346
  return;
245
347
  }
246
348
 
247
- renderHeader("NexusBloom — Interactive Runner", { subtitle: "Select a tool to run" });
349
+ // ── Interactive: optional search, then the tool picker ──
350
+ let pool = tools;
351
+ let query = search;
352
+
353
+ // Long catalogue + no explicit query → offer search before the picker,
354
+ // since scrolling a plain select through hundreds of tools is unusable.
355
+ if (!query && tools.length > PAGE_SIZE) {
356
+ const answer = await promptInput(
357
+ chalk.cyan(` Search ${tools.length} tools (blank to browse):`)
358
+ );
359
+ if (answer && answer.trim()) query = answer.trim();
360
+ }
361
+
362
+ if (query) {
363
+ pool = filterTools(tools, query);
364
+ if (pool.length === 0) {
365
+ console.log(chalk.yellow(` No tools matching "${query}".`));
366
+ console.log(chalk.dim(" Try a broader query, or run `nxb list` to browse everything."));
367
+ return;
368
+ }
369
+ renderHeader("Search Results", { subtitle: `${pool.length} matching "${query}"` });
370
+ } else {
371
+ renderHeader("NexusBloom — Interactive Runner", { subtitle: "Select a tool to run" });
372
+ }
248
373
 
249
- // 1. Select a tool
250
- const choices = tools.map((t) => ({
374
+ const choices = pool.map((t) => ({
251
375
  name: `${t.icon || "🔧"} ${t.name || t.slug} ${typeBadge(t)}`,
252
376
  value: t,
253
377
  description: t.short_description || t.description || "",
@@ -256,15 +380,30 @@ export async function runInteractive(opts = {}) {
256
380
  const selected = await promptSelect("Choose a tool:", choices, { pageSize: 15 });
257
381
  if (!selected) return;
258
382
 
259
- const slug = selected.slug;
260
- const resolved = await resolveToolSlug(slug, tools);
383
+ return await runInteractiveFor(selected.slug, { ...opts, json: jsonMode, tools });
384
+ }
385
+
386
+ /**
387
+ * Interactive configuration for a slug that is already known.
388
+ *
389
+ * Shared by the tool picker above and by `nxb run <slug>` with no input of any
390
+ * kind: a bare `nxb run text-statistics` has nothing to send, so asking for the
391
+ * fields is the useful behaviour rather than failing schema validation with
392
+ * "Missing required fields: text".
393
+ *
394
+ * Returns the executed slug, or undefined when the user backs out or the tool
395
+ * has no schema to configure.
396
+ */
397
+ export async function runInteractiveFor(slug, opts = {}) {
398
+ const { json: jsonMode, tools: knownTools } = opts;
399
+ const resolved = await resolveToolSlug(slug, knownTools);
261
400
 
262
401
  // 2. Fetch manifest / schema
263
402
  const toolInfo = await fetchToolSource(resolved);
264
403
  if (!toolInfo?.manifest) {
265
404
  console.log(chalk.yellow(` No schema available for "${resolved}". Falling back to raw input.`));
266
- await runCommand(resolved, {}, { ...opts, jsonMode });
267
- return;
405
+ await runCommand(resolved, {}, { ...opts, json: jsonMode });
406
+ return resolved;
268
407
  }
269
408
 
270
409
  const { manifest } = toolInfo;
@@ -278,12 +417,7 @@ export async function runInteractive(opts = {}) {
278
417
  const required = schema.required || [];
279
418
 
280
419
  for (const [key, prop] of Object.entries(props)) {
281
- const req = required.includes(key);
282
- const hint = prop.description || "";
283
- const suffix = req ? chalk.red(" *required") : chalk.dim(" (optional)");
284
- const label = `${key}${suffix}${hint ? chalk.dim(` — ${hint}`) : ""}`;
285
-
286
- const val = await promptField(key, prop);
420
+ const val = await promptField(key, prop, required.includes(key));
287
421
  if (val !== undefined) body[key] = val;
288
422
  }
289
423
 
@@ -315,42 +449,43 @@ export async function runInteractive(opts = {}) {
315
449
  if (!runIt) {
316
450
  console.log(chalk.dim(" Command copied to clipboard (if available):"));
317
451
  console.log(chalk.gray(` ${fullCommand}`));
318
- return;
452
+ return resolved;
319
453
  }
320
454
 
321
- // Execute directly via the same path
322
- await runCommand(resolved, body, { ...opts, jsonMode, resolved });
455
+ await runCommand(resolved, body, { ...opts, json: jsonMode, resolved });
456
+ return resolved;
323
457
  }
324
458
 
325
459
  /** Prompt for a single schema field based on its type. */
326
- async function promptField(key, prop) {
460
+ async function promptField(key, prop, required = false) {
327
461
  const { type, enum: enumVals, default: def } = prop;
462
+ const label = `${key}${required ? chalk.red(" *required") : chalk.dim(" (optional)")}`;
328
463
 
329
464
  // Enum → select
330
465
  if (enumVals && Array.isArray(enumVals) && enumVals.length > 0) {
331
466
  const choices = enumVals.map((v) => ({
332
467
  name: String(v),
333
468
  value: v,
334
- ...(v === def && { checked: true }),
469
+ ...(v === def ? { checked: true } : {}),
335
470
  }));
336
471
  if (def !== undefined) choices.unshift({ name: chalk.gray("(default)"), value: def });
337
- return promptSelect(`${key}:`, choices);
472
+ return promptSelect(`${label}:`, choices);
338
473
  }
339
474
 
340
475
  // Number / integer
341
476
  if (type === "number" || type === "integer") {
342
- return promptNumber(`${key}:`, { min: prop.minimum, max: prop.maximum, default: def });
477
+ return promptNumber(`${label}:`, { min: prop.minimum, max: prop.maximum, default: def });
343
478
  }
344
479
 
345
480
  // Boolean
346
481
  if (type === "boolean") {
347
- return promptConfirm(`${key}:`, { default: def ?? true });
482
+ return promptConfirm(`${label}:`, { default: def ?? true });
348
483
  }
349
484
 
350
485
  // Object / array → JSON string input
351
486
  if (type === "object" || type === "array") {
352
- const defaultStr = def !== undefined ? JSON.stringify(def) : [];
353
- const raw = await promptInput(`${key}: (JSON)`, { defaultValue: defaultStr });
487
+ const defaultStr = def !== undefined ? JSON.stringify(def) : "";
488
+ const raw = await promptInput(`${label}: (JSON)`, { defaultValue: defaultStr });
354
489
  if (!raw) return def;
355
490
  try {
356
491
  return JSON.parse(raw);
@@ -361,7 +496,7 @@ async function promptField(key, prop) {
361
496
  }
362
497
 
363
498
  // Default: string input
364
- return promptInput(`${key}:`, { defaultValue: def });
499
+ return promptInput(`${label}:`, { defaultValue: def });
365
500
  }
366
501
 
367
502
  /** Execute a resolved slug+body, choosing local vs remote. */
@@ -370,8 +505,7 @@ async function runCommand(slug, body, opts = {}) {
370
505
  const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
371
506
 
372
507
  if (local || !apiKey) {
373
- await runLocal(slug, body, { jsonMode, slugResolved: resolved });
374
- return;
508
+ return runLocal(slug, body, { jsonMode, slugResolved: resolved });
375
509
  }
376
- await runRemote(slug, body, { apiKey, jsonMode, slugResolved: resolved });
510
+ return runRemote(slug, body, { apiKey, jsonMode, slugResolved: resolved });
377
511
  }
package/src/runner.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * runner.js — Sandbox child process for a single tool execution.
3
+ *
4
+ * Never run directly. `sandbox.js` forks this file, sends one `run` message,
5
+ * and hard-kills the process when the timeout expires.
6
+ *
7
+ * Living in a separate process is what makes a hostile tool survivable:
8
+ * - an infinite loop can be killed (it cannot be interrupted in-process)
9
+ * - an OOM kills only this child, not the CLI
10
+ * - a crash or process.exit() in the tool does not take the CLI down
11
+ *
12
+ * This is isolation, not a security sandbox: the child runs with the same
13
+ * user and filesystem permissions as the CLI. Treat downloaded tool source as
14
+ * trusted-but-unverified, and prefer remote execution for untrusted tools.
15
+ */
16
+
17
+ import { compileCoreLogic } from "./compile.js";
18
+
19
+ /** Serialise a value for IPC, tolerating cycles and non-JSON values. */
20
+ function safe(value, depth = 0) {
21
+ if (value === undefined) return { __undefined: true };
22
+ if (value === null) return null;
23
+ const t = typeof value;
24
+ if (t === "number") {
25
+ if (Number.isNaN(value)) return { __nan: true };
26
+ if (!Number.isFinite(value)) return { __inf: value > 0 ? 1 : -1 };
27
+ return value;
28
+ }
29
+ if (t === "string" || t === "boolean") return value;
30
+ if (t === "bigint") return { __bigint: value.toString() };
31
+ if (t === "function") return { __function: true };
32
+ if (t === "symbol") return { __symbol: String(value) };
33
+ if (depth > 12) return null;
34
+ if (Array.isArray(value)) return value.map((v) => safe(v, depth + 1));
35
+ if (value instanceof Date) return { __date: value.toISOString() };
36
+ if (value instanceof Error) return { __error: value.message };
37
+ if (typeof value.toJSON === "function") {
38
+ try {
39
+ return safe(value.toJSON(), depth + 1);
40
+ } catch {
41
+ /* fall through to plain object handling */
42
+ }
43
+ }
44
+ if (t === "object") {
45
+ const out = {};
46
+ for (const [k, v] of Object.entries(value)) {
47
+ out[k] = safe(v, depth + 1);
48
+ }
49
+ return out;
50
+ }
51
+ return String(value);
52
+ }
53
+
54
+ function reply(message) {
55
+ try {
56
+ process.send?.(message);
57
+ } catch {
58
+ /* parent went away — nothing useful to do */
59
+ }
60
+ }
61
+
62
+ process.on("message", async (msg) => {
63
+ if (!msg || msg.type !== "run") return;
64
+
65
+ try {
66
+ const coreLogic = compileCoreLogic(msg.source);
67
+ const result = await coreLogic(msg.input);
68
+ reply({ type: "result", ok: true, result: safe(result) });
69
+ } catch (err) {
70
+ reply({ type: "result", ok: false, error: err?.message || String(err) });
71
+ } finally {
72
+ // Exit explicitly: a tool may have left timers/handles open, and we do
73
+ // not want to wait for them — the parent's timeout is the real deadline.
74
+ process.exit(0);
75
+ }
76
+ });
77
+
78
+ // If the parent dies, do not linger.
79
+ process.on("disconnect", () => process.exit(0));
80
+
81
+ // An unhandled rejection or fatal error must still report back, not vanish.
82
+ process.on("uncaughtException", (err) => {
83
+ reply({ type: "result", ok: false, error: err?.message || String(err) });
84
+ process.exit(0);
85
+ });
86
+ process.on("unhandledRejection", (err) => {
87
+ reply({ type: "result", ok: false, error: err?.message || String(err) });
88
+ process.exit(0);
89
+ });