@labelbox/recursion-cli 0.0.0 → 0.0.41
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 +113 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +38 -0
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +122 -0
- package/dist/embed.d.ts +41 -0
- package/dist/embed.js +153 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +190 -0
- package/dist/manifest.d.ts +402 -0
- package/dist/manifest.js +406 -0
- package/dist/permissions.d.ts +31 -0
- package/dist/permissions.js +73 -0
- package/dist/program.d.ts +112 -0
- package/dist/program.js +875 -0
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.js +43 -0
- package/dist/run.d.ts +39 -0
- package/dist/run.js +80 -0
- package/dist/skills.d.ts +69 -0
- package/dist/skills.js +256 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +50 -4
package/dist/program.js
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { dispatchOperation } from './dispatch.js';
|
|
4
|
+
import { addGitHostCommands } from './git-host.js';
|
|
5
|
+
import { shapeMetaEntries } from './manifest.js';
|
|
6
|
+
import { missingPermission } from './permissions.js';
|
|
7
|
+
import { addSkillsCommands } from './skills.js';
|
|
8
|
+
// The CLI is a thin, generic driver. It builds its whole command tree + docs at
|
|
9
|
+
// runtime from a manifest **fetched live** from the target server (see
|
|
10
|
+
// `manifest.ts`), keyed by each operation's `callPath` — the exact path used as
|
|
11
|
+
// `rl.<noun>.<verb>(...)` in the TS SDK. Adding/changing a backend endpoint → the
|
|
12
|
+
// command appears (or changes) after the backend deploys, with NO CLI release.
|
|
13
|
+
// Dispatch is generic (`dispatch.ts`): there is no per-operation code and no baked
|
|
14
|
+
// client.
|
|
15
|
+
/** camelCase → kebab-case for command + flag names (was `@labelbox/recursion-sdk/nesting`). */
|
|
16
|
+
export function kebab(value) {
|
|
17
|
+
return value.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean']);
|
|
20
|
+
// Flag names the CLI reserves for its own meta-options. If a generated operation
|
|
21
|
+
// ever declares a param whose kebab name collides with one of these, commander
|
|
22
|
+
// would silently register the flag twice (last-registration-wins), so we fail
|
|
23
|
+
// fast at build time instead. `from-json`/`data` are body meta-flags; the rest
|
|
24
|
+
// are program-level globals.
|
|
25
|
+
const RESERVED_FLAGS = new Set([
|
|
26
|
+
'from-json',
|
|
27
|
+
'data',
|
|
28
|
+
'api-key',
|
|
29
|
+
'base-url',
|
|
30
|
+
'quiet',
|
|
31
|
+
'help',
|
|
32
|
+
'version',
|
|
33
|
+
]);
|
|
34
|
+
// Top-level command names the CLI registers itself (the docs browse groups + the
|
|
35
|
+
// bespoke `skills` action group, added after the operation loop). An operation
|
|
36
|
+
// whose top-level `callPath` segment kebabs onto one of these would be silently
|
|
37
|
+
// shadowed — commander resolves duplicates to the first registration — so we
|
|
38
|
+
// reserve them and fail fast, mirroring RESERVED_FLAGS for flags.
|
|
39
|
+
const RESERVED_COMMANDS = new Set([
|
|
40
|
+
'resources',
|
|
41
|
+
'recipes',
|
|
42
|
+
'explain',
|
|
43
|
+
'tutorials',
|
|
44
|
+
'skills',
|
|
45
|
+
'scaffold',
|
|
46
|
+
'submit',
|
|
47
|
+
]);
|
|
48
|
+
const TRUE_VALUES = new Set(['true', '1', 'yes']);
|
|
49
|
+
const FALSE_VALUES = new Set(['false', '0', 'no']);
|
|
50
|
+
/** Coerce a string flag value to its declared scalar type. */
|
|
51
|
+
export function coerce(value, type) {
|
|
52
|
+
if (typeof value !== 'string')
|
|
53
|
+
return value;
|
|
54
|
+
if (type === 'number' || type === 'integer') {
|
|
55
|
+
const n = Number(value);
|
|
56
|
+
// Reject NaN and non-finite values (e.g. Infinity, 1e999): the latter
|
|
57
|
+
// JSON-serialize to null, which the API would silently misinterpret.
|
|
58
|
+
if (!Number.isFinite(n)) {
|
|
59
|
+
throw new Error(`invalid ${type} value ${JSON.stringify(value)} (expected a number)`);
|
|
60
|
+
}
|
|
61
|
+
if (type === 'integer' && !Number.isInteger(n)) {
|
|
62
|
+
throw new Error(`invalid integer value ${JSON.stringify(value)} (expected a whole number)`);
|
|
63
|
+
}
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
if (type === 'boolean') {
|
|
67
|
+
const lower = value.toLowerCase();
|
|
68
|
+
if (TRUE_VALUES.has(lower))
|
|
69
|
+
return true;
|
|
70
|
+
if (FALSE_VALUES.has(lower))
|
|
71
|
+
return false;
|
|
72
|
+
throw new Error(`invalid boolean value ${JSON.stringify(value)} (expected true or false)`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the flat options object from parsed CLI flags + a pre-parsed body base
|
|
78
|
+
* (from `--from-json` / `--data`). Path and query params and scalar body fields
|
|
79
|
+
* come from individual flags; scalar flags override the JSON base. Required scalar
|
|
80
|
+
* body fields are enforced here — after the flag and JSON sources are merged — so a
|
|
81
|
+
* value supplied via `--from-json`/`--data` satisfies the requirement just as its
|
|
82
|
+
* own flag would (a clear CLI error rather than a server-side 4xx).
|
|
83
|
+
*/
|
|
84
|
+
export function assembleParams(entry, opts, bodyBase) {
|
|
85
|
+
const params = {};
|
|
86
|
+
for (const param of entry.params) {
|
|
87
|
+
if (param.in === 'path' || param.in === 'query') {
|
|
88
|
+
const value = opts[param.name];
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
// A non-scalar query param (array/object) must reach dispatch as a real
|
|
92
|
+
// array/object — the query serializer dispatches on JS type, so a raw string
|
|
93
|
+
// would be mis-serialized. Parse those flag values as JSON; scalars use
|
|
94
|
+
// `coerce`. (Path params are always plain string segments.)
|
|
95
|
+
params[param.name] =
|
|
96
|
+
param.in === 'query' && !SCALAR_TYPES.has(param.type)
|
|
97
|
+
? parseJson(typeof value === 'string' ? value : '', `--${kebab(param.name)}`)
|
|
98
|
+
: coerce(value, param.type);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (entry.bodyKey) {
|
|
102
|
+
const body = { ...bodyBase };
|
|
103
|
+
const missing = [];
|
|
104
|
+
for (const param of entry.params) {
|
|
105
|
+
if (param.in === 'body' && SCALAR_TYPES.has(param.type)) {
|
|
106
|
+
const value = opts[param.name];
|
|
107
|
+
if (value !== undefined)
|
|
108
|
+
body[param.name] = coerce(value, param.type);
|
|
109
|
+
if (param.required && body[param.name] === undefined)
|
|
110
|
+
missing.push(kebab(param.name));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (missing.length > 0) {
|
|
114
|
+
throw new Error(`missing required field(s): ${missing.map((m) => `--${m}`).join(', ')} ` +
|
|
115
|
+
// Names only --data: it is the one body source present in every
|
|
116
|
+
// program, whereas --from-json is absent from the embedded one, and a
|
|
117
|
+
// message naming a flag that does not exist sends the reader hunting.
|
|
118
|
+
'(pass the flag or include it in the --data JSON body)');
|
|
119
|
+
}
|
|
120
|
+
params[entry.bodyKey] = body;
|
|
121
|
+
}
|
|
122
|
+
return params;
|
|
123
|
+
}
|
|
124
|
+
function isRecord(value) {
|
|
125
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
126
|
+
}
|
|
127
|
+
/** Parse JSON, surfacing a clean `error:`-friendly message tagged by source. */
|
|
128
|
+
function parseJson(text, source) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(text);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
134
|
+
throw new Error(`invalid JSON in ${source}: ${detail}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function parseBodyBase(opts) {
|
|
138
|
+
const { fromJson, data } = opts;
|
|
139
|
+
if (typeof fromJson === 'string' && typeof data === 'string') {
|
|
140
|
+
throw new Error('--from-json and --data are mutually exclusive; pass only one');
|
|
141
|
+
}
|
|
142
|
+
let parsed;
|
|
143
|
+
// Tracked so the failure below can name the flag the caller actually passed.
|
|
144
|
+
// Naming both would send a reader of the embedded program — where `--from-json`
|
|
145
|
+
// is not registered — hunting for a flag that does not exist there.
|
|
146
|
+
let source;
|
|
147
|
+
if (typeof fromJson === 'string') {
|
|
148
|
+
let contents;
|
|
149
|
+
try {
|
|
150
|
+
contents = readFileSync(fromJson, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error(`could not read --from-json file ${JSON.stringify(fromJson)}`);
|
|
154
|
+
}
|
|
155
|
+
source = '--from-json';
|
|
156
|
+
parsed = parseJson(contents, source);
|
|
157
|
+
}
|
|
158
|
+
else if (typeof data === 'string') {
|
|
159
|
+
source = '--data';
|
|
160
|
+
parsed = parseJson(data, source);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
if (!isRecord(parsed)) {
|
|
166
|
+
throw new Error(`${source} must contain a JSON object`);
|
|
167
|
+
}
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Render a result for stdout. In `--quiet` mode, prints the result's `id` (an
|
|
172
|
+
* empty line for results without one). Otherwise pretty-prints the JSON, or `OK`
|
|
173
|
+
* for a void response. The trailing newline is part of the returned string.
|
|
174
|
+
*/
|
|
175
|
+
export function formatOutput(result, quiet) {
|
|
176
|
+
if (quiet) {
|
|
177
|
+
const id = typeof result === 'object' && result !== null && 'id' in result ? result.id : undefined;
|
|
178
|
+
return `${id === undefined ? '' : String(id)}\n`;
|
|
179
|
+
}
|
|
180
|
+
return `${result === undefined ? 'OK' : JSON.stringify(result, null, 2)}\n`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Render a caught error for stderr. Generic dispatch throws the server's JSON error
|
|
184
|
+
* body (printed as-is) or an `Error` (its message). A raw, detail-less object (or a
|
|
185
|
+
* thrown empty value) maps to a message that points at the likely cause.
|
|
186
|
+
*/
|
|
187
|
+
export function formatError(err) {
|
|
188
|
+
if (err instanceof Error)
|
|
189
|
+
return err.message;
|
|
190
|
+
const json = JSON.stringify(err, null, 2);
|
|
191
|
+
if (json === undefined || json === '{}') {
|
|
192
|
+
return 'request failed — the server could not be reached (check --base-url and your network)';
|
|
193
|
+
}
|
|
194
|
+
return json;
|
|
195
|
+
}
|
|
196
|
+
// Per-key CLI tag labels. Driven by `shapeMetaEntries` (the shared spine in
|
|
197
|
+
// `manifest.ts`); `satisfies Record<ShapeMetaKey, string>` makes a new `ShapeNode`
|
|
198
|
+
// constraint a compile error here until it gets a label.
|
|
199
|
+
const CLI_META_LABELS = {
|
|
200
|
+
enum: 'enum',
|
|
201
|
+
itemEnum: 'values',
|
|
202
|
+
default: 'default',
|
|
203
|
+
format: 'format',
|
|
204
|
+
example: 'example',
|
|
205
|
+
minimum: 'min',
|
|
206
|
+
exclusiveMinimum: 'min',
|
|
207
|
+
maximum: 'max',
|
|
208
|
+
exclusiveMaximum: 'max',
|
|
209
|
+
minLength: 'minLength',
|
|
210
|
+
maxLength: 'maxLength',
|
|
211
|
+
minItems: 'minItems',
|
|
212
|
+
maxItems: 'maxItems',
|
|
213
|
+
pattern: 'pattern',
|
|
214
|
+
nullable: 'nullable',
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Bracketed spec-metadata tags for a node — allowed values, default, format,
|
|
218
|
+
* example, the numeric/length/size constraints, and nullability. Shared by the
|
|
219
|
+
* per-flag help and the request-body / returns shape trees so both surface the
|
|
220
|
+
* same facts. (Required-ness is conveyed separately: commander marks required
|
|
221
|
+
* path/query flags, and the tree marks optional fields with a `?`.)
|
|
222
|
+
*/
|
|
223
|
+
function metaTags(node) {
|
|
224
|
+
return shapeMetaEntries(node).map(([key, value]) => {
|
|
225
|
+
if (key === 'nullable')
|
|
226
|
+
return `[${CLI_META_LABELS[key]}]`;
|
|
227
|
+
const rendered = Array.isArray(value) ? value.join('|') : value;
|
|
228
|
+
return `[${CLI_META_LABELS[key]}: ${rendered}]`;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Help text for a flag: its description plus any spec metadata, bracketed. */
|
|
232
|
+
function helpText(param) {
|
|
233
|
+
const parts = [];
|
|
234
|
+
if (param.description)
|
|
235
|
+
parts.push(param.description);
|
|
236
|
+
parts.push(...metaTags(param));
|
|
237
|
+
// Body scalar fields are non-mandatory at the commander level (so --from-json
|
|
238
|
+
// can satisfy them) — commander's help won't mark them required, so surface it.
|
|
239
|
+
if (param.in === 'body' && param.required)
|
|
240
|
+
parts.push('[required]');
|
|
241
|
+
return parts.join(' ');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Render a request-body / response shape as an indented tree of lines. Each node
|
|
245
|
+
* shows `name` (or `name?` when optional), its type label, and its metadata tags,
|
|
246
|
+
* then recurses into whatever sub-shape it has.
|
|
247
|
+
*/
|
|
248
|
+
export function renderShapeTree(nodes, indent) {
|
|
249
|
+
return nodes.flatMap((node) => {
|
|
250
|
+
const label = node.name;
|
|
251
|
+
if (!label)
|
|
252
|
+
return [];
|
|
253
|
+
return renderShapeNode(node, label, indent);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** One node as `<label>: <type> <tags> — <desc>`, followed by its nested sub-shape. */
|
|
257
|
+
function renderShapeNode(node, label, indent) {
|
|
258
|
+
const optional = node.required === false ? '?' : '';
|
|
259
|
+
const tags = metaTags(node);
|
|
260
|
+
const desc = node.description ? ` — ${node.description}` : '';
|
|
261
|
+
const head = `${indent}${label}${optional}: ${node.type}${tags.length ? ` ${tags.join(' ')}` : ''}${desc}`;
|
|
262
|
+
return [head, ...renderShapeChildren(node, `${indent} `)];
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A node's nested sub-shape, recursed generically: a union lists each variant under
|
|
266
|
+
* `one of:`; an object renders its `fields`; an array descends into its `items`.
|
|
267
|
+
*/
|
|
268
|
+
function renderShapeChildren(node, indent) {
|
|
269
|
+
if (node.variants?.length) {
|
|
270
|
+
return [
|
|
271
|
+
`${indent}one of:`,
|
|
272
|
+
...node.variants.flatMap((variant, index) => renderShapeNode(variant, variant.name ?? `variant ${index + 1}`, `${indent} `)),
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
if (node.fields?.length) {
|
|
276
|
+
return renderShapeTree(node.fields, indent);
|
|
277
|
+
}
|
|
278
|
+
if (node.items) {
|
|
279
|
+
return renderShapeNode(node.items, 'items', indent);
|
|
280
|
+
}
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The "Request body" + "Returns" help sections appended after a leaf command's
|
|
285
|
+
* built-in help. The body section renders the body params' full nested shape; the
|
|
286
|
+
* returns section renders the success response's full shape — an object's fields,
|
|
287
|
+
* an `array of <element>` with the element's shape, or a scalar's type — or a note
|
|
288
|
+
* when the op returns no body.
|
|
289
|
+
*/
|
|
290
|
+
export function leafShapeHelp(entry) {
|
|
291
|
+
const sections = [];
|
|
292
|
+
const bodyParams = entry.params.filter((p) => p.in === 'body');
|
|
293
|
+
if (entry.bodyKey && bodyParams.length > 0) {
|
|
294
|
+
sections.push('Request body:', ...renderShapeTree(bodyParams, ' '), '');
|
|
295
|
+
}
|
|
296
|
+
const response = entry.response;
|
|
297
|
+
if (!response) {
|
|
298
|
+
sections.push('Returns: (no documented response body)');
|
|
299
|
+
}
|
|
300
|
+
else if (response.fields && response.fields.length > 0) {
|
|
301
|
+
const tags = metaTags(response);
|
|
302
|
+
const desc = response.description ? ` — ${response.description}` : '';
|
|
303
|
+
sections.push(`Returns: ${response.type}${tags.length ? ` ${tags.join(' ')}` : ''}${desc}`, ...renderShapeTree(response.fields, ' '));
|
|
304
|
+
}
|
|
305
|
+
else if (response.items) {
|
|
306
|
+
const item = response.items;
|
|
307
|
+
const tags = metaTags(response);
|
|
308
|
+
const desc = response.description ? ` — ${response.description}` : '';
|
|
309
|
+
sections.push(`Returns: array of ${item.type}${tags.length ? ` ${tags.join(' ')}` : ''}${desc}`, ...renderShapeNode(item, 'items', ' '));
|
|
310
|
+
}
|
|
311
|
+
else if (response.variants?.length) {
|
|
312
|
+
const tags = metaTags(response);
|
|
313
|
+
const desc = response.description ? ` — ${response.description}` : '';
|
|
314
|
+
sections.push(`Returns: ${response.type}${tags.length ? ` ${tags.join(' ')}` : ''}${desc}`, ...renderShapeChildren(response, ' '));
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const tags = metaTags(response);
|
|
318
|
+
const desc = response.description ? ` — ${response.description}` : '';
|
|
319
|
+
sections.push(`Returns: ${response.type}${tags.length ? ` ${tags.join(' ')}` : ''}${desc}`);
|
|
320
|
+
}
|
|
321
|
+
return `\n${sections.join('\n')}`;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Register an operation's flags.
|
|
325
|
+
*
|
|
326
|
+
* `fileFlags` is false when the program has no developer checkout — the embedded
|
|
327
|
+
* server case. `--from-json` reads a file from the *server's* filesystem there, so
|
|
328
|
+
* `assertAllowedArgv` refuses it; advertising a flag in `--help` that is then
|
|
329
|
+
* refused on use sends a caller looking for a permission or configuration problem
|
|
330
|
+
* that does not exist. Not registered at all, so `--help` shows only what works
|
|
331
|
+
* and `--data` is the single documented way to pass a body.
|
|
332
|
+
*/
|
|
333
|
+
export function addOptions(command, entry, fileFlags = true) {
|
|
334
|
+
// Flags already claimed on this command. A body field can legitimately repeat
|
|
335
|
+
// a path parameter's name — several upstream PATCH operations take the full
|
|
336
|
+
// resource type as their request body, so `agent_id` is both the path
|
|
337
|
+
// parameter and a property of the body schema. Commander throws when the same
|
|
338
|
+
// flag is added twice, and that throw happens while the whole program is
|
|
339
|
+
// being built, so one such operation takes down `rl` entirely rather than
|
|
340
|
+
// just itself. Path and query win: they address the resource and are what the
|
|
341
|
+
// URL is built from. The shadowed body field is still settable through
|
|
342
|
+
// `--from-json` / `--data`.
|
|
343
|
+
const claimed = new Set();
|
|
344
|
+
const ordered = [
|
|
345
|
+
...entry.params.filter((p) => p.in === 'path' || p.in === 'query'),
|
|
346
|
+
...entry.params.filter((p) => p.in !== 'path' && p.in !== 'query'),
|
|
347
|
+
];
|
|
348
|
+
for (const param of ordered) {
|
|
349
|
+
const registersFlag = param.in === 'path' ||
|
|
350
|
+
param.in === 'query' ||
|
|
351
|
+
(param.in === 'body' && SCALAR_TYPES.has(param.type));
|
|
352
|
+
if (!registersFlag)
|
|
353
|
+
continue;
|
|
354
|
+
const name = kebab(param.name);
|
|
355
|
+
if (claimed.has(name))
|
|
356
|
+
continue;
|
|
357
|
+
claimed.add(name);
|
|
358
|
+
if (RESERVED_FLAGS.has(name)) {
|
|
359
|
+
throw new Error(`operation "${entry.operationId}" declares param "${param.name}" whose flag ` +
|
|
360
|
+
`--${name} collides with a reserved CLI flag; rename it in the backend spec.`);
|
|
361
|
+
}
|
|
362
|
+
const flag = `--${name} <${name}>`;
|
|
363
|
+
const nonScalarQuery = param.in === 'query' && !SCALAR_TYPES.has(param.type);
|
|
364
|
+
const description = nonScalarQuery
|
|
365
|
+
? [helpText(param), '[pass as JSON]'].filter(Boolean).join(' ')
|
|
366
|
+
: helpText(param);
|
|
367
|
+
if (param.in === 'path') {
|
|
368
|
+
command.requiredOption(flag, description);
|
|
369
|
+
}
|
|
370
|
+
else if (param.in === 'query') {
|
|
371
|
+
if (param.required)
|
|
372
|
+
command.requiredOption(flag, description);
|
|
373
|
+
else
|
|
374
|
+
command.option(flag, description);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
// Scalar body field — always a plain option (requiredness enforced in
|
|
378
|
+
// assembleParams once flag + JSON sources are merged).
|
|
379
|
+
command.option(flag, description);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (entry.bodyKey) {
|
|
383
|
+
if (fileFlags) {
|
|
384
|
+
command.option('--from-json <file>', 'Read the request body from a JSON file');
|
|
385
|
+
}
|
|
386
|
+
command.option('--data <json>', 'Inline JSON request body');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// ── docs browse surfaces — one consistent shape: `rl <group> [<id>]` ─────────
|
|
390
|
+
//
|
|
391
|
+
// All four read-only browse groups (resources / recipes / explain / tutorials)
|
|
392
|
+
// take one positional shape: bare lists, an id shows that one. No `show`/`list`
|
|
393
|
+
// verbs (those are the action layer's, `rl <noun> <verb>`). All data is the live
|
|
394
|
+
// manifest; these commands are pure presentation (no further network).
|
|
395
|
+
// The Labelbox app host (not the recursion API base) — recipes/docs render in-app.
|
|
396
|
+
const RECIPE_DOCS_BASE_URL = 'https://app.labelbox.com';
|
|
397
|
+
/** Output formats `rl recipes <id>` can render, mapped to the composed snippet field. */
|
|
398
|
+
const RECIPE_FORMATS = { cli: 'cli', ts: 'sdk', curl: 'curl' };
|
|
399
|
+
function isRecipeFormat(value) {
|
|
400
|
+
return Object.hasOwn(RECIPE_FORMATS, value);
|
|
401
|
+
}
|
|
402
|
+
/** Group entries under their domain (in nav order), with anything domainless last. */
|
|
403
|
+
function groupByDomain(entries, domainOf, domains) {
|
|
404
|
+
const order = new Map(domains.map((d, i) => [d.id, { title: d.title, index: i }]));
|
|
405
|
+
const buckets = new Map();
|
|
406
|
+
for (const entry of entries) {
|
|
407
|
+
const key = domainOf(entry) ?? '';
|
|
408
|
+
const bucket = buckets.get(key) ?? [];
|
|
409
|
+
bucket.push(entry);
|
|
410
|
+
buckets.set(key, bucket);
|
|
411
|
+
}
|
|
412
|
+
const groups = [...buckets.entries()].map(([id, items]) => ({
|
|
413
|
+
id,
|
|
414
|
+
title: order.get(id)?.title ?? 'Other',
|
|
415
|
+
index: order.get(id)?.index ?? Number.MAX_SAFE_INTEGER,
|
|
416
|
+
entries: items,
|
|
417
|
+
}));
|
|
418
|
+
groups.sort((a, b) => a.index - b.index || a.title.localeCompare(b.title, 'en'));
|
|
419
|
+
return groups.map(({ title, entries: items }) => ({ title, entries: items }));
|
|
420
|
+
}
|
|
421
|
+
const ACTIONS_HELP_GROUP = 'Actions:';
|
|
422
|
+
const DOCS_HELP_GROUP = 'Documentation:';
|
|
423
|
+
const GIT_HOST_HELP_GROUP = 'Coding tasks:';
|
|
424
|
+
/** Nested resource groups (`rl problems --help`, etc.) — allowed subcommands. */
|
|
425
|
+
const COMMANDS_HELP_GROUP = 'Commands';
|
|
426
|
+
/** Nested resource groups — subcommands the caller can't run (separate help section). */
|
|
427
|
+
const UNAVAILABLE_HELP_GROUP = 'Unavailable (missing permission)';
|
|
428
|
+
// ── permission gating (per-caller) ───────────────────────────────────────────
|
|
429
|
+
//
|
|
430
|
+
// A command the caller can't run stays *visible* (nothing is hidden) but is
|
|
431
|
+
// grouped under "Unavailable (missing permission)", marked in its description,
|
|
432
|
+
// and pre-empted on invoke. The listing marker, the leaf `--help` note, and the
|
|
433
|
+
// invocation error all derive from the same missing-permission slug, so they
|
|
434
|
+
// can't disagree. When permissions are unknown (fail-open) `missing` is undefined
|
|
435
|
+
// everywhere and none of this fires.
|
|
436
|
+
/** A leaf command's description when the caller lacks a required permission. */
|
|
437
|
+
function gatedSummary(summary, missing) {
|
|
438
|
+
return missing === undefined ? summary : `requires \`${missing}\` — ${summary}`;
|
|
439
|
+
}
|
|
440
|
+
/** Banner above a resource group's `--help` when it lists unavailable subcommands. */
|
|
441
|
+
function permissionGroupBanner(gatedCount) {
|
|
442
|
+
const noun = gatedCount === 1 ? 'command requires a permission' : 'commands require permissions';
|
|
443
|
+
return `\n${gatedCount} ${noun} you do not have (listed under "${UNAVAILABLE_HELP_GROUP}").\n`;
|
|
444
|
+
}
|
|
445
|
+
/** The error printed when the caller invokes a command they lack permission for. */
|
|
446
|
+
function permissionDeniedMessage(missing) {
|
|
447
|
+
return `you don't have permission to run this command (requires \`${missing}\`)`;
|
|
448
|
+
}
|
|
449
|
+
/** A prominent note appended to a gated command's `--help` output. */
|
|
450
|
+
function permissionHelpNote(missing) {
|
|
451
|
+
return `\nYou don't have permission to run this command (requires \`${missing}\`).`;
|
|
452
|
+
}
|
|
453
|
+
/** A resource's domain: its own, else the nearest ancestor's (walking `parent`). */
|
|
454
|
+
function resolveResourceDomain(resource, byId) {
|
|
455
|
+
let current = resource;
|
|
456
|
+
const seen = new Set();
|
|
457
|
+
while (current && !seen.has(current.id)) {
|
|
458
|
+
if (current.domain)
|
|
459
|
+
return current.domain;
|
|
460
|
+
seen.add(current.id);
|
|
461
|
+
current =
|
|
462
|
+
current.parent && Object.hasOwn(byId, current.parent) ? byId[current.parent] : undefined;
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
/** The `rl <callPath>` invocation for an operation, with its required-flag hints. */
|
|
467
|
+
function operationInvocation(op) {
|
|
468
|
+
const flags = [];
|
|
469
|
+
for (const param of op.params) {
|
|
470
|
+
if (param.in === 'path' || (param.in === 'query' && param.required)) {
|
|
471
|
+
const name = kebab(param.name);
|
|
472
|
+
flags.push(`--${name} <${name}>`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (op.bodyKey)
|
|
476
|
+
flags.push('--data <json>');
|
|
477
|
+
// kebab each segment so the printed command matches the real one (the tree is
|
|
478
|
+
// built from `kebab(segment)`) — `rl environments attach-external-id`, not the
|
|
479
|
+
// camelCase callPath.
|
|
480
|
+
const command = op.callPath.map(kebab).join(' ');
|
|
481
|
+
return `rl ${command}${flags.length ? ` ${flags.join(' ')}` : ''}`;
|
|
482
|
+
}
|
|
483
|
+
/** `rl resources [<id>]` — the Reference hub (grouped browse, or one resource). */
|
|
484
|
+
function addResourcesCommand(program, manifest, io) {
|
|
485
|
+
const byId = manifest.resources;
|
|
486
|
+
program
|
|
487
|
+
.command('resources [id]')
|
|
488
|
+
.helpGroup(DOCS_HELP_GROUP)
|
|
489
|
+
.description('Browse resource reference hubs (run without an argument to list all)')
|
|
490
|
+
.action((id) => {
|
|
491
|
+
if (id === undefined) {
|
|
492
|
+
io.stdout(renderResourceList(manifest));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// `Object.hasOwn`, not `byId[id]`: a Zod `z.record` is a plain object, so a bare
|
|
496
|
+
// read walks the prototype chain — `rl resources constructor` would resolve to a
|
|
497
|
+
// truthy inherited member, bypass the not-found guard, and crash in the renderer.
|
|
498
|
+
const entry = Object.hasOwn(byId, id) ? byId[id] : undefined;
|
|
499
|
+
if (!entry) {
|
|
500
|
+
throw new Error(`unknown resource "${id}". Run \`rl resources\` to list resources.`);
|
|
501
|
+
}
|
|
502
|
+
io.stdout(renderResourceShow(entry, manifest));
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
function renderResourceList(manifest) {
|
|
506
|
+
const byId = manifest.resources;
|
|
507
|
+
const all = Object.values(byId);
|
|
508
|
+
const groups = groupByDomain(all, (r) => resolveResourceDomain(r, byId), manifest.domains);
|
|
509
|
+
const lines = ['Resources — run `rl resources <id>` for the full overview.', ''];
|
|
510
|
+
for (const group of groups) {
|
|
511
|
+
lines.push(group.title);
|
|
512
|
+
const sorted = group.entries.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id, 'en'));
|
|
513
|
+
for (const r of sorted) {
|
|
514
|
+
lines.push(r.summary ? ` ${r.id} — ${r.summary}` : ` ${r.id}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
518
|
+
}
|
|
519
|
+
function renderResourceShow(resource, manifest) {
|
|
520
|
+
const parts = [resource.title];
|
|
521
|
+
if (resource.summary)
|
|
522
|
+
parts.push(resource.summary);
|
|
523
|
+
if (resource.description)
|
|
524
|
+
parts.push('', resource.description);
|
|
525
|
+
if (resource.object && resource.object.fields.length > 0) {
|
|
526
|
+
parts.push('', `Object: ${resource.object.name}`, ...renderShapeTree(resource.object.fields, ' '));
|
|
527
|
+
}
|
|
528
|
+
const ops = resource.operationIds
|
|
529
|
+
// Object.hasOwn guards the prototype chain (matching the argv-driven lookups):
|
|
530
|
+
// `manifest.operations` is a Zod `z.record` plain object, so a stray operationId
|
|
531
|
+
// that's a JS prototype key (constructor/valueOf/…) would otherwise resolve to a
|
|
532
|
+
// truthy inherited member, survive the filter, and render as garbage.
|
|
533
|
+
.map((opId) => Object.hasOwn(manifest.operations, opId) ? manifest.operations[opId] : undefined)
|
|
534
|
+
.filter((op) => op !== undefined)
|
|
535
|
+
.sort((a, b) => a.callPath.join(' ').localeCompare(b.callPath.join(' '), 'en'));
|
|
536
|
+
if (ops.length > 0) {
|
|
537
|
+
parts.push('', 'Operations:');
|
|
538
|
+
for (const op of ops)
|
|
539
|
+
parts.push(` ${operationInvocation(op)} — ${op.summary}`);
|
|
540
|
+
}
|
|
541
|
+
const opIds = new Set(resource.operationIds);
|
|
542
|
+
const recipes = Object.values(manifest.recipes)
|
|
543
|
+
.filter((r) => r.steps.some((s) => s.operationId !== undefined && opIds.has(s.operationId)))
|
|
544
|
+
.sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
545
|
+
if (recipes.length > 0) {
|
|
546
|
+
parts.push('', 'Recipes that use this:');
|
|
547
|
+
for (const r of recipes)
|
|
548
|
+
parts.push(` ${r.id} — ${r.title}`);
|
|
549
|
+
}
|
|
550
|
+
return `${parts.join('\n')}\n`;
|
|
551
|
+
}
|
|
552
|
+
/** `rl recipes [<id>]` — How-to (list grouped by category, or one recipe). */
|
|
553
|
+
function addRecipesCommand(program, manifest, io) {
|
|
554
|
+
program
|
|
555
|
+
.command('recipes [id]')
|
|
556
|
+
.helpGroup(DOCS_HELP_GROUP)
|
|
557
|
+
.description('Browse documented recipes — multi-step, user-goal guides for the app')
|
|
558
|
+
.option('--format <format>', `Output format: ${Object.keys(RECIPE_FORMATS).join(' | ')}`, 'cli')
|
|
559
|
+
.action((id, opts) => {
|
|
560
|
+
if (id === undefined) {
|
|
561
|
+
io.stdout(renderRecipeList(manifest.recipes));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// Object.hasOwn guards the prototype chain (see the resources hub above).
|
|
565
|
+
const entry = Object.hasOwn(manifest.recipes, id) ? manifest.recipes[id] : undefined;
|
|
566
|
+
if (!entry) {
|
|
567
|
+
throw new Error(`unknown recipe "${id}". Run \`rl recipes\` to see all recipes.`);
|
|
568
|
+
}
|
|
569
|
+
const format = opts.format ?? 'cli';
|
|
570
|
+
// `Object.hasOwn`, not `format in RECIPE_FORMATS`: `in` walks the prototype
|
|
571
|
+
// chain, so `--format toString` (or valueOf/constructor/__proto__/…) would
|
|
572
|
+
// pass and then crash in renderRecipeShow with an unactionable TypeError.
|
|
573
|
+
// Mirrors `resolveSkill`'s own-key check in skills.controller.ts.
|
|
574
|
+
if (!isRecipeFormat(format)) {
|
|
575
|
+
throw new Error(`invalid --format "${format}" (expected: ${Object.keys(RECIPE_FORMATS).join(', ')})`);
|
|
576
|
+
}
|
|
577
|
+
io.stdout(renderRecipeShow(entry, format, manifest.recipes));
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/** The full catalog, grouped by category, for `rl recipes`. */
|
|
581
|
+
export function renderRecipeList(reference) {
|
|
582
|
+
const entries = Object.values(reference);
|
|
583
|
+
if (entries.length === 0)
|
|
584
|
+
return 'No recipes are available.\n';
|
|
585
|
+
const byCategory = new Map();
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
const group = byCategory.get(entry.category) ?? [];
|
|
588
|
+
group.push(entry);
|
|
589
|
+
byCategory.set(entry.category, group);
|
|
590
|
+
}
|
|
591
|
+
const lines = ['Recipes — run `rl recipes <id>` for the full walkthrough.', ''];
|
|
592
|
+
for (const category of [...byCategory.keys()].sort()) {
|
|
593
|
+
lines.push(category);
|
|
594
|
+
const group = byCategory.get(category) ?? [];
|
|
595
|
+
for (const entry of group.sort((a, b) => a.id.localeCompare(b.id, 'en'))) {
|
|
596
|
+
lines.push(` ${entry.id} — ${entry.title}`);
|
|
597
|
+
lines.push(` ${entry.goal}`);
|
|
598
|
+
}
|
|
599
|
+
lines.push('');
|
|
600
|
+
}
|
|
601
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The "Related" + "Unblocks" block for a recipe — its place in the graph. The
|
|
605
|
+
* stored links (`requires` / `variationOf` / `learnMore`) come off the recipe;
|
|
606
|
+
* **Unblocks** is *derived* (never stored): the recipes that name THIS one as a
|
|
607
|
+
* `requires` recipe, so an agent reading one recipe sees both what to do first
|
|
608
|
+
* and where it can go next. Returns `''` when the recipe is an island.
|
|
609
|
+
*/
|
|
610
|
+
export function renderRelatedBlock(entry, allRecipes) {
|
|
611
|
+
const related = entry.related;
|
|
612
|
+
const lines = [];
|
|
613
|
+
const requires = related?.requires ?? [];
|
|
614
|
+
if (requires.length > 0) {
|
|
615
|
+
lines.push(' Requires');
|
|
616
|
+
for (const req of requires) {
|
|
617
|
+
if (req.type === 'recipe') {
|
|
618
|
+
lines.push(` • ${req.id}`);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const via = req.via ? ` (via ${req.via.id})` : '';
|
|
622
|
+
lines.push(` • state: ${req.explanation}${via}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (related?.variationOf) {
|
|
627
|
+
lines.push(' Variation of');
|
|
628
|
+
lines.push(` • ${related.variationOf}`);
|
|
629
|
+
}
|
|
630
|
+
const learnMore = related?.learnMore ?? [];
|
|
631
|
+
if (learnMore.length > 0) {
|
|
632
|
+
lines.push(' Learn more');
|
|
633
|
+
for (const target of learnMore)
|
|
634
|
+
lines.push(` • ${target.type}: ${target.id}`);
|
|
635
|
+
}
|
|
636
|
+
// Unblocks — the inverse of `requires` (this recipe is a prerequisite of …).
|
|
637
|
+
// Matches the same edge set the cycle gate walks + the skill traverses: a
|
|
638
|
+
// direct `recipe` requirement AND a `state` requirement reached `via` this
|
|
639
|
+
// recipe, so the three surfaces agree on what counts as a prerequisite edge.
|
|
640
|
+
const unblocks = Object.values(allRecipes)
|
|
641
|
+
.filter((r) => (r.related?.requires ?? []).some((q) => (q.type === 'recipe' && q.id === entry.id) ||
|
|
642
|
+
(q.type === 'state' && q.via?.type === 'recipe' && q.via.id === entry.id)))
|
|
643
|
+
.map((r) => r.id)
|
|
644
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
645
|
+
if (unblocks.length > 0) {
|
|
646
|
+
lines.push(' Unblocks');
|
|
647
|
+
for (const id of unblocks)
|
|
648
|
+
lines.push(` • ${id}`);
|
|
649
|
+
}
|
|
650
|
+
return lines.length > 0 ? `Related\n${lines.join('\n')}\n` : '';
|
|
651
|
+
}
|
|
652
|
+
/** One recipe rendered for `rl recipes <id>`: goal + composed code + related links + a docs link. */
|
|
653
|
+
export function renderRecipeShow(entry, format, allRecipes) {
|
|
654
|
+
const { setup, main } = entry[RECIPE_FORMATS[format]];
|
|
655
|
+
const snippet = [setup, main].filter(Boolean).join('\n\n');
|
|
656
|
+
const relatedBlock = renderRelatedBlock(entry, allRecipes);
|
|
657
|
+
return [
|
|
658
|
+
entry.title,
|
|
659
|
+
entry.goal,
|
|
660
|
+
'',
|
|
661
|
+
snippet,
|
|
662
|
+
'',
|
|
663
|
+
...(relatedBlock ? [relatedBlock] : []),
|
|
664
|
+
// "Full docs", not "Learn more": the Related block above already has a
|
|
665
|
+
// "Learn more" subsection (related concepts/tutorials), and two same-named
|
|
666
|
+
// labels in agent-facing output is confusing. This is the recipe's own page.
|
|
667
|
+
`Full docs: ${RECIPE_DOCS_BASE_URL}/admin/docs?page=recipe:${entry.id}`,
|
|
668
|
+
'',
|
|
669
|
+
].join('\n');
|
|
670
|
+
}
|
|
671
|
+
/** `rl explain [<concept>]` — Explanation (list grouped by domain, or one page). */
|
|
672
|
+
function addExplainCommand(program, manifest, io) {
|
|
673
|
+
program
|
|
674
|
+
.command('explain [concept]')
|
|
675
|
+
.helpGroup(DOCS_HELP_GROUP)
|
|
676
|
+
.description('Browse explanation concept pages (run without an argument to list all)')
|
|
677
|
+
.action((concept) => {
|
|
678
|
+
if (concept === undefined) {
|
|
679
|
+
io.stdout(renderConceptList(manifest));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Object.hasOwn guards the prototype chain (see the resources hub above).
|
|
683
|
+
const entry = Object.hasOwn(manifest.concepts, concept)
|
|
684
|
+
? manifest.concepts[concept]
|
|
685
|
+
: undefined;
|
|
686
|
+
if (!entry) {
|
|
687
|
+
throw new Error(`unknown concept "${concept}". Run \`rl explain\` to list concepts.`);
|
|
688
|
+
}
|
|
689
|
+
io.stdout(renderConceptShow(entry));
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
function renderConceptList(manifest) {
|
|
693
|
+
const groups = groupByDomain(Object.values(manifest.concepts), (c) => c.domain, manifest.domains);
|
|
694
|
+
const lines = ['Explanations — run `rl explain <concept>` for the full page.', ''];
|
|
695
|
+
for (const group of groups) {
|
|
696
|
+
lines.push(group.title);
|
|
697
|
+
for (const c of group.entries.sort((a, b) => a.id.localeCompare(b.id, 'en'))) {
|
|
698
|
+
lines.push(` ${c.id} — ${c.title}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
702
|
+
}
|
|
703
|
+
function renderConceptShow(concept) {
|
|
704
|
+
const parts = [concept.body];
|
|
705
|
+
if (concept.related.length > 0) {
|
|
706
|
+
// Strip the `resource:` / `recipe:` / `concept:` kind prefix for a clean footer.
|
|
707
|
+
const related = concept.related.map((slug) => slug.replace(/^[a-z]+:/u, ''));
|
|
708
|
+
parts.push('', `Related: ${related.join(' · ')}`);
|
|
709
|
+
}
|
|
710
|
+
return `${parts.join('\n')}\n`;
|
|
711
|
+
}
|
|
712
|
+
/** `rl tutorials [<id>]` — Tutorials (the existing getting-started docs). */
|
|
713
|
+
function addTutorialsCommand(program, manifest, io) {
|
|
714
|
+
program
|
|
715
|
+
.command('tutorials [id]')
|
|
716
|
+
.helpGroup(DOCS_HELP_GROUP)
|
|
717
|
+
.description('Browse getting-started tutorials (run without an argument to list all)')
|
|
718
|
+
.action((id) => {
|
|
719
|
+
if (id === undefined) {
|
|
720
|
+
const lines = ['Tutorials — run `rl tutorials <id>` for the full text.', ''];
|
|
721
|
+
for (const t of manifest.tutorials)
|
|
722
|
+
lines.push(` ${t.id} — ${t.title}`);
|
|
723
|
+
io.stdout(`${lines.join('\n')}\n`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const entry = manifest.tutorials.find((t) => t.id === id);
|
|
727
|
+
if (!entry) {
|
|
728
|
+
throw new Error(`unknown tutorial "${id}". Run \`rl tutorials\` to list tutorials.`);
|
|
729
|
+
}
|
|
730
|
+
if (entry.body === null) {
|
|
731
|
+
io.stdout(`${entry.title}\n\nThis tutorial is a notebook — open it in the app: ` +
|
|
732
|
+
`${RECIPE_DOCS_BASE_URL}/admin/docs?page=${entry.id}\n`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
io.stdout(`${entry.body}\n`);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
/** The program shell — name, description, version, and the global options. */
|
|
739
|
+
export function buildBaseProgram(version, io) {
|
|
740
|
+
return (new Command('rl')
|
|
741
|
+
// Route commander's own output (help text, unknown-command errors, invalid
|
|
742
|
+
// option values) through the caller's sinks, and turn its `process.exit`
|
|
743
|
+
// calls into thrown `CommanderError`s.
|
|
744
|
+
//
|
|
745
|
+
// Both settings MUST be applied here, on the root, *before* any `.command()`
|
|
746
|
+
// call: commander copies `_outputConfiguration` and `_exitCallback` onto a
|
|
747
|
+
// subcommand at creation time (`copyInheritedSettings`), so applying them
|
|
748
|
+
// after the tree is built would leave every subcommand still writing to the
|
|
749
|
+
// process streams and still calling `process.exit`.
|
|
750
|
+
.configureOutput({ writeOut: io.stdout, writeErr: io.stderr })
|
|
751
|
+
.exitOverride()
|
|
752
|
+
.description('Command-line interface for the Recursion RL platform')
|
|
753
|
+
.version(version)
|
|
754
|
+
.option('--api-key <key>', 'API key (defaults to the LABELBOX_API_KEY env var)')
|
|
755
|
+
.option('--base-url <url>', 'Recursion API base URL (defaults to the RECURSION_BASE_URL env var, then the production host)')
|
|
756
|
+
.option('--quiet', 'Output only the resulting resource id (JSON otherwise)'));
|
|
757
|
+
}
|
|
758
|
+
/** Build the full `rl` program from a fetched manifest. */
|
|
759
|
+
export function buildProgram(manifest, ctx) {
|
|
760
|
+
// `ctx` structurally satisfies CliIo, so the root program — and by inheritance
|
|
761
|
+
// every command built below — writes to the caller's sinks and never exits.
|
|
762
|
+
const program = buildBaseProgram(ctx.version, ctx);
|
|
763
|
+
// Cache of group (non-leaf) commands by their joined path prefix so siblings
|
|
764
|
+
// share intermediate nodes (and each gets its own `--help`).
|
|
765
|
+
const groups = new Map();
|
|
766
|
+
// Parents whose `--help` should carry the unavailable-command banner.
|
|
767
|
+
const gatedCountByParent = new Map();
|
|
768
|
+
// Register a child command, failing fast if its name already exists under this
|
|
769
|
+
// parent (or shadows a reserved top-level group). `kebab()` is non-injective and
|
|
770
|
+
// commander registers duplicates silently — resolving `.find()` to the first — so
|
|
771
|
+
// a colliding op would otherwise become an unreachable command with no error.
|
|
772
|
+
const claim = (parent, name, source) => {
|
|
773
|
+
const clashesReserved = parent === program && RESERVED_COMMANDS.has(name);
|
|
774
|
+
if (clashesReserved || parent.commands.some((c) => c.name() === name)) {
|
|
775
|
+
throw new Error(`${source} maps to command "${name}"${parent === program ? '' : ` under "${parent.name()}"`}, ` +
|
|
776
|
+
'which is already taken — two operations’ call paths collide (or one hits a reserved ' +
|
|
777
|
+
'top-level command). Rename one in the backend spec.');
|
|
778
|
+
}
|
|
779
|
+
return parent.command(name);
|
|
780
|
+
};
|
|
781
|
+
const entries = Object.values(manifest.operations).sort((a, b) => {
|
|
782
|
+
const aDenied = missingPermission(a, ctx.granted) !== undefined ? 1 : 0;
|
|
783
|
+
const bDenied = missingPermission(b, ctx.granted) !== undefined ? 1 : 0;
|
|
784
|
+
if (aDenied !== bDenied)
|
|
785
|
+
return aDenied - bDenied;
|
|
786
|
+
return a.callPath.join(' ').localeCompare(b.callPath.join(' '), 'en');
|
|
787
|
+
});
|
|
788
|
+
for (const entry of entries) {
|
|
789
|
+
const parents = entry.callPath.slice(0, -1);
|
|
790
|
+
const leaf = entry.callPath[entry.callPath.length - 1];
|
|
791
|
+
if (leaf === undefined)
|
|
792
|
+
continue;
|
|
793
|
+
let parent = program;
|
|
794
|
+
let prefix = '';
|
|
795
|
+
for (const segment of parents) {
|
|
796
|
+
const key = prefix === '' ? segment : `${prefix}.${segment}`;
|
|
797
|
+
let group = groups.get(key);
|
|
798
|
+
if (!group) {
|
|
799
|
+
// Label the group with its resource's one-line summary. The synthesizer's
|
|
800
|
+
// tag is singular while its namespace is plural, so fall back to the
|
|
801
|
+
// singular form; sub-namespaces match no resource and keep a generic label.
|
|
802
|
+
const groupName = kebab(segment);
|
|
803
|
+
const resourceFor = (key) => Object.hasOwn(manifest.resources, key) ? manifest.resources[key] : undefined;
|
|
804
|
+
const resource = resourceFor(groupName) ?? resourceFor(groupName.replace(/s$/u, ''));
|
|
805
|
+
group = claim(parent, groupName, `operation "${entry.operationId}"`).description(resource?.summary ?? `${groupName} commands`);
|
|
806
|
+
if (parent === program)
|
|
807
|
+
group.helpGroup(ACTIONS_HELP_GROUP);
|
|
808
|
+
groups.set(key, group);
|
|
809
|
+
}
|
|
810
|
+
parent = group;
|
|
811
|
+
prefix = key;
|
|
812
|
+
}
|
|
813
|
+
// The first required permission the caller lacks (undefined → runnable /
|
|
814
|
+
// ungated / fail-open). Drives the listing marker, the help note, and the
|
|
815
|
+
// pre-emptive invocation error from one source so they always agree.
|
|
816
|
+
const missing = missingPermission(entry, ctx.granted);
|
|
817
|
+
const leafCommand = claim(parent, kebab(leaf), `operation "${entry.operationId}"`).description(gatedSummary(entry.summary, missing));
|
|
818
|
+
if (parent === program) {
|
|
819
|
+
leafCommand.helpGroup(missing === undefined ? ACTIONS_HELP_GROUP : UNAVAILABLE_HELP_GROUP);
|
|
820
|
+
}
|
|
821
|
+
else if (missing === undefined) {
|
|
822
|
+
leafCommand.helpGroup(COMMANDS_HELP_GROUP);
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
leafCommand.helpGroup(UNAVAILABLE_HELP_GROUP);
|
|
826
|
+
gatedCountByParent.set(parent, (gatedCountByParent.get(parent) ?? 0) + 1);
|
|
827
|
+
}
|
|
828
|
+
addOptions(leafCommand, entry, ctx.localCheckout);
|
|
829
|
+
leafCommand.addHelpText('after', leafShapeHelp(entry));
|
|
830
|
+
if (missing !== undefined)
|
|
831
|
+
leafCommand.addHelpText('after', permissionHelpNote(missing));
|
|
832
|
+
leafCommand.action(async (localOpts) => {
|
|
833
|
+
try {
|
|
834
|
+
// Pre-empt a doomed request with a clear local error, instead of letting
|
|
835
|
+
// it round-trip to a server 403.
|
|
836
|
+
if (missing !== undefined)
|
|
837
|
+
throw new Error(permissionDeniedMessage(missing));
|
|
838
|
+
const params = assembleParams(entry, localOpts, parseBodyBase(localOpts));
|
|
839
|
+
const result = await dispatchOperation(entry, params, {
|
|
840
|
+
apiKey: ctx.apiKey,
|
|
841
|
+
baseUrl: ctx.baseUrl,
|
|
842
|
+
});
|
|
843
|
+
const global = program.opts();
|
|
844
|
+
ctx.stdout(formatOutput(result, global.quiet === true));
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
// Normalize to an Error carrying the rendered text, then rethrow: commander
|
|
848
|
+
// rejects `parseAsync`, and `run()` is the single place that renders
|
|
849
|
+
// `error: <message>`. Dispatch throws the server's raw JSON error body (not
|
|
850
|
+
// an Error), so formatting has to happen here, while that shape is intact.
|
|
851
|
+
throw new Error(formatError(err));
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
for (const [parent, gatedCount] of gatedCountByParent) {
|
|
856
|
+
parent.addHelpText('before', permissionGroupBanner(gatedCount));
|
|
857
|
+
}
|
|
858
|
+
// The docs browse groups + the bespoke `rl skills` action group.
|
|
859
|
+
addResourcesCommand(program, manifest, ctx);
|
|
860
|
+
addRecipesCommand(program, manifest, ctx);
|
|
861
|
+
addExplainCommand(program, manifest, ctx);
|
|
862
|
+
addTutorialsCommand(program, manifest, ctx);
|
|
863
|
+
if (ctx.localCheckout) {
|
|
864
|
+
addSkillsCommands(program, DOCS_HELP_GROUP);
|
|
865
|
+
addGitHostCommands(program, GIT_HOST_HELP_GROUP);
|
|
866
|
+
}
|
|
867
|
+
program.configureHelp({ sortSubcommands: true });
|
|
868
|
+
program.commandsGroup(DOCS_HELP_GROUP);
|
|
869
|
+
// Lazy implicit help skips _initCommandGroup unless created via helpCommand(true).
|
|
870
|
+
program.helpCommand(true);
|
|
871
|
+
program.addHelpText('after', `
|
|
872
|
+
Run \`rl <command> --help\` for details and flags on any command.
|
|
873
|
+
Browse the full reference with \`rl resources\`, or guided walkthroughs with \`rl recipes\`.`);
|
|
874
|
+
return program;
|
|
875
|
+
}
|