@nexusbloom/cli 0.3.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -34
- package/TESTING.md +125 -0
- package/package.json +10 -3
- package/src/abbr.js +27 -20
- package/src/commands.js +253 -0
- package/src/compile.js +69 -0
- package/src/diagnostics.js +41 -0
- package/src/index.js +330 -280
- package/src/jsonish.js +213 -0
- package/src/list.js +29 -23
- package/src/paths.js +42 -16
- package/src/pipe.js +709 -0
- package/src/pipelines.js +76 -0
- package/src/run.js +203 -100
- package/src/runner.js +89 -0
- package/src/sandbox.js +167 -0
- package/src/tools.js +177 -26
- package/src/types.js +178 -0
- package/src/ui.js +20 -0
- package/src/values.js +260 -0
- package/src/wizard.js +349 -0
package/src/commands.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.js — `nxb pipe`, `nxb doctor` and the shared execution glue.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of index.js so the command definitions stay readable and so the
|
|
5
|
+
* wizard can reuse the same run path the CLI uses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
|
|
13
|
+
import { fetchToolSource, fetchTools, filterTools, rankTools } from "./tools.js";
|
|
14
|
+
import { execInSandbox } from "./sandbox.js";
|
|
15
|
+
import { runRemote, reportError } from "./run.js";
|
|
16
|
+
import { configGet } from "./tools.js";
|
|
17
|
+
import {
|
|
18
|
+
PipeError,
|
|
19
|
+
buildPlan,
|
|
20
|
+
describeOutput,
|
|
21
|
+
parsePipeline,
|
|
22
|
+
renderPlan,
|
|
23
|
+
runPipeline,
|
|
24
|
+
} from "./pipe.js";
|
|
25
|
+
import { inferType, schemaTypes, typeLabel } from "./types.js";
|
|
26
|
+
import { MISSING, getPath, preview, transformNames, parsePath, formatPath } from "./values.js";
|
|
27
|
+
import { buildWizard } from "./wizard.js";
|
|
28
|
+
|
|
29
|
+
/** Shared manifest/tool lookup used by the plan, the runner and the wizard. */
|
|
30
|
+
const getManifest = (slug) => fetchToolSource(slug).then((t) => t ?? null);
|
|
31
|
+
const getTool = (slug) => fetchToolSource(slug);
|
|
32
|
+
|
|
33
|
+
/** Prefer local execution; fall back to the API when a key is configured. */
|
|
34
|
+
function makeExecutor({ jsonMode, forceRemote }) {
|
|
35
|
+
return async ({ slug, source, input }) => {
|
|
36
|
+
const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
37
|
+
if (forceRemote && apiKey) {
|
|
38
|
+
const res = await runRemote(slug, input, { apiKey, jsonMode: false, quiet: true });
|
|
39
|
+
if (res?.ok) return { ok: true, result: res.result };
|
|
40
|
+
return { ok: false, error: res?.error || "remote execution failed" };
|
|
41
|
+
}
|
|
42
|
+
if (source) {
|
|
43
|
+
const manifest = (await getManifest(slug))?.manifest;
|
|
44
|
+
return execInSandbox({ source, input, timeoutMs: manifest?.timeout_ms || 5000 });
|
|
45
|
+
}
|
|
46
|
+
if (apiKey) {
|
|
47
|
+
const res = await runRemote(slug, input, { apiKey, jsonMode: false, quiet: true });
|
|
48
|
+
if (res?.ok) return { ok: true, result: res.result };
|
|
49
|
+
return { ok: false, error: res?.error || "remote execution failed" };
|
|
50
|
+
}
|
|
51
|
+
return { ok: false, error: `No runnable source available for "${slug}".` };
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `nxb pipe` — run a pipeline from a command string.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} spec e.g. "tool-a x=@in | tool-b count=$n|len"
|
|
59
|
+
*/
|
|
60
|
+
export async function pipeAction(spec, opts = {}) {
|
|
61
|
+
const { json: jsonMode, dryRun = false, remote = false } = opts;
|
|
62
|
+
|
|
63
|
+
let steps;
|
|
64
|
+
try {
|
|
65
|
+
steps = parsePipeline(spec);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return reportError(jsonMode, err.message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let plan;
|
|
71
|
+
try {
|
|
72
|
+
plan = await buildPlan(steps, getManifest);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return reportError(jsonMode, `Could not resolve tools: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!jsonMode) console.error(renderPlan(plan));
|
|
78
|
+
|
|
79
|
+
if (!plan.ok) {
|
|
80
|
+
return reportError(jsonMode, "Pipeline wiring has errors (see above). Fix them and re-run.");
|
|
81
|
+
}
|
|
82
|
+
if (dryRun) {
|
|
83
|
+
if (jsonMode) console.log(JSON.stringify({ ok: true, dryRun: true, steps: plan.steps.map((s) => s.slug) }, null, 2));
|
|
84
|
+
return { ok: true, dryRun: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Standard input is consumed lazily: a pipeline with no @in must not block.
|
|
88
|
+
let stdinValue = null;
|
|
89
|
+
const usesStdin = steps.some((s) => s.inputs.some((i) => i.expr.kind === "stdin"));
|
|
90
|
+
if (usesStdin) {
|
|
91
|
+
const { readStdin } = await import("./tools.js");
|
|
92
|
+
const raw = await readStdin();
|
|
93
|
+
if (raw && raw.trim()) {
|
|
94
|
+
try {
|
|
95
|
+
stdinValue = JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
stdinValue = raw;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const execute = makeExecutor({ jsonMode, forceRemote: remote });
|
|
103
|
+
const quiet = jsonMode || opts.quiet === true;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await runPipeline({
|
|
107
|
+
steps: plan.steps,
|
|
108
|
+
getTool,
|
|
109
|
+
execute,
|
|
110
|
+
stdin: stdinValue,
|
|
111
|
+
onStep: quiet
|
|
112
|
+
? undefined
|
|
113
|
+
: ({ phase, index, step, output, durationMs, resolvedInputs }) => {
|
|
114
|
+
if (phase === "start") {
|
|
115
|
+
const wires = Object.entries(resolvedInputs || {})
|
|
116
|
+
.filter(([, v]) => v.from !== "literal")
|
|
117
|
+
.map(([k, v]) => `${k}=${preview(v.value, 18)}`)
|
|
118
|
+
.join(" ");
|
|
119
|
+
console.error(
|
|
120
|
+
chalk.gray(` ▶ ${index + 1}/${plan.steps.length} ${step.slug.padEnd(26)}`) +
|
|
121
|
+
(wires ? chalk.dim(` ${wires}`) : "")
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
console.error(
|
|
125
|
+
chalk.green(` ✓ ${String(durationMs ?? 0).padStart(5)}ms `) + describeOutput(output)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const final = result.outputs[result.outputs.length - 1];
|
|
132
|
+
if (jsonMode) {
|
|
133
|
+
console.log(JSON.stringify({ success: true, output: final, steps: result.outputs }, null, 2));
|
|
134
|
+
} else {
|
|
135
|
+
console.error(chalk.dim("\n Result:"));
|
|
136
|
+
console.log(typeof final === "string" ? final : JSON.stringify(final, null, 2));
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, output: final };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err instanceof PipeError) return reportError(jsonMode, err.message);
|
|
141
|
+
return reportError(jsonMode, err.message || String(err));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `nxb pipe --transforms` — list the value transforms available in connections. */
|
|
146
|
+
export function listTransforms(jsonMode) {
|
|
147
|
+
const names = transformNames();
|
|
148
|
+
if (jsonMode) {
|
|
149
|
+
console.log(JSON.stringify({ transforms: names }, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.log(chalk.bold("\n Connection transforms\n"));
|
|
153
|
+
const rows = [
|
|
154
|
+
["len / count / words / lines / chars", "size of a string, array or object"],
|
|
155
|
+
["first / last / get(i) / slice(a,b)", "reduce an array to a single value"],
|
|
156
|
+
["join(sep) / split(sep) / reverse / sort(key)", "reshape collections"],
|
|
157
|
+
["keys / values / pick(field)", "reach into an object"],
|
|
158
|
+
["upper / lower / trim / replace(a,b) / pad(n)", "edit text"],
|
|
159
|
+
["int() / float() / num / str / bool()", "change type — required for casts"],
|
|
160
|
+
["json / unjson", "serialise / parse"],
|
|
161
|
+
["default(v)", "supply a value when the path is missing"],
|
|
162
|
+
];
|
|
163
|
+
for (const [a, b] of rows) console.log(` ${chalk.cyan(a.padEnd(42))} ${chalk.dim(b)}`);
|
|
164
|
+
console.log(chalk.dim("\n Use: key=$path|transform (no spaces around |)\n"));
|
|
165
|
+
return { ok: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `nxb doctor <slug>` — run a tool once and compare what it returned against
|
|
170
|
+
* its declared output_schema.
|
|
171
|
+
*
|
|
172
|
+
* Declared schemas are the only static typing the pipeline planner has, so
|
|
173
|
+
* drift here is what makes a pipeline fail for no visible reason.
|
|
174
|
+
*/
|
|
175
|
+
export async function doctorAction(slug, opts = {}) {
|
|
176
|
+
const { json: jsonMode } = opts;
|
|
177
|
+
const info = await getManifest(slug);
|
|
178
|
+
if (!info?.manifest) return reportError(jsonMode, `Tool "${slug}" not found.`);
|
|
179
|
+
|
|
180
|
+
const { manifest, coreLogicSource } = info;
|
|
181
|
+
const declared = manifest.output_schema?.properties || {};
|
|
182
|
+
const declaredKeys = Object.keys(declared);
|
|
183
|
+
|
|
184
|
+
if (!coreLogicSource) {
|
|
185
|
+
return reportError(jsonMode, `"${slug}" publishes no runnable source — cannot compare output.`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Build the smallest valid input the schema allows.
|
|
189
|
+
const input = buildSampleInput(manifest.input_schema);
|
|
190
|
+
const res = await execInSandbox({ source: coreLogicSource, input, timeoutMs: manifest.timeout_ms || 5000 });
|
|
191
|
+
if (!res.ok) return reportError(jsonMode, `Could not run "${slug}": ${res.error}`);
|
|
192
|
+
|
|
193
|
+
const actual = res.result && typeof res.result === "object" ? res.result : { value: res.result };
|
|
194
|
+
const actualKeys = Object.keys(actual);
|
|
195
|
+
|
|
196
|
+
const undeclared = actualKeys.filter((k) => !declaredKeys.includes(k));
|
|
197
|
+
const missing = declaredKeys.filter((k) => !actualKeys.includes(k));
|
|
198
|
+
const mistyped = actualKeys
|
|
199
|
+
.filter((k) => declared[k])
|
|
200
|
+
.map((k) => ({ key: k, declared: typeLabel(schemaTypes(declared[k])), actual: inferType(actual[k]) }))
|
|
201
|
+
.filter((r) => {
|
|
202
|
+
const d = r.declared.split("|");
|
|
203
|
+
return !d.includes(r.actual) && !(r.actual === "integer" && d.includes("number"));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const healthy = !undeclared.length && !missing.length && !mistyped.length;
|
|
207
|
+
|
|
208
|
+
if (jsonMode) {
|
|
209
|
+
console.log(
|
|
210
|
+
JSON.stringify(
|
|
211
|
+
{ slug, healthy, input, declaredKeys, actualKeys, undeclared, missing, mistyped, sample: actual },
|
|
212
|
+
null,
|
|
213
|
+
2
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
return { ok: healthy };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log(chalk.bold(`\n ${slug}\n`));
|
|
220
|
+
console.log(` ${chalk.gray("input :")} ${chalk.dim(JSON.stringify(input))}`);
|
|
221
|
+
if (healthy) {
|
|
222
|
+
console.log(chalk.green(` ✓ output_schema matches the tool's real output (${actualKeys.length} fields)`));
|
|
223
|
+
} else {
|
|
224
|
+
if (undeclared.length) {
|
|
225
|
+
console.log(chalk.yellow(` ! undeclared output fields: ${undeclared.join(", ")}`));
|
|
226
|
+
console.log(chalk.dim(" These are invisible to the pipeline planner — only the runtime check sees them."));
|
|
227
|
+
}
|
|
228
|
+
if (missing.length) console.log(chalk.yellow(` ! declared but never returned: ${missing.join(", ")}`));
|
|
229
|
+
for (const m of mistyped) {
|
|
230
|
+
console.log(chalk.red(` ✗ ${m.key}: declared ${m.declared}, actually returns ${m.actual}`));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.log();
|
|
234
|
+
return { ok: healthy };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Minimal valid input for a schema — enough to make a tool run once. */
|
|
238
|
+
function buildSampleInput(schema) {
|
|
239
|
+
const props = schema?.properties || {};
|
|
240
|
+
const input = {};
|
|
241
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
242
|
+
if (prop.default !== undefined) input[key] = prop.default;
|
|
243
|
+
else if (Array.isArray(prop.enum) && prop.enum.length) input[key] = prop.enum[0];
|
|
244
|
+
else if (prop.type === "string") input[key] = "sample";
|
|
245
|
+
else if (prop.type === "integer" || prop.type === "number") input[key] = 1;
|
|
246
|
+
else if (prop.type === "boolean") input[key] = true;
|
|
247
|
+
else if (prop.type === "array") input[key] = [];
|
|
248
|
+
else if (prop.type === "object") input[key] = {};
|
|
249
|
+
}
|
|
250
|
+
return input;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { buildWizard, filterTools, rankTools, fetchTools, getPath, MISSING, parsePath, formatPath, transformNames };
|
package/src/compile.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compile.js — Turn a tool's v2 source text into a callable `coreLogic`.
|
|
3
|
+
*
|
|
4
|
+
* Tool source is an ES module, but the sandbox runner has no module loader, so
|
|
5
|
+
* module syntax is stripped before evaluation. Only ever call this from inside
|
|
6
|
+
* the child process (see runner.js / sandbox.js) — it evaluates code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Remove `import` / `export` syntax so the body can run as a bare script.
|
|
11
|
+
*
|
|
12
|
+
* Statements are matched on a boundary (start of text, line start, or just
|
|
13
|
+
* after `;`/`{`/`}`) rather than on `^\s*`, because real tool files put several
|
|
14
|
+
* exports on one line: `export const A = 1;export const B = 2;`.
|
|
15
|
+
*/
|
|
16
|
+
export function stripModuleSyntax(source) {
|
|
17
|
+
// $1 preserves the boundary character so the remaining code stays valid.
|
|
18
|
+
const B = "(^|[;{}])";
|
|
19
|
+
|
|
20
|
+
return String(source)
|
|
21
|
+
// import ... from "mod" (named, default, namespace, multi-line)
|
|
22
|
+
.replace(new RegExp(`${B}\\s*import\\s+[^;]*?\\s+from\\s+["'][^"']+["']\\s*;?`, "gm"), "$1")
|
|
23
|
+
// import "mod"
|
|
24
|
+
.replace(new RegExp(`${B}\\s*import\\s+["'][^"']+["']\\s*;?`, "gm"), "$1")
|
|
25
|
+
// export { a, b } [from "mod"]
|
|
26
|
+
.replace(
|
|
27
|
+
new RegExp(`${B}\\s*export\\s*\\{[^}]*\\}\\s*(?:from\\s+["'][^"']+["'])?\\s*;?`, "gm"),
|
|
28
|
+
"$1"
|
|
29
|
+
)
|
|
30
|
+
// export default
|
|
31
|
+
.replace(new RegExp(`${B}\\s*export\\s+default\\s+`, "gm"), "$1 ")
|
|
32
|
+
// export const/let/var/function/async/class
|
|
33
|
+
.replace(
|
|
34
|
+
new RegExp(`${B}\\s*export\\s+(?=(?:const|let|var|function|async|class)\\b)`, "gm"),
|
|
35
|
+
"$1 "
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compile source text into a `coreLogic(input)` function.
|
|
41
|
+
*
|
|
42
|
+
* Supports `function coreLogic`, `async function coreLogic`, and
|
|
43
|
+
* `const coreLogic = (…) => …` (sync or async). Throws if no callable
|
|
44
|
+
* `coreLogic` is exported.
|
|
45
|
+
*/
|
|
46
|
+
export function compileCoreLogic(source) {
|
|
47
|
+
const clean = stripModuleSyntax(source);
|
|
48
|
+
|
|
49
|
+
const attempts = [
|
|
50
|
+
// Named `coreLogic` binding (declaration or const/arrow assignment).
|
|
51
|
+
`return (function() {\n${clean}\n;return typeof coreLogic !== "undefined" ? coreLogic : undefined;})();`,
|
|
52
|
+
// Default-export fallback: the module body evaluates to the function.
|
|
53
|
+
`return (function() {\n${clean}\n})();`,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
let lastError;
|
|
57
|
+
for (const body of attempts) {
|
|
58
|
+
try {
|
|
59
|
+
// eslint-disable-next-line no-new-func
|
|
60
|
+
const value = new Function(body)();
|
|
61
|
+
if (typeof value === "function") return value;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
lastError = err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Tool source does not export a coreLogic() function${lastError ? `: ${lastError.message}` : ""}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diagnostics.js — Explain an empty tool catalogue.
|
|
3
|
+
*
|
|
4
|
+
* "0 tools" is indistinguishable from a broken network, a typo'd base URL, or
|
|
5
|
+
* a cache that was never populated. Saying which one it is turns a dead end
|
|
6
|
+
* into an actionable message.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { API_BASE } from "./tools.js";
|
|
10
|
+
import { reportError } from "./run.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{offline?: boolean, jsonMode?: boolean, source?: string}} opts
|
|
14
|
+
*/
|
|
15
|
+
export async function diagnoseEmptyCatalogue({ offline = false, jsonMode = false, source = "cache" } = {}) {
|
|
16
|
+
if (offline) {
|
|
17
|
+
return reportError(
|
|
18
|
+
jsonMode,
|
|
19
|
+
`No tools in the local ${source}. Run \`nxb list --refresh\` once while online to populate it.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const base = API_BASE;
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${base}/api/tools`, { signal: AbortSignal.timeout(8000) });
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
return reportError(jsonMode, `Tool catalogue unavailable: HTTP ${res.status} from ${base}/api/tools`);
|
|
28
|
+
}
|
|
29
|
+
const data = await res.json().catch(() => null);
|
|
30
|
+
if (Array.isArray(data?.tools)) {
|
|
31
|
+
return reportError(jsonMode, `The API returned no tools. Run \`nxb list --refresh\` after publishing one.`);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return reportError(
|
|
35
|
+
jsonMode,
|
|
36
|
+
`Could not reach ${base}. Check your connection or set NEXUSBLOOM_API_URL, or use \`nxb list --offline\`.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return reportError(jsonMode, "No tools available. Run `nxb list --refresh` to re-fetch the catalogue.");
|
|
41
|
+
}
|