@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.
- package/README.md +133 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +39 -0
- package/dist/compute-session.d.ts +135 -0
- package/dist/compute-session.js +373 -0
- package/dist/default-base-url.generated.d.ts +5 -0
- package/dist/default-base-url.generated.js +5 -0
- package/dist/dispatch.d.ts +40 -0
- package/dist/dispatch.js +265 -0
- package/dist/embed.d.ts +39 -0
- package/dist/embed.js +51 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +184 -0
- package/dist/json-operation-callability.d.ts +31 -0
- package/dist/json-operation-callability.js +57 -0
- package/dist/manifest.d.ts +531 -0
- package/dist/manifest.js +558 -0
- package/dist/permissions.d.ts +46 -0
- package/dist/permissions.js +106 -0
- package/dist/program.d.ts +129 -0
- package/dist/program.js +985 -0
- package/dist/request-timeout.d.ts +8 -0
- package/dist/request-timeout.js +33 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.js +111 -0
- package/dist/run.d.ts +44 -0
- package/dist/run.js +81 -0
- package/dist/skills.d.ts +73 -0
- package/dist/skills.js +235 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +61 -4
package/dist/program.js
ADDED
|
@@ -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
|
+
}
|