@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
package/README.md
CHANGED
|
@@ -1,3 +1,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @labelbox/horizon-cli
|
|
2
2
|
|
|
3
|
-
|
|
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
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 {};
|