@labelbox/recursion-cli 0.0.0 → 0.0.41

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,12 @@
1
+ /** Read a global flag's value from argv (`--name value` or `--name=value`). */
2
+ export declare function flagValue(argv: string[], name: string): string | undefined;
3
+ /** `--api-key` wins over `LABELBOX_API_KEY`; undefined when neither is set. */
4
+ export declare function resolveApiKey(argv: string[]): string | undefined;
5
+ /**
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 `rl skills`
8
+ * resolver so the precedence lives — and is tested — in one place.
9
+ */
10
+ export declare function envBaseUrl(): string | undefined;
11
+ /** `--base-url` wins over `RECURSION_BASE_URL` (or the deprecated `RL_GYM_BASE_URL`), falling back to the production host. */
12
+ export declare function resolveBaseUrl(argv: string[]): string;
@@ -0,0 +1,43 @@
1
+ import process from 'node:process';
2
+ import { DEFAULT_BASE_URL } from './manifest.js';
3
+ // Resolving the API key + base URL from argv + env, before commander parses — the
4
+ // live CLI needs both up front to fetch the command manifest the rest of the tree
5
+ // is built from. Global flags precede the command (documented), so a flat argv scan
6
+ // is sufficient and matches commander's later parse. Extracted from bin.ts so the
7
+ // precedence is unit-testable (bin.ts runs `main()` on import).
8
+ /** Read a global flag's value from argv (`--name value` or `--name=value`). */
9
+ export function flagValue(argv, name) {
10
+ const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
11
+ if (eq)
12
+ return eq.slice(name.length + 3);
13
+ const index = argv.indexOf(`--${name}`);
14
+ if (index >= 0) {
15
+ // The space-form value is the next token — but only if it isn't itself an
16
+ // option (`rl --api-key --quiet`): a dangling `--api-key` has no value, so we
17
+ // return undefined and let the env fallback apply rather than silently
18
+ // consuming `--quiet` as a bogus (non-empty) key. Values that legitimately
19
+ // start with `-` use the `--name=value` form above.
20
+ const next = argv[index + 1];
21
+ if (next !== undefined && !next.startsWith('-'))
22
+ return next;
23
+ }
24
+ return undefined;
25
+ }
26
+ /** `--api-key` wins over `LABELBOX_API_KEY`; undefined when neither is set. */
27
+ export function resolveApiKey(argv) {
28
+ const { LABELBOX_API_KEY: envApiKey } = process.env;
29
+ return flagValue(argv, 'api-key') ?? envApiKey;
30
+ }
31
+ /**
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 `rl skills`
34
+ * resolver so the precedence lives — and is tested — in one place.
35
+ */
36
+ export function envBaseUrl() {
37
+ const { RECURSION_BASE_URL: recursionBaseUrl, RL_GYM_BASE_URL: legacyBaseUrl } = process.env;
38
+ return recursionBaseUrl ?? legacyBaseUrl;
39
+ }
40
+ /** `--base-url` wins over `RECURSION_BASE_URL` (or the deprecated `RL_GYM_BASE_URL`), falling back to the production host. */
41
+ export function resolveBaseUrl(argv) {
42
+ return flagValue(argv, 'base-url') ?? envBaseUrl() ?? DEFAULT_BASE_URL;
43
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Manifest } from './manifest.js';
2
+ import type { GrantedPermissions } from './permissions.js';
3
+ export interface RunDeps {
4
+ /** Full argv including the node + script entries, as `parseAsync` expects. */
5
+ argv: string[];
6
+ /**
7
+ * Resolves the CLI version lazily. A thunk (not a plain string) so a throw from
8
+ * reading package.json happens *inside* `run()` — surfacing as a rejected promise
9
+ * bin.ts renders via `fail()`, not a raw stack trace before the promise exists.
10
+ */
11
+ version: () => string;
12
+ fetchManifest: (baseUrl: string, apiKey: string) => Promise<Manifest>;
13
+ /**
14
+ * Resolves the caller's granted permissions for the per-command gate. Never
15
+ * rejects — it resolves to `undefined` (fail-open) on any failure, so a
16
+ * permissions outage degrades to "gate nothing", never to a broken CLI.
17
+ */
18
+ fetchPermissions: (baseUrl: string, apiKey: string) => Promise<GrantedPermissions>;
19
+ stdout: (text: string) => void;
20
+ stderr: (text: string) => void;
21
+ /**
22
+ * Whether `scaffold`, `submit`, and `skills` are registered at all. See
23
+ * `ProgramContext.localCheckout` — the terminal entrypoint passes `true`, an
24
+ * embedding server passes `false`.
25
+ */
26
+ localCheckout: boolean;
27
+ }
28
+ /**
29
+ * Resolve `--version` and the no-key branches with no network, then fetch the
30
+ * manifest and build + run the command tree.
31
+ *
32
+ * **Never throws and never exits.** It returns the exit code the caller should
33
+ * use, having already rendered any failure to `deps.stderr`. That contract is
34
+ * what lets the MCP endpoint run one `rl` invocation in-process on behalf of an
35
+ * agent: a thrown error would become a 500 instead of a tool result, and a
36
+ * `process.exit` would take the API server down mid-request. `bin.ts` is the only
37
+ * caller that turns the returned code back into a real exit.
38
+ */
39
+ export declare function run(deps: RunDeps): Promise<number>;
package/dist/run.js ADDED
@@ -0,0 +1,80 @@
1
+ import { CommanderError } from 'commander';
2
+ import { buildBaseProgram, buildProgram, formatError } from './program.js';
3
+ import { resolveApiKey, resolveBaseUrl } from './resolve.js';
4
+ // The CLI orchestration, extracted from bin.ts (which runs it on import) so it has a
5
+ // seam for unit tests: the `--version` short-circuit and the no-key branches all
6
+ // resolve *before* any network, and that "works offline / with no key" guarantee
7
+ // lives only here. Dependencies (the manifest fetch + the output sink) are injected.
8
+ const MISSING_KEY_MESSAGE = 'missing API key — set LABELBOX_API_KEY or pass --api-key (the CLI fetches its commands from the server)';
9
+ /**
10
+ * Resolve `--version` and the no-key branches with no network, then fetch the
11
+ * manifest and build + run the command tree.
12
+ *
13
+ * **Never throws and never exits.** It returns the exit code the caller should
14
+ * use, having already rendered any failure to `deps.stderr`. That contract is
15
+ * what lets the MCP endpoint run one `rl` invocation in-process on behalf of an
16
+ * agent: a thrown error would become a 500 instead of a tool result, and a
17
+ * `process.exit` would take the API server down mid-request. `bin.ts` is the only
18
+ * caller that turns the returned code back into a real exit.
19
+ */
20
+ export async function run(deps) {
21
+ try {
22
+ return await execute(deps);
23
+ }
24
+ catch (err) {
25
+ // Commander signals help and `--version` by throwing with exitCode 0 — the text
26
+ // has already gone to the sinks, so that is a success, not a failure. Its real
27
+ // errors (unknown command, bad option value) have likewise already been written
28
+ // by `writeErr`, so re-rendering them here would duplicate the message.
29
+ if (err instanceof CommanderError)
30
+ return err.exitCode;
31
+ deps.stderr(`error: ${formatError(err)}\n`);
32
+ return 1;
33
+ }
34
+ }
35
+ async function execute(deps) {
36
+ const args = deps.argv.slice(2);
37
+ // Resolve inside execute() (not in bin.ts at the call site) so a throw is caught
38
+ // by run()'s handler and rendered, not an uncaught synchronous crash.
39
+ const version = deps.version();
40
+ // `--version` is the one thing that works with no key and no network — resolve it
41
+ // before fetching the manifest the rest of the CLI is built from.
42
+ if (args.includes('--version') || args.includes('-V')) {
43
+ deps.stdout(`${version}\n`);
44
+ return 0;
45
+ }
46
+ // Pre-resolve auth (before commander parses) — threaded into the program so the
47
+ // manifest fetch and per-op dispatch use exactly one source.
48
+ const apiKey = resolveApiKey(args);
49
+ const baseUrl = resolveBaseUrl(args);
50
+ if (!apiKey) {
51
+ // Top-level help still prints (global flags + a hint); everything else needs the
52
+ // key, since the command tree itself comes from the gated manifest.
53
+ if (args.includes('--help') || args.includes('-h')) {
54
+ buildBaseProgram(version, deps)
55
+ .addHelpText('after', '\nSet LABELBOX_API_KEY (or pass --api-key) — the CLI fetches its commands from the server.')
56
+ .outputHelp();
57
+ return 0;
58
+ }
59
+ throw new Error(MISSING_KEY_MESSAGE);
60
+ }
61
+ // The manifest (command surface) and the caller's permissions (the per-command
62
+ // gate) are independent fetches — run them together. The permissions fetch
63
+ // never rejects (fail-open), so this can't turn a permissions outage into a
64
+ // CLI failure.
65
+ const [manifest, granted] = await Promise.all([
66
+ deps.fetchManifest(baseUrl, apiKey),
67
+ deps.fetchPermissions(baseUrl, apiKey),
68
+ ]);
69
+ const program = buildProgram(manifest, {
70
+ apiKey,
71
+ baseUrl,
72
+ version,
73
+ granted,
74
+ localCheckout: deps.localCheckout,
75
+ stdout: deps.stdout,
76
+ stderr: deps.stderr,
77
+ });
78
+ await program.parseAsync(deps.argv);
79
+ return 0;
80
+ }
@@ -0,0 +1,69 @@
1
+ import type { Command } from 'commander';
2
+ import { z } from 'zod';
3
+ /** sha256 (hex) of the markdown with any injected `skill-version:` line removed. */
4
+ export declare function skillVersionHash(text: string): string;
5
+ /** The `skill-version` value stamped into a file's frontmatter, if present. */
6
+ export declare function stampedVersion(content: string): string | undefined;
7
+ export type SkillStatus = 'missing' | 'up-to-date' | 'out-of-date';
8
+ /** Compare an installed file (or its absence) against the latest published version. */
9
+ export declare function skillStatus(installed: string | undefined, latestVersion: string): SkillStatus;
10
+ /**
11
+ * True if the installed file looks locally modified: its recomputed body hash
12
+ * doesn't match its own `skill-version` stamp, or it carries no stamp at all
13
+ * (unknown provenance). Such a file is never overwritten without `--force`.
14
+ */
15
+ export declare function isHandEdited(installed: string): boolean;
16
+ declare const SkillPayloadSchema: z.ZodObject<{
17
+ name: z.ZodString;
18
+ version: z.ZodString;
19
+ content: z.ZodString;
20
+ }, z.core.$strip>;
21
+ type SkillPayload = z.infer<typeof SkillPayloadSchema>;
22
+ declare const SkillSummarySchema: z.ZodObject<{
23
+ name: z.ZodString;
24
+ description: z.ZodString;
25
+ }, z.core.$strip>;
26
+ type SkillSummary = z.infer<typeof SkillSummarySchema>;
27
+ /**
28
+ * Fetch `{ name, version, content }` for a skill from `GET /v1/skills/<name>`.
29
+ *
30
+ * Exported only so `support-urls.test.ts` can assert its hand-written URL join
31
+ * against the committed spec, alongside the other support endpoints.
32
+ */
33
+ export declare function fetchSkill(name: string, apiKey: string, baseUrl: string): Promise<SkillPayload>;
34
+ /**
35
+ * Fetch the catalog of installable skills from `GET /skills`. The endpoint is now
36
+ * gated (the CLI always has a key — `bin.ts` requires one before any command), so
37
+ * the key is sent when present; it stays optional here only so the function is
38
+ * reusable in contexts that legitimately have none.
39
+ */
40
+ export declare function listSkills(opts: {
41
+ apiKey?: string | undefined;
42
+ baseUrl: string;
43
+ }): Promise<SkillSummary[]>;
44
+ /** Programmatic core of `rl skills check` — fetch latest, read installed, compare. */
45
+ export declare function checkSkill(name: string, opts: {
46
+ apiKey: string;
47
+ baseUrl: string;
48
+ skillFile?: string | undefined;
49
+ }): Promise<{
50
+ status: SkillStatus;
51
+ latestVersion: string;
52
+ }>;
53
+ /** Raised when `install` refuses to clobber a locally-modified file (no `--force`). */
54
+ export declare class HandEditedError extends Error {
55
+ }
56
+ /** Programmatic core of `rl skills install` — fetch latest and write it (guarded). */
57
+ export declare function installSkill(name: string, opts: {
58
+ apiKey: string;
59
+ baseUrl: string;
60
+ skillFile?: string | undefined;
61
+ force?: boolean | undefined;
62
+ }): Promise<{
63
+ path: string;
64
+ action: 'installed' | 'updated' | 'unchanged';
65
+ version: string;
66
+ }>;
67
+ /** Register the `skills check` / `skills install` command group on `program`. */
68
+ export declare function addSkillsCommands(program: Command, docsHelpGroup: string): void;
69
+ export {};
package/dist/skills.js ADDED
@@ -0,0 +1,256 @@
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
+ import { DEFAULT_BASE_URL, supportUrl } from './manifest.js';
8
+ import { envBaseUrl } from './resolve.js';
9
+ // ── bespoke `rl skills` group (hand-written, not spec-derived) ────────────────
10
+ //
11
+ // Keeps a downloaded Claude skill current. The platform serves each skill from
12
+ // `GET /skills/:skillName` with a content-hash version; this group fetches that,
13
+ // compares it to the installed `~/.claude/skills/<name>/SKILL.md`, and refreshes
14
+ // it. The skill's own doctor preflight runs `rl skills check` each session and
15
+ // refreshes via `rl skills install` when stale. Deliberately NOT an `@SdkRoute`
16
+ // endpoint — that would auto-generate a colliding `rl skills` command.
17
+ /** The skill installed by default when no name is given. */
18
+ const DEFAULT_SKILL = 'recursion';
19
+ // ── hash contract — MUST stay byte-identical to apps/recursion/api/src/skills/skill-hash.ts ──
20
+ // (the two packages share no published runtime dependency). A committed fixture
21
+ // pins the same hash in both test suites so the duplication can't drift.
22
+ /** A single `skill-version:` line (with its trailing newline), anywhere in the text. */
23
+ const SKILL_VERSION_LINE = /^skill-version:.*\r?\n/m;
24
+ /** sha256 (hex) of the markdown with any injected `skill-version:` line removed. */
25
+ export function skillVersionHash(text) {
26
+ return createHash('sha256').update(text.replace(SKILL_VERSION_LINE, ''), 'utf8').digest('hex');
27
+ }
28
+ /** The `skill-version` value stamped into a file's frontmatter, if present. */
29
+ export function stampedVersion(content) {
30
+ return /^skill-version:[ \t]*(\S+)[ \t]*\r?$/m.exec(content)?.[1];
31
+ }
32
+ /** Compare an installed file (or its absence) against the latest published version. */
33
+ export function skillStatus(installed, latestVersion) {
34
+ if (installed === undefined)
35
+ return 'missing';
36
+ return stampedVersion(installed) === latestVersion ? 'up-to-date' : 'out-of-date';
37
+ }
38
+ /**
39
+ * True if the installed file looks locally modified: its recomputed body hash
40
+ * doesn't match its own `skill-version` stamp, or it carries no stamp at all
41
+ * (unknown provenance). Such a file is never overwritten without `--force`.
42
+ */
43
+ export function isHandEdited(installed) {
44
+ const stamped = stampedVersion(installed);
45
+ if (stamped === undefined)
46
+ return true;
47
+ return skillVersionHash(installed) !== stamped;
48
+ }
49
+ // Validate every API response at the I/O boundary with Zod (code-standards.md:
50
+ // "Validate externally, type internally"). The backend owns the authoritative
51
+ // schemas in skills.dto.ts; these are the minimal CLI-side mirrors.
52
+ const SkillPayloadSchema = z.object({
53
+ name: z.string(),
54
+ version: z.string(),
55
+ content: z.string(),
56
+ });
57
+ const SkillSummarySchema = z.object({
58
+ name: z.string(),
59
+ description: z.string(),
60
+ });
61
+ /** Resolve the base URL for the skills endpoints (never throws — they are public). */
62
+ function resolveBaseUrl(program) {
63
+ const { baseUrl } = program.opts();
64
+ // --base-url wins over the env vars; none set → the production proxy. Shares
65
+ // envBaseUrl() with resolve.ts so the RECURSION_BASE_URL / RL_GYM_BASE_URL
66
+ // precedence lives in one tested place.
67
+ return baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL;
68
+ }
69
+ /** The configured API key (--api-key or LABELBOX_API_KEY), if any. */
70
+ function apiKeyOrUndefined(program) {
71
+ const { apiKey } = program.opts();
72
+ const { LABELBOX_API_KEY: envApiKey } = process.env;
73
+ return apiKey ?? envApiKey;
74
+ }
75
+ function resolveAuth(program) {
76
+ const apiKey = apiKeyOrUndefined(program);
77
+ if (!apiKey) {
78
+ throw new Error('missing API key — set LABELBOX_API_KEY or pass --api-key');
79
+ }
80
+ return { apiKey, baseUrl: resolveBaseUrl(program) };
81
+ }
82
+ // Each `rl skills` fetch is bounded for the same reason as the manifest fetch
83
+ // (see manifest.ts): a server that accepts the connection but never responds would
84
+ // otherwise hang `rl skills check`/`install`/`list` indefinitely. The timeout makes
85
+ // `fetch` reject so the caller surfaces a clear error rather than blocking forever.
86
+ const SKILLS_FETCH_TIMEOUT_MS = 30_000;
87
+ /**
88
+ * Fetch `{ name, version, content }` for a skill from `GET /v1/skills/<name>`.
89
+ *
90
+ * Exported only so `support-urls.test.ts` can assert its hand-written URL join
91
+ * against the committed spec, alongside the other support endpoints.
92
+ */
93
+ export async function fetchSkill(name, apiKey, baseUrl) {
94
+ const url = supportUrl(baseUrl, `/skills/${encodeURIComponent(name)}`);
95
+ const res = await fetch(url, {
96
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
97
+ headers: { Authorization: `Bearer ${apiKey}` },
98
+ signal: AbortSignal.timeout(SKILLS_FETCH_TIMEOUT_MS),
99
+ });
100
+ if (res.status === 404) {
101
+ throw new Error(`unknown skill "${name}" (the platform has no skill by that name)`);
102
+ }
103
+ if (!res.ok) {
104
+ throw new Error(`could not fetch skill "${name}" — HTTP ${res.status}`);
105
+ }
106
+ const parsed = SkillPayloadSchema.safeParse(await res.json());
107
+ if (!parsed.success) {
108
+ throw new Error(`unexpected response shape from ${url}`);
109
+ }
110
+ return parsed.data;
111
+ }
112
+ /**
113
+ * Fetch the catalog of installable skills from `GET /skills`. The endpoint is now
114
+ * gated (the CLI always has a key — `bin.ts` requires one before any command), so
115
+ * the key is sent when present; it stays optional here only so the function is
116
+ * reusable in contexts that legitimately have none.
117
+ */
118
+ export async function listSkills(opts) {
119
+ const url = supportUrl(opts.baseUrl, '/skills');
120
+ const res = await fetch(url, {
121
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
122
+ headers: opts.apiKey === undefined ? {} : { Authorization: `Bearer ${opts.apiKey}` },
123
+ signal: AbortSignal.timeout(SKILLS_FETCH_TIMEOUT_MS),
124
+ });
125
+ if (!res.ok) {
126
+ throw new Error(`could not list skills — HTTP ${res.status}`);
127
+ }
128
+ const parsed = z.array(SkillSummarySchema).safeParse(await res.json());
129
+ if (!parsed.success) {
130
+ throw new Error(`unexpected response shape from ${url}`);
131
+ }
132
+ return parsed.data;
133
+ }
134
+ /** Global install path for a skill, unless `--skill-file` overrides it. */
135
+ function installedPath(name, skillFile) {
136
+ return skillFile ?? join(homedir(), '.claude', 'skills', name, 'SKILL.md');
137
+ }
138
+ function readInstalled(path) {
139
+ try {
140
+ return readFileSync(path, 'utf8');
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ /** Programmatic core of `rl skills check` — fetch latest, read installed, compare. */
147
+ export async function checkSkill(name, opts) {
148
+ const latest = await fetchSkill(name, opts.apiKey, opts.baseUrl);
149
+ const installed = readInstalled(installedPath(name, opts.skillFile));
150
+ return { status: skillStatus(installed, latest.version), latestVersion: latest.version };
151
+ }
152
+ /** Raised when `install` refuses to clobber a locally-modified file (no `--force`). */
153
+ export class HandEditedError extends Error {
154
+ }
155
+ /** Programmatic core of `rl skills install` — fetch latest and write it (guarded). */
156
+ export async function installSkill(name, opts) {
157
+ const latest = await fetchSkill(name, opts.apiKey, opts.baseUrl);
158
+ const path = installedPath(name, opts.skillFile);
159
+ const existing = readInstalled(path);
160
+ if (existing !== undefined && opts.force !== true && isHandEdited(existing)) {
161
+ throw new HandEditedError(`${path} looks hand-edited (its content no longer matches its skill-version stamp). ` +
162
+ 'Refusing to overwrite — re-run with --force to replace it with the published version.');
163
+ }
164
+ // Already the published version — don't rewrite the file (no mtime churn) or
165
+ // claim a change happened; `install` run directly is then honest that nothing
166
+ // needs to take effect.
167
+ if (existing === latest.content) {
168
+ return { path, action: 'unchanged', version: latest.version };
169
+ }
170
+ mkdirSync(dirname(path), { recursive: true });
171
+ writeFileSync(path, latest.content);
172
+ return {
173
+ path,
174
+ action: existing === undefined ? 'installed' : 'updated',
175
+ version: latest.version,
176
+ };
177
+ }
178
+ /** Register the `skills check` / `skills install` command group on `program`. */
179
+ export function addSkillsCommands(program, docsHelpGroup) {
180
+ const skills = program
181
+ .command('skills')
182
+ .helpGroup(docsHelpGroup)
183
+ .description('Install and update the downloadable Claude skills for this platform');
184
+ skills
185
+ .command('list')
186
+ .description('List the skills available to install')
187
+ .action(async () => {
188
+ try {
189
+ // The CLI always has a key by the time any command runs (bin.ts requires
190
+ // one), so pass it through; the endpoint is gated like everything else.
191
+ const available = await listSkills({
192
+ apiKey: apiKeyOrUndefined(program),
193
+ baseUrl: resolveBaseUrl(program),
194
+ });
195
+ if (available.length === 0) {
196
+ process.stdout.write('No skills are available.\n');
197
+ return;
198
+ }
199
+ const lines = available.map((s) => s.description ? ` ${s.name} — ${s.description}` : ` ${s.name}`);
200
+ process.stdout.write(`Installable skills (run \`rl skills install <name>\`):\n${lines.join('\n')}\n`);
201
+ }
202
+ catch (err) {
203
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
204
+ process.exit(1);
205
+ }
206
+ });
207
+ skills
208
+ .command('check [name]')
209
+ .description('Report whether the installed skill matches the latest published version')
210
+ .option('--skill-file <path>', 'Check this file instead of the global ~/.claude/skills path')
211
+ .action(async (name, opts) => {
212
+ const skill = name ?? DEFAULT_SKILL;
213
+ try {
214
+ const { apiKey, baseUrl } = resolveAuth(program);
215
+ const { status } = await checkSkill(skill, { apiKey, baseUrl, skillFile: opts.skillFile });
216
+ if (status === 'up-to-date') {
217
+ process.stdout.write(`${skill} is up to date.\n`);
218
+ return;
219
+ }
220
+ const lead = status === 'missing' ? 'is not installed' : 'is out of date';
221
+ process.stdout.write(`${skill} ${lead} — run \`rl skills install ${skill}\`.\n`);
222
+ process.exit(1);
223
+ }
224
+ catch (err) {
225
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
226
+ process.exit(1);
227
+ }
228
+ });
229
+ skills
230
+ .command('install [name]')
231
+ .description('Install or update a skill to the latest published version')
232
+ .option('--force', 'Overwrite even if the installed file looks hand-edited')
233
+ .option('--skill-file <path>', 'Write to this file instead of the global ~/.claude/skills path')
234
+ .action(async (name, opts) => {
235
+ const skill = name ?? DEFAULT_SKILL;
236
+ try {
237
+ const { apiKey, baseUrl } = resolveAuth(program);
238
+ const { path, action, version } = await installSkill(skill, {
239
+ apiKey,
240
+ baseUrl,
241
+ skillFile: opts.skillFile,
242
+ force: opts.force,
243
+ });
244
+ if (action === 'unchanged') {
245
+ process.stdout.write(`${skill} is already up to date (version ${version.slice(0, 12)}).\n`);
246
+ return;
247
+ }
248
+ process.stdout.write(`${action === 'installed' ? 'Installed' : 'Updated'} ${skill} → ${path} ` +
249
+ `(version ${version.slice(0, 12)}). Takes effect on the next /${skill}.\n`);
250
+ }
251
+ catch (err) {
252
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
253
+ process.exit(1);
254
+ }
255
+ });
256
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Read at runtime so the tag-only publish flow's stamped version is reported;
3
+ * local/dev builds will report the committed placeholder (0.0.0).
4
+ */
5
+ export declare function readCliVersion(): string;
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // Kept out of `program.ts` deliberately. This is the only place in the package that
3
+ // touches `import.meta.url`. The backend is a CommonJS build and loads this package
4
+ // through `require(esm)`, which needs Node >= 22.12 — a floor pinned by the
5
+ // *backend's* `engines` (">= 24") and its `node:24` image, not by this package,
6
+ // which declares none. So the embeddable chain has to stay clear of anything a CJS
7
+ // consumer cannot reach. Nothing but the terminal entrypoint needs the version: the
8
+ // program takes it as an input, so an embedder supplies its own.
9
+ /**
10
+ * Read at runtime so the tag-only publish flow's stamped version is reported;
11
+ * local/dev builds will report the committed placeholder (0.0.0).
12
+ */
13
+ export function readCliVersion() {
14
+ const parsed = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
+ if (typeof parsed === 'object' &&
16
+ parsed !== null &&
17
+ 'version' in parsed &&
18
+ typeof parsed.version === 'string') {
19
+ return parsed.version;
20
+ }
21
+ throw new Error('could not read the CLI version from package.json');
22
+ }
package/package.json CHANGED
@@ -1,11 +1,57 @@
1
1
  {
2
2
  "name": "@labelbox/recursion-cli",
3
- "version": "0.0.0",
4
- "description": "Placeholder so Trusted Publishing can be attached. Real versions come from CI.",
5
- "license": "UNLICENSED",
3
+ "version": "0.0.41",
4
+ "type": "module",
6
5
  "repository": {
7
6
  "type": "git",
8
7
  "url": "git+https://github.com/Labelbox/recursion-platform.git"
9
8
  },
10
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" }
9
+ "bin": {
10
+ "rl": "./dist/bin.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "registry": "https://registry.npmjs.org"
18
+ },
19
+ "exports": {
20
+ "./program": {
21
+ "types": "./dist/program.d.ts",
22
+ "import": "./dist/program.js"
23
+ },
24
+ "./embed": {
25
+ "types": "./dist/embed.d.ts",
26
+ "default": "./dist/embed.js"
27
+ }
28
+ },
29
+ "typesVersions": {
30
+ "*": {
31
+ "embed": [
32
+ "./dist/embed.d.ts"
33
+ ],
34
+ "program": [
35
+ "./dist/program.d.ts"
36
+ ]
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "check": "yarn lint && tsc --noEmit",
42
+ "lint": "biome ci --error-on-warnings --config-path ./biome.json .",
43
+ "lint:fix": "biome check --config-path ./biome.json --write .",
44
+ "test": "vitest run",
45
+ "prepare": "yarn build",
46
+ "type-check": "tsc --noEmit"
47
+ },
48
+ "dependencies": {
49
+ "commander": "^14.0.0",
50
+ "zod": "^4.3.6"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "2.4.6",
54
+ "typescript": "^5.9.3",
55
+ "vitest": "^4.0.18"
56
+ }
11
57
  }