@labelbox/horizon-cli 0.0.0-stage → 0.0.1

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