@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 CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # @labelbox/horizon-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `horizon` — the Horizon CLI. It mirrors the TypeScript SDK
4
+ (`@labelbox/horizon-sdk`) exactly: where the SDK is `horizon.synthesizers.create(...)`, the
5
+ CLI is `horizon synthesizers create …`. Dots become spaces; you get `--help` at every
6
+ level. `horizon` is the only executable name.
7
+
8
+ ```sh
9
+ horizon --help # list nouns (synthesizers, synthesizer-runs)
10
+ horizon synthesizers --help # list verbs (create, get, list, …)
11
+ horizon synthesizers create --help # list flags
12
+ ```
13
+
14
+ ## How it works (fully live, zero per-operation code)
15
+
16
+ The CLI ships **no** baked API reference and has **no** `@labelbox/horizon-sdk` dependency.
17
+ On each run it **revalidates the manifest** from `GET /v1/cli/manifest` when the server
18
+ selected by `--base-url` is reachable (production by default, or staging, or
19
+ `localhost`). If the server is unavailable, it falls back only to a locally cached
20
+ manifest that was previously validated. From that manifest it builds its entire
21
+ command tree, `--help`, request/response shapes, and docs browse surfaces
22
+ (`src/manifest.ts`). Dispatch is generic (`src/dispatch.ts`):
23
+ each request is built straight from the manifest operation's HTTP method + path
24
+ template + params + body — there is no hand-written command per operation and no
25
+ baked client.
26
+
27
+ The result: **adding or changing a backend endpoint needs zero CLI release** — the
28
+ live CLI reflects it as soon as the backend deploys. The CLI is re-released only when
29
+ its own engine code changes. When the server is reachable, the manifest is
30
+ revalidated on every run via a conditional fetch (ETag / `If-None-Match`) and cached
31
+ per base-url under `~/.cache/horizon/`. When the server is unreachable, the CLI may
32
+ use the last locally validated copy so commands remain available offline. Validated
33
+ fresh HTTP `200` responses use atomic same-directory rename and therefore retain
34
+ last-network-writer behavior. Replacement preserves an existing file's POSIX mode
35
+ bits, but deliberately publishes a new inode and does not preserve its ACLs or
36
+ extended attributes. Publication requires parent-directory write/search permission;
37
+ if unavailable, the CLI keeps using the validated in-memory result and never falls
38
+ back to a partial direct write.
39
+
40
+ ### Docs browse surfaces
41
+
42
+ Beyond the executable `horizon <noun> <verb>` operations, the manifest carries the docs,
43
+ exposed as one consistent positional shape — `horizon <group> [<id>]` (bare lists, an id
44
+ shows that one):
45
+
46
+ ```sh
47
+ horizon resources [<id>] # Reference — resource hubs: object shape, operations, recipes
48
+ horizon recipes [<id>] # How-to — multi-step user-goal guides (--format cli|ts|curl)
49
+ horizon explain [<concept>] # Explanation — concept pages
50
+ horizon tutorials [<id>] # Tutorials — getting-started docs
51
+ ```
52
+
53
+ ## Install / run
54
+
55
+ The package is not published yet, so no npm install command is valid today.
56
+ The manual **Release / CLI** workflow builds and externally consumes an
57
+ immutable `0.0.1` bootstrap archive, but executable release authority refuses
58
+ npm and GitHub mutation until a Labelbox npm owner reserves the package and
59
+ registers its Trusted Publisher.
60
+
61
+ When activated, the CLI remains standalone and pulls in no
62
+ `@labelbox/horizon-sdk`. It is versioned independently of the SDK, remains below
63
+ `1.0.0`, and may include breaking changes.
64
+
65
+ From a monorepo checkout, run the bin directly without installing:
66
+
67
+ ```sh
68
+ yarn workspace @labelbox/horizon-cli build
69
+ node ./packages/horizon-cli/dist/bin.js --help
70
+ ```
71
+
72
+ ## Auth
73
+
74
+ Set `LABELBOX_API_KEY` in the environment without printing it or putting it on a
75
+ command line. Override the host with `--base-url <url>` (defaults to the public
76
+ Horizon API gateway, see `DEFAULT_BASE_URL` in `src/manifest.ts`).
77
+
78
+ ## Flags
79
+
80
+ - **Path / query params** are individual flags: `--environment-id`, `--limit`, …
81
+ Required path and query params must be passed as flags. A query param whose type
82
+ is an array or object (e.g. `--models`, `--enrichment-filters`) takes a JSON value
83
+ (`--models '["a","b"]'`); its help text is tagged `[pass as JSON]`.
84
+ - **Operation headers** declared by the API are individual flags too. Required
85
+ concurrency and replay controls therefore appear as `--if-match` and
86
+ `--idempotency-key` and are forwarded under their declared HTTP names.
87
+ - **Request body**: scalar top-level fields are individual flags (`--name`,
88
+ `--system-prompt`, …); pass the full body — including complex fields like
89
+ `contextInputs` / `targetFields` — with `--from-json <file>` or `--data <json>`
90
+ (mutually exclusive — pass only one). Scalar flags override values from
91
+ `--from-json`/`--data`. A required scalar body field can be supplied by either
92
+ its flag or the JSON body.
93
+ - **Multipart request body**: binary fields are local-path flags such as
94
+ `--file ./skill.zip`; scalar form fields remain ordinary flags. The CLI reads
95
+ the file only in its terminal process and lets the HTTP runtime set the
96
+ multipart boundary. Filesystem-less embedded callers expose JSON operations
97
+ only.
98
+ - **Output**: prints every non-binary result as the JSON HTTP envelope
99
+ `{ data, status, headers }` by default. A bodyless `204` or `304` is encoded as
100
+ `data: null` because JSON has no `undefined`. `--quiet` deliberately discards
101
+ that HTTP metadata and prints only the resulting resource's `id`; an operation
102
+ may instead declare one scalar response field for quiet output (for example,
103
+ the handoff-access command's ephemeral URL). Results without either value print
104
+ a blank line in quiet mode. Binary downloads are written to stdout byte-for-byte
105
+ with no encoding or trailing newline, so redirect them to a file.
106
+
107
+ The embedded dispatcher exposes JSON/text operations and returns each success as
108
+ `{ data, status, headers }`. `headers` contains the response headers declared for
109
+ that status in OpenAPI (including `ETag`, `Location`, and
110
+ `Idempotency-Replayed`). Bodyless `data` is `undefined`. Operations with a binary
111
+ success representation are excluded from embedded and MCP callers; use the
112
+ terminal CLI, which streams their bytes directly to stdout.
113
+
114
+ ```sh
115
+ horizon environments get --environment-id 784e2386-e297-4f9d-a886-838422383b65
116
+ horizon synthesizers get --synthesizer-job-id b45c081d-3069-4e32-98d4-aa5ec3d442c6
117
+ ```
118
+
119
+ ## The command surface is the live server
120
+
121
+ There is **nothing to regenerate or commit** for the CLI — the command surface is
122
+ revalidated against the target server's `GET /v1/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.
129
+
130
+ The *engine* (manifest fetch + cache + validation, generic dispatch, flag mapping,
131
+ help formatting, the request-body / returns shape trees, and the docs browse
132
+ renderers) is verified by `src/*.test.ts`, which build the program from a fixture
133
+ manifest in-memory and assert the realized flags + `--help` + browse output — no
134
+ committed golden snapshot to maintain.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { fetchManifest } from './manifest.js';
4
+ import { fetchPermissions } from './permissions.js';
5
+ import { run } from './run.js';
6
+ import { readCliVersion } from './version.js';
7
+ // The orchestration lives in run.ts (testable); this entrypoint only binds the real
8
+ // dependencies and turns the returned code into a process exit. Runs on import —
9
+ // kept logic-free so it never needs its own test.
10
+ //
11
+ // `run()` never throws and never exits: it renders every failure (including a
12
+ // missing key, a manifest fetch/validation error, and synchronous throws from
13
+ // buildProgram() such as a reserved-flag collision) to the `stderr` sink as
14
+ // `error: <message>`, then returns the exit code. The `.catch()` below is a
15
+ // backstop for a defect in that contract, not a routine path.
16
+ run({
17
+ argv: process.argv,
18
+ // Pass the reader, not its result — run() invokes it so a throw (corrupt install)
19
+ // is rendered as `error: <message>` rather than a raw stack trace.
20
+ version: readCliVersion,
21
+ fetchManifest,
22
+ fetchPermissions,
23
+ // The terminal entrypoint: `scaffold`, `submit`, and `skills` act on the
24
+ // developer's own checkout, so this is the one caller that registers them.
25
+ localCheckout: true,
26
+ stdout: (text) => process.stdout.write(text),
27
+ binaryStdout: process.stdout,
28
+ stderr: (text) => process.stderr.write(text),
29
+ })
30
+ .then((code) => {
31
+ // Only exit non-zero. Calling process.exit(0) here could truncate a large
32
+ // pending stdout write; letting node drain and exit naturally cannot.
33
+ if (code !== 0)
34
+ process.exit(code);
35
+ })
36
+ .catch((err) => {
37
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
38
+ process.exit(1);
39
+ });
@@ -0,0 +1,135 @@
1
+ import type { Command } from 'commander';
2
+ import { z } from 'zod';
3
+ import { type GrantedPermissions } from './permissions.js';
4
+ /** Mirrors `ComputeAccessSessionDto`; `mintAccessSession`'s 201 body. */
5
+ declare const AccessSessionSchema: z.ZodObject<{
6
+ redemptionUrl: z.ZodURL;
7
+ redemptionUrlExpiresAt: z.ZodString;
8
+ }, z.core.$strip>;
9
+ export interface ComputeSession {
10
+ /** `https://c-<compute-id>.<subdomain-root>`, taken from the redemption URL —
11
+ * the edge's domain is the server's to choose, never assembled client-side. */
12
+ origin: string;
13
+ /** Streamable-HTTP MCP endpoint on that origin.
14
+ *
15
+ * The trailing slash is load-bearing: the world's nginx routes MCP with
16
+ * `location /mcp/ { proxy_pass http://mcp:8000; }`, an nginx *prefix* match, so
17
+ * a request to `/mcp` does not match it and never reaches the MCP server. */
18
+ mcpEndpoint: string;
19
+ /** Plain-JSON enumeration of every tool the world serves, on the same origin
20
+ * and behind the same cookie — nginx rewrites `/mcp-health/` to the MCP
21
+ * server's `/health/`, so this is its `/health/tools`. Answers "what is in
22
+ * this world?" without an MCP handshake or a JSON-RPC client. */
23
+ toolsEndpoint: string;
24
+ /** `name=value`, ready to send verbatim as a `Cookie` request header. */
25
+ cookie: string;
26
+ }
27
+ export declare const SESSION_FORMATS: readonly ['header-file', 'claude-code', 'json', 'cookie'];
28
+ export type SessionFormat = (typeof SESSION_FORMATS)[number];
29
+ /** The default, and the reason it is the default: every other format puts a
30
+ * bearer-equivalent credential somewhere durable. */
31
+ export declare const DEFAULT_SESSION_FORMAT: SessionFormat;
32
+ /**
33
+ * Bind the redemption origin **before** anything is sent to it.
34
+ *
35
+ * The redemption URL comes back over the wire, and the next thing the CLI does is
36
+ * make a request to it — so it is untrusted input on the way in and a request
37
+ * target on the way out. Everything downstream (the MCP endpoint, the cookie's
38
+ * eventual destination) is derived from this origin, which makes a bad one a
39
+ * credential-exfiltration primitive rather than a cosmetic error.
40
+ *
41
+ * Port of `proxyUrlFromRedemption` (devbox.ts): TLS only, no userinfo (which would
42
+ * let `https://c-<id>@attacker.example/` read as the expected host), a hostname
43
+ * that *starts with* `c-<computeId>.` and is strictly longer than that prefix (so
44
+ * the compute the caller named is the compute the session is for, and a bare
45
+ * `c-<id>.` with no domain is refused), and the exact redemption pathname.
46
+ */
47
+ export declare function redemptionOrigin(redemptionUrl: string, computeId: string): string;
48
+ /** `POST /v1/computes/:computeId/access/sessions` — mints a short-lived,
49
+ * single-use redemption URL for a running compute the caller owns. */
50
+ export declare function mintAccessSession(args: {
51
+ apiKey: string;
52
+ baseUrl: string;
53
+ computeId: string;
54
+ }): Promise<z.infer<typeof AccessSessionSchema>>;
55
+ /** Redeem a minted URL into the edge session cookie.
56
+ *
57
+ * `computeId` is not decoration: the URL is validated against it *before* the
58
+ * request goes out (see `redemptionOrigin`), because sending it is the act that
59
+ * would leak.
60
+ *
61
+ * `redirect: 'manual'` is load-bearing: the cookie rides on the 302 itself, and
62
+ * following the redirect would consume the only header this request exists to
63
+ * read. The URL is single-use and TTL-bounded (60s upstream), so a failure here
64
+ * is not retryable with the same URL — mint a fresh one. */
65
+ export declare function redeemSession(redemptionUrl: string, computeId: string): Promise<string>;
66
+ /** Mint and redeem in one step, returning everything an MCP client needs. */
67
+ export declare function openComputeSession(args: {
68
+ apiKey: string;
69
+ baseUrl: string;
70
+ computeId: string;
71
+ }): Promise<ComputeSession>;
72
+ /** One tool as the world's MCP server reports it on `/health/tools`.
73
+ *
74
+ * Mirrors `MCPToolHealth` in `worldsim_platform/mcp/server.py`. Only the fields the
75
+ * grouped view reads are modelled, and the object is *loose* on purpose: the world
76
+ * ships on its own cadence, so a tool gaining a field must neither break enumeration
77
+ * nor silently vanish. A stripping object would drop `method`, `path` and
78
+ * `example_arguments` — exactly the call-shape fields `--json` exists to carry. */
79
+ declare const WorldToolSchema: z.ZodObject<{
80
+ service: z.ZodString;
81
+ name: z.ZodString;
82
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
83
+ }, z.core.$loose>;
84
+ export type WorldTool = z.infer<typeof WorldToolSchema>;
85
+ /** Every tool the world currently serves.
86
+ *
87
+ * This is the post-allowlist set: the MCP server filters by `WORLDSIM_MCP_TOOLS_FILE`
88
+ * before registering, so it answers what an agent in this world can actually call,
89
+ * not what the bake could expose. The bake's full catalogue is a different question,
90
+ * answered by `run-config-versions discover-mcp-tools`.
91
+ *
92
+ * Plain JSON over the same session cookie as `/mcp/` — no MCP handshake, so no
93
+ * `initialize` round-trip and no JSON-RPC pagination to unroll. */
94
+ export declare function fetchWorldTools(session: ComputeSession): Promise<WorldTool[]>;
95
+ /** Group by service, because a world's tools are namespaced by the service that
96
+ * backs them (`gitea.create_user`) and a flat list of 200 hides that structure. */
97
+ export declare function formatWorldTools(tools: WorldTool[]): string;
98
+ /**
99
+ * The MCP server name a compute registers under when `--name` isn't given.
100
+ *
101
+ * A compute is a generic remote machine — `tools/dx devbox` runs devboxes on the
102
+ * same primitive — so the name is derived from the compute rather than from any
103
+ * one workload that happens to run on it. Derived rather than fixed so two open
104
+ * computes don't collide under one name in the client's config.
105
+ */
106
+ export declare function defaultServerName(computeId: string): string;
107
+ export declare function parseServerName(value: string): string;
108
+ /**
109
+ * Write the `Cookie:` request header to a file only the current user can read,
110
+ * and return its path.
111
+ *
112
+ * In its own `mkdtempSync` directory (mode 0700) rather than a fixed, guessable
113
+ * path under `tmpdir()`, and with the `wx` flag, which refuses to follow or
114
+ * overwrite an existing path — the same shape `git-host.ts` uses for its askpass
115
+ * helper, and for the same local-attacker-pre-plants-a-symlink reason. Unlike that
116
+ * one, this file *is* the secret, hence 0600.
117
+ */
118
+ export declare function writeSessionHeaderFile(session: ComputeSession): string;
119
+ export declare function formatSession(session: ComputeSession, format: SessionFormat, serverName: string): string;
120
+ /** Attach `open` and `tools` to the manifest-built `computes` group.
121
+ *
122
+ * Registered after the operation loop, so the group already exists — unless the
123
+ * backend serves no compute operations at all, in which case there is nothing to
124
+ * attach to and nothing to offer. A name collision means the backend grew its own
125
+ * `computes open` or `computes tools`; fail loudly rather than let commander silently shadow one,
126
+ * mirroring `claim()` in program.ts.
127
+ *
128
+ * Returns the `computes` group and number of gated commands when they were
129
+ * registered in their gated form, so the caller can fold them into that group's
130
+ * "N commands require permissions" banner; `undefined` otherwise. */
131
+ export declare function addComputeSessionCommands(program: Command, helpGroup: string, granted: GrantedPermissions): {
132
+ parent: Command;
133
+ gatedCount: number;
134
+ } | undefined;
135
+ export {};