@labelbox/recursion-cli 0.0.45 → 0.0.47
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 +50 -29
- package/dist/embed.d.ts +2 -2
- package/dist/embed.js +4 -4
- package/dist/git-host.js +1 -1
- package/dist/manifest.d.ts +11 -2
- package/dist/manifest.js +136 -24
- package/dist/program.d.ts +5 -5
- package/dist/program.js +35 -29
- package/dist/resolve.d.ts +1 -1
- package/dist/resolve.js +2 -2
- package/dist/run.d.ts +1 -1
- package/dist/run.js +1 -1
- package/dist/skills.d.ts +2 -2
- package/dist/skills.js +10 -10
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,44 +1,63 @@
|
|
|
1
1
|
# @labelbox/recursion-cli
|
|
2
2
|
|
|
3
|
-
`
|
|
3
|
+
`recursion` — the Recursion CLI. It mirrors the TypeScript SDK
|
|
4
4
|
(`@labelbox/recursion-sdk`) exactly: where the SDK is `rl.synthesizers.create(...)`, the
|
|
5
|
-
CLI is `
|
|
6
|
-
level.
|
|
5
|
+
CLI is `recursion synthesizers create …`. Dots become spaces; you get `--help` at every
|
|
6
|
+
level. The existing `rl` executable remains a temporary compatibility alias and
|
|
7
|
+
runs the same command tree; new usage should prefer `recursion`.
|
|
7
8
|
|
|
8
9
|
```sh
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
recursion --help # list nouns (synthesizers, synthesizer-runs)
|
|
11
|
+
recursion synthesizers --help # list verbs (create, get, list, …)
|
|
12
|
+
recursion synthesizers create --help # list flags
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
## How it works (fully live, zero per-operation code)
|
|
15
16
|
|
|
16
17
|
The CLI ships **no** baked API reference and has **no** `@labelbox/recursion-sdk` dependency.
|
|
17
|
-
On each run it **
|
|
18
|
-
`--base-url`
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
On each run it **revalidates the manifest** from `GET /cli/manifest` when the server
|
|
19
|
+
selected by `--base-url` is reachable (production by default, or staging, or
|
|
20
|
+
`localhost`). If the server is unavailable, it falls back only to a locally cached
|
|
21
|
+
manifest that was previously validated. From that manifest it builds its entire
|
|
22
|
+
command tree, `--help`, request/response shapes, and docs browse surfaces
|
|
23
|
+
(`src/manifest.ts`). Dispatch is generic (`src/dispatch.ts`):
|
|
21
24
|
each request is built straight from the manifest operation's HTTP method + path
|
|
22
25
|
template + params + body — there is no hand-written command per operation and no
|
|
23
26
|
baked client.
|
|
24
27
|
|
|
25
28
|
The result: **adding or changing a backend endpoint needs zero CLI release** — the
|
|
26
29
|
live CLI reflects it as soon as the backend deploys. The CLI is re-released only when
|
|
27
|
-
its own engine code changes.
|
|
28
|
-
conditional fetch (ETag / `If-None-Match`)
|
|
29
|
-
`~/.cache/
|
|
30
|
+
its own engine code changes. When the server is reachable, the manifest is
|
|
31
|
+
revalidated on every run via a conditional fetch (ETag / `If-None-Match`) and cached
|
|
32
|
+
per base-url under `~/.cache/recursion/`. When the server is unreachable, the CLI may
|
|
33
|
+
use the last locally validated copy so commands remain available offline. If only the
|
|
34
|
+
previous `~/.cache/rl-gym/` entry exists, the CLI validates and uses it immediately,
|
|
35
|
+
then adopts it into the Recursion cache non-destructively. The
|
|
36
|
+
old entry is retained unchanged for rollback; new server responses are written only
|
|
37
|
+
to the Recursion cache.
|
|
38
|
+
|
|
39
|
+
Legacy adoption uses atomic create-if-absent publication: a validated copy is
|
|
40
|
+
hard-linked from a unique same-directory temporary file, so it never replaces a
|
|
41
|
+
canonical entry created concurrently. A valid concurrent canonical entry wins; an
|
|
42
|
+
invalid one is left untouched while that invocation uses the validated legacy value
|
|
43
|
+
in memory. Validated fresh HTTP `200` responses use atomic same-directory rename and
|
|
44
|
+
therefore retain last-network-writer behavior. Replacement preserves an existing
|
|
45
|
+
file's POSIX mode bits, but deliberately publishes a new inode and does not preserve
|
|
46
|
+
its ACLs or extended attributes. Both paths require parent-directory write/search
|
|
47
|
+
permission; if unavailable, the CLI keeps using the validated in-memory result and
|
|
48
|
+
never falls back to a partial direct write.
|
|
30
49
|
|
|
31
50
|
### Docs browse surfaces
|
|
32
51
|
|
|
33
|
-
Beyond the executable `
|
|
34
|
-
exposed as one consistent positional shape — `
|
|
52
|
+
Beyond the executable `recursion <noun> <verb>` operations, the manifest carries the docs,
|
|
53
|
+
exposed as one consistent positional shape — `recursion <group> [<id>]` (bare lists, an id
|
|
35
54
|
shows that one):
|
|
36
55
|
|
|
37
56
|
```sh
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
recursion resources [<id>] # Reference — resource hubs: object shape, operations, recipes
|
|
58
|
+
recursion recipes [<id>] # How-to — multi-step user-goal guides (--format cli|ts|curl)
|
|
59
|
+
recursion explain [<concept>] # Explanation — concept pages
|
|
60
|
+
recursion tutorials [<id>] # Tutorials — getting-started docs
|
|
42
61
|
```
|
|
43
62
|
|
|
44
63
|
## Install / run
|
|
@@ -48,9 +67,9 @@ setup beyond Node itself.
|
|
|
48
67
|
|
|
49
68
|
```sh
|
|
50
69
|
npm uninstall -g @recursion/cli @labelbox/rl-cli # no-op unless a pre-rename package is installed
|
|
51
|
-
npm install -g @labelbox/recursion-cli #
|
|
70
|
+
npm install -g @labelbox/recursion-cli # old packages also own the `rl` alias — EEXIST otherwise
|
|
52
71
|
# or, zero-install:
|
|
53
|
-
npx @labelbox/recursion-cli --help
|
|
72
|
+
npx --package @labelbox/recursion-cli recursion --help
|
|
54
73
|
```
|
|
55
74
|
|
|
56
75
|
The CLI is standalone, so this pulls in no `@labelbox/recursion-sdk`.
|
|
@@ -92,19 +111,21 @@ host with `--base-url <url>` (defaults to the public Recursion API gateway, see
|
|
|
92
111
|
no-content operations).
|
|
93
112
|
|
|
94
113
|
```sh
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
recursion synthesizers get --synthesizer-job-id sj_01HX...
|
|
115
|
+
recursion synthesizers create --environment-id env_01HX... --from-json ./body.json
|
|
116
|
+
recursion synthesizer-runs trigger --problem-version-id pv_01HX... --from-json ./run.json
|
|
98
117
|
```
|
|
99
118
|
|
|
100
119
|
## The command surface is the live server
|
|
101
120
|
|
|
102
121
|
There is **nothing to regenerate or commit** for the CLI — the command surface is
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
revalidated against the target server's `GET /cli/manifest` on every run when the
|
|
123
|
+
server is reachable, with a validated local-cache fallback when it is offline. A
|
|
124
|
+
backend change therefore flows through automatically after deploy with no CLI step.
|
|
125
|
+
The manifest itself is assembled by `yarn generate cli:manifest` (included in
|
|
126
|
+
`yarn generate prerequisites`) from the spec-derived reference files and embedded
|
|
127
|
+
into the backend; to add or change a command, change the backend `@SdkRoute` — not
|
|
128
|
+
this package.
|
|
108
129
|
|
|
109
130
|
The *engine* (manifest fetch + cache + validation, generic dispatch, flag mapping,
|
|
110
131
|
help formatting, the request-body / returns shape trees, and the docs browse
|
package/dist/embed.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ExecuteCliOptions {
|
|
|
8
8
|
apiKey: string;
|
|
9
9
|
/** The API the CLI dispatches against. Server configuration, not caller input. */
|
|
10
10
|
baseUrl: string;
|
|
11
|
-
/** Reported by `
|
|
11
|
+
/** Reported by `recursion --version`. */
|
|
12
12
|
version: string;
|
|
13
13
|
/** The pre-assembled manifest — served from memory, never re-fetched over HTTP. */
|
|
14
14
|
manifest: Manifest;
|
|
@@ -31,7 +31,7 @@ export interface CliResult {
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function assertAllowedArgv(args: readonly string[]): void;
|
|
33
33
|
/**
|
|
34
|
-
* Run one `
|
|
34
|
+
* Run one `recursion` invocation in-process and capture its output.
|
|
35
35
|
*
|
|
36
36
|
* Resolves for both success and failure — a non-zero exit is a normal outcome the
|
|
37
37
|
* model should see and correct from, not an exception. It rejects only on a
|
package/dist/embed.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { run } from './run.js';
|
|
2
|
-
// Running `
|
|
2
|
+
// Running `recursion` inside a server process, on behalf of an agent, without a shell.
|
|
3
3
|
//
|
|
4
4
|
// The MCP endpoint exposes one tool whose argument is an argv array. This module
|
|
5
5
|
// is the whole surface it needs: `executeCli` builds the argv, runs the same
|
|
@@ -91,11 +91,11 @@ export function assertAllowedArgv(args) {
|
|
|
91
91
|
const command = args.find((arg) => !arg.startsWith('-'));
|
|
92
92
|
if (command !== undefined && BLOCKED_COMMANDS.has(command)) {
|
|
93
93
|
throw new Error(`"${command}" is not available here: it acts on a local developer checkout, ` +
|
|
94
|
-
'which this server does not have. Run it from your own terminal with the
|
|
94
|
+
'which this server does not have. Run it from your own terminal with the recursion CLI.');
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
/**
|
|
98
|
-
* Run one `
|
|
98
|
+
* Run one `recursion` invocation in-process and capture its output.
|
|
99
99
|
*
|
|
100
100
|
* Resolves for both success and failure — a non-zero exit is a normal outcome the
|
|
101
101
|
* model should see and correct from, not an exception. It rejects only on a
|
|
@@ -125,7 +125,7 @@ export async function executeCli(options) {
|
|
|
125
125
|
// argv can outrank it.
|
|
126
126
|
argv: [
|
|
127
127
|
'node',
|
|
128
|
-
'
|
|
128
|
+
'recursion',
|
|
129
129
|
`--api-key=${options.apiKey}`,
|
|
130
130
|
`--base-url=${options.baseUrl}`,
|
|
131
131
|
...options.args,
|
package/dist/git-host.js
CHANGED
|
@@ -128,7 +128,7 @@ function stashClaim(repoDir, claim) {
|
|
|
128
128
|
function readStashedClaim(repoDir) {
|
|
129
129
|
const path = claimFilePath(repoDir);
|
|
130
130
|
if (!existsSync(path)) {
|
|
131
|
-
throw new Error(`no claim found at ${path} — run \`
|
|
131
|
+
throw new Error(`no claim found at ${path} — run \`recursion scaffold\` in this directory first, or pass --path to point at a scaffolded repo`);
|
|
132
132
|
}
|
|
133
133
|
const parsed = StashedClaimSchema.safeParse(JSON.parse(readFileSync(path, 'utf8')));
|
|
134
134
|
if (!parsed.success) {
|
package/dist/manifest.d.ts
CHANGED
|
@@ -398,13 +398,22 @@ export type Manifest = z.infer<typeof ManifestSchema>;
|
|
|
398
398
|
* and only a deliberate format bump trips this.
|
|
399
399
|
*/
|
|
400
400
|
export declare function parseManifest(value: unknown, source: string): Manifest;
|
|
401
|
+
/**
|
|
402
|
+
* A filesystem-safe, collision-free slug for a base URL, so each server caches
|
|
403
|
+
* independently. The readable part is the sanitized URL (handy when eyeballing the
|
|
404
|
+
* cache dir); a short hash of the *full* URL is appended so two URLs that sanitize
|
|
405
|
+
* to the same string (e.g. `https://x.com:8080` vs `https://x-com-8080`) still get
|
|
406
|
+
* distinct cache files rather than silently sharing — and poisoning — one.
|
|
407
|
+
*/
|
|
408
|
+
export declare function hostSlug(baseUrl: string): string;
|
|
401
409
|
/**
|
|
402
410
|
* Fetch the command manifest, revalidating the cache on every run:
|
|
403
411
|
* - send `If-None-Match` with the cached ETag → `304` means the cache is provably
|
|
404
412
|
* current (use it); `200` means the surface changed (validate + replace cache).
|
|
405
413
|
* - a network error (incl. a timeout) falls back to the cached copy with a warning;
|
|
406
414
|
* with no cache it errors clearly.
|
|
407
|
-
*
|
|
408
|
-
*
|
|
415
|
+
* When the server is reachable, the cache is revalidated and a backend redeploy is
|
|
416
|
+
* picked up automatically. Offline, the last locally validated copy may be older
|
|
417
|
+
* than the server but keeps the CLI usable.
|
|
409
418
|
*/
|
|
410
419
|
export declare function fetchManifest(baseUrl: string, apiKey: string): Promise<Manifest>;
|
package/dist/manifest.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync,
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fchmodSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import process from 'node:process';
|
|
@@ -9,8 +9,8 @@ import { z } from 'zod';
|
|
|
9
9
|
// from `GET /cli/manifest` on whatever server `--base-url` points at, instead of
|
|
10
10
|
// baking a compiled-in `@labelbox/recursion-sdk` reference. This module owns:
|
|
11
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
|
|
13
|
-
//
|
|
12
|
+
// 2. `fetchManifest` — a conditional-fetch cache (ETag / If-None-Match) that is
|
|
13
|
+
// revalidated whenever the server is reachable and remains usable offline.
|
|
14
14
|
// The schema lives here, not in sdk-ts: the CLI no longer depends on that package
|
|
15
15
|
// (see the plan's standalone-CLI decision). The backend embeds the same manifest at
|
|
16
16
|
// build time (`dx manifest:generate`); this is the consumer mirror.
|
|
@@ -159,7 +159,7 @@ export const ManifestOperationSchema = z.object({
|
|
|
159
159
|
params: z.array(ManifestParamSchema),
|
|
160
160
|
response: ShapeNodeSchema.optional(),
|
|
161
161
|
});
|
|
162
|
-
// ── Resources — the Reference browse surface (`
|
|
162
|
+
// ── Resources — the Reference browse surface (`recursion resources [<id>]`) ──────────
|
|
163
163
|
const ManifestResourceObjectSchema = z.object({
|
|
164
164
|
name: z.string(),
|
|
165
165
|
fields: z.array(ShapeNodeSchema),
|
|
@@ -175,14 +175,14 @@ export const ManifestResourceSchema = z.object({
|
|
|
175
175
|
object: ManifestResourceObjectSchema.optional(),
|
|
176
176
|
operationIds: z.array(z.string()),
|
|
177
177
|
});
|
|
178
|
-
// ── Recipes — the How-to browse surface (`
|
|
178
|
+
// ── Recipes — the How-to browse surface (`recursion recipes [<id>]`) ─────────────────
|
|
179
179
|
const RecipeSnippetSchema = z.object({ setup: z.string(), main: z.string() });
|
|
180
180
|
// Steps are read only to map a recipe to the resources it touches (via each SDK
|
|
181
181
|
// step's operationId), so they're modeled loosely — just the fields the CLI uses.
|
|
182
182
|
const ManifestRecipeStepSchema = z.object({ operationId: z.string().optional() });
|
|
183
183
|
// A recipe's place in the relationship graph (mirrors `RecipeRelated` in
|
|
184
184
|
// sdk-ts's recipes-schema). Modeled loosely here — the CLI only reads these to
|
|
185
|
-
// render the "Related" / "Unblocks" block on `
|
|
185
|
+
// render the "Related" / "Unblocks" block on `recursion recipes <id>`; the generator
|
|
186
186
|
// is the source of truth that validates them. Without these fields the manifest
|
|
187
187
|
// parse would silently strip `related`, so the CLI would never see the links.
|
|
188
188
|
const ManifestLinkTargetSchema = z.object({
|
|
@@ -224,7 +224,7 @@ export const ManifestRecipeSchema = z.object({
|
|
|
224
224
|
// The recipe's relationship links (optional — absent for an island recipe).
|
|
225
225
|
related: ManifestRelatedSchema.optional(),
|
|
226
226
|
});
|
|
227
|
-
// ── Concepts — the Explanation browse surface (`
|
|
227
|
+
// ── Concepts — the Explanation browse surface (`recursion explain [<concept>]`) ──────
|
|
228
228
|
export const ManifestConceptSchema = z.object({
|
|
229
229
|
id: z.string(),
|
|
230
230
|
title: z.string(),
|
|
@@ -234,7 +234,7 @@ export const ManifestConceptSchema = z.object({
|
|
|
234
234
|
// metadata — the frontend fetches the `.md` separately; the CLI bundles it).
|
|
235
235
|
body: z.string(),
|
|
236
236
|
});
|
|
237
|
-
// ── Tutorials — the getting-started docs (`
|
|
237
|
+
// ── Tutorials — the getting-started docs (`recursion tutorials [<id>]`) ──────────────
|
|
238
238
|
export const ManifestTutorialSchema = z.object({
|
|
239
239
|
id: z.string(),
|
|
240
240
|
title: z.string(),
|
|
@@ -279,7 +279,7 @@ export function parseManifest(value, source) {
|
|
|
279
279
|
// means no pre-rename package (@labelbox/rl-cli, or the older
|
|
280
280
|
// @recursion/cli) owns the `rl` bin. A pre-rename user sees the
|
|
281
281
|
// pre-rename binary's own message instead.
|
|
282
|
-
'this `
|
|
282
|
+
'this `recursion` CLI is out of date — upgrade it (`npm install -g @labelbox/recursion-cli`)'
|
|
283
283
|
: 'the server is older than this CLI — point --base-url at an up-to-date server';
|
|
284
284
|
throw new Error(`command-manifest format mismatch: this CLI speaks v${MANIFEST_FORMAT_VERSION}, ` +
|
|
285
285
|
`${source} served v${formatVersion}. ${direction}.`);
|
|
@@ -300,12 +300,15 @@ const CacheSchema = z.object({ etag: z.string().optional(), manifest: z.unknown(
|
|
|
300
300
|
* to the same string (e.g. `https://x.com:8080` vs `https://x-com-8080`) still get
|
|
301
301
|
* distinct cache files rather than silently sharing — and poisoning — one.
|
|
302
302
|
*/
|
|
303
|
-
function hostSlug(baseUrl) {
|
|
303
|
+
export function hostSlug(baseUrl) {
|
|
304
304
|
const readable = baseUrl.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') || 'default';
|
|
305
305
|
const hash = createHash('sha256').update(baseUrl, 'utf8').digest('hex').slice(0, 8);
|
|
306
306
|
return `${readable}-${hash}`;
|
|
307
307
|
}
|
|
308
|
-
function
|
|
308
|
+
function canonicalCachePathFor(baseUrl) {
|
|
309
|
+
return join(homedir(), '.cache', 'recursion', `${hostSlug(baseUrl)}.json`);
|
|
310
|
+
}
|
|
311
|
+
function legacyCachePathFor(baseUrl) {
|
|
309
312
|
return join(homedir(), '.cache', 'rl-gym', `${hostSlug(baseUrl)}.json`);
|
|
310
313
|
}
|
|
311
314
|
function readCache(path) {
|
|
@@ -333,23 +336,131 @@ function readCache(path) {
|
|
|
333
336
|
// arrives). Treating it as cold lets the next fetch pull — and re-cache — a full
|
|
334
337
|
// body, so the CLI self-heals without a manual cache delete.
|
|
335
338
|
try {
|
|
336
|
-
return {
|
|
339
|
+
return {
|
|
340
|
+
etag: parsed.data.etag,
|
|
341
|
+
manifest: parseManifest(parsed.data.manifest, 'the cache'),
|
|
342
|
+
raw,
|
|
343
|
+
};
|
|
337
344
|
}
|
|
338
345
|
catch {
|
|
339
346
|
return undefined;
|
|
340
347
|
}
|
|
341
348
|
}
|
|
342
|
-
|
|
349
|
+
const CACHE_TEMP_PREFIX = '.rlc-';
|
|
350
|
+
function uniqueCacheTempPath(directory) {
|
|
351
|
+
return join(directory, `${CACHE_TEMP_PREFIX}${process.pid.toString()}-${randomUUID()}.tmp`);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Create, populate, close, and publish one exclusively owned same-directory temp.
|
|
355
|
+
*
|
|
356
|
+
* Ownership begins immediately after `openSync` succeeds, so every later failure —
|
|
357
|
+
* including a partial write or close failure — triggers best-effort pathname cleanup.
|
|
358
|
+
* Descriptor ownership is cleared before the single close attempt: retrying a numeric
|
|
359
|
+
* descriptor could close an unrelated resource if the operating system reused it.
|
|
360
|
+
* Publication happens only after close succeeds. The caller reports whether it
|
|
361
|
+
* transferred the inode (rename) or retained it (hard-link adoption), which determines
|
|
362
|
+
* final cleanup ownership.
|
|
363
|
+
*/
|
|
364
|
+
function publishFromOwnedTemp(path, raw, prepare, publish) {
|
|
365
|
+
let tempPath;
|
|
366
|
+
let descriptor;
|
|
367
|
+
let ownsTemp = false;
|
|
343
368
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
369
|
+
const directory = dirname(path);
|
|
370
|
+
mkdirSync(directory, { recursive: true });
|
|
371
|
+
tempPath = uniqueCacheTempPath(directory);
|
|
372
|
+
descriptor = openSync(tempPath, 'wx');
|
|
373
|
+
ownsTemp = true;
|
|
374
|
+
const bytes = Buffer.from(raw, 'utf8');
|
|
375
|
+
let offset = 0;
|
|
376
|
+
while (offset < bytes.byteLength) {
|
|
377
|
+
const written = writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
|
|
378
|
+
if (written === 0)
|
|
379
|
+
throw new Error('writing the cache temp made no progress');
|
|
380
|
+
offset += written;
|
|
381
|
+
}
|
|
382
|
+
prepare(descriptor);
|
|
383
|
+
const descriptorToClose = descriptor;
|
|
384
|
+
descriptor = undefined;
|
|
385
|
+
closeSync(descriptorToClose);
|
|
386
|
+
if (publish(tempPath) === 'transferred')
|
|
387
|
+
ownsTemp = false;
|
|
346
388
|
}
|
|
347
389
|
catch {
|
|
348
|
-
//
|
|
349
|
-
//
|
|
390
|
+
// Cache persistence is a bandwidth optimization. The caller already has a
|
|
391
|
+
// validated in-memory manifest, so publication failures are non-fatal.
|
|
350
392
|
}
|
|
393
|
+
finally {
|
|
394
|
+
if (descriptor !== undefined) {
|
|
395
|
+
const descriptorToClose = descriptor;
|
|
396
|
+
descriptor = undefined;
|
|
397
|
+
try {
|
|
398
|
+
closeSync(descriptorToClose);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Never retry a numeric descriptor: it may already have been reused.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (ownsTemp && tempPath !== undefined) {
|
|
405
|
+
try {
|
|
406
|
+
unlinkSync(tempPath);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Pathname cleanup is best-effort; readers inspect only the final path.
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Replace a cache with a validated HTTP 200 response via same-directory rename.
|
|
416
|
+
*
|
|
417
|
+
* Rename deliberately publishes a new inode: an existing destination's POSIX mode
|
|
418
|
+
* bits are copied to that inode, but its ACLs and xattrs are not. The operation
|
|
419
|
+
* requires write/search permission on the parent directory; it never falls back to
|
|
420
|
+
* a partial direct write when temporary-file creation or rename is unavailable.
|
|
421
|
+
*/
|
|
422
|
+
function replaceCacheAtomically(path, raw) {
|
|
423
|
+
publishFromOwnedTemp(path, raw, (descriptor) => {
|
|
424
|
+
// Read the mode immediately before publication. Concurrent HTTP 200 writers all
|
|
425
|
+
// preserve the established mode while rename keeps last-network-writer-wins.
|
|
426
|
+
const destination = statSync(path, { throwIfNoEntry: false });
|
|
427
|
+
if (destination !== undefined)
|
|
428
|
+
fchmodSync(descriptor, destination.mode & 0o7777);
|
|
429
|
+
}, (tempPath) => {
|
|
430
|
+
renameSync(tempPath, path);
|
|
431
|
+
return 'transferred';
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Publish validated legacy bytes only if the canonical destination is absent.
|
|
436
|
+
*
|
|
437
|
+
* The hard link is the no-replace commit point: unlike rename, it fails if any
|
|
438
|
+
* concurrent invocation has created the destination. Best-effort temporary-path
|
|
439
|
+
* cleanup is attempted afterward, while a winning canonical path is never overwritten.
|
|
440
|
+
*/
|
|
441
|
+
function publishCacheIfAbsent(path, raw) {
|
|
442
|
+
publishFromOwnedTemp(path, raw, () => { }, (tempPath) => {
|
|
443
|
+
linkSync(tempPath, path);
|
|
444
|
+
return 'retained';
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** Prefer canonical cache; otherwise adopt a validated legacy file without mutating it. */
|
|
448
|
+
function selectCache(baseUrl) {
|
|
449
|
+
const canonicalPath = canonicalCachePathFor(baseUrl);
|
|
450
|
+
const canonical = readCache(canonicalPath);
|
|
451
|
+
if (canonical !== undefined)
|
|
452
|
+
return canonical;
|
|
453
|
+
const legacy = readCache(legacyCachePathFor(baseUrl));
|
|
454
|
+
if (legacy === undefined)
|
|
455
|
+
return undefined;
|
|
456
|
+
// Copy the validated bytes exactly. Hard-link publication cannot replace a
|
|
457
|
+
// canonical file that appears after the reads above. Re-read afterward: prefer a
|
|
458
|
+
// valid concurrent winner, but leave an invalid winner untouched and keep using
|
|
459
|
+
// the already-validated legacy value in memory.
|
|
460
|
+
publishCacheIfAbsent(canonicalPath, legacy.raw);
|
|
461
|
+
return readCache(canonicalPath) ?? legacy;
|
|
351
462
|
}
|
|
352
|
-
// Every `
|
|
463
|
+
// Every `recursion` invocation gates on this fetch, so it must be bounded: a server that
|
|
353
464
|
// accepts the connection but never responds (a hung gateway, a stalled captive
|
|
354
465
|
// portal) would otherwise hang the CLI forever. A timeout makes `fetch` reject, so
|
|
355
466
|
// the catch below degrades to the cached manifest (or the clear cold-cache error).
|
|
@@ -360,13 +471,14 @@ const MANIFEST_FETCH_TIMEOUT_MS = 30_000;
|
|
|
360
471
|
* current (use it); `200` means the surface changed (validate + replace cache).
|
|
361
472
|
* - a network error (incl. a timeout) falls back to the cached copy with a warning;
|
|
362
473
|
* with no cache it errors clearly.
|
|
363
|
-
*
|
|
364
|
-
*
|
|
474
|
+
* When the server is reachable, the cache is revalidated and a backend redeploy is
|
|
475
|
+
* picked up automatically. Offline, the last locally validated copy may be older
|
|
476
|
+
* than the server but keeps the CLI usable.
|
|
365
477
|
*/
|
|
366
478
|
export async function fetchManifest(baseUrl, apiKey) {
|
|
367
479
|
const url = supportUrl(baseUrl, '/cli/manifest');
|
|
368
|
-
const
|
|
369
|
-
const cached =
|
|
480
|
+
const canonicalCachePath = canonicalCachePathFor(baseUrl);
|
|
481
|
+
const cached = selectCache(baseUrl);
|
|
370
482
|
let res;
|
|
371
483
|
try {
|
|
372
484
|
res = await fetch(url, {
|
|
@@ -410,6 +522,6 @@ export async function fetchManifest(baseUrl, apiKey) {
|
|
|
410
522
|
'points at a recursion API (not a login page or proxy).');
|
|
411
523
|
}
|
|
412
524
|
const manifest = parseManifest(json, url);
|
|
413
|
-
|
|
525
|
+
replaceCacheAtomically(canonicalCachePath, JSON.stringify({ etag: res.headers.get('etag') ?? undefined, manifest: json }));
|
|
414
526
|
return manifest;
|
|
415
527
|
}
|
package/dist/program.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare function parseBodyBase(opts: Record<string, unknown>): Record<str
|
|
|
20
20
|
* Where CLI output goes, and the reason nothing here touches `process` directly.
|
|
21
21
|
*
|
|
22
22
|
* The terminal entrypoint (`bin.ts`) binds these to the real streams. The MCP
|
|
23
|
-
* endpoint binds them to string buffers so one `
|
|
23
|
+
* endpoint binds them to string buffers so one `recursion` invocation can be executed
|
|
24
24
|
* **in-process** on behalf of an agent and its output returned as a tool result.
|
|
25
25
|
* A stray `process.stdout.write` would leak an agent's output into the server's
|
|
26
26
|
* logs; a stray `process.exit` would take the whole API server down mid-request.
|
|
@@ -86,7 +86,7 @@ export declare function leafShapeHelp(entry: ManifestOperation): string;
|
|
|
86
86
|
* and `--data` is the single documented way to pass a body.
|
|
87
87
|
*/
|
|
88
88
|
export declare function addOptions(command: Command, entry: ManifestOperation, fileFlags?: boolean): void;
|
|
89
|
-
/** Output formats `
|
|
89
|
+
/** Output formats `recursion recipes <id>` can render, mapped to the composed snippet field. */
|
|
90
90
|
declare const RECIPE_FORMATS: {
|
|
91
91
|
readonly cli: "cli";
|
|
92
92
|
readonly ts: "sdk";
|
|
@@ -94,7 +94,7 @@ declare const RECIPE_FORMATS: {
|
|
|
94
94
|
readonly curl: "curl";
|
|
95
95
|
};
|
|
96
96
|
export type RecipeFormat = keyof typeof RECIPE_FORMATS;
|
|
97
|
-
/** The full catalog, grouped by category, for `
|
|
97
|
+
/** The full catalog, grouped by category, for `recursion recipes`. */
|
|
98
98
|
export declare function renderRecipeList(reference: Record<string, ManifestRecipe>): string;
|
|
99
99
|
/**
|
|
100
100
|
* The "Related" + "Unblocks" block for a recipe — its place in the graph. The
|
|
@@ -104,10 +104,10 @@ export declare function renderRecipeList(reference: Record<string, ManifestRecip
|
|
|
104
104
|
* and where it can go next. Returns `''` when the recipe is an island.
|
|
105
105
|
*/
|
|
106
106
|
export declare function renderRelatedBlock(entry: ManifestRecipe, allRecipes: Record<string, ManifestRecipe>): string;
|
|
107
|
-
/** One recipe rendered for `
|
|
107
|
+
/** One recipe rendered for `recursion recipes <id>`: goal + composed code + related links + a docs link. */
|
|
108
108
|
export declare function renderRecipeShow(entry: ManifestRecipe, format: RecipeFormat, allRecipes: Record<string, ManifestRecipe>): string;
|
|
109
109
|
/** The program shell — name, description, version, and the global options. */
|
|
110
110
|
export declare function buildBaseProgram(version: string, io: CliIo): Command;
|
|
111
|
-
/** Build the full `
|
|
111
|
+
/** Build the full `recursion` program from a fetched manifest. */
|
|
112
112
|
export declare function buildProgram(manifest: Manifest, ctx: ProgramContext): Command;
|
|
113
113
|
export {};
|
package/dist/program.js
CHANGED
|
@@ -336,7 +336,7 @@ export function addOptions(command, entry, fileFlags = true) {
|
|
|
336
336
|
// resource type as their request body, so `agent_id` is both the path
|
|
337
337
|
// parameter and a property of the body schema. Commander throws when the same
|
|
338
338
|
// flag is added twice, and that throw happens while the whole program is
|
|
339
|
-
// being built, so one such operation takes down `
|
|
339
|
+
// being built, so one such operation takes down `recursion` entirely rather than
|
|
340
340
|
// just itself. Path and query win: they address the resource and are what the
|
|
341
341
|
// URL is built from. The shadowed body field is still settable through
|
|
342
342
|
// `--from-json` / `--data`.
|
|
@@ -386,15 +386,15 @@ export function addOptions(command, entry, fileFlags = true) {
|
|
|
386
386
|
command.option('--data <json>', 'Inline JSON request body');
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
|
-
// ── docs browse surfaces — one consistent shape: `
|
|
389
|
+
// ── docs browse surfaces — one consistent shape: `recursion <group> [<id>]` ─────────
|
|
390
390
|
//
|
|
391
391
|
// All four read-only browse groups (resources / recipes / explain / tutorials)
|
|
392
392
|
// take one positional shape: bare lists, an id shows that one. No `show`/`list`
|
|
393
|
-
// verbs (those are the action layer's, `
|
|
393
|
+
// verbs (those are the action layer's, `recursion <noun> <verb>`). All data is the live
|
|
394
394
|
// manifest; these commands are pure presentation (no further network).
|
|
395
395
|
// The Labelbox app host (not the recursion API base) — recipes/docs render in-app.
|
|
396
396
|
const RECIPE_DOCS_BASE_URL = 'https://app.labelbox.com';
|
|
397
|
-
/** Output formats `
|
|
397
|
+
/** Output formats `recursion recipes <id>` can render, mapped to the composed snippet field. */
|
|
398
398
|
const RECIPE_FORMATS = { cli: 'cli', ts: 'sdk', py: 'python', curl: 'curl' };
|
|
399
399
|
function isRecipeFormat(value) {
|
|
400
400
|
return Object.hasOwn(RECIPE_FORMATS, value);
|
|
@@ -421,7 +421,7 @@ function groupByDomain(entries, domainOf, domains) {
|
|
|
421
421
|
const ACTIONS_HELP_GROUP = 'Actions:';
|
|
422
422
|
const DOCS_HELP_GROUP = 'Documentation:';
|
|
423
423
|
const GIT_HOST_HELP_GROUP = 'Coding tasks:';
|
|
424
|
-
/** Nested resource groups (`
|
|
424
|
+
/** Nested resource groups (`recursion problems --help`, etc.) — allowed subcommands. */
|
|
425
425
|
const COMMANDS_HELP_GROUP = 'Commands';
|
|
426
426
|
/** Nested resource groups — subcommands the caller can't run (separate help section). */
|
|
427
427
|
const UNAVAILABLE_HELP_GROUP = 'Unavailable (missing permission)';
|
|
@@ -463,7 +463,7 @@ function resolveResourceDomain(resource, byId) {
|
|
|
463
463
|
}
|
|
464
464
|
return undefined;
|
|
465
465
|
}
|
|
466
|
-
/** The `
|
|
466
|
+
/** The `recursion <callPath>` invocation for an operation, with its required-flag hints. */
|
|
467
467
|
function operationInvocation(op) {
|
|
468
468
|
const flags = [];
|
|
469
469
|
for (const param of op.params) {
|
|
@@ -475,12 +475,12 @@ function operationInvocation(op) {
|
|
|
475
475
|
if (op.bodyKey)
|
|
476
476
|
flags.push('--data <json>');
|
|
477
477
|
// kebab each segment so the printed command matches the real one (the tree is
|
|
478
|
-
// built from `kebab(segment)`) — `
|
|
478
|
+
// built from `kebab(segment)`) — `recursion environments attach-external-id`, not the
|
|
479
479
|
// camelCase callPath.
|
|
480
480
|
const command = op.callPath.map(kebab).join(' ');
|
|
481
|
-
return `
|
|
481
|
+
return `recursion ${command}${flags.length ? ` ${flags.join(' ')}` : ''}`;
|
|
482
482
|
}
|
|
483
|
-
/** `
|
|
483
|
+
/** `recursion resources [<id>]` — the Reference hub (grouped browse, or one resource). */
|
|
484
484
|
function addResourcesCommand(program, manifest, io) {
|
|
485
485
|
const byId = manifest.resources;
|
|
486
486
|
program
|
|
@@ -493,11 +493,11 @@ function addResourcesCommand(program, manifest, io) {
|
|
|
493
493
|
return;
|
|
494
494
|
}
|
|
495
495
|
// `Object.hasOwn`, not `byId[id]`: a Zod `z.record` is a plain object, so a bare
|
|
496
|
-
// read walks the prototype chain — `
|
|
496
|
+
// read walks the prototype chain — `recursion resources constructor` would resolve to a
|
|
497
497
|
// truthy inherited member, bypass the not-found guard, and crash in the renderer.
|
|
498
498
|
const entry = Object.hasOwn(byId, id) ? byId[id] : undefined;
|
|
499
499
|
if (!entry) {
|
|
500
|
-
throw new Error(`unknown resource "${id}". Run \`
|
|
500
|
+
throw new Error(`unknown resource "${id}". Run \`recursion resources\` to list resources.`);
|
|
501
501
|
}
|
|
502
502
|
io.stdout(renderResourceShow(entry, manifest));
|
|
503
503
|
});
|
|
@@ -506,7 +506,7 @@ function renderResourceList(manifest) {
|
|
|
506
506
|
const byId = manifest.resources;
|
|
507
507
|
const all = Object.values(byId);
|
|
508
508
|
const groups = groupByDomain(all, (r) => resolveResourceDomain(r, byId), manifest.domains);
|
|
509
|
-
const lines = ['Resources — run `
|
|
509
|
+
const lines = ['Resources — run `recursion resources <id>` for the full overview.', ''];
|
|
510
510
|
for (const group of groups) {
|
|
511
511
|
lines.push(group.title);
|
|
512
512
|
const sorted = group.entries.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id, 'en'));
|
|
@@ -549,7 +549,7 @@ function renderResourceShow(resource, manifest) {
|
|
|
549
549
|
}
|
|
550
550
|
return `${parts.join('\n')}\n`;
|
|
551
551
|
}
|
|
552
|
-
/** `
|
|
552
|
+
/** `recursion recipes [<id>]` — How-to (list grouped by category, or one recipe). */
|
|
553
553
|
function addRecipesCommand(program, manifest, io) {
|
|
554
554
|
program
|
|
555
555
|
.command('recipes [id]')
|
|
@@ -564,7 +564,7 @@ function addRecipesCommand(program, manifest, io) {
|
|
|
564
564
|
// Object.hasOwn guards the prototype chain (see the resources hub above).
|
|
565
565
|
const entry = Object.hasOwn(manifest.recipes, id) ? manifest.recipes[id] : undefined;
|
|
566
566
|
if (!entry) {
|
|
567
|
-
throw new Error(`unknown recipe "${id}". Run \`
|
|
567
|
+
throw new Error(`unknown recipe "${id}". Run \`recursion recipes\` to see all recipes.`);
|
|
568
568
|
}
|
|
569
569
|
const format = opts.format ?? 'cli';
|
|
570
570
|
// `Object.hasOwn`, not `format in RECIPE_FORMATS`: `in` walks the prototype
|
|
@@ -577,7 +577,7 @@ function addRecipesCommand(program, manifest, io) {
|
|
|
577
577
|
io.stdout(renderRecipeShow(entry, format, manifest.recipes));
|
|
578
578
|
});
|
|
579
579
|
}
|
|
580
|
-
/** The full catalog, grouped by category, for `
|
|
580
|
+
/** The full catalog, grouped by category, for `recursion recipes`. */
|
|
581
581
|
export function renderRecipeList(reference) {
|
|
582
582
|
const entries = Object.values(reference);
|
|
583
583
|
if (entries.length === 0)
|
|
@@ -588,7 +588,7 @@ export function renderRecipeList(reference) {
|
|
|
588
588
|
group.push(entry);
|
|
589
589
|
byCategory.set(entry.category, group);
|
|
590
590
|
}
|
|
591
|
-
const lines = ['Recipes — run `
|
|
591
|
+
const lines = ['Recipes — run `recursion recipes <id>` for the full walkthrough.', ''];
|
|
592
592
|
for (const category of [...byCategory.keys()].sort()) {
|
|
593
593
|
lines.push(category);
|
|
594
594
|
const group = byCategory.get(category) ?? [];
|
|
@@ -649,7 +649,7 @@ export function renderRelatedBlock(entry, allRecipes) {
|
|
|
649
649
|
}
|
|
650
650
|
return lines.length > 0 ? `Related\n${lines.join('\n')}\n` : '';
|
|
651
651
|
}
|
|
652
|
-
/** One recipe rendered for `
|
|
652
|
+
/** One recipe rendered for `recursion recipes <id>`: goal + composed code + related links + a docs link. */
|
|
653
653
|
export function renderRecipeShow(entry, format, allRecipes) {
|
|
654
654
|
// Optional on the wire: an older server predates the Python surface, so
|
|
655
655
|
// `--format py` renders the note rather than crashing on a missing snippet.
|
|
@@ -672,7 +672,7 @@ export function renderRecipeShow(entry, format, allRecipes) {
|
|
|
672
672
|
'',
|
|
673
673
|
].join('\n');
|
|
674
674
|
}
|
|
675
|
-
/** `
|
|
675
|
+
/** `recursion explain [<concept>]` — Explanation (list grouped by domain, or one page). */
|
|
676
676
|
function addExplainCommand(program, manifest, io) {
|
|
677
677
|
program
|
|
678
678
|
.command('explain [concept]')
|
|
@@ -688,14 +688,17 @@ function addExplainCommand(program, manifest, io) {
|
|
|
688
688
|
? manifest.concepts[concept]
|
|
689
689
|
: undefined;
|
|
690
690
|
if (!entry) {
|
|
691
|
-
throw new Error(`unknown concept "${concept}". Run \`
|
|
691
|
+
throw new Error(`unknown concept "${concept}". Run \`recursion explain\` to list concepts.`);
|
|
692
692
|
}
|
|
693
693
|
io.stdout(renderConceptShow(entry));
|
|
694
694
|
});
|
|
695
695
|
}
|
|
696
696
|
function renderConceptList(manifest) {
|
|
697
697
|
const groups = groupByDomain(Object.values(manifest.concepts), (c) => c.domain, manifest.domains);
|
|
698
|
-
const lines = [
|
|
698
|
+
const lines = [
|
|
699
|
+
'Explanations — run `recursion explain <concept>` for the full page.',
|
|
700
|
+
'',
|
|
701
|
+
];
|
|
699
702
|
for (const group of groups) {
|
|
700
703
|
lines.push(group.title);
|
|
701
704
|
for (const c of group.entries.sort((a, b) => a.id.localeCompare(b.id, 'en'))) {
|
|
@@ -713,7 +716,7 @@ function renderConceptShow(concept) {
|
|
|
713
716
|
}
|
|
714
717
|
return `${parts.join('\n')}\n`;
|
|
715
718
|
}
|
|
716
|
-
/** `
|
|
719
|
+
/** `recursion tutorials [<id>]` — Tutorials (the existing getting-started docs). */
|
|
717
720
|
function addTutorialsCommand(program, manifest, io) {
|
|
718
721
|
program
|
|
719
722
|
.command('tutorials [id]')
|
|
@@ -721,7 +724,10 @@ function addTutorialsCommand(program, manifest, io) {
|
|
|
721
724
|
.description('Browse getting-started tutorials (run without an argument to list all)')
|
|
722
725
|
.action((id) => {
|
|
723
726
|
if (id === undefined) {
|
|
724
|
-
const lines = [
|
|
727
|
+
const lines = [
|
|
728
|
+
'Tutorials — run `recursion tutorials <id>` for the full text.',
|
|
729
|
+
'',
|
|
730
|
+
];
|
|
725
731
|
for (const t of manifest.tutorials)
|
|
726
732
|
lines.push(` ${t.id} — ${t.title}`);
|
|
727
733
|
io.stdout(`${lines.join('\n')}\n`);
|
|
@@ -729,7 +735,7 @@ function addTutorialsCommand(program, manifest, io) {
|
|
|
729
735
|
}
|
|
730
736
|
const entry = manifest.tutorials.find((t) => t.id === id);
|
|
731
737
|
if (!entry) {
|
|
732
|
-
throw new Error(`unknown tutorial "${id}". Run \`
|
|
738
|
+
throw new Error(`unknown tutorial "${id}". Run \`recursion tutorials\` to list tutorials.`);
|
|
733
739
|
}
|
|
734
740
|
if (entry.body === null) {
|
|
735
741
|
io.stdout(`${entry.title}\n\nThis tutorial is a notebook — open it in the app: ` +
|
|
@@ -741,7 +747,7 @@ function addTutorialsCommand(program, manifest, io) {
|
|
|
741
747
|
}
|
|
742
748
|
/** The program shell — name, description, version, and the global options. */
|
|
743
749
|
export function buildBaseProgram(version, io) {
|
|
744
|
-
return (new Command('
|
|
750
|
+
return (new Command('recursion')
|
|
745
751
|
// Route commander's own output (help text, unknown-command errors, invalid
|
|
746
752
|
// option values) through the caller's sinks, and turn its `process.exit`
|
|
747
753
|
// calls into thrown `CommanderError`s.
|
|
@@ -753,13 +759,13 @@ export function buildBaseProgram(version, io) {
|
|
|
753
759
|
// process streams and still calling `process.exit`.
|
|
754
760
|
.configureOutput({ writeOut: io.stdout, writeErr: io.stderr })
|
|
755
761
|
.exitOverride()
|
|
756
|
-
.description('Command-line interface for the Recursion
|
|
762
|
+
.description('Command-line interface for the Recursion Platform')
|
|
757
763
|
.version(version)
|
|
758
764
|
.option('--api-key <key>', 'API key (defaults to the LABELBOX_API_KEY env var)')
|
|
759
765
|
.option('--base-url <url>', 'Recursion API base URL (defaults to the RECURSION_BASE_URL env var, then the production host)')
|
|
760
766
|
.option('--quiet', 'Output only the resulting resource id (JSON otherwise)'));
|
|
761
767
|
}
|
|
762
|
-
/** Build the full `
|
|
768
|
+
/** Build the full `recursion` program from a fetched manifest. */
|
|
763
769
|
export function buildProgram(manifest, ctx) {
|
|
764
770
|
// `ctx` structurally satisfies CliIo, so the root program — and by inheritance
|
|
765
771
|
// every command built below — writes to the caller's sinks and never exits.
|
|
@@ -859,7 +865,7 @@ export function buildProgram(manifest, ctx) {
|
|
|
859
865
|
for (const [parent, gatedCount] of gatedCountByParent) {
|
|
860
866
|
parent.addHelpText('before', permissionGroupBanner(gatedCount));
|
|
861
867
|
}
|
|
862
|
-
// The docs browse groups + the bespoke `
|
|
868
|
+
// The docs browse groups + the bespoke `recursion skills` action group.
|
|
863
869
|
addResourcesCommand(program, manifest, ctx);
|
|
864
870
|
addRecipesCommand(program, manifest, ctx);
|
|
865
871
|
addExplainCommand(program, manifest, ctx);
|
|
@@ -873,7 +879,7 @@ export function buildProgram(manifest, ctx) {
|
|
|
873
879
|
// Lazy implicit help skips _initCommandGroup unless created via helpCommand(true).
|
|
874
880
|
program.helpCommand(true);
|
|
875
881
|
program.addHelpText('after', `
|
|
876
|
-
Run \`
|
|
877
|
-
Browse the full reference with \`
|
|
882
|
+
Run \`recursion <command> --help\` for details and flags on any command.
|
|
883
|
+
Browse the full reference with \`recursion resources\`, or guided walkthroughs with \`recursion recipes\`.`);
|
|
878
884
|
return program;
|
|
879
885
|
}
|
package/dist/resolve.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare function flagValue(argv: string[], name: string): string | undefi
|
|
|
4
4
|
export declare function resolveApiKey(argv: string[]): string | undefined;
|
|
5
5
|
/**
|
|
6
6
|
* The base URL from the environment: the current `RECURSION_BASE_URL`, else the
|
|
7
|
-
* deprecated `RL_GYM_BASE_URL`. Shared by `resolveBaseUrl` here and the `
|
|
7
|
+
* deprecated `RL_GYM_BASE_URL`. Shared by `resolveBaseUrl` here and the `recursion skills`
|
|
8
8
|
* resolver so the precedence lives — and is tested — in one place.
|
|
9
9
|
*/
|
|
10
10
|
export declare function envBaseUrl(): string | undefined;
|
package/dist/resolve.js
CHANGED
|
@@ -13,7 +13,7 @@ export function flagValue(argv, name) {
|
|
|
13
13
|
const index = argv.indexOf(`--${name}`);
|
|
14
14
|
if (index >= 0) {
|
|
15
15
|
// The space-form value is the next token — but only if it isn't itself an
|
|
16
|
-
// option (`
|
|
16
|
+
// option (`recursion --api-key --quiet`): a dangling `--api-key` has no value, so we
|
|
17
17
|
// return undefined and let the env fallback apply rather than silently
|
|
18
18
|
// consuming `--quiet` as a bogus (non-empty) key. Values that legitimately
|
|
19
19
|
// start with `-` use the `--name=value` form above.
|
|
@@ -30,7 +30,7 @@ export function resolveApiKey(argv) {
|
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* The base URL from the environment: the current `RECURSION_BASE_URL`, else the
|
|
33
|
-
* deprecated `RL_GYM_BASE_URL`. Shared by `resolveBaseUrl` here and the `
|
|
33
|
+
* deprecated `RL_GYM_BASE_URL`. Shared by `resolveBaseUrl` here and the `recursion skills`
|
|
34
34
|
* resolver so the precedence lives — and is tested — in one place.
|
|
35
35
|
*/
|
|
36
36
|
export function envBaseUrl() {
|
package/dist/run.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface RunDeps {
|
|
|
31
31
|
*
|
|
32
32
|
* **Never throws and never exits.** It returns the exit code the caller should
|
|
33
33
|
* use, having already rendered any failure to `deps.stderr`. That contract is
|
|
34
|
-
* what lets the MCP endpoint run one `
|
|
34
|
+
* what lets the MCP endpoint run one `recursion` invocation in-process on behalf of an
|
|
35
35
|
* agent: a thrown error would become a 500 instead of a tool result, and a
|
|
36
36
|
* `process.exit` would take the API server down mid-request. `bin.ts` is the only
|
|
37
37
|
* caller that turns the returned code back into a real exit.
|
package/dist/run.js
CHANGED
|
@@ -12,7 +12,7 @@ const MISSING_KEY_MESSAGE = 'missing API key — set LABELBOX_API_KEY or pass --
|
|
|
12
12
|
*
|
|
13
13
|
* **Never throws and never exits.** It returns the exit code the caller should
|
|
14
14
|
* use, having already rendered any failure to `deps.stderr`. That contract is
|
|
15
|
-
* what lets the MCP endpoint run one `
|
|
15
|
+
* what lets the MCP endpoint run one `recursion` invocation in-process on behalf of an
|
|
16
16
|
* agent: a thrown error would become a 500 instead of a tool result, and a
|
|
17
17
|
* `process.exit` would take the API server down mid-request. `bin.ts` is the only
|
|
18
18
|
* caller that turns the returned code back into a real exit.
|
package/dist/skills.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export declare function listSkills(opts: {
|
|
|
41
41
|
apiKey?: string | undefined;
|
|
42
42
|
baseUrl: string;
|
|
43
43
|
}): Promise<SkillSummary[]>;
|
|
44
|
-
/** Programmatic core of `
|
|
44
|
+
/** Programmatic core of `recursion skills check` — fetch latest, read installed, compare. */
|
|
45
45
|
export declare function checkSkill(name: string, opts: {
|
|
46
46
|
apiKey: string;
|
|
47
47
|
baseUrl: string;
|
|
@@ -53,7 +53,7 @@ export declare function checkSkill(name: string, opts: {
|
|
|
53
53
|
/** Raised when `install` refuses to clobber a locally-modified file (no `--force`). */
|
|
54
54
|
export declare class HandEditedError extends Error {
|
|
55
55
|
}
|
|
56
|
-
/** Programmatic core of `
|
|
56
|
+
/** Programmatic core of `recursion skills install` — fetch latest and write it (guarded). */
|
|
57
57
|
export declare function installSkill(name: string, opts: {
|
|
58
58
|
apiKey: string;
|
|
59
59
|
baseUrl: string;
|
package/dist/skills.js
CHANGED
|
@@ -6,14 +6,14 @@ import process from 'node:process';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { DEFAULT_BASE_URL, supportUrl } from './manifest.js';
|
|
8
8
|
import { envBaseUrl } from './resolve.js';
|
|
9
|
-
// ── bespoke `
|
|
9
|
+
// ── bespoke `recursion skills` group (hand-written, not spec-derived) ────────────────
|
|
10
10
|
//
|
|
11
11
|
// Keeps a downloaded Claude skill current. The platform serves each skill from
|
|
12
12
|
// `GET /skills/:skillName` with a content-hash version; this group fetches that,
|
|
13
13
|
// compares it to the installed `~/.claude/skills/<name>/SKILL.md`, and refreshes
|
|
14
|
-
// it. The skill's own doctor preflight runs `
|
|
15
|
-
// refreshes via `
|
|
16
|
-
// endpoint — that would auto-generate a colliding `
|
|
14
|
+
// it. The skill's own doctor preflight runs `recursion skills check` each session and
|
|
15
|
+
// refreshes via `recursion skills install` when stale. Deliberately NOT an `@SdkRoute`
|
|
16
|
+
// endpoint — that would auto-generate a colliding `recursion skills` command.
|
|
17
17
|
/** The skill installed by default when no name is given. */
|
|
18
18
|
const DEFAULT_SKILL = 'recursion';
|
|
19
19
|
// ── hash contract — MUST stay byte-identical to apps/recursion/api/src/skills/skill-hash.ts ──
|
|
@@ -79,9 +79,9 @@ function resolveAuth(program) {
|
|
|
79
79
|
}
|
|
80
80
|
return { apiKey, baseUrl: resolveBaseUrl(program) };
|
|
81
81
|
}
|
|
82
|
-
// Each `
|
|
82
|
+
// Each `recursion skills` fetch is bounded for the same reason as the manifest fetch
|
|
83
83
|
// (see manifest.ts): a server that accepts the connection but never responds would
|
|
84
|
-
// otherwise hang `
|
|
84
|
+
// otherwise hang `recursion skills check`/`install`/`list` indefinitely. The timeout makes
|
|
85
85
|
// `fetch` reject so the caller surfaces a clear error rather than blocking forever.
|
|
86
86
|
const SKILLS_FETCH_TIMEOUT_MS = 30_000;
|
|
87
87
|
/**
|
|
@@ -143,7 +143,7 @@ function readInstalled(path) {
|
|
|
143
143
|
return undefined;
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
-
/** Programmatic core of `
|
|
146
|
+
/** Programmatic core of `recursion skills check` — fetch latest, read installed, compare. */
|
|
147
147
|
export async function checkSkill(name, opts) {
|
|
148
148
|
const latest = await fetchSkill(name, opts.apiKey, opts.baseUrl);
|
|
149
149
|
const installed = readInstalled(installedPath(name, opts.skillFile));
|
|
@@ -152,7 +152,7 @@ export async function checkSkill(name, opts) {
|
|
|
152
152
|
/** Raised when `install` refuses to clobber a locally-modified file (no `--force`). */
|
|
153
153
|
export class HandEditedError extends Error {
|
|
154
154
|
}
|
|
155
|
-
/** Programmatic core of `
|
|
155
|
+
/** Programmatic core of `recursion skills install` — fetch latest and write it (guarded). */
|
|
156
156
|
export async function installSkill(name, opts) {
|
|
157
157
|
const latest = await fetchSkill(name, opts.apiKey, opts.baseUrl);
|
|
158
158
|
const path = installedPath(name, opts.skillFile);
|
|
@@ -197,7 +197,7 @@ export function addSkillsCommands(program, docsHelpGroup) {
|
|
|
197
197
|
return;
|
|
198
198
|
}
|
|
199
199
|
const lines = available.map((s) => s.description ? ` ${s.name} — ${s.description}` : ` ${s.name}`);
|
|
200
|
-
process.stdout.write(`Installable skills (run \`
|
|
200
|
+
process.stdout.write(`Installable skills (run \`recursion skills install <name>\`):\n${lines.join('\n')}\n`);
|
|
201
201
|
}
|
|
202
202
|
catch (err) {
|
|
203
203
|
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -218,7 +218,7 @@ export function addSkillsCommands(program, docsHelpGroup) {
|
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
220
|
const lead = status === 'missing' ? 'is not installed' : 'is out of date';
|
|
221
|
-
process.stdout.write(`${skill} ${lead} — run \`
|
|
221
|
+
process.stdout.write(`${skill} ${lead} — run \`recursion skills install ${skill}\`.\n`);
|
|
222
222
|
process.exit(1);
|
|
223
223
|
}
|
|
224
224
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@labelbox/recursion-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/Labelbox/recursion-platform.git"
|
|
8
8
|
},
|
|
9
9
|
"bin": {
|
|
10
|
+
"recursion": "./dist/bin.js",
|
|
10
11
|
"rl": "./dist/bin.js"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|