@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/ui.js
CHANGED
|
@@ -19,6 +19,18 @@ import {
|
|
|
19
19
|
|
|
20
20
|
export const PAGE_SIZE = 20;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* True when it is safe to open an interactive prompt.
|
|
24
|
+
*
|
|
25
|
+
* Prompts must never be attempted when stdout/stdin is piped or run under CI —
|
|
26
|
+
* inquirer would either hang forever or throw. Any list long enough to page
|
|
27
|
+
* through should degrade to a plain, searchable dump instead.
|
|
28
|
+
*/
|
|
29
|
+
export function isInteractive() {
|
|
30
|
+
if (process.env.NXB_NO_INTERACTIVE || process.env.CI) return false;
|
|
31
|
+
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
// ─── Simple helpers ──────────────────────────────────────────────────────────
|
|
23
35
|
|
|
24
36
|
/**
|
|
@@ -116,6 +128,9 @@ export function paginate(items, pageSize = PAGE_SIZE, currentPage = 1) {
|
|
|
116
128
|
/**
|
|
117
129
|
* Interactive paginator: renders via `renderPage`, offers prev/next/done/cancel.
|
|
118
130
|
* Returns the last page result or null on cancel.
|
|
131
|
+
*
|
|
132
|
+
* When the terminal is not interactive (piped output, CI), it renders the
|
|
133
|
+
* first page once and returns instead of opening a prompt.
|
|
119
134
|
*/
|
|
120
135
|
export async function paginateInteractive(
|
|
121
136
|
items,
|
|
@@ -132,6 +147,11 @@ export async function paginateInteractive(
|
|
|
132
147
|
let page = 1;
|
|
133
148
|
let result = paginate(all, pageSize, page);
|
|
134
149
|
|
|
150
|
+
if (options.interactive === false || !isInteractive()) {
|
|
151
|
+
renderPage(result);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
135
155
|
while (true) {
|
|
136
156
|
renderPage(result);
|
|
137
157
|
|
package/src/values.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* values.js — Value paths and pure transforms for pipelines.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs:
|
|
5
|
+
* 1. Address values inside a tool's output: `total_words`, `data[0].email`,
|
|
6
|
+
* and `$` for the whole object. Arrays are unavoidable — `data-generator`
|
|
7
|
+
* returns `data: array`, so the next step needs an index.
|
|
8
|
+
* 2. Convert between shapes without needing a separate tool: `$text|len`,
|
|
9
|
+
* `$max|int`, `$data|first`.
|
|
10
|
+
*
|
|
11
|
+
* Every transform is pure and synchronous, which is what makes the wiring plan
|
|
12
|
+
* checkable before anything runs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Distinguishes "the field is absent" from "the field holds undefined". */
|
|
16
|
+
export const MISSING = Symbol("missing");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a path expression into segments.
|
|
20
|
+
*
|
|
21
|
+
* "" / "$" → [] (the whole value)
|
|
22
|
+
* "total_words" → ["total_words"]
|
|
23
|
+
* "data[0]" → ["data", 0]
|
|
24
|
+
* "a.b[2].c" → ["a", "b", 2, "c"]
|
|
25
|
+
*
|
|
26
|
+
* @throws {Error} on malformed paths
|
|
27
|
+
*/
|
|
28
|
+
export function parsePath(path) {
|
|
29
|
+
if (path == null) return [];
|
|
30
|
+
const raw = String(path).trim();
|
|
31
|
+
if (!raw || raw === "$" || raw === ".") return [];
|
|
32
|
+
|
|
33
|
+
const segments = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
// A leading identifier segment.
|
|
36
|
+
while (i < raw.length) {
|
|
37
|
+
const c = raw[i];
|
|
38
|
+
if (c === ".") {
|
|
39
|
+
i++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === "[") {
|
|
43
|
+
const close = raw.indexOf("]", i);
|
|
44
|
+
if (close === -1) throw new Error(`Unclosed "[" in path "${raw}"`);
|
|
45
|
+
const inner = raw.slice(i + 1, close).trim();
|
|
46
|
+
if (/^\d+$/.test(inner)) segments.push(Number(inner));
|
|
47
|
+
else if (/^["'].*["']$/.test(inner)) segments.push(inner.slice(1, -1));
|
|
48
|
+
else throw new Error(`Invalid index "[${inner}]" in path "${raw}"`);
|
|
49
|
+
i = close + 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let j = i;
|
|
53
|
+
while (j < raw.length && raw[j] !== "." && raw[j] !== "[") j++;
|
|
54
|
+
const key = raw.slice(i, j);
|
|
55
|
+
if (!key) throw new Error(`Empty segment in path "${raw}"`);
|
|
56
|
+
segments.push(key);
|
|
57
|
+
i = j;
|
|
58
|
+
}
|
|
59
|
+
return segments;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read a path out of a value.
|
|
64
|
+
* @returns {value|typeof MISSING}
|
|
65
|
+
*/
|
|
66
|
+
export function getPath(root, path) {
|
|
67
|
+
const segments = Array.isArray(path) ? path : parsePath(path);
|
|
68
|
+
if (segments.length === 0) return root;
|
|
69
|
+
|
|
70
|
+
let cur = root;
|
|
71
|
+
for (const seg of segments) {
|
|
72
|
+
if (cur === null || cur === undefined) return MISSING;
|
|
73
|
+
if (Array.isArray(cur)) {
|
|
74
|
+
const idx = typeof seg === "number" ? seg : Number(seg);
|
|
75
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) return MISSING;
|
|
76
|
+
cur = cur[idx];
|
|
77
|
+
} else if (typeof cur === "object") {
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(cur, seg)) return MISSING;
|
|
79
|
+
cur = cur[seg];
|
|
80
|
+
} else {
|
|
81
|
+
return MISSING;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return cur === undefined ? MISSING : cur;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render a path back to its `$`-prefixed source form. */
|
|
88
|
+
export function formatPath(segments) {
|
|
89
|
+
const arr = Array.isArray(segments) ? segments : parsePath(segments);
|
|
90
|
+
if (!arr.length) return "$";
|
|
91
|
+
let out = "";
|
|
92
|
+
for (const seg of arr) {
|
|
93
|
+
if (typeof seg === "number") out += `[${seg}]`;
|
|
94
|
+
else out += out ? `.${seg}` : seg;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Coercion helpers ────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const toStr = (v) => {
|
|
102
|
+
if (v === null || v === undefined) return "";
|
|
103
|
+
if (typeof v === "string") return v;
|
|
104
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
105
|
+
return String(v);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const asNumber = (v) => {
|
|
109
|
+
if (typeof v === "number") return v;
|
|
110
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
111
|
+
if (typeof v === "string") {
|
|
112
|
+
const n = Number(v.trim());
|
|
113
|
+
return Number.isNaN(n) ? MISSING : n;
|
|
114
|
+
}
|
|
115
|
+
return MISSING;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Transform library.
|
|
120
|
+
*
|
|
121
|
+
* Each entry is `(value, ...args) => value`. Throwing is allowed — pipe.js
|
|
122
|
+
* turns a throw into a connection-level error naming the transform.
|
|
123
|
+
*
|
|
124
|
+
* `needsArg` marks transforms that are meaningless without an argument, so
|
|
125
|
+
* `$x|slice` can be reported at plan time rather than silently.
|
|
126
|
+
*/
|
|
127
|
+
export const TRANSFORMS = {
|
|
128
|
+
// — shape —
|
|
129
|
+
len: (v) => (typeof v === "string" || Array.isArray(v) ? v.length : v && typeof v === "object" ? Object.keys(v).length : 0),
|
|
130
|
+
// Alias of `len`. A count *of what* would need an argument; this one does not
|
|
131
|
+
// take one, and silently ignoring an argument is how `$text|count("a")` would
|
|
132
|
+
// quietly return the string length instead of the number of matches.
|
|
133
|
+
count: (v) => TRANSFORMS.len(v),
|
|
134
|
+
words: (v) => (toStr(v).trim() ? toStr(v).trim().split(/\s+/).length : 0),
|
|
135
|
+
lines: (v) => (toStr(v) === "" ? 0 : toStr(v).split(/\r?\n/).length),
|
|
136
|
+
chars: (v) => toStr(v).length,
|
|
137
|
+
|
|
138
|
+
// Absence must be MISSING, never raw `undefined`: `default()` and the
|
|
139
|
+
// runtime's missing-value check both compare against MISSING, so returning
|
|
140
|
+
// undefined would slip past a fallback the user explicitly asked for.
|
|
141
|
+
first: (v) => (Array.isArray(v) ? (v.length ? v[0] : MISSING) : v),
|
|
142
|
+
last: (v) => (Array.isArray(v) ? (v.length ? v[v.length - 1] : MISSING) : v),
|
|
143
|
+
get: (v, i) => {
|
|
144
|
+
if (!Array.isArray(v)) return v;
|
|
145
|
+
const n = Number(i);
|
|
146
|
+
if (!Number.isInteger(n) || n < 0 || n >= v.length) return MISSING;
|
|
147
|
+
return v[n];
|
|
148
|
+
},
|
|
149
|
+
slice: (v, a, b) => (Array.isArray(v) || typeof v === "string" ? v.slice(Number(a), b == null ? undefined : Number(b)) : v),
|
|
150
|
+
join: (v, sep = ",") => (Array.isArray(v) ? v.join(sep) : toStr(v)),
|
|
151
|
+
keys: (v) => (v && typeof v === "object" ? Object.keys(v) : []),
|
|
152
|
+
values: (v) => (v && typeof v === "object" ? Object.values(v) : []),
|
|
153
|
+
reverse: (v) => (Array.isArray(v) ? [...v].reverse() : v),
|
|
154
|
+
sort: (v, key) => {
|
|
155
|
+
if (!Array.isArray(v)) return v;
|
|
156
|
+
const copy = [...v];
|
|
157
|
+
copy.sort((a, b) => {
|
|
158
|
+
const av = key ? getPath(a, key) : a;
|
|
159
|
+
const bv = key ? getPath(b, key) : b;
|
|
160
|
+
if (av === bv) return 0;
|
|
161
|
+
if (av === MISSING) return 1;
|
|
162
|
+
if (bv === MISSING) return -1;
|
|
163
|
+
return av < bv ? -1 : 1;
|
|
164
|
+
});
|
|
165
|
+
return copy;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// — text —
|
|
169
|
+
upper: (v) => toStr(v).toUpperCase(),
|
|
170
|
+
lower: (v) => toStr(v).toLowerCase(),
|
|
171
|
+
trim: (v) => toStr(v).trim(),
|
|
172
|
+
replace: (v, a, b) => toStr(v).split(a).join(b ?? ""),
|
|
173
|
+
pad: (v, n, c = " ") => toStr(v).padStart(Number(n), c),
|
|
174
|
+
split: (v, sep) => toStr(v).split(sep),
|
|
175
|
+
|
|
176
|
+
// — type coercion —
|
|
177
|
+
str: (v) => toStr(v),
|
|
178
|
+
int: (v) => {
|
|
179
|
+
const n = asNumber(v);
|
|
180
|
+
if (n === MISSING) return MISSING;
|
|
181
|
+
return Math.trunc(n);
|
|
182
|
+
},
|
|
183
|
+
float: (v) => asNumber(v),
|
|
184
|
+
num: (v) => asNumber(v),
|
|
185
|
+
bool: (v) => {
|
|
186
|
+
if (typeof v === "boolean") return v;
|
|
187
|
+
const s = toStr(v).trim().toLowerCase();
|
|
188
|
+
if (["true", "1", "yes", "y", "on"].includes(s)) return true;
|
|
189
|
+
if (["false", "0", "no", "n", "off", ""].includes(s)) return false;
|
|
190
|
+
return MISSING;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// — structure —
|
|
194
|
+
json: (v) => toStr(v),
|
|
195
|
+
unjson: (v) => {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(toStr(v));
|
|
198
|
+
} catch {
|
|
199
|
+
return MISSING;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
pick: (v, key) => {
|
|
203
|
+
const r = getPath(v, key);
|
|
204
|
+
return r;
|
|
205
|
+
},
|
|
206
|
+
default: (v, fallback) => (v === MISSING || v === undefined || v === null || v === "" ? fallback : v),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Transforms that are no-ops on their argument order, used for plan hints. */
|
|
210
|
+
export const TRANSFORM_ARITY = {
|
|
211
|
+
get: 2,
|
|
212
|
+
slice: 3,
|
|
213
|
+
join: 2,
|
|
214
|
+
split: 2,
|
|
215
|
+
replace: 3,
|
|
216
|
+
pad: 3,
|
|
217
|
+
sort: 2,
|
|
218
|
+
pick: 2,
|
|
219
|
+
default: 2,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export function transformNames() {
|
|
223
|
+
return Object.keys(TRANSFORMS).sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Apply a chain of transforms to a value.
|
|
228
|
+
* @param {any} value
|
|
229
|
+
* @param {Array<{name: string, args: any[]}>} chain
|
|
230
|
+
* @returns {any|typeof MISSING}
|
|
231
|
+
*/
|
|
232
|
+
export function applyTransforms(value, chain) {
|
|
233
|
+
let cur = value;
|
|
234
|
+
for (const step of chain) {
|
|
235
|
+
const fn = TRANSFORMS[step.name];
|
|
236
|
+
if (!fn) throw new Error(`Unknown transform "${step.name}". Run \`nxb pipe --transforms\`.`);
|
|
237
|
+
// A missing value stays missing unless the transform can supply one —
|
|
238
|
+
// only `default()` is allowed to invent a value.
|
|
239
|
+
if (cur === MISSING && step.name !== "default") return MISSING;
|
|
240
|
+
// A step may legitimately take no argument; treat a missing `args` as
|
|
241
|
+
// empty rather than failing to iterate undefined.
|
|
242
|
+
cur = fn(cur, ...(step.args || []));
|
|
243
|
+
if (cur === MISSING) return MISSING;
|
|
244
|
+
}
|
|
245
|
+
return cur;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Short, safe preview of a value for the wiring table. */
|
|
249
|
+
export function preview(value, max = 28) {
|
|
250
|
+
if (value === MISSING) return "";
|
|
251
|
+
if (value === null) return "null";
|
|
252
|
+
if (typeof value === "string") {
|
|
253
|
+
const one = value.replace(/\s+/g, " ");
|
|
254
|
+
return `"${one.length > max ? `${one.slice(0, max)}…` : one}"`;
|
|
255
|
+
}
|
|
256
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
257
|
+
if (Array.isArray(value)) return `[${value.length}]`;
|
|
258
|
+
if (typeof value === "object") return `{${Object.keys(value).length}}`;
|
|
259
|
+
return String(value);
|
|
260
|
+
}
|
package/src/wizard.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wizard.js — Interactive pipeline builder.
|
|
3
|
+
*
|
|
4
|
+
* The design point: wires are made against a tool's *real* output, not its
|
|
5
|
+
* declared `output_schema`. Run the tool, inspect what actually came back —
|
|
6
|
+
* including fields the schema never mentioned — and only then choose what to
|
|
7
|
+
* send onward. A browser node builder cannot do this without executing the
|
|
8
|
+
* tool, which is exactly why this belongs in the CLI.
|
|
9
|
+
*
|
|
10
|
+
* The wizard is stateless: render, prompt, act, render. Nothing is redrawn
|
|
11
|
+
* while a prompt is open, because @inquirer/core cannot update a live prompt.
|
|
12
|
+
* That keeps it safe over SSH and leaves every step in the scrollback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import * as inquirer from "@inquirer/prompts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every prompt goes through this bundle so the wizard's decision logic can be
|
|
20
|
+
* driven by tests without a terminal. Rendering stays in @inquirer; what is
|
|
21
|
+
* worth testing is which question gets asked and what it does with the answer.
|
|
22
|
+
*/
|
|
23
|
+
export const defaultPrompts = {
|
|
24
|
+
search: inquirer.search,
|
|
25
|
+
select: inquirer.select,
|
|
26
|
+
input: inquirer.input,
|
|
27
|
+
number: inquirer.number,
|
|
28
|
+
confirm: inquirer.confirm,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
import { fetchTools, rankTools } from "./tools.js";
|
|
32
|
+
import { execInSandbox } from "./sandbox.js";
|
|
33
|
+
import { configGet } from "./tools.js";
|
|
34
|
+
import { runRemote } from "./run.js";
|
|
35
|
+
import { inferType, schemaTypes, typeLabel, compatibility } from "./types.js";
|
|
36
|
+
import { formatPath, parsePath, preview, transformNames } from "./values.js";
|
|
37
|
+
import { isInteractive } from "./ui.js";
|
|
38
|
+
import { renderHeader } from "./ui.js";
|
|
39
|
+
|
|
40
|
+
// ─── Execution ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
async function loadTool(slug) {
|
|
43
|
+
const { fetchToolSource } = await import("./tools.js");
|
|
44
|
+
return fetchToolSource(slug);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function runOnce(slug, input) {
|
|
48
|
+
const info = await loadTool(slug);
|
|
49
|
+
if (!info?.manifest) return { ok: false, error: `Tool "${slug}" not found.` };
|
|
50
|
+
|
|
51
|
+
const { manifest, coreLogicSource } = info;
|
|
52
|
+
const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
53
|
+
|
|
54
|
+
if (!coreLogicSource && apiKey) {
|
|
55
|
+
const res = await runRemote(slug, input, { apiKey, jsonMode: false, quiet: true });
|
|
56
|
+
return res?.ok ? { ok: true, result: res.result } : { ok: false, error: res?.error || "remote failed" };
|
|
57
|
+
}
|
|
58
|
+
if (!coreLogicSource) {
|
|
59
|
+
return { ok: false, error: `"${slug}" has no runnable source. Set an API key or use --local.` };
|
|
60
|
+
}
|
|
61
|
+
return execInSandbox({ source: coreLogicSource, input, timeoutMs: manifest.timeout_ms || 5000 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Output introspection ────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Flat list of leaf fields in a tool's actual output, with runtime types. */
|
|
67
|
+
export function describeOutputFields(value, prefix = [], depth = 0) {
|
|
68
|
+
const fields = [];
|
|
69
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || depth > 2) {
|
|
70
|
+
if (prefix.length) fields.push({ path: prefix, value });
|
|
71
|
+
return fields;
|
|
72
|
+
}
|
|
73
|
+
for (const [k, v] of Object.entries(value)) {
|
|
74
|
+
const path = [...prefix, k];
|
|
75
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v) && depth < 2) {
|
|
76
|
+
fields.push(...describeOutputFields(v, path, depth + 1));
|
|
77
|
+
} else {
|
|
78
|
+
fields.push({ path, value: v });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return fields;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Render the observed output so the user can read it before wiring. */
|
|
85
|
+
function renderObservedOutput(fields) {
|
|
86
|
+
if (!fields.length) {
|
|
87
|
+
console.log(chalk.yellow(" (the tool returned nothing to pass on)"));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const width = Math.max(...fields.map((f) => formatPath(f.path).length));
|
|
91
|
+
for (const f of fields) {
|
|
92
|
+
const path = formatPath(f.path).padEnd(width);
|
|
93
|
+
console.log(` ${chalk.cyan(path)} ${chalk.gray(inferType(f.value).padEnd(8))} ${chalk.dim(preview(f.value, 34))}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Prompts ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function pickTool(tools, exclude = [], P = defaultPrompts) {
|
|
100
|
+
const pool = tools.filter((t) => !exclude.includes(t.slug));
|
|
101
|
+
const source = async (term) => {
|
|
102
|
+
const hits = term ? rankTools(pool, term) : pool;
|
|
103
|
+
return hits.slice(0, 50).map((t) => ({
|
|
104
|
+
value: t,
|
|
105
|
+
name: `${t.icon || "🔧"} ${(t.slug || "").padEnd(28)} ${t.short_description || t.description || ""}`,
|
|
106
|
+
}));
|
|
107
|
+
};
|
|
108
|
+
return P.search({ message: "Tool", source, pageSize: 15 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Prompt for one literal input, honouring the declared type. */
|
|
112
|
+
async function promptLiteral(field, prop, P = defaultPrompts) {
|
|
113
|
+
const types = schemaTypes(prop);
|
|
114
|
+
if (Array.isArray(prop?.enum) && prop.enum.length) {
|
|
115
|
+
return P.select({
|
|
116
|
+
message: `${field} ${chalk.gray(typeLabel(types) + " (enum)")}`,
|
|
117
|
+
choices: [
|
|
118
|
+
...(prop.default !== undefined ? [{ name: chalk.gray(`default: ${prop.default}`), value: prop.default }] : []),
|
|
119
|
+
...prop.enum.map((v) => ({ name: String(v), value: v })),
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (types.includes("boolean")) {
|
|
124
|
+
return P.confirm({ message: `${field} ${chalk.gray("boolean")}`, default: prop.default ?? true });
|
|
125
|
+
}
|
|
126
|
+
if (types.includes("integer") || types.includes("number")) {
|
|
127
|
+
const min = types.includes("integer") ? Math.ceil(prop?.minimum ?? -Infinity) : prop?.minimum;
|
|
128
|
+
const max = types.includes("integer") ? Math.floor(prop?.maximum ?? Infinity) : prop?.maximum;
|
|
129
|
+
return P.number({
|
|
130
|
+
message: `${field} ${chalk.gray(typeLabel(types))}`,
|
|
131
|
+
...(min != null && Number.isFinite(min) ? { min } : {}),
|
|
132
|
+
...(max != null && Number.isFinite(max) ? { max } : {}),
|
|
133
|
+
...(prop?.default !== undefined ? { default: prop.default } : {}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const answer = await P.input({
|
|
137
|
+
message: `${field} ${chalk.gray(typeLabel(types))}`,
|
|
138
|
+
...(prop?.default !== undefined ? { default: String(prop.default) } : {}),
|
|
139
|
+
...(prop?.description ? { description: undefined } : {}),
|
|
140
|
+
});
|
|
141
|
+
return answer === "" && prop?.default !== undefined ? prop.default : answer;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Ask how each input field of the next tool is supplied: wired from the
|
|
146
|
+
* previous output, or a literal.
|
|
147
|
+
*/
|
|
148
|
+
async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
|
|
149
|
+
const props = manifest.input_schema?.properties || {};
|
|
150
|
+
const required = new Set(manifest.input_schema?.required || []);
|
|
151
|
+
const assignments = [];
|
|
152
|
+
|
|
153
|
+
for (const [field, prop] of Object.entries(props)) {
|
|
154
|
+
const targetTypes = schemaTypes(prop);
|
|
155
|
+
const canSkip = !required.has(field);
|
|
156
|
+
|
|
157
|
+
const choices = [
|
|
158
|
+
{
|
|
159
|
+
name: chalk.gray("· set by hand"),
|
|
160
|
+
value: { kind: "literal" },
|
|
161
|
+
},
|
|
162
|
+
...prevFields.map((f) => {
|
|
163
|
+
const path = formatPath(f.path);
|
|
164
|
+
const from = inferType(f.value);
|
|
165
|
+
const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
|
|
166
|
+
const flag =
|
|
167
|
+
compat.level === "cast" ? chalk.red(" · needs a transform")
|
|
168
|
+
: compat.level === "lossy" ? chalk.yellow(" · lossy")
|
|
169
|
+
: compat.level === "invalid" ? chalk.red(" · type mismatch")
|
|
170
|
+
: "";
|
|
171
|
+
return {
|
|
172
|
+
name: `${chalk.cyan(path.padEnd(24))} ${chalk.gray(from.padEnd(8))} ${chalk.dim(preview(f.value, 24))}${flag}`,
|
|
173
|
+
value: { kind: "wire", path },
|
|
174
|
+
};
|
|
175
|
+
}),
|
|
176
|
+
...(canSkip ? [{ name: chalk.gray("· leave empty"), value: { kind: "skip" } }] : []),
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const choice = await P.select({
|
|
180
|
+
message: `${prevSlug} → ${chalk.cyan(field)} ${chalk.gray(typeLabel(targetTypes))}${required.has(field) ? chalk.red(" *") : ""}`,
|
|
181
|
+
choices,
|
|
182
|
+
pageSize: 14,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (choice.kind === "skip") continue;
|
|
186
|
+
|
|
187
|
+
if (choice.kind === "literal") {
|
|
188
|
+
const value = await promptLiteral(field, prop, P);
|
|
189
|
+
assignments.push({ kind: "literal", text: `${field}=${formatLiteral(value)}` });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A transform was flagged as necessary — offer the suggestion inline.
|
|
194
|
+
const source = prevFields.find((f) => formatPath(f.path) === choice.path);
|
|
195
|
+
const from = source ? inferType(source.value) : "unknown";
|
|
196
|
+
const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
|
|
197
|
+
let transform = "";
|
|
198
|
+
|
|
199
|
+
if (compat.level === "cast" && compat.suggestion) {
|
|
200
|
+
const use = await P.confirm({
|
|
201
|
+
message: `${field} expects ${typeLabel(targetTypes)} — apply ${chalk.cyan(compat.suggestion)}?`,
|
|
202
|
+
default: true,
|
|
203
|
+
});
|
|
204
|
+
if (use) transform = `|${compat.suggestion}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
assignments.push({ kind: "wire", text: `${field}=$${choice.path}${transform}` });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return assignments;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formatLiteral(value) {
|
|
214
|
+
if (typeof value === "string" && value !== "" && !/^[\w.@/+-]+$/.test(value)) {
|
|
215
|
+
return JSON.stringify(value);
|
|
216
|
+
}
|
|
217
|
+
if (typeof value === "string" && value === "") return '""';
|
|
218
|
+
return String(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Wizard ──────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build a pipeline interactively.
|
|
225
|
+
* @returns {Promise<{ok: boolean, spec?: string}>}
|
|
226
|
+
*/
|
|
227
|
+
export async function buildWizard(opts = {}) {
|
|
228
|
+
const P = opts.prompts || defaultPrompts;
|
|
229
|
+
const listTools = opts.fetchTools || fetchTools;
|
|
230
|
+
const getTool = opts.loadTool || loadTool;
|
|
231
|
+
const execute = opts.runOnce || runOnce;
|
|
232
|
+
// Tests drive a non-TTY session; production does not.
|
|
233
|
+
if (opts.interactive === undefined ? !isInteractive() : !opts.interactive) {
|
|
234
|
+
console.log(chalk.yellow("\n The pipeline builder needs an interactive terminal."));
|
|
235
|
+
console.log(chalk.dim(" For scripted or piped use:\n"));
|
|
236
|
+
console.log(chalk.dim(' nxb pipe \'tool-a x=@in | tool-b count=$total|len\'\n'));
|
|
237
|
+
return { ok: false };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tools = await listTools();
|
|
241
|
+
if (!tools || !tools.length) {
|
|
242
|
+
console.log(chalk.yellow(" No tools available. Check your connection, or run `nxb list --refresh`."));
|
|
243
|
+
return { ok: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const steps = [];
|
|
247
|
+
const outputs = [];
|
|
248
|
+
const usedSlugs = [];
|
|
249
|
+
|
|
250
|
+
for (;;) {
|
|
251
|
+
renderHeader(
|
|
252
|
+
steps.length ? `Step ${steps.length + 1}` : "Build a pipeline",
|
|
253
|
+
{ subtitle: steps.length ? "Pick the next tool to feed" : "Start with any tool" }
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const tool = await pickTool(tools, usedSlugs, P);
|
|
257
|
+
if (!tool) break;
|
|
258
|
+
|
|
259
|
+
const info = await getTool(tool.slug);
|
|
260
|
+
const manifest = info?.manifest;
|
|
261
|
+
if (!manifest) {
|
|
262
|
+
console.log(chalk.yellow(` "${tool.slug}" has no manifest — skipping.`));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Wire from the previous step, if there is one.
|
|
267
|
+
let assignments = [];
|
|
268
|
+
if (outputs.length) {
|
|
269
|
+
const prev = outputs[outputs.length - 1];
|
|
270
|
+
assignments = await wireFields(manifest, prev.slug, prev.fields, P);
|
|
271
|
+
} else {
|
|
272
|
+
// First step: ask for each field by hand.
|
|
273
|
+
const props = manifest.input_schema?.properties || {};
|
|
274
|
+
for (const [field, prop] of Object.entries(props)) {
|
|
275
|
+
const value = await promptLiteral(field, prop, P);
|
|
276
|
+
assignments.push({ kind: "literal", text: `${field}=${formatLiteral(value)}` });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const spec = `${tool.slug} ${assignments.map((a) => a.text).join(" ")}`.trim();
|
|
281
|
+
steps.push({ slug: tool.slug, spec, assignments });
|
|
282
|
+
usedSlugs.push(tool.slug);
|
|
283
|
+
|
|
284
|
+
console.log(chalk.gray(`\n Running ${spec}\n`));
|
|
285
|
+
const started = Date.now();
|
|
286
|
+
const result = await execute(tool.slug, literalInput(assignments));
|
|
287
|
+
|
|
288
|
+
if (!result.ok) {
|
|
289
|
+
console.log(chalk.red(` ✗ ${result.error}`));
|
|
290
|
+
// Drop the step either way: only steps that actually ran belong in the
|
|
291
|
+
// replay command we hand back.
|
|
292
|
+
steps.pop();
|
|
293
|
+
usedSlugs.pop();
|
|
294
|
+
const again = await P.confirm({ message: "Change the inputs and retry?", default: true });
|
|
295
|
+
if (again) continue;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
console.log(chalk.green(` ✓ completed in ${Date.now() - started}ms\n`));
|
|
300
|
+
console.log(chalk.bold(" Output"));
|
|
301
|
+
const fields = describeOutputFields(result.result);
|
|
302
|
+
renderObservedOutput(fields);
|
|
303
|
+
console.log();
|
|
304
|
+
|
|
305
|
+
outputs.push({ slug: tool.slug, value: result.result, fields });
|
|
306
|
+
|
|
307
|
+
if (fields.length === 0) break;
|
|
308
|
+
const more = await P.confirm({ message: "Send this output into another tool?", default: steps.length < 2 });
|
|
309
|
+
if (!more) break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!steps.length) return { ok: false };
|
|
313
|
+
|
|
314
|
+
const pipeline = steps.map((s) => s.spec).join(" | ");
|
|
315
|
+
console.log(chalk.bold("\n Pipeline\n"));
|
|
316
|
+
console.log(` ${chalk.green(pipeline)}\n`);
|
|
317
|
+
console.log(chalk.dim(` Replay it with:\n nxb pipe '${pipeline}'\n`));
|
|
318
|
+
|
|
319
|
+
const save = await P.confirm({ message: "Save this pipeline?", default: false });
|
|
320
|
+
if (save) {
|
|
321
|
+
const name = await P.input({ message: " Name", default: "my-pipeline" });
|
|
322
|
+
const { savePipeline } = await import("./pipelines.js");
|
|
323
|
+
const file = savePipeline(name, { pipeline, steps: steps.map((s) => ({ slug: s.slug, spec: s.spec })) });
|
|
324
|
+
console.log(chalk.green(` Saved to ${file}`));
|
|
325
|
+
console.log(chalk.dim(` Run it with: nxb pipeline run ${name}\n`));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { ok: true, spec: pipeline };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Build the input object for the trial run from literal assignments. */
|
|
332
|
+
function literalInput(assignments) {
|
|
333
|
+
const input = {};
|
|
334
|
+
for (const a of assignments) {
|
|
335
|
+
const eq = a.text.indexOf("=");
|
|
336
|
+
if (eq === -1) continue;
|
|
337
|
+
const key = a.text.slice(0, eq);
|
|
338
|
+
const raw = a.text.slice(eq + 1);
|
|
339
|
+
if (raw.startsWith("$")) continue; // wired fields are not needed for a first run
|
|
340
|
+
try {
|
|
341
|
+
input[key] = JSON.parse(raw);
|
|
342
|
+
} catch {
|
|
343
|
+
input[key] = raw;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return input;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export { transformNames, parsePath };
|