@labelbox/horizon-cli 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ import { z } from 'zod';
2
+ import { supportUrl } from './manifest.js';
3
+ import { requestTimeoutMs } from './request-timeout.js';
4
+ /** `/me/permissions` returns a bare JSON array of permission-entry strings. */
5
+ const PermissionsResponseSchema = z.array(z.string());
6
+ // Bound the fetch so a hung gateway can't stall the CLI (mirrors the manifest
7
+ // fetch). On any failure we fall back to the fail-open "unknown" set.
8
+ const PERMISSIONS_FETCH_TIMEOUT_MS = 30_000;
9
+ /**
10
+ * Fetch the caller's granted permissions. Network, response, and decoding errors
11
+ * resolve to `undefined` (fail-open); invalid local timeout/deadline configuration
12
+ * fails before the request. An empty array also resolves to `undefined` — the
13
+ * backend returns `[]` for callers with no computed permissions
14
+ * (local/standalone/S2S), where enforcement is bypassed, so the CLI must not treat
15
+ * that as "deny everything". (An E2E caller with a present-but-empty
16
+ * `X-Permissions` also gets `[]` here; the backend would 403, but the CLI only
17
+ * skips local pre-emption — it does not hide or allow the call.)
18
+ */
19
+ export async function fetchPermissions(baseUrl, apiKey) {
20
+ const url = supportUrl(baseUrl, '/me/permissions');
21
+ const timeoutMs = requestTimeoutMs(PERMISSIONS_FETCH_TIMEOUT_MS);
22
+ let res;
23
+ try {
24
+ res = await fetch(url, {
25
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
26
+ headers: { Authorization: `Bearer ${apiKey}` },
27
+ signal: AbortSignal.timeout(timeoutMs),
28
+ });
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ if (!res.ok)
34
+ return undefined;
35
+ let json;
36
+ try {
37
+ json = await res.json();
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ const parsed = PermissionsResponseSchema.safeParse(json);
43
+ if (!parsed.success || parsed.data.length === 0)
44
+ return undefined;
45
+ return new Set(parsed.data);
46
+ }
47
+ /**
48
+ * Whether a granted set satisfies a single required permission. Mirrors
49
+ * `hasPermission` in `@recursion/shared` — the CLI can't depend on that private
50
+ * package, and (like the manifest schema) re-declares the tiny bit it needs.
51
+ * Wildcards: `*` grants everything; `resource:*` grants all actions on a resource.
52
+ */
53
+ export function hasPermission(granted, required) {
54
+ if (granted.has('*'))
55
+ return true;
56
+ if (granted.has(required))
57
+ return true;
58
+ const resource = required.split(':')[0];
59
+ if (resource === undefined || resource === '')
60
+ return false;
61
+ return granted.has(`${resource}:*`);
62
+ }
63
+ /**
64
+ * The first required permission the caller lacks, or `undefined` when the command
65
+ * is runnable — because it's ungated (no `requiredPermissions`), the caller holds
66
+ * all of them, or permissions are unknown (fail-open). Drives both the `--help`
67
+ * marker and the pre-emptive error on invocation, so they always agree.
68
+ */
69
+ export function missingPermission(op, granted) {
70
+ if (granted === undefined)
71
+ return undefined;
72
+ const required = op.requiredPermissions;
73
+ if (!required || required.length === 0)
74
+ return undefined;
75
+ return required.find((perm) => !hasPermission(granted, perm));
76
+ }
77
+ // ── gating presentation (shared with hand-written commands) ──────────────────
78
+ //
79
+ // These live here, next to `missingPermission`, rather than in program.ts so that
80
+ // hand-written commands (compute-session.ts) can present a missing permission
81
+ // exactly the way the manifest-driven loop does without importing program.ts,
82
+ // which imports them — a cycle.
83
+ /** Nested resource groups — subcommands the caller can't run (separate help section). */
84
+ export const UNAVAILABLE_HELP_GROUP = 'Unavailable (missing permission)';
85
+ /** A leaf command's description when the caller lacks a required permission. */
86
+ export function gatedSummary(summary, missing) {
87
+ return missing === undefined ? summary : `requires \`${missing}\` — ${summary}`;
88
+ }
89
+ /** The error printed when the caller invokes a command they lack permission for. */
90
+ export function permissionDeniedMessage(missing) {
91
+ return `you don't have permission to run this command (requires \`${missing}\`)`;
92
+ }
93
+ /** A prominent note appended to a gated command's `--help` output. */
94
+ export function permissionHelpNote(missing) {
95
+ return `\nYou don't have permission to run this command (requires \`${missing}\`).`;
96
+ }
97
+ /**
98
+ * `missingPermission` for a command that carries its requirement in code rather
99
+ * than in the manifest: the permission back, or `undefined` when the caller holds
100
+ * it or permissions are unknown (fail-open, same as the manifest path).
101
+ */
102
+ export function missingRequiredPermission(granted, required) {
103
+ if (granted === undefined)
104
+ return undefined;
105
+ return hasPermission(granted, required) ? undefined : required;
106
+ }
@@ -0,0 +1,129 @@
1
+ import { type Writable } from 'node:stream';
2
+ import { Command } from 'commander';
3
+ import { type DispatchResponse } from './dispatch.js';
4
+ import type { Manifest, ManifestOperation, ManifestRecipe, ShapeNode } from './manifest.js';
5
+ import { type GrantedPermissions } from './permissions.js';
6
+ /** camelCase → kebab-case for command + flag names (shared with `@labelbox/horizon-sdk`). */
7
+ export declare function kebab(value: string): string;
8
+ /**
9
+ * The key commander stores a parsed flag under. Commander camelCases the long
10
+ * flag name, so `--if-match` lands on `opts.ifMatch` — a wire header name like
11
+ * `If-Match` never round-trips through `kebab()` back to itself the way a
12
+ * camelCase path or query param name does, and reading `opts[param.name]`
13
+ * silently dropped the header.
14
+ */
15
+ export declare function optionAttributeName(value: string): string;
16
+ /** Coerce a string flag value to its declared scalar type. */
17
+ export declare function coerce(value: unknown, type: string): unknown;
18
+ /**
19
+ * Build the flat options object from parsed CLI flags + a pre-parsed body base
20
+ * (from `--from-json` / `--data`). Path and query params and scalar body fields
21
+ * come from individual flags; scalar flags override the JSON base. Required scalar
22
+ * body fields are enforced here — after the available sources are merged — so a
23
+ * missing JSON field or multipart form field fails locally rather than becoming a
24
+ * server-side 4xx.
25
+ */
26
+ export declare function assembleParams(entry: ManifestOperation, opts: Record<string, unknown>, bodyBase: Record<string, unknown>): Record<string, unknown>;
27
+ export declare function parseBodyBase(opts: Record<string, unknown>): Record<string, unknown>;
28
+ /** The pre-resolved auth + version context bin.ts threads into the program. */
29
+ /**
30
+ * Where CLI output goes, and the reason nothing here touches `process` directly.
31
+ *
32
+ * The terminal entrypoint (`bin.ts`) binds these to the real streams. The MCP
33
+ * endpoint binds them to string buffers so one `horizon` invocation can be executed
34
+ * **in-process** on behalf of an agent and its output returned as a tool result.
35
+ * A stray `process.stdout.write` would leak an agent's output into the server's
36
+ * logs; a stray `process.exit` would take the whole API server down mid-request.
37
+ * Both are therefore banned in this module — see `buildBaseProgram`, which also
38
+ * routes commander's own help/error output through these sinks.
39
+ */
40
+ export interface CliIo {
41
+ stdout: (text: string) => void;
42
+ stderr: (text: string) => void;
43
+ }
44
+ export interface ProgramContext extends CliIo {
45
+ /** Byte-preserving terminal output. Absent for embedded callers, which omit binary operations. */
46
+ binaryStdout?: Writable;
47
+ apiKey: string;
48
+ baseUrl: string;
49
+ version: string;
50
+ granted: GrantedPermissions;
51
+ /**
52
+ * Whether the process is running against a developer's own checkout.
53
+ *
54
+ * `false` when the CLI is embedded in a server (see `embed.ts`), which omits
55
+ * `scaffold`, `submit`, `skills`, `computes open`, and `computes tools` from the command tree
56
+ * entirely. Those are the package's only routes to `spawnSync('git', …)`, to
57
+ * writes under `~/.claude/`, to the `process.exit` calls in
58
+ * `skills.ts`/`git-host.ts` that would kill an API server mid-request, and to the
59
+ * direct `process.stdout` write in `compute-session.ts` that would put a
60
+ * bearer-equivalent cookie in the server's logs instead of the tool result. Not
61
+ * registering them removes the capability, which is a stronger guarantee than
62
+ * inspecting argv for their names — and it keeps five commands a server cannot
63
+ * honour out of the `--help` an agent reads. It also disables fallback to the
64
+ * server process's organization/environment variables, which are configuration
65
+ * for the server rather than authenticated scope supplied by its caller.
66
+ */
67
+ localCheckout: boolean;
68
+ }
69
+ /**
70
+ * Render a response for stdout. Normal mode preserves the HTTP result as a JSON
71
+ * envelope; JSON encodes a bodyless response's `undefined` data as `null` so the
72
+ * field remains explicit. `--quiet` is the deliberate lossy projection: it prints
73
+ * a manifest-declared scalar output field, or otherwise the response data's `id`
74
+ * (an empty line for data without one). Binary data is always written byte-for-byte.
75
+ * Text output includes a trailing newline.
76
+ */
77
+ export declare function formatOutput(response: DispatchResponse, quiet: boolean, outputField?: string): string | ReadableStream<Uint8Array>;
78
+ /**
79
+ * Render a caught error for stderr. Generic dispatch throws the server's JSON error
80
+ * body (printed as-is) or an `Error` (its message). A raw, detail-less object (or a
81
+ * thrown empty value) maps to a message that points at the likely cause.
82
+ */
83
+ export declare function formatError(err: unknown): string;
84
+ /**
85
+ * Render a request-body / response shape as an indented tree of lines. Each node
86
+ * shows `name` (or `name?` when optional), its type label, and its metadata tags,
87
+ * then recurses into whatever sub-shape it has.
88
+ */
89
+ export declare function renderShapeTree(nodes: ShapeNode[], indent: string): string[];
90
+ /**
91
+ * The "Request body" + "Returns" help sections appended after a leaf command's
92
+ * built-in help. The body section renders the body params' full nested shape; the
93
+ * returns section renders the success response's full shape — an object's fields,
94
+ * an `array of <element>` with the element's shape, or a scalar's type — or a note
95
+ * when the op returns no body.
96
+ */
97
+ export declare function leafShapeHelp(entry: ManifestOperation): string;
98
+ /**
99
+ * Register an operation's flags.
100
+ *
101
+ * `fileFlags` is false when the program has no developer checkout. In that mode,
102
+ * `--from-json` would read from the host process rather than the user's machine,
103
+ * so it is not registered and `--data` remains the inline alternative.
104
+ */
105
+ export declare function addOptions(command: Command, entry: ManifestOperation, fileFlags?: boolean): void;
106
+ /** Output formats `horizon recipes <id>` can render, mapped to the composed snippet field. */
107
+ declare const RECIPE_FORMATS: {
108
+ readonly cli: 'cli';
109
+ readonly ts: 'sdk';
110
+ readonly curl: 'curl';
111
+ };
112
+ export type RecipeFormat = keyof typeof RECIPE_FORMATS;
113
+ /** The full catalog, grouped by category, for `horizon recipes`. */
114
+ export declare function renderRecipeList(reference: Record<string, ManifestRecipe>): string;
115
+ /**
116
+ * The "Related" + "Unblocks" block for a recipe — its place in the graph. The
117
+ * stored links (`requires` / `variationOf` / `learnMore`) come off the recipe;
118
+ * **Unblocks** is *derived* (never stored): the recipes that name THIS one as a
119
+ * `requires` recipe, so an agent reading one recipe sees both what to do first
120
+ * and where it can go next. Returns `''` when the recipe is an island.
121
+ */
122
+ export declare function renderRelatedBlock(entry: ManifestRecipe, allRecipes: Record<string, ManifestRecipe>): string;
123
+ /** One recipe rendered for `horizon recipes <id>`: goal + composed code + related links + a docs link. */
124
+ export declare function renderRecipeShow(entry: ManifestRecipe, format: RecipeFormat, allRecipes: Record<string, ManifestRecipe>): string;
125
+ /** The program shell — name, description, version, and the global options. */
126
+ export declare function buildBaseProgram(version: string, io: CliIo, allowEnvironmentFallback?: boolean): Command;
127
+ /** Build the full `horizon` program from a fetched manifest. */
128
+ export declare function buildProgram(manifest: Manifest, ctx: ProgramContext): Command;
129
+ export {};