@nexusbloom/cli 0.3.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sandbox.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * sandbox.js — Execute tool source in a disposable child process.
3
+ *
4
+ * `Promise.race` cannot stop an infinite loop in the current process: the
5
+ * event loop never gets a turn, so the timeout never fires. A child process
6
+ * can simply be killed. This module is therefore the safety floor for any
7
+ * execution of downloaded code — single tools and pipelines alike.
8
+ *
9
+ * Guarantees:
10
+ * - the deadline is enforced with SIGKILL, which a tool cannot trap
11
+ * - the deadline starts at spawn, so a loop in the module top level (not
12
+ * just in coreLogic) is still killed
13
+ * - the child cannot write to the CLI's stdout/stderr
14
+ * - a crash, OOM, or `process.exit()` inside the tool does not kill the CLI
15
+ */
16
+
17
+ import { fork } from "node:child_process";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const RUNNER_PATH = path.join(__dirname, "runner.js");
23
+
24
+ /** Default V8 heap ceiling for tool code, in MB. */
25
+ const DEFAULT_MEMORY_MB = 512;
26
+
27
+ /** Node's own boot time is not counted against the tool's timeout budget. */
28
+ const SPAWN_GRACE_MS = 750;
29
+
30
+ let runnerMissing = false;
31
+
32
+ /**
33
+ * Run a tool's source with the given input.
34
+ *
35
+ * @param {object} opts
36
+ * @param {string} opts.source v2 source text
37
+ * @param {object} opts.input input object passed to coreLogic
38
+ * @param {number} [opts.timeoutMs] wall-clock budget for the whole execution
39
+ * @param {number} [opts.memoryMb] V8 heap cap for the child
40
+ * @returns {Promise<{ok: boolean, result?: any, error?: string, timedOut?: boolean,
41
+ * durationMs: number, stderr?: string}>}
42
+ */
43
+ export function execInSandbox({
44
+ source,
45
+ input = {},
46
+ timeoutMs = 5000,
47
+ memoryMb = DEFAULT_MEMORY_MB,
48
+ } = {}) {
49
+ return new Promise((resolve) => {
50
+ const startedAt = Date.now();
51
+
52
+ if (runnerMissing) {
53
+ return resolve(fail("Sandbox runner is unavailable (runner.js not found).", startedAt));
54
+ }
55
+ if (typeof source !== "string" || !source.trim()) {
56
+ return resolve(fail("No tool source to execute.", startedAt));
57
+ }
58
+
59
+ const budgetMs = Math.max(1, timeoutMs) + SPAWN_GRACE_MS;
60
+ let child;
61
+ let settled = false;
62
+ let timer;
63
+
64
+ const done = (payload) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ // Best-effort teardown; a tool may already be gone.
69
+ try {
70
+ if (child && !child.killed) child.kill("SIGKILL");
71
+ } catch {
72
+ /* already dead */
73
+ }
74
+ resolve({ durationMs: Date.now() - startedAt, ...payload });
75
+ };
76
+
77
+ try {
78
+ child = fork(RUNNER_PATH, [], {
79
+ cwd: process.cwd(),
80
+ env: { ...process.env, NXB_SANDBOX: "1" },
81
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
82
+ execArgv: [`--max-old-space-size=${memoryMb}`],
83
+ serialization: "advanced",
84
+ });
85
+ } catch (err) {
86
+ return done({ ok: false, error: `Could not start sandbox: ${err.message}` });
87
+ }
88
+
89
+ // Tool stdout/stderr is captured, never forwarded — a tool must not be
90
+ // able to corrupt the CLI's output or the user's terminal.
91
+ let stderr = "";
92
+ child.stderr?.on("data", (d) => {
93
+ if (stderr.length < 2000) stderr += String(d);
94
+ });
95
+ child.stdout?.resume();
96
+
97
+ child.on("message", (msg) => {
98
+ if (!msg || msg.type !== "result") return;
99
+ if (msg.ok) done({ ok: true, result: revive(msg.result) });
100
+ else done({ ok: false, error: msg.error || "Tool execution failed.", stderr });
101
+ });
102
+
103
+ // The deadline is armed immediately — before we know whether the source
104
+ // compiles — so a top-level infinite loop is still bounded.
105
+ timer = setTimeout(() => {
106
+ done({
107
+ ok: false,
108
+ timedOut: true,
109
+ error: `Timed out after ${timeoutMs}ms (tool process killed).`,
110
+ stderr,
111
+ });
112
+ }, budgetMs);
113
+
114
+ child.on("error", (err) => done({ ok: false, error: `Sandbox error: ${err.message}`, stderr }));
115
+
116
+ child.on("exit", (code, signal) => {
117
+ // Exiting without a result means the tool killed itself, or the child
118
+ // was OOM-killed. Distinguish the common case for a clearer message.
119
+ const reason =
120
+ stderr.includes("JavaScript heap out of memory") || stderr.includes("Allocation failed")
121
+ ? "Tool ran out of memory."
122
+ : signal
123
+ ? `Tool process terminated (${signal}).`
124
+ : `Tool process exited unexpectedly (code ${code}).`;
125
+ done({ ok: false, error: reason, stderr });
126
+ });
127
+
128
+ try {
129
+ child.send({ type: "run", source, input });
130
+ } catch (err) {
131
+ done({ ok: false, error: `Could not send input to sandbox: ${err.message}` });
132
+ }
133
+ });
134
+ }
135
+
136
+ /** Best-effort fallback for a CLI install with a broken/partial `src/`. */
137
+ function fail(message, startedAt) {
138
+ return Promise.resolve({ ok: false, error: message, durationMs: Date.now() - startedAt });
139
+ }
140
+
141
+ /** Re-expand the sentinels runner.js uses for non-JSON values. */
142
+ function revive(value) {
143
+ if (value === null || typeof value !== "object") return value;
144
+ if (Array.isArray(value)) return value.map(revive);
145
+ if (value.__undefined) return undefined;
146
+ if (value.__nan) return NaN;
147
+ if (value.__inf) return value.__inf > 0 ? Infinity : -Infinity;
148
+ if (value.__bigint) return BigInt(value.__bigint);
149
+ if (value.__function) return undefined;
150
+ if (value.__symbol) return Symbol(value.__symbol);
151
+ if (value.__date) return new Date(value.__date);
152
+ if (value.__error) return new Error(value.__error);
153
+ const out = {};
154
+ for (const [k, v] of Object.entries(value)) out[k] = revive(v);
155
+ return out;
156
+ }
157
+
158
+ /** Verify the runner exists once, so failures explain themselves. */
159
+ export async function sandboxAvailable() {
160
+ if (runnerMissing) return false;
161
+ const fs = await import("node:fs");
162
+ if (!fs.existsSync(RUNNER_PATH)) {
163
+ runnerMissing = true;
164
+ return false;
165
+ }
166
+ return true;
167
+ }
package/src/tools.js CHANGED
@@ -12,10 +12,12 @@
12
12
  * 1. Local filesystem → 2. cache (`tools/<slug>.json`) → 3. API/Supabase
13
13
  *
14
14
  * Exports:
15
- * loadConfig / saveConfig / configGet / configSet
15
+ * loadConfig / saveConfig / configGet / configSet / redactedConfig
16
16
  * fetchTools() — resolves list (cache-first on network failure)
17
17
  * refreshTools() — force a fresh fetch + persist to cache
18
18
  * fetchToolSource(slug) — { manifest, coreLogicSource }
19
+ * filterTools / rankTools — shared search used by list / search / abbr / run
20
+ * isValidSlug / normalizeSlug
19
21
  * scanLocalTools()
20
22
  * findLocalTool(slug)
21
23
  * generateAbbreviations(slugs)
@@ -26,33 +28,35 @@
26
28
 
27
29
  import fs from "fs";
28
30
  import path from "path";
29
- import os from "os";
30
- import { createRequire } from "module";
31
- import { fileURLToPath } from "url";
31
+
32
+ import { parseJsonish } from "./jsonish.js";
32
33
 
33
34
  import {
34
- CONFIG_DIR,
35
35
  CONFIG_PATH,
36
36
  ensureConfigDir,
37
37
  ensureCacheDir,
38
38
  TOOLS_CACHE_DIR,
39
39
  TOOLS_LIST_CACHE_PATH,
40
40
  } from "./paths.js";
41
- import chalk from "chalk";
42
-
43
- const require = createRequire(import.meta.url);
44
41
 
45
42
  // ─── Constants ────────────────────────────────────────────────────────────────
46
43
 
47
44
  export const API_BASE = process.env.NEXUSBLOOM_API_URL || "https://www.nexusbloom.dev";
48
45
 
49
- const SUPABASE_URL_DEFAULT = "https://dhxaqatmuprehrkszxzs.supabase.co";
50
- const SUPABASE_ANON_KEY_DEFAULT =
51
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRoeGFxYXRtdXByZWhya3N6eHpzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxNjIzNTQsImV4cCI6MjA4ODczODM1NH0.pq7DxW66ETQt6QlIAu2WiLO08tfA7JHQVPd2SvvA3sM";
46
+ /**
47
+ * Supabase fallback is opt-in. No key is bundled with the CLI: a shipped
48
+ * anon key in a public npm package is readable by anyone, and rotating it
49
+ * would break every installed copy. Set NEXUSBLOOM_SUPABASE_ANON_KEY (or run
50
+ * `nxb config set supabaseKey ...`) to enable the offline fallback.
51
+ */
52
+ export const SUPABASE_URL_DEFAULT = "https://dhxaqatmuprehrkszxzs.supabase.co";
52
53
 
53
54
  // Cache freshness threshold (30 min)
54
55
  const CACHE_TTL_MS = 30 * 60 * 1000;
55
56
 
57
+ // Config keys whose values must never be printed or committed.
58
+ const SECRET_CONFIG_KEYS = new Set(["key", "apikey", "apiKey", "supabaseKey", "token"]);
59
+
56
60
  // Common local-tool search roots (relative to CWD)
57
61
  const LOCAL_TOOL_DIRS = [
58
62
  "src/tools",
@@ -60,6 +64,24 @@ const LOCAL_TOOL_DIRS = [
60
64
  "apps/main-site/frontend/src/tools",
61
65
  ];
62
66
 
67
+ // ─── Slug validation ─────────────────────────────────────────────────────────
68
+
69
+ // Slugs are used as filenames (cache keys) and inside PostgREST query strings,
70
+ // so they must be a strict, closed character set — no path separators, no
71
+ // commas/parens that would let a caller rewrite the filter expression.
72
+ const SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
73
+
74
+ export function isValidSlug(slug) {
75
+ return typeof slug === "string" && SLUG_RE.test(slug);
76
+ }
77
+
78
+ /** Normalise CLI input into a safe slug, or return null if it is not one. */
79
+ export function normalizeSlug(slug) {
80
+ if (typeof slug !== "string") return null;
81
+ const s = slug.trim();
82
+ return SLUG_RE.test(s) ? s : null;
83
+ }
84
+
63
85
  // ─── Config helpers ─────────────────────────────────────────────────────────
64
86
 
65
87
  export function loadConfig() {
@@ -75,7 +97,14 @@ export function loadConfig() {
75
97
 
76
98
  export function saveConfig(store = {}) {
77
99
  ensureConfigDir();
78
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2), "utf-8");
100
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 });
101
+ // writeFileSync only applies `mode` when it creates the file — enforce it
102
+ // on every write so a pre-existing 0644 file gets tightened.
103
+ try {
104
+ fs.chmodSync(CONFIG_PATH, 0o600);
105
+ } catch {
106
+ /* best effort (e.g. non-POSIX fs) */
107
+ }
79
108
  }
80
109
 
81
110
  export function configGet(key) {
@@ -89,21 +118,62 @@ export function configSet(key, value) {
89
118
  saveConfig(store);
90
119
  }
91
120
 
121
+ /**
122
+ * Remove a config key. Without this there is no way to clear a stored API key
123
+ * from the CLI — `set` refuses an empty value, and hand-editing a 0600 file
124
+ * with a live secret in it is not a reasonable thing to ask of a user.
125
+ * @returns {boolean} true if the key was present
126
+ */
127
+ export function configUnset(key) {
128
+ const store = loadConfig();
129
+ if (!(key in store)) return false;
130
+ delete store[key];
131
+ saveConfig(store);
132
+ return true;
133
+ }
134
+
135
+ /** True when a config key holds a secret that must be redacted on display. */
136
+ export function isSecretKey(key) {
137
+ return SECRET_CONFIG_KEYS.has(key) || /key|token|secret|password/i.test(key);
138
+ }
139
+
140
+ /** Config with secret values masked — safe to print or log. */
141
+ export function redactedConfig(store = loadConfig()) {
142
+ const out = {};
143
+ for (const [k, v] of Object.entries(store || {})) {
144
+ const s = v == null ? "" : String(v);
145
+ out[k] = isSecretKey(k)
146
+ ? s.length <= 8
147
+ ? "********"
148
+ : `${s.slice(0, 4)}…${s.slice(-4)}`
149
+ : v;
150
+ }
151
+ return out;
152
+ }
153
+
92
154
  // ─── Supabase helpers ───────────────────────────────────────────────────────
93
155
 
94
156
  export function getSupabaseKey() {
95
- return process.env.NEXUSBLOOM_SUPABASE_ANON_KEY || configGet("supabaseKey") || SUPABASE_ANON_KEY_DEFAULT;
157
+ return process.env.NEXUSBLOOM_SUPABASE_ANON_KEY || configGet("supabaseKey") || null;
96
158
  }
97
159
 
98
160
  export function getSupabaseUrl() {
99
- return process.env.NEXUSBLOOM_SUPABASE_URL || SUPABASE_URL_DEFAULT;
161
+ return process.env.NEXUSBLOOM_SUPABASE_URL || configGet("supabaseUrl") || SUPABASE_URL_DEFAULT;
100
162
  }
101
163
 
102
164
  export function supabaseHeaders(extra = {}) {
103
165
  const key = getSupabaseKey();
166
+ if (!key) return {};
104
167
  return { apikey: key, Authorization: `Bearer ${key}`, ...extra };
105
168
  }
106
169
 
170
+ /** Human-readable reason the Supabase fallback is unavailable (or null if it is). */
171
+ export function supabaseUnavailableReason() {
172
+ if (!getSupabaseKey())
173
+ return "Set NEXUSBLOOM_SUPABASE_ANON_KEY or run `nxb config set supabaseKey <key>`.";
174
+ return null;
175
+ }
176
+
107
177
  // ─── Tools list cache (tools-list.json) ──────────────────────────────────────
108
178
 
109
179
  /**
@@ -186,6 +256,7 @@ function normalizeTool(t) {
186
256
  * Normalises to the same shape the API returns.
187
257
  */
188
258
  async function fetchToolsFromSupabase() {
259
+ if (!getSupabaseKey()) return null; // no key configured — skip the round-trip
189
260
  const headers = supabaseHeaders();
190
261
  const supabaseUrl = getSupabaseUrl();
191
262
  try {
@@ -290,6 +361,71 @@ function mergeLocalTools(remoteTools) {
290
361
  return [...local, ...deduped];
291
362
  }
292
363
 
364
+ // ─── Search ──────────────────────────────────────────────────────────────────
365
+
366
+ /**
367
+ * Filter a tool list by a free-text query.
368
+ *
369
+ * Case-insensitive across slug, name, description, category, type and tags.
370
+ * A multi-word query ("json format") matches when *every* word matches at
371
+ * least one field, so extra words narrow the list rather than emptying it.
372
+ *
373
+ * filterTools(tools, "ssl") → tools whose slug/name/desc mentions ssl
374
+ * filterTools(tools, "json pd") → tools matching both "json" and "pd"
375
+ */
376
+ export function filterTools(tools, query) {
377
+ const list = Array.isArray(tools) ? tools : [];
378
+ const q = (query || "").trim().toLowerCase();
379
+ if (!q) return list;
380
+
381
+ const words = q.split(/\s+/).filter(Boolean);
382
+ const haystack = (t) =>
383
+ [
384
+ t.slug,
385
+ t.name,
386
+ t.short_description,
387
+ t.description,
388
+ t.category,
389
+ t.type,
390
+ t.runtime,
391
+ ...(Array.isArray(t.tags) ? t.tags : []),
392
+ ]
393
+ .filter(Boolean)
394
+ .join(" ")
395
+ .toLowerCase();
396
+
397
+ return list.filter((t) => {
398
+ const hay = haystack(t);
399
+ return words.every((w) => hay.includes(w));
400
+ });
401
+ }
402
+
403
+ /**
404
+ * Score + sort tools for a query so the closest match is first.
405
+ * Exact slug > slug prefix > slug contains > name/description contains.
406
+ */
407
+ export function rankTools(tools, query) {
408
+ const q = (query || "").trim().toLowerCase();
409
+ if (!q) return Array.isArray(tools) ? tools : [];
410
+
411
+ const score = (t) => {
412
+ const slug = (t.slug || "").toLowerCase();
413
+ const name = (t.name || "").toLowerCase();
414
+ if (slug === q) return 0;
415
+ if (name === q) return 1;
416
+ if (slug.startsWith(q)) return 2;
417
+ if (slug.includes(q)) return 3;
418
+ if (name.startsWith(q)) return 4;
419
+ if (name.includes(q)) return 5;
420
+ return 6;
421
+ };
422
+
423
+ return filterTools(tools, q)
424
+ .map((t, i) => ({ t, i, s: score(t) }))
425
+ .sort((a, b) => a.s - b.s || a.i - b.i)
426
+ .map(({ t }) => t);
427
+ }
428
+
293
429
  // ─── Single tool source (for --local execution) ──────────────────────────────
294
430
 
295
431
  /**
@@ -297,7 +433,10 @@ function mergeLocalTools(remoteTools) {
297
433
  *
298
434
  * 1. Local filesystem → 2. per-tool cache → 3. API → 4. Supabase
299
435
  */
300
- export async function fetchToolSource(slug) {
436
+ export async function fetchToolSource(rawSlug) {
437
+ const slug = normalizeSlug(rawSlug);
438
+ if (!slug) return null;
439
+
301
440
  ensureCacheDir();
302
441
  const cacheFile = path.join(TOOLS_CACHE_DIR, `${slug}.json`);
303
442
 
@@ -305,7 +444,7 @@ export async function fetchToolSource(slug) {
305
444
  const localPath = findLocalTool(slug);
306
445
  if (localPath) {
307
446
  const source = fs.readFileSync(localPath, "utf-8");
308
- const manifest = extractManifest(source);
447
+ const manifest = extractManifest(source, { trusted: true });
309
448
  if (manifest) {
310
449
  // Refresh cache from local source
311
450
  fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource: source, cachedAt: new Date().toISOString() }), "utf-8");
@@ -361,6 +500,7 @@ export async function fetchToolSource(slug) {
361
500
  * Returns { manifest, coreLogicSource } or null.
362
501
  */
363
502
  export async function fetchToolFromSupabase(slug) {
503
+ if (!getSupabaseKey()) return null;
364
504
  const headers = supabaseHeaders();
365
505
  const supabaseUrl = getSupabaseUrl();
366
506
  try {
@@ -404,22 +544,33 @@ export async function fetchToolFromSupabase(slug) {
404
544
  // ─── Manifest extraction ────────────────────────────────────────────────────
405
545
 
406
546
  /**
407
- * Extract a MANIFEST object from v2 source text.
408
- * Handles JS object literals (unquoted keys, single quotes) gracefully.
547
+ * Extract the MANIFEST object literal from v2 source text.
548
+ *
549
+ * The literal is parsed, never evaluated: manifest text fetched from the API or
550
+ * Supabase is untrusted input, and `new Function` on it would execute it.
551
+ * `trusted: true` is passed only for files read off the developer's own disk.
552
+ *
553
+ * @param {string} source
554
+ * @param {{trusted?: boolean}} [opts]
555
+ * @returns {object|null}
409
556
  */
410
- export function extractManifest(source) {
557
+ export function extractManifest(source, { trusted = false } = {}) {
411
558
  if (!source) return null;
412
559
  const match = source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});/);
413
560
  if (!match) return null;
561
+
562
+ const parsed = parseJsonish(match[1]);
563
+ if (parsed && typeof parsed === "object") return parsed;
564
+ if (!trusted) return null;
565
+
566
+ // Developer-owned file that used syntax the literal parser does not accept.
567
+ // Only reached for local src/tools — never for network content.
414
568
  try {
415
569
  // eslint-disable-next-line no-new-func
416
- return new Function("return " + match[1])();
570
+ const value = new Function(`return ${match[1]}`)();
571
+ return value && typeof value === "object" ? value : null;
417
572
  } catch {
418
- try {
419
- return JSON.parse(match[1].replace(/(\w+):/g, '"$1":').replace(/'/g, '"'));
420
- } catch {
421
- return null;
422
- }
573
+ return null;
423
574
  }
424
575
  }
425
576
 
@@ -472,7 +623,7 @@ export function scanLocalTools() {
472
623
  if (!fs.existsSync(v2Path)) continue;
473
624
  try {
474
625
  const source = fs.readFileSync(v2Path, "utf-8");
475
- const manifest = extractManifest(source);
626
+ const manifest = extractManifest(source, { trusted: true });
476
627
  if (!manifest) continue;
477
628
  tools.push({
478
629
  slug: manifest.slug || entry.name,
package/src/types.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * types.js — Type inference and the compatibility rules for pipeline wiring.
3
+ *
4
+ * The rule the whole pipeline design rests on: conversions that cannot lose
5
+ * information happen silently, conversions that can lose information require an
6
+ * explicit transform. That is what "keep types in mind" means in practice —
7
+ * `$max → count` (integer → integer) just works, `$text → count` does not.
8
+ */
9
+
10
+ /** Runtime type of a concrete value. */
11
+ export function inferType(value) {
12
+ if (value === null || value === undefined) return "null";
13
+ if (Array.isArray(value)) return "array";
14
+ const t = typeof value;
15
+ if (t === "number") return Number.isInteger(value) ? "integer" : "number";
16
+ if (t === "string") return "string";
17
+ if (t === "boolean") return "boolean";
18
+ if (t === "object") return "object";
19
+ return t;
20
+ }
21
+
22
+ /** Normalise a JSON-schema `type` field into a list of type names. */
23
+ export function schemaTypes(prop) {
24
+ if (!prop || typeof prop !== "object") return [];
25
+ const t = prop.type;
26
+ if (Array.isArray(t)) return t.filter((x) => typeof x === "string");
27
+ if (typeof t === "string") return [t];
28
+ if (t === undefined && prop.properties) return ["object"];
29
+ if (t === undefined && prop.items) return ["array"];
30
+ return [];
31
+ }
32
+
33
+ /** Human label for display, e.g. `integer`, `string`, `string|number`. */
34
+ export function typeLabel(types) {
35
+ const list = Array.isArray(types) ? types : [types];
36
+ if (!list.length) return "any";
37
+ return list.join("|");
38
+ }
39
+
40
+ /** Ordered by widening: an integer satisfies a number target. */
41
+ const WIDENS = { integer: ["number"] };
42
+
43
+ /**
44
+ * Decide whether a value of `fromType` may feed a schema field accepting
45
+ * `toTypes`, ignoring transforms (the caller applies those first).
46
+ *
47
+ * @returns {{level: "ok"|"lossy"|"cast"|"invalid", note: string, suggestion?: string}}
48
+ */
49
+ export function compatibility(fromType, toTypes) {
50
+ const targets = Array.isArray(toTypes) ? toTypes : [toTypes];
51
+ const from = Array.isArray(fromType) ? fromType[0] : fromType;
52
+
53
+ if (!targets.length) return { level: "ok", note: "untyped target" };
54
+ if (!from || from === "null" || from === "unknown") {
55
+ return { level: "cast", note: "source has no fixed type", suggestion: "default()" };
56
+ }
57
+ if (targets.includes(from)) return { level: "ok", note: "exact match" };
58
+
59
+ // Lossless widening: integer into number.
60
+ for (const w of WIDENS[from] || []) {
61
+ if (targets.includes(w)) return { level: "ok", note: `${from} widens to ${w}` };
62
+ }
63
+
64
+ // Lossy narrowing: number into integer.
65
+ if (from === "number" && targets.includes("integer")) {
66
+ return { level: "lossy", note: "may lose the fraction", suggestion: "int()" };
67
+ }
68
+
69
+ // Everything renders to a string, and nothing is lost in the text.
70
+ if (targets.includes("string") && ["number", "integer", "boolean"].includes(from)) {
71
+ return { level: "ok", note: `rendered as string` };
72
+ }
73
+
74
+ // String into a typed field needs a deliberate cast.
75
+ if (from === "string" && targets.some((t) => ["integer", "number"].includes(t))) {
76
+ return { level: "cast", note: "string is not a number", suggestion: targets.includes("integer") ? "int()" : "float()" };
77
+ }
78
+ if (from === "string" && targets.includes("boolean")) {
79
+ return { level: "cast", note: "string is not a boolean", suggestion: "bool()" };
80
+ }
81
+
82
+ // Collections must be reduced before they can be scalars.
83
+ if (from === "array" && targets.some((t) => t !== "array")) {
84
+ return { level: "cast", note: "array used as a single value", suggestion: "first()" };
85
+ }
86
+ if (from === "object" && targets.some((t) => t !== "object")) {
87
+ return { level: "cast", note: "object used as a single value", suggestion: "pick(field)" };
88
+ }
89
+
90
+ return {
91
+ level: "invalid",
92
+ note: `${from} is not accepted by ${typeLabel(targets)}`,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Runtime check of a value against a schema field. Enums are enforced here,
98
+ * because a declared enum is the one place a "compatible" type can still be
99
+ * wrong at runtime.
100
+ *
101
+ * @returns {{ok: boolean, error?: string, suggestion?: string}}
102
+ */
103
+ export function validateValue(value, prop) {
104
+ const targets = schemaTypes(prop);
105
+ const enumValues = Array.isArray(prop?.enum) ? prop.enum : null;
106
+
107
+ // Absent is not the same as null: report it as a missing value so the fix
108
+ // points at supplying one rather than at a type mismatch.
109
+ if (value === undefined) {
110
+ return { ok: false, error: "no value was supplied", suggestion: "add |default(...)" };
111
+ }
112
+
113
+ const actual = inferType(value);
114
+
115
+ if (value === null) {
116
+ // JSON-Schema "null" only when explicitly allowed; otherwise treated as missing.
117
+ if (targets.includes("null")) return { ok: true };
118
+ return { ok: false, error: `expected ${typeLabel(targets)}, got null`, suggestion: "default()" };
119
+ }
120
+
121
+ if (enumValues && !enumValues.includes(value)) {
122
+ return {
123
+ ok: false,
124
+ error: `${JSON.stringify(value)} is not one of: ${enumValues.map((v) => JSON.stringify(v)).join(", ")}`,
125
+ };
126
+ }
127
+
128
+ if (!targets.length) return { ok: true };
129
+
130
+ const widened = actual === "integer" && targets.includes("number");
131
+ if (targets.includes(actual) || widened) return { ok: true };
132
+
133
+ const hint =
134
+ actual === "string" && (targets.includes("integer") || targets.includes("number"))
135
+ ? targets.includes("integer") ? "add |int()" : "add |float()"
136
+ : actual === "array" ? "add |first()"
137
+ : actual === "number" && targets.includes("integer") ? "add |int()"
138
+ : null;
139
+
140
+ return {
141
+ ok: false,
142
+ error: `expected ${typeLabel(targets)}, got ${actual}`,
143
+ suggestion: hint,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Validate a whole input object against a tool's `input_schema`.
149
+ * Missing required fields are reported too, so a pipeline can fail before it
150
+ * executes rather than on step 3.
151
+ *
152
+ * @returns {Array<{field: string, error: string, suggestion?: string}>}
153
+ */
154
+ export function validateInput(input, inputSchema) {
155
+ const problems = [];
156
+ if (!inputSchema || typeof inputSchema !== "object") return problems;
157
+
158
+ const props = inputSchema.properties || {};
159
+ const required = Array.isArray(inputSchema.required) ? inputSchema.required : [];
160
+
161
+ for (const field of required) {
162
+ const v = input?.[field];
163
+ // `false` and `0` are legitimate values, so only absent/null count as missing.
164
+ if (v === undefined || v === null) {
165
+ problems.push({ field, error: "required field is missing" });
166
+ }
167
+ }
168
+
169
+ for (const [field, value] of Object.entries(input || {})) {
170
+ if (!props[field]) continue; // extra fields are allowed by many tools
171
+ const check = validateValue(value, props[field]);
172
+ if (!check.ok) {
173
+ problems.push({ field, error: check.error, suggestion: check.suggestion });
174
+ }
175
+ }
176
+
177
+ return problems;
178
+ }