@labelbox/recursion-cli 0.0.0 → 0.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,879 @@
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', py: 'python', 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
+ // Optional on the wire: an older server predates the Python surface, so
655
+ // `--format py` renders the note rather than crashing on a missing snippet.
656
+ const composed = entry[RECIPE_FORMATS[format]];
657
+ const snippet = composed
658
+ ? [composed.setup, composed.main].filter(Boolean).join('\n\n')
659
+ : `(no ${format} example — the server that served this manifest does not compose one)`;
660
+ const relatedBlock = renderRelatedBlock(entry, allRecipes);
661
+ return [
662
+ entry.title,
663
+ entry.goal,
664
+ '',
665
+ snippet,
666
+ '',
667
+ ...(relatedBlock ? [relatedBlock] : []),
668
+ // "Full docs", not "Learn more": the Related block above already has a
669
+ // "Learn more" subsection (related concepts/tutorials), and two same-named
670
+ // labels in agent-facing output is confusing. This is the recipe's own page.
671
+ `Full docs: ${RECIPE_DOCS_BASE_URL}/admin/docs?page=recipe:${entry.id}`,
672
+ '',
673
+ ].join('\n');
674
+ }
675
+ /** `rl explain [<concept>]` — Explanation (list grouped by domain, or one page). */
676
+ function addExplainCommand(program, manifest, io) {
677
+ program
678
+ .command('explain [concept]')
679
+ .helpGroup(DOCS_HELP_GROUP)
680
+ .description('Browse explanation concept pages (run without an argument to list all)')
681
+ .action((concept) => {
682
+ if (concept === undefined) {
683
+ io.stdout(renderConceptList(manifest));
684
+ return;
685
+ }
686
+ // Object.hasOwn guards the prototype chain (see the resources hub above).
687
+ const entry = Object.hasOwn(manifest.concepts, concept)
688
+ ? manifest.concepts[concept]
689
+ : undefined;
690
+ if (!entry) {
691
+ throw new Error(`unknown concept "${concept}". Run \`rl explain\` to list concepts.`);
692
+ }
693
+ io.stdout(renderConceptShow(entry));
694
+ });
695
+ }
696
+ function renderConceptList(manifest) {
697
+ const groups = groupByDomain(Object.values(manifest.concepts), (c) => c.domain, manifest.domains);
698
+ const lines = ['Explanations — run `rl explain <concept>` for the full page.', ''];
699
+ for (const group of groups) {
700
+ lines.push(group.title);
701
+ for (const c of group.entries.sort((a, b) => a.id.localeCompare(b.id, 'en'))) {
702
+ lines.push(` ${c.id} — ${c.title}`);
703
+ }
704
+ }
705
+ return `${lines.join('\n').trimEnd()}\n`;
706
+ }
707
+ function renderConceptShow(concept) {
708
+ const parts = [concept.body];
709
+ if (concept.related.length > 0) {
710
+ // Strip the `resource:` / `recipe:` / `concept:` kind prefix for a clean footer.
711
+ const related = concept.related.map((slug) => slug.replace(/^[a-z]+:/u, ''));
712
+ parts.push('', `Related: ${related.join(' · ')}`);
713
+ }
714
+ return `${parts.join('\n')}\n`;
715
+ }
716
+ /** `rl tutorials [<id>]` — Tutorials (the existing getting-started docs). */
717
+ function addTutorialsCommand(program, manifest, io) {
718
+ program
719
+ .command('tutorials [id]')
720
+ .helpGroup(DOCS_HELP_GROUP)
721
+ .description('Browse getting-started tutorials (run without an argument to list all)')
722
+ .action((id) => {
723
+ if (id === undefined) {
724
+ const lines = ['Tutorials — run `rl tutorials <id>` for the full text.', ''];
725
+ for (const t of manifest.tutorials)
726
+ lines.push(` ${t.id} — ${t.title}`);
727
+ io.stdout(`${lines.join('\n')}\n`);
728
+ return;
729
+ }
730
+ const entry = manifest.tutorials.find((t) => t.id === id);
731
+ if (!entry) {
732
+ throw new Error(`unknown tutorial "${id}". Run \`rl tutorials\` to list tutorials.`);
733
+ }
734
+ if (entry.body === null) {
735
+ io.stdout(`${entry.title}\n\nThis tutorial is a notebook — open it in the app: ` +
736
+ `${RECIPE_DOCS_BASE_URL}/admin/docs?page=${entry.id}\n`);
737
+ return;
738
+ }
739
+ io.stdout(`${entry.body}\n`);
740
+ });
741
+ }
742
+ /** The program shell — name, description, version, and the global options. */
743
+ export function buildBaseProgram(version, io) {
744
+ return (new Command('rl')
745
+ // Route commander's own output (help text, unknown-command errors, invalid
746
+ // option values) through the caller's sinks, and turn its `process.exit`
747
+ // calls into thrown `CommanderError`s.
748
+ //
749
+ // Both settings MUST be applied here, on the root, *before* any `.command()`
750
+ // call: commander copies `_outputConfiguration` and `_exitCallback` onto a
751
+ // subcommand at creation time (`copyInheritedSettings`), so applying them
752
+ // after the tree is built would leave every subcommand still writing to the
753
+ // process streams and still calling `process.exit`.
754
+ .configureOutput({ writeOut: io.stdout, writeErr: io.stderr })
755
+ .exitOverride()
756
+ .description('Command-line interface for the Recursion RL platform')
757
+ .version(version)
758
+ .option('--api-key <key>', 'API key (defaults to the LABELBOX_API_KEY env var)')
759
+ .option('--base-url <url>', 'Recursion API base URL (defaults to the RECURSION_BASE_URL env var, then the production host)')
760
+ .option('--quiet', 'Output only the resulting resource id (JSON otherwise)'));
761
+ }
762
+ /** Build the full `rl` program from a fetched manifest. */
763
+ export function buildProgram(manifest, ctx) {
764
+ // `ctx` structurally satisfies CliIo, so the root program — and by inheritance
765
+ // every command built below — writes to the caller's sinks and never exits.
766
+ const program = buildBaseProgram(ctx.version, ctx);
767
+ // Cache of group (non-leaf) commands by their joined path prefix so siblings
768
+ // share intermediate nodes (and each gets its own `--help`).
769
+ const groups = new Map();
770
+ // Parents whose `--help` should carry the unavailable-command banner.
771
+ const gatedCountByParent = new Map();
772
+ // Register a child command, failing fast if its name already exists under this
773
+ // parent (or shadows a reserved top-level group). `kebab()` is non-injective and
774
+ // commander registers duplicates silently — resolving `.find()` to the first — so
775
+ // a colliding op would otherwise become an unreachable command with no error.
776
+ const claim = (parent, name, source) => {
777
+ const clashesReserved = parent === program && RESERVED_COMMANDS.has(name);
778
+ if (clashesReserved || parent.commands.some((c) => c.name() === name)) {
779
+ throw new Error(`${source} maps to command "${name}"${parent === program ? '' : ` under "${parent.name()}"`}, ` +
780
+ 'which is already taken — two operations’ call paths collide (or one hits a reserved ' +
781
+ 'top-level command). Rename one in the backend spec.');
782
+ }
783
+ return parent.command(name);
784
+ };
785
+ const entries = Object.values(manifest.operations).sort((a, b) => {
786
+ const aDenied = missingPermission(a, ctx.granted) !== undefined ? 1 : 0;
787
+ const bDenied = missingPermission(b, ctx.granted) !== undefined ? 1 : 0;
788
+ if (aDenied !== bDenied)
789
+ return aDenied - bDenied;
790
+ return a.callPath.join(' ').localeCompare(b.callPath.join(' '), 'en');
791
+ });
792
+ for (const entry of entries) {
793
+ const parents = entry.callPath.slice(0, -1);
794
+ const leaf = entry.callPath[entry.callPath.length - 1];
795
+ if (leaf === undefined)
796
+ continue;
797
+ let parent = program;
798
+ let prefix = '';
799
+ for (const segment of parents) {
800
+ const key = prefix === '' ? segment : `${prefix}.${segment}`;
801
+ let group = groups.get(key);
802
+ if (!group) {
803
+ // Label the group with its resource's one-line summary. The synthesizer's
804
+ // tag is singular while its namespace is plural, so fall back to the
805
+ // singular form; sub-namespaces match no resource and keep a generic label.
806
+ const groupName = kebab(segment);
807
+ const resourceFor = (key) => Object.hasOwn(manifest.resources, key) ? manifest.resources[key] : undefined;
808
+ const resource = resourceFor(groupName) ?? resourceFor(groupName.replace(/s$/u, ''));
809
+ group = claim(parent, groupName, `operation "${entry.operationId}"`).description(resource?.summary ?? `${groupName} commands`);
810
+ if (parent === program)
811
+ group.helpGroup(ACTIONS_HELP_GROUP);
812
+ groups.set(key, group);
813
+ }
814
+ parent = group;
815
+ prefix = key;
816
+ }
817
+ // The first required permission the caller lacks (undefined → runnable /
818
+ // ungated / fail-open). Drives the listing marker, the help note, and the
819
+ // pre-emptive invocation error from one source so they always agree.
820
+ const missing = missingPermission(entry, ctx.granted);
821
+ const leafCommand = claim(parent, kebab(leaf), `operation "${entry.operationId}"`).description(gatedSummary(entry.summary, missing));
822
+ if (parent === program) {
823
+ leafCommand.helpGroup(missing === undefined ? ACTIONS_HELP_GROUP : UNAVAILABLE_HELP_GROUP);
824
+ }
825
+ else if (missing === undefined) {
826
+ leafCommand.helpGroup(COMMANDS_HELP_GROUP);
827
+ }
828
+ else {
829
+ leafCommand.helpGroup(UNAVAILABLE_HELP_GROUP);
830
+ gatedCountByParent.set(parent, (gatedCountByParent.get(parent) ?? 0) + 1);
831
+ }
832
+ addOptions(leafCommand, entry, ctx.localCheckout);
833
+ leafCommand.addHelpText('after', leafShapeHelp(entry));
834
+ if (missing !== undefined)
835
+ leafCommand.addHelpText('after', permissionHelpNote(missing));
836
+ leafCommand.action(async (localOpts) => {
837
+ try {
838
+ // Pre-empt a doomed request with a clear local error, instead of letting
839
+ // it round-trip to a server 403.
840
+ if (missing !== undefined)
841
+ throw new Error(permissionDeniedMessage(missing));
842
+ const params = assembleParams(entry, localOpts, parseBodyBase(localOpts));
843
+ const result = await dispatchOperation(entry, params, {
844
+ apiKey: ctx.apiKey,
845
+ baseUrl: ctx.baseUrl,
846
+ });
847
+ const global = program.opts();
848
+ ctx.stdout(formatOutput(result, global.quiet === true));
849
+ }
850
+ catch (err) {
851
+ // Normalize to an Error carrying the rendered text, then rethrow: commander
852
+ // rejects `parseAsync`, and `run()` is the single place that renders
853
+ // `error: <message>`. Dispatch throws the server's raw JSON error body (not
854
+ // an Error), so formatting has to happen here, while that shape is intact.
855
+ throw new Error(formatError(err));
856
+ }
857
+ });
858
+ }
859
+ for (const [parent, gatedCount] of gatedCountByParent) {
860
+ parent.addHelpText('before', permissionGroupBanner(gatedCount));
861
+ }
862
+ // The docs browse groups + the bespoke `rl skills` action group.
863
+ addResourcesCommand(program, manifest, ctx);
864
+ addRecipesCommand(program, manifest, ctx);
865
+ addExplainCommand(program, manifest, ctx);
866
+ addTutorialsCommand(program, manifest, ctx);
867
+ if (ctx.localCheckout) {
868
+ addSkillsCommands(program, DOCS_HELP_GROUP);
869
+ addGitHostCommands(program, GIT_HOST_HELP_GROUP);
870
+ }
871
+ program.configureHelp({ sortSubcommands: true });
872
+ program.commandsGroup(DOCS_HELP_GROUP);
873
+ // Lazy implicit help skips _initCommandGroup unless created via helpCommand(true).
874
+ program.helpCommand(true);
875
+ program.addHelpText('after', `
876
+ Run \`rl <command> --help\` for details and flags on any command.
877
+ Browse the full reference with \`rl resources\`, or guided walkthroughs with \`rl recipes\`.`);
878
+ return program;
879
+ }