@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.
- package/README.md +133 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +39 -0
- package/dist/compute-session.d.ts +135 -0
- package/dist/compute-session.js +373 -0
- package/dist/default-base-url.generated.d.ts +5 -0
- package/dist/default-base-url.generated.js +5 -0
- package/dist/dispatch.d.ts +40 -0
- package/dist/dispatch.js +265 -0
- package/dist/embed.d.ts +39 -0
- package/dist/embed.js +51 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +184 -0
- package/dist/json-operation-callability.d.ts +31 -0
- package/dist/json-operation-callability.js +57 -0
- package/dist/manifest.d.ts +531 -0
- package/dist/manifest.js +558 -0
- package/dist/permissions.d.ts +46 -0
- package/dist/permissions.js +106 -0
- package/dist/program.d.ts +129 -0
- package/dist/program.js +985 -0
- package/dist/request-timeout.d.ts +8 -0
- package/dist/request-timeout.js +33 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.js +111 -0
- package/dist/run.d.ts +44 -0
- package/dist/run.js +81 -0
- package/dist/skills.d.ts +73 -0
- package/dist/skills.js +235 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +61 -4
|
@@ -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 {};
|