@labelbox/recursion-cli 0.0.0 → 0.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import process from 'node:process';
6
+ import { z } from 'zod';
7
+ // The live CLI fetches its entire command surface — operations, request/response
8
+ // shapes, and the docs browse data (resources / recipes / concepts / tutorials) —
9
+ // from `GET /cli/manifest` on whatever server `--base-url` points at, instead of
10
+ // baking a compiled-in `@labelbox/recursion-sdk` reference. This module owns:
11
+ // 1. the manifest's runtime Zod schema + TS types (the validation boundary), and
12
+ // 2. `fetchManifest` — a conditional-fetch cache (ETag / If-None-Match) that can
13
+ // never serve stale data and needs no manual busting.
14
+ // The schema lives here, not in sdk-ts: the CLI no longer depends on that package
15
+ // (see the plan's standalone-CLI decision). The backend embeds the same manifest at
16
+ // build time (`dx manifest:generate`); this is the consumer mirror.
17
+ /**
18
+ * The DEFAULT production base URL: the public API gateway origin. No path
19
+ * suffix — the API version lives in each operation's path (`/v1/...`), so the
20
+ * CLI joins this origin with the manifest's paths verbatim.
21
+ *
22
+ * Keep in sync with `DEFAULT_BASE_URL` in `packages/sdk-ts/src/index.ts`,
23
+ * `PUBLIC_API_BASE_URL` in `packages/sdk-ts/openapi-ts.config.ts`, and
24
+ * `CURL_BASE_URL` in `tools/dx/src/commands/sdk.ts`.
25
+ */
26
+ export const DEFAULT_BASE_URL = 'https://api.recursion.labelbox.com';
27
+ /**
28
+ * The platform's API version segment, as it appears in every canonical path.
29
+ *
30
+ * Spec-derived operations get this for free — `buildUrl` joins the manifest's
31
+ * `op.path`, which already carries it. The CLI's few HAND-WRITTEN support calls
32
+ * (`/cli/manifest`, `/me/permissions`, `/skills`) have no manifest entry to read
33
+ * it from, so they must supply it themselves via `supportUrl` below. Omitting it
34
+ * is a 404 against a bare-origin base URL, and `fetchManifest` treats a 404 as
35
+ * fatal (only a *network* error falls back to cache), so the CLI cannot build any
36
+ * command at all — the reason this is a single helper rather than three string
37
+ * literals.
38
+ */
39
+ const API_VERSION_SEGMENT = 'v1';
40
+ /**
41
+ * Join a hand-written support-endpoint path onto the base URL, under the API
42
+ * version segment. `path` is version-relative and must start with `/`.
43
+ */
44
+ export function supportUrl(baseUrl, path) {
45
+ return `${baseUrl.replace(/\/$/u, '')}/${API_VERSION_SEGMENT}${path}`;
46
+ }
47
+ /**
48
+ * The manifest format the engine understands. Bumped only on a *breaking* schema
49
+ * change (a renamed/removed required field, a changed enum) — additive fields are
50
+ * backward-compatible because the schema strips unknown keys rather than rejecting
51
+ * them, so a newer server never breaks an older CLI by adding data. A mismatch is
52
+ * surfaced with an actionable upgrade message at the validation boundary.
53
+ */
54
+ export const MANIFEST_FORMAT_VERSION = 1;
55
+ const ScalarValueSchema = z.union([z.string(), z.number(), z.boolean()]);
56
+ // Lenient (no `.strict()`): unknown keys are stripped, never rejected — this is
57
+ // what makes an additive backend change safe for an older CLI (see the format-version
58
+ // note above). Recursive fields defer via `z.lazy` to the exported alias below.
59
+ const ShapeNodeObjectSchema = z.object({
60
+ name: z.string().optional(),
61
+ type: z.string(),
62
+ required: z.boolean().optional(),
63
+ description: z.string().optional(),
64
+ enum: z.array(z.string()).optional(),
65
+ itemEnum: z.array(z.string()).optional(),
66
+ default: ScalarValueSchema.optional(),
67
+ format: z.string().optional(),
68
+ example: ScalarValueSchema.optional(),
69
+ nullable: z.boolean().optional(),
70
+ minimum: z.number().optional(),
71
+ maximum: z.number().optional(),
72
+ exclusiveMinimum: z.number().optional(),
73
+ exclusiveMaximum: z.number().optional(),
74
+ minLength: z.number().optional(),
75
+ maxLength: z.number().optional(),
76
+ minItems: z.number().optional(),
77
+ maxItems: z.number().optional(),
78
+ pattern: z.string().optional(),
79
+ fields: z.array(z.lazy(() => ShapeNodeSchema)).optional(),
80
+ items: z.lazy(() => ShapeNodeSchema).optional(),
81
+ variants: z.array(z.lazy(() => ShapeNodeSchema)).optional(),
82
+ });
83
+ export const ShapeNodeSchema = ShapeNodeObjectSchema;
84
+ function shapeMetaKeys(values) {
85
+ return values;
86
+ }
87
+ // Canonical render order. The helper requires every ShapeMetaKey exactly once
88
+ // and rejects extra keys, keeping this in sync with ShapeNode and CLI_META_LABELS.
89
+ const SHAPE_META_KEYS = shapeMetaKeys([
90
+ 'enum',
91
+ 'itemEnum',
92
+ 'default',
93
+ 'format',
94
+ 'example',
95
+ 'minimum',
96
+ 'exclusiveMinimum',
97
+ 'maximum',
98
+ 'exclusiveMaximum',
99
+ 'minLength',
100
+ 'maxLength',
101
+ 'minItems',
102
+ 'maxItems',
103
+ 'pattern',
104
+ 'nullable',
105
+ ]);
106
+ /** A node's present metadata as ordered `[key, value]` pairs (drives the CLI tags). */
107
+ export function shapeMetaEntries(node) {
108
+ const entries = [];
109
+ for (const key of SHAPE_META_KEYS) {
110
+ const value = node[key];
111
+ if (value === undefined)
112
+ continue;
113
+ if (key === 'enum' || key === 'itemEnum') {
114
+ if (Array.isArray(value) && value.length > 0)
115
+ entries.push([key, value]);
116
+ }
117
+ else if (key === 'nullable') {
118
+ if (value === true)
119
+ entries.push([key, true]);
120
+ }
121
+ else if (key === 'exclusiveMinimum') {
122
+ entries.push([key, `>${value}`]);
123
+ }
124
+ else if (key === 'exclusiveMaximum') {
125
+ entries.push([key, `<${value}`]);
126
+ }
127
+ else {
128
+ entries.push([key, value]);
129
+ }
130
+ }
131
+ return entries;
132
+ }
133
+ // ── Operations — the executable command surface ──────────────────────────────
134
+ /** A request param: a named shape node plus its HTTP location (for generic dispatch). */
135
+ export const ManifestParamSchema = ShapeNodeObjectSchema.extend({
136
+ name: z.string(),
137
+ required: z.boolean(),
138
+ in: z.enum(['path', 'query', 'body']),
139
+ });
140
+ /**
141
+ * One operation, carrying everything the CLI needs to build its command and
142
+ * dispatch generically: the `callPath` (command tree), the `httpMethod` + `path`
143
+ * template + `params[].in` + `bodyKey` (dispatch), and the recursive request/response
144
+ * shapes (`--help`). Mirrors the relevant subset of sdk-ts's `SdkReferenceEntry`.
145
+ */
146
+ export const ManifestOperationSchema = z.object({
147
+ operationId: z.string(),
148
+ callPath: z.array(z.string()).min(1),
149
+ summary: z.string(),
150
+ description: z.string().optional(),
151
+ httpMethod: z.enum(['get', 'post', 'put', 'patch', 'delete']),
152
+ path: z.string(),
153
+ bodyKey: z.string().optional(),
154
+ // Permissions the caller must hold to run this command (from the backend's
155
+ // `@RequirePermissions`). Absent → the command is never gated. The CLI marks
156
+ // commands the caller can't run and pre-empts them with a clear error, checked
157
+ // against the caller's `/me/permissions` set (see permissions.ts).
158
+ requiredPermissions: z.array(z.string()).optional(),
159
+ params: z.array(ManifestParamSchema),
160
+ response: ShapeNodeSchema.optional(),
161
+ });
162
+ // ── Resources — the Reference browse surface (`rl resources [<id>]`) ──────────
163
+ const ManifestResourceObjectSchema = z.object({
164
+ name: z.string(),
165
+ fields: z.array(ShapeNodeSchema),
166
+ });
167
+ export const ManifestResourceSchema = z.object({
168
+ id: z.string(),
169
+ title: z.string(),
170
+ parent: z.string().optional(),
171
+ order: z.number(),
172
+ domain: z.string().optional(),
173
+ summary: z.string().optional(),
174
+ description: z.string().optional(),
175
+ object: ManifestResourceObjectSchema.optional(),
176
+ operationIds: z.array(z.string()),
177
+ });
178
+ // ── Recipes — the How-to browse surface (`rl recipes [<id>]`) ─────────────────
179
+ const RecipeSnippetSchema = z.object({ setup: z.string(), main: z.string() });
180
+ // Steps are read only to map a recipe to the resources it touches (via each SDK
181
+ // step's operationId), so they're modeled loosely — just the fields the CLI uses.
182
+ const ManifestRecipeStepSchema = z.object({ operationId: z.string().optional() });
183
+ // A recipe's place in the relationship graph (mirrors `RecipeRelated` in
184
+ // sdk-ts's recipes-schema). Modeled loosely here — the CLI only reads these to
185
+ // render the "Related" / "Unblocks" block on `rl recipes <id>`; the generator
186
+ // is the source of truth that validates them. Without these fields the manifest
187
+ // parse would silently strip `related`, so the CLI would never see the links.
188
+ const ManifestLinkTargetSchema = z.object({
189
+ type: z.enum(['recipe', 'concept', 'tutorial', 'resource']),
190
+ id: z.string(),
191
+ });
192
+ const ManifestRequiresEntrySchema = z.union([
193
+ z.object({ type: z.literal('recipe'), id: z.string() }),
194
+ z.object({
195
+ type: z.literal('state'),
196
+ explanation: z.string(),
197
+ predicate: z.string().optional(),
198
+ via: ManifestLinkTargetSchema.optional(),
199
+ }),
200
+ ]);
201
+ export const ManifestRelatedSchema = z.object({
202
+ requires: z.array(ManifestRequiresEntrySchema).optional(),
203
+ variationOf: z.string().optional(),
204
+ learnMore: z.array(ManifestLinkTargetSchema).optional(),
205
+ });
206
+ export const ManifestRecipeSchema = z.object({
207
+ id: z.string(),
208
+ title: z.string(),
209
+ goal: z.string(),
210
+ category: z.string(),
211
+ steps: z.array(ManifestRecipeStepSchema),
212
+ sdk: RecipeSnippetSchema,
213
+ // Optional on the WIRE, though the producer always emits it. The
214
+ // version-mismatch check is a strict `!==` in both directions, so making this
215
+ // required would abort the whole command tree of every already-installed CLI
216
+ // the moment a newer server deployed — for a docs-only field an older CLI
217
+ // would otherwise just strip. `manifest.test.ts` asserts the producer emits
218
+ // every key this schema declares, so the field is still guaranteed present in
219
+ // practice without a required-field bump. See `.claude/rules/packages-cli.md`:
220
+ // a backend change must need no CLI release.
221
+ python: RecipeSnippetSchema.optional(),
222
+ cli: RecipeSnippetSchema,
223
+ curl: RecipeSnippetSchema,
224
+ // The recipe's relationship links (optional — absent for an island recipe).
225
+ related: ManifestRelatedSchema.optional(),
226
+ });
227
+ // ── Concepts — the Explanation browse surface (`rl explain [<concept>]`) ──────
228
+ export const ManifestConceptSchema = z.object({
229
+ id: z.string(),
230
+ title: z.string(),
231
+ domain: z.string(),
232
+ related: z.array(z.string()),
233
+ // The markdown body, added by the assembler (the generated reference holds only
234
+ // metadata — the frontend fetches the `.md` separately; the CLI bundles it).
235
+ body: z.string(),
236
+ });
237
+ // ── Tutorials — the getting-started docs (`rl tutorials [<id>]`) ──────────────
238
+ export const ManifestTutorialSchema = z.object({
239
+ id: z.string(),
240
+ title: z.string(),
241
+ // The markdown prose, or `null` for a notebook (listed as a link, never dumped).
242
+ body: z.string().nullable(),
243
+ });
244
+ // ── Domains — the product taxonomy that groups the browse surfaces ────────────
245
+ export const ManifestDomainSchema = z.object({
246
+ id: z.string(),
247
+ title: z.string(),
248
+ order: z.number(),
249
+ });
250
+ // ── The envelope ─────────────────────────────────────────────────────────────
251
+ export const ManifestSchema = z.object({
252
+ formatVersion: z.number(),
253
+ hash: z.string(),
254
+ operations: z.record(z.string(), ManifestOperationSchema),
255
+ resources: z.record(z.string(), ManifestResourceSchema),
256
+ recipes: z.record(z.string(), ManifestRecipeSchema),
257
+ concepts: z.record(z.string(), ManifestConceptSchema),
258
+ tutorials: z.array(ManifestTutorialSchema),
259
+ domains: z.array(ManifestDomainSchema),
260
+ });
261
+ /**
262
+ * Validate a fetched/cached manifest at the I/O boundary and check engine↔manifest
263
+ * format compatibility. A version mismatch is the one case worth an actionable
264
+ * message: the schema itself stays lenient (so additive server changes are safe),
265
+ * and only a deliberate format bump trips this.
266
+ */
267
+ export function parseManifest(value, source) {
268
+ // Peek at the format version BEFORE the full-shape parse. The version bumps only
269
+ // on a *breaking* schema change (renamed/removed required field, changed enum) —
270
+ // which would also fail `ManifestSchema.safeParse`. Validating shape first would
271
+ // therefore shadow the actionable upgrade hint with a generic zod dump for exactly
272
+ // the case the version field exists to flag.
273
+ const versionPeek = z.object({ formatVersion: z.number() }).safeParse(value);
274
+ if (versionPeek.success && versionPeek.data.formatVersion !== MANIFEST_FORMAT_VERSION) {
275
+ const { formatVersion } = versionPeek.data;
276
+ const direction = formatVersion > MANIFEST_FORMAT_VERSION
277
+ ? // No uninstall hint here: anyone who can print this message is already
278
+ // running @labelbox/recursion-cli, which per the EEXIST bin collision
279
+ // means no pre-rename package (@labelbox/rl-cli, or the older
280
+ // @recursion/cli) owns the `rl` bin. A pre-rename user sees the
281
+ // pre-rename binary's own message instead.
282
+ 'this `rl` CLI is out of date — upgrade it (`npm install -g @labelbox/recursion-cli`)'
283
+ : 'the server is older than this CLI — point --base-url at an up-to-date server';
284
+ throw new Error(`command-manifest format mismatch: this CLI speaks v${MANIFEST_FORMAT_VERSION}, ` +
285
+ `${source} served v${formatVersion}. ${direction}.`);
286
+ }
287
+ const parsed = ManifestSchema.safeParse(value);
288
+ if (!parsed.success) {
289
+ throw new Error(`the command manifest from ${source} has an unexpected shape: ${parsed.error.message}`);
290
+ }
291
+ return parsed.data;
292
+ }
293
+ // ── Conditional-fetch cache (no TTL, never hand-busted) ──────────────────────
294
+ /** What we persist per base-url: the transport ETag + the last manifest payload. */
295
+ const CacheSchema = z.object({ etag: z.string().optional(), manifest: z.unknown() });
296
+ /**
297
+ * A filesystem-safe, collision-free slug for a base URL, so each server caches
298
+ * independently. The readable part is the sanitized URL (handy when eyeballing the
299
+ * cache dir); a short hash of the *full* URL is appended so two URLs that sanitize
300
+ * to the same string (e.g. `https://x.com:8080` vs `https://x-com-8080`) still get
301
+ * distinct cache files rather than silently sharing — and poisoning — one.
302
+ */
303
+ function hostSlug(baseUrl) {
304
+ const readable = baseUrl.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') || 'default';
305
+ const hash = createHash('sha256').update(baseUrl, 'utf8').digest('hex').slice(0, 8);
306
+ return `${readable}-${hash}`;
307
+ }
308
+ function cachePathFor(baseUrl) {
309
+ return join(homedir(), '.cache', 'rl-gym', `${hostSlug(baseUrl)}.json`);
310
+ }
311
+ function readCache(path) {
312
+ let raw;
313
+ try {
314
+ raw = readFileSync(path, 'utf8');
315
+ }
316
+ catch {
317
+ return undefined;
318
+ }
319
+ let json;
320
+ try {
321
+ json = JSON.parse(raw);
322
+ }
323
+ catch {
324
+ return undefined; // corrupt cache → treat as cold (will refetch).
325
+ }
326
+ const parsed = CacheSchema.safeParse(json);
327
+ if (!parsed.success)
328
+ return undefined;
329
+ // The stored manifest must still parse for the cache to be usable. A cache whose
330
+ // body is corrupt — or in a format this CLI rejects — must NOT seed `If-None-Match`:
331
+ // a 304 would reuse the bad payload and trap the CLI in a revalidation loop the
332
+ // server can never break (it keeps answering 304, so no fresh 200 body ever
333
+ // arrives). Treating it as cold lets the next fetch pull — and re-cache — a full
334
+ // body, so the CLI self-heals without a manual cache delete.
335
+ try {
336
+ return { etag: parsed.data.etag, manifest: parseManifest(parsed.data.manifest, 'the cache') };
337
+ }
338
+ catch {
339
+ return undefined;
340
+ }
341
+ }
342
+ function writeCache(path, entry) {
343
+ try {
344
+ mkdirSync(dirname(path), { recursive: true });
345
+ writeFileSync(path, JSON.stringify(entry));
346
+ }
347
+ catch {
348
+ // A non-writable cache dir is a bandwidth optimization lost, not a failure —
349
+ // the command still ran off the fetched manifest. Stay silent.
350
+ }
351
+ }
352
+ // Every `rl` invocation gates on this fetch, so it must be bounded: a server that
353
+ // accepts the connection but never responds (a hung gateway, a stalled captive
354
+ // portal) would otherwise hang the CLI forever. A timeout makes `fetch` reject, so
355
+ // the catch below degrades to the cached manifest (or the clear cold-cache error).
356
+ const MANIFEST_FETCH_TIMEOUT_MS = 30_000;
357
+ /**
358
+ * Fetch the command manifest, revalidating the cache on every run:
359
+ * - send `If-None-Match` with the cached ETag → `304` means the cache is provably
360
+ * current (use it); `200` means the surface changed (validate + replace cache).
361
+ * - a network error (incl. a timeout) falls back to the cached copy with a warning;
362
+ * with no cache it errors clearly.
363
+ * The cache therefore can never serve stale data and never needs manual busting — a
364
+ * backend redeploy is picked up automatically on the next command.
365
+ */
366
+ export async function fetchManifest(baseUrl, apiKey) {
367
+ const url = supportUrl(baseUrl, '/cli/manifest');
368
+ const cachePath = cachePathFor(baseUrl);
369
+ const cached = readCache(cachePath);
370
+ let res;
371
+ try {
372
+ res = await fetch(url, {
373
+ headers: {
374
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
375
+ Authorization: `Bearer ${apiKey}`,
376
+ // `If-None-Match` is a quoted string key, so it needs no naming-convention suppression.
377
+ ...(cached?.etag ? { 'If-None-Match': cached.etag } : {}),
378
+ },
379
+ signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS),
380
+ });
381
+ }
382
+ catch {
383
+ if (cached) {
384
+ process.stderr.write(`warning: could not reach ${url} — using the cached command manifest.\n`);
385
+ return cached.manifest; // validated when read.
386
+ }
387
+ throw new Error(`could not reach ${url} to fetch the command manifest, and no cached copy exists ` +
388
+ '(check --base-url and your network).');
389
+ }
390
+ if (res.status === 304 && cached) {
391
+ return cached.manifest; // validated when read.
392
+ }
393
+ if (res.status === 401 || res.status === 403) {
394
+ throw new Error(`API key rejected fetching the command manifest (HTTP ${res.status})`);
395
+ }
396
+ if (!res.ok) {
397
+ throw new Error(`could not fetch the command manifest from ${url} — HTTP ${res.status}`);
398
+ }
399
+ // A 200 isn't a guarantee of JSON — a captive portal / reverse-proxy login page
400
+ // returns HTML, a misconfigured gateway an empty body. `res.json()` would throw a
401
+ // raw `SyntaxError` ("Unexpected token '<'…") that reaches the user as an
402
+ // unactionable `error:` line; surface a clear one instead, matching how
403
+ // `parseManifest` and `dispatch.ts` degrade.
404
+ let json;
405
+ try {
406
+ json = await res.json();
407
+ }
408
+ catch {
409
+ throw new Error(`the command manifest from ${url} was not valid JSON — check that --base-url ` +
410
+ 'points at a recursion API (not a login page or proxy).');
411
+ }
412
+ const manifest = parseManifest(json, url);
413
+ writeCache(cachePath, { etag: res.headers.get('etag') ?? undefined, manifest: json });
414
+ return manifest;
415
+ }
@@ -0,0 +1,31 @@
1
+ import { type ManifestOperation } from './manifest.js';
2
+ /**
3
+ * The caller's granted permission entries, or `undefined` when they couldn't be
4
+ * determined (fetch failed, or the server returned an empty set). `undefined`
5
+ * means "unknown" → gate nothing.
6
+ */
7
+ export type GrantedPermissions = ReadonlySet<string> | undefined;
8
+ /**
9
+ * Fetch the caller's granted permissions. Never throws: any error (network,
10
+ * non-2xx, non-JSON, wrong shape) resolves to `undefined` (fail-open). An empty
11
+ * array also resolves to `undefined` — the backend returns `[]` for callers with
12
+ * no computed permissions (local/standalone/S2S), where enforcement is bypassed,
13
+ * so the CLI must not treat that as "deny everything". (An E2E caller with a
14
+ * present-but-empty `X-Permissions` also gets `[]` here; the backend would 403,
15
+ * but the CLI only skips local pre-emption — it does not hide or allow the call.)
16
+ */
17
+ export declare function fetchPermissions(baseUrl: string, apiKey: string): Promise<GrantedPermissions>;
18
+ /**
19
+ * Whether a granted set satisfies a single required permission. Mirrors
20
+ * `hasPermission` in `@recursion/shared` — the CLI can't depend on that private
21
+ * package, and (like the manifest schema) re-declares the tiny bit it needs.
22
+ * Wildcards: `*` grants everything; `resource:*` grants all actions on a resource.
23
+ */
24
+ export declare function hasPermission(granted: ReadonlySet<string>, required: string): boolean;
25
+ /**
26
+ * The first required permission the caller lacks, or `undefined` when the command
27
+ * is runnable — because it's ungated (no `requiredPermissions`), the caller holds
28
+ * all of them, or permissions are unknown (fail-open). Drives both the `--help`
29
+ * marker and the pre-emptive error on invocation, so they always agree.
30
+ */
31
+ export declare function missingPermission(op: ManifestOperation, granted: GrantedPermissions): string | undefined;
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import { supportUrl } from './manifest.js';
3
+ /** `/me/permissions` returns a bare JSON array of permission-entry strings. */
4
+ const PermissionsResponseSchema = z.array(z.string());
5
+ // Bound the fetch so a hung gateway can't stall the CLI (mirrors the manifest
6
+ // fetch). On any failure we fall back to the fail-open "unknown" set.
7
+ const PERMISSIONS_FETCH_TIMEOUT_MS = 30_000;
8
+ /**
9
+ * Fetch the caller's granted permissions. Never throws: any error (network,
10
+ * non-2xx, non-JSON, wrong shape) resolves to `undefined` (fail-open). An empty
11
+ * array also resolves to `undefined` — the backend returns `[]` for callers with
12
+ * no computed permissions (local/standalone/S2S), where enforcement is bypassed,
13
+ * so the CLI must not treat that as "deny everything". (An E2E caller with a
14
+ * present-but-empty `X-Permissions` also gets `[]` here; the backend would 403,
15
+ * but the CLI only skips local pre-emption — it does not hide or allow the call.)
16
+ */
17
+ export async function fetchPermissions(baseUrl, apiKey) {
18
+ const url = supportUrl(baseUrl, '/me/permissions');
19
+ let res;
20
+ try {
21
+ res = await fetch(url, {
22
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
23
+ headers: { Authorization: `Bearer ${apiKey}` },
24
+ signal: AbortSignal.timeout(PERMISSIONS_FETCH_TIMEOUT_MS),
25
+ });
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ if (!res.ok)
31
+ return undefined;
32
+ let json;
33
+ try {
34
+ json = await res.json();
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ const parsed = PermissionsResponseSchema.safeParse(json);
40
+ if (!parsed.success || parsed.data.length === 0)
41
+ return undefined;
42
+ return new Set(parsed.data);
43
+ }
44
+ /**
45
+ * Whether a granted set satisfies a single required permission. Mirrors
46
+ * `hasPermission` in `@recursion/shared` — the CLI can't depend on that private
47
+ * package, and (like the manifest schema) re-declares the tiny bit it needs.
48
+ * Wildcards: `*` grants everything; `resource:*` grants all actions on a resource.
49
+ */
50
+ export function hasPermission(granted, required) {
51
+ if (granted.has('*'))
52
+ return true;
53
+ if (granted.has(required))
54
+ return true;
55
+ const resource = required.split(':')[0];
56
+ if (resource === undefined || resource === '')
57
+ return false;
58
+ return granted.has(`${resource}:*`);
59
+ }
60
+ /**
61
+ * The first required permission the caller lacks, or `undefined` when the command
62
+ * is runnable — because it's ungated (no `requiredPermissions`), the caller holds
63
+ * all of them, or permissions are unknown (fail-open). Drives both the `--help`
64
+ * marker and the pre-emptive error on invocation, so they always agree.
65
+ */
66
+ export function missingPermission(op, granted) {
67
+ if (granted === undefined)
68
+ return undefined;
69
+ const required = op.requiredPermissions;
70
+ if (!required || required.length === 0)
71
+ return undefined;
72
+ return required.find((perm) => !hasPermission(granted, perm));
73
+ }
@@ -0,0 +1,113 @@
1
+ import { Command } from 'commander';
2
+ import type { Manifest, ManifestOperation, ManifestRecipe, ShapeNode } from './manifest.js';
3
+ import { type GrantedPermissions } from './permissions.js';
4
+ /** camelCase → kebab-case for command + flag names (was `@labelbox/recursion-sdk/nesting`). */
5
+ export declare function kebab(value: string): string;
6
+ /** Coerce a string flag value to its declared scalar type. */
7
+ export declare function coerce(value: unknown, type: string): unknown;
8
+ /**
9
+ * Build the flat options object from parsed CLI flags + a pre-parsed body base
10
+ * (from `--from-json` / `--data`). Path and query params and scalar body fields
11
+ * come from individual flags; scalar flags override the JSON base. Required scalar
12
+ * body fields are enforced here — after the flag and JSON sources are merged — so a
13
+ * value supplied via `--from-json`/`--data` satisfies the requirement just as its
14
+ * own flag would (a clear CLI error rather than a server-side 4xx).
15
+ */
16
+ export declare function assembleParams(entry: ManifestOperation, opts: Record<string, unknown>, bodyBase: Record<string, unknown>): Record<string, unknown>;
17
+ export declare function parseBodyBase(opts: Record<string, unknown>): Record<string, unknown>;
18
+ /** The pre-resolved auth + version context bin.ts threads into the program. */
19
+ /**
20
+ * Where CLI output goes, and the reason nothing here touches `process` directly.
21
+ *
22
+ * The terminal entrypoint (`bin.ts`) binds these to the real streams. The MCP
23
+ * endpoint binds them to string buffers so one `rl` invocation can be executed
24
+ * **in-process** on behalf of an agent and its output returned as a tool result.
25
+ * A stray `process.stdout.write` would leak an agent's output into the server's
26
+ * logs; a stray `process.exit` would take the whole API server down mid-request.
27
+ * Both are therefore banned in this module — see `buildBaseProgram`, which also
28
+ * routes commander's own help/error output through these sinks.
29
+ */
30
+ export interface CliIo {
31
+ stdout: (text: string) => void;
32
+ stderr: (text: string) => void;
33
+ }
34
+ export interface ProgramContext extends CliIo {
35
+ apiKey: string;
36
+ baseUrl: string;
37
+ version: string;
38
+ granted: GrantedPermissions;
39
+ /**
40
+ * Whether the process is running against a developer's own checkout.
41
+ *
42
+ * `false` when the CLI is embedded in a server (see `embed.ts`), which omits
43
+ * `scaffold`, `submit`, and `skills` from the command tree entirely. Those are the
44
+ * package's only routes to `spawnSync('git', …)`, to writes under `~/.claude/`, and
45
+ * to the `process.exit` calls in `skills.ts`/`git-host.ts` that would kill an API
46
+ * server mid-request. Not registering them removes the capability, which is a
47
+ * stronger guarantee than inspecting argv for their names — and it keeps three
48
+ * commands a server cannot honour out of the `--help` an agent reads.
49
+ */
50
+ localCheckout: boolean;
51
+ }
52
+ /**
53
+ * Render a result for stdout. In `--quiet` mode, prints the result's `id` (an
54
+ * empty line for results without one). Otherwise pretty-prints the JSON, or `OK`
55
+ * for a void response. The trailing newline is part of the returned string.
56
+ */
57
+ export declare function formatOutput(result: unknown, quiet: boolean): string;
58
+ /**
59
+ * Render a caught error for stderr. Generic dispatch throws the server's JSON error
60
+ * body (printed as-is) or an `Error` (its message). A raw, detail-less object (or a
61
+ * thrown empty value) maps to a message that points at the likely cause.
62
+ */
63
+ export declare function formatError(err: unknown): string;
64
+ /**
65
+ * Render a request-body / response shape as an indented tree of lines. Each node
66
+ * shows `name` (or `name?` when optional), its type label, and its metadata tags,
67
+ * then recurses into whatever sub-shape it has.
68
+ */
69
+ export declare function renderShapeTree(nodes: ShapeNode[], indent: string): string[];
70
+ /**
71
+ * The "Request body" + "Returns" help sections appended after a leaf command's
72
+ * built-in help. The body section renders the body params' full nested shape; the
73
+ * returns section renders the success response's full shape — an object's fields,
74
+ * an `array of <element>` with the element's shape, or a scalar's type — or a note
75
+ * when the op returns no body.
76
+ */
77
+ export declare function leafShapeHelp(entry: ManifestOperation): string;
78
+ /**
79
+ * Register an operation's flags.
80
+ *
81
+ * `fileFlags` is false when the program has no developer checkout — the embedded
82
+ * server case. `--from-json` reads a file from the *server's* filesystem there, so
83
+ * `assertAllowedArgv` refuses it; advertising a flag in `--help` that is then
84
+ * refused on use sends a caller looking for a permission or configuration problem
85
+ * that does not exist. Not registered at all, so `--help` shows only what works
86
+ * and `--data` is the single documented way to pass a body.
87
+ */
88
+ export declare function addOptions(command: Command, entry: ManifestOperation, fileFlags?: boolean): void;
89
+ /** Output formats `rl recipes <id>` can render, mapped to the composed snippet field. */
90
+ declare const RECIPE_FORMATS: {
91
+ readonly cli: "cli";
92
+ readonly ts: "sdk";
93
+ readonly py: "python";
94
+ readonly curl: "curl";
95
+ };
96
+ export type RecipeFormat = keyof typeof RECIPE_FORMATS;
97
+ /** The full catalog, grouped by category, for `rl recipes`. */
98
+ export declare function renderRecipeList(reference: Record<string, ManifestRecipe>): string;
99
+ /**
100
+ * The "Related" + "Unblocks" block for a recipe — its place in the graph. The
101
+ * stored links (`requires` / `variationOf` / `learnMore`) come off the recipe;
102
+ * **Unblocks** is *derived* (never stored): the recipes that name THIS one as a
103
+ * `requires` recipe, so an agent reading one recipe sees both what to do first
104
+ * and where it can go next. Returns `''` when the recipe is an island.
105
+ */
106
+ export declare function renderRelatedBlock(entry: ManifestRecipe, allRecipes: Record<string, ManifestRecipe>): string;
107
+ /** One recipe rendered for `rl recipes <id>`: goal + composed code + related links + a docs link. */
108
+ export declare function renderRecipeShow(entry: ManifestRecipe, format: RecipeFormat, allRecipes: Record<string, ManifestRecipe>): string;
109
+ /** The program shell — name, description, version, and the global options. */
110
+ export declare function buildBaseProgram(version: string, io: CliIo): Command;
111
+ /** Build the full `rl` program from a fetched manifest. */
112
+ export declare function buildProgram(manifest: Manifest, ctx: ProgramContext): Command;
113
+ export {};