@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.
- package/README.md +113 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +38 -0
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +122 -0
- package/dist/embed.d.ts +41 -0
- package/dist/embed.js +153 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +190 -0
- package/dist/manifest.d.ts +410 -0
- package/dist/manifest.js +415 -0
- package/dist/permissions.d.ts +31 -0
- package/dist/permissions.js +73 -0
- package/dist/program.d.ts +113 -0
- package/dist/program.js +879 -0
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.js +43 -0
- package/dist/run.d.ts +39 -0
- package/dist/run.js +80 -0
- package/dist/skills.d.ts +69 -0
- package/dist/skills.js +256 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +50 -4
package/dist/manifest.js
ADDED
|
@@ -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 {};
|