@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,8 @@
|
|
|
1
|
+
export interface RequestBudget {
|
|
2
|
+
readonly requestTimeoutMs: number;
|
|
3
|
+
readonly invocationTimeoutMs?: number;
|
|
4
|
+
}
|
|
5
|
+
/** Resolve the local request cap and optional whole-invocation recipe deadline. */
|
|
6
|
+
export declare function requestBudget(defaultTimeoutMs: number): RequestBudget;
|
|
7
|
+
/** Bound one finite fetch by its local cap and any whole-invocation deadline. */
|
|
8
|
+
export declare function requestTimeoutMs(defaultTimeoutMs: number): number;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_ENV = 'HORIZON_REQUEST_TIMEOUT_MS';
|
|
2
|
+
const REQUEST_DEADLINE_ENV = 'HORIZON_REQUEST_DEADLINE_MS';
|
|
3
|
+
const MAX_NODE_TIMER_DURATION_MS = 2_147_483_647;
|
|
4
|
+
function positiveIntegerEnv(name) {
|
|
5
|
+
const raw = process.env[name];
|
|
6
|
+
if (raw === undefined)
|
|
7
|
+
return undefined;
|
|
8
|
+
const parsed = Number(raw);
|
|
9
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
10
|
+
throw new Error(`${name} must be a positive safe integer (received ${raw})`);
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
/** Resolve the local request cap and optional whole-invocation recipe deadline. */
|
|
15
|
+
export function requestBudget(defaultTimeoutMs) {
|
|
16
|
+
const configuredTimeoutMs = positiveIntegerEnv(REQUEST_TIMEOUT_ENV) ?? defaultTimeoutMs;
|
|
17
|
+
const deadlineMs = positiveIntegerEnv(REQUEST_DEADLINE_ENV);
|
|
18
|
+
const invocationTimeoutMs = deadlineMs === undefined ? undefined : deadlineMs - Date.now();
|
|
19
|
+
if (invocationTimeoutMs !== undefined && invocationTimeoutMs <= 0) {
|
|
20
|
+
throw new Error(`${REQUEST_DEADLINE_ENV} elapsed before the request could start`);
|
|
21
|
+
}
|
|
22
|
+
const boundedInvocationTimeoutMs = Math.min(invocationTimeoutMs ?? MAX_NODE_TIMER_DURATION_MS, MAX_NODE_TIMER_DURATION_MS);
|
|
23
|
+
return {
|
|
24
|
+
requestTimeoutMs: Math.min(defaultTimeoutMs, configuredTimeoutMs, boundedInvocationTimeoutMs),
|
|
25
|
+
...(invocationTimeoutMs === undefined
|
|
26
|
+
? {}
|
|
27
|
+
: { invocationTimeoutMs: boundedInvocationTimeoutMs }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Bound one finite fetch by its local cap and any whole-invocation deadline. */
|
|
31
|
+
export function requestTimeoutMs(defaultTimeoutMs) {
|
|
32
|
+
return requestBudget(defaultTimeoutMs).requestTimeoutMs;
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { DispatchContext as DispatchScope } from './dispatch.js';
|
|
3
|
+
/** Read a global flag's value from argv (`--name value` or `--name=value`). */
|
|
4
|
+
export declare function flagValue(argv: string[], name: string): string | undefined;
|
|
5
|
+
/** `--api-key` wins over `LABELBOX_API_KEY`; undefined when neither is set. */
|
|
6
|
+
export declare function resolveApiKey(argv: string[]): string | undefined;
|
|
7
|
+
/** The canonical Horizon API base URL from the environment. */
|
|
8
|
+
export declare function envBaseUrl(): string | undefined;
|
|
9
|
+
/** `--base-url` wins over `HORIZON_BASE_URL`, falling back to the production host. */
|
|
10
|
+
export declare function resolveBaseUrl(argv: string[]): string;
|
|
11
|
+
/**
|
|
12
|
+
* The options declared on the root `horizon` command, as seen by anything that
|
|
13
|
+
* reads `program.opts()`. The single definition for the whole package — program.ts,
|
|
14
|
+
* git-host.ts, skills.ts and compute-session.ts all read the same five flags, and
|
|
15
|
+
* four copies of this shape could disagree about which ones exist.
|
|
16
|
+
*/
|
|
17
|
+
export interface GlobalOptions {
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
/** Only the manifest-driven dispatch loop renders differently for it. */
|
|
21
|
+
quiet?: boolean;
|
|
22
|
+
/** Scope headers for operations whose authorization is not derivable from the
|
|
23
|
+
* body — see `DispatchContext` in dispatch.ts. */
|
|
24
|
+
scopeOrganizationExternalId?: string;
|
|
25
|
+
scopeEnvironmentExternalId?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Program-scoped equivalent of `resolveBaseUrl`, for commands registered outside
|
|
28
|
+
* the manifest-driven dispatch loop. Never throws — some of them (the `skills`
|
|
29
|
+
* reads) hit public endpoints and need a base URL without a key. */
|
|
30
|
+
export declare function commandBaseUrl(program: Command): string;
|
|
31
|
+
/** Program-scoped equivalent of `resolveApiKey`; undefined when neither
|
|
32
|
+
* `--api-key` nor `LABELBOX_API_KEY` is set. */
|
|
33
|
+
export declare function commandApiKey(program: Command): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The scope headers to attach to a dispatched request, omitting either when it is
|
|
36
|
+
* not configured.
|
|
37
|
+
*
|
|
38
|
+
* Returned as a partial object so the caller can spread it: `exactOptionalPropertyTypes`
|
|
39
|
+
* makes an explicit `undefined` a type error where the field is `?:`, and an absent
|
|
40
|
+
* key is also what `dispatchOperation` tests for.
|
|
41
|
+
*
|
|
42
|
+
* Environment fallback belongs only to the terminal CLI. Embedded callers run
|
|
43
|
+
* inside a server process whose environment is not part of the authenticated
|
|
44
|
+
* caller's request context, so `buildProgram` disables it for that mode.
|
|
45
|
+
*/
|
|
46
|
+
export declare function commandScope(program: Command, allowEnvironmentFallback?: boolean): Pick<DispatchScope, 'organizationExternalId' | 'environmentExternalId'>;
|
|
47
|
+
/** Program-scoped equivalent of `resolveApiKey` + `resolveBaseUrl`, for commands
|
|
48
|
+
* registered outside the manifest-driven dispatch loop. Throws when no key is
|
|
49
|
+
* configured, since every such command authenticates as the caller. */
|
|
50
|
+
export declare function resolveCommandAuth(program: Command): {
|
|
51
|
+
apiKey: string;
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
};
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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 (`horizon --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
|
+
/** The canonical Horizon API base URL from the environment. */
|
|
32
|
+
export function envBaseUrl() {
|
|
33
|
+
return process.env['HORIZON_BASE_URL'];
|
|
34
|
+
}
|
|
35
|
+
/** `--base-url` wins over `HORIZON_BASE_URL`, falling back to the production host. */
|
|
36
|
+
export function resolveBaseUrl(argv) {
|
|
37
|
+
return flagValue(argv, 'base-url') ?? envBaseUrl() ?? DEFAULT_BASE_URL;
|
|
38
|
+
}
|
|
39
|
+
/** Program-scoped equivalent of `resolveBaseUrl`, for commands registered outside
|
|
40
|
+
* the manifest-driven dispatch loop. Never throws — some of them (the `skills`
|
|
41
|
+
* reads) hit public endpoints and need a base URL without a key. */
|
|
42
|
+
export function commandBaseUrl(program) {
|
|
43
|
+
const { baseUrl } = program.opts();
|
|
44
|
+
return baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL;
|
|
45
|
+
}
|
|
46
|
+
/** Program-scoped equivalent of `resolveApiKey`; undefined when neither
|
|
47
|
+
* `--api-key` nor `LABELBOX_API_KEY` is set. */
|
|
48
|
+
export function commandApiKey(program) {
|
|
49
|
+
const { apiKey } = program.opts();
|
|
50
|
+
const { LABELBOX_API_KEY: envApiKey } = process.env;
|
|
51
|
+
return apiKey ?? envApiKey;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The scope headers to attach to a dispatched request, omitting either when it is
|
|
55
|
+
* not configured.
|
|
56
|
+
*
|
|
57
|
+
* Returned as a partial object so the caller can spread it: `exactOptionalPropertyTypes`
|
|
58
|
+
* makes an explicit `undefined` a type error where the field is `?:`, and an absent
|
|
59
|
+
* key is also what `dispatchOperation` tests for.
|
|
60
|
+
*
|
|
61
|
+
* Environment fallback belongs only to the terminal CLI. Embedded callers run
|
|
62
|
+
* inside a server process whose environment is not part of the authenticated
|
|
63
|
+
* caller's request context, so `buildProgram` disables it for that mode.
|
|
64
|
+
*/
|
|
65
|
+
export function commandScope(program, allowEnvironmentFallback = true) {
|
|
66
|
+
const { scopeOrganizationExternalId, scopeEnvironmentExternalId } = program.opts();
|
|
67
|
+
const envOrg = allowEnvironmentFallback
|
|
68
|
+
? process.env['LABELBOX_ORGANIZATION_EXTERNAL_ID']
|
|
69
|
+
: undefined;
|
|
70
|
+
const envEnvironment = allowEnvironmentFallback
|
|
71
|
+
? process.env['HORIZON_ENVIRONMENT_EXTERNAL_ID']
|
|
72
|
+
: undefined;
|
|
73
|
+
const org = scopeOrganizationExternalId ?? envOrg;
|
|
74
|
+
const environment = scopeEnvironmentExternalId ?? envEnvironment;
|
|
75
|
+
if (org !== undefined && org.trim() === '') {
|
|
76
|
+
throw new Error('organization scope must not be empty — set --scope-organization-external-id' +
|
|
77
|
+
(allowEnvironmentFallback ? ' or LABELBOX_ORGANIZATION_EXTERNAL_ID' : '') +
|
|
78
|
+
' to a non-empty value');
|
|
79
|
+
}
|
|
80
|
+
if (environment !== undefined && environment.trim() === '') {
|
|
81
|
+
throw new Error('environment scope must not be empty — set --scope-environment-external-id' +
|
|
82
|
+
(allowEnvironmentFallback ? ' or HORIZON_ENVIRONMENT_EXTERNAL_ID' : '') +
|
|
83
|
+
' to a non-empty value');
|
|
84
|
+
}
|
|
85
|
+
// Env structurally requires org — the guard's own hierarchy, enforced before any
|
|
86
|
+
// lookup. Sending env alone earns a bare `ForbiddenException()` with no body,
|
|
87
|
+
// which surfaces as an opaque `Forbidden` that names neither the missing header
|
|
88
|
+
// nor the unset env var. Unlike the guard's other 403s, this one is a
|
|
89
|
+
// well-formedness violation rather than an authorization answer, so pre-empting
|
|
90
|
+
// it locally costs no oracle-resistance and matches how every other doomed
|
|
91
|
+
// request in this CLI is caught before it leaves.
|
|
92
|
+
if (environment !== undefined && org === undefined) {
|
|
93
|
+
throw new Error('an environment scope implies an organization scope — set --scope-organization-external-id' +
|
|
94
|
+
(allowEnvironmentFallback ? ' or LABELBOX_ORGANIZATION_EXTERNAL_ID' : '') +
|
|
95
|
+
' alongside the environment');
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
...(org === undefined ? {} : { organizationExternalId: org }),
|
|
99
|
+
...(environment === undefined ? {} : { environmentExternalId: environment }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Program-scoped equivalent of `resolveApiKey` + `resolveBaseUrl`, for commands
|
|
103
|
+
* registered outside the manifest-driven dispatch loop. Throws when no key is
|
|
104
|
+
* configured, since every such command authenticates as the caller. */
|
|
105
|
+
export function resolveCommandAuth(program) {
|
|
106
|
+
const apiKey = commandApiKey(program);
|
|
107
|
+
if (!apiKey) {
|
|
108
|
+
throw new Error('missing API key — set LABELBOX_API_KEY or pass --api-key');
|
|
109
|
+
}
|
|
110
|
+
return { apiKey, baseUrl: commandBaseUrl(program) };
|
|
111
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import type { Manifest } from './manifest.js';
|
|
3
|
+
import type { GrantedPermissions } from './permissions.js';
|
|
4
|
+
export interface RunDeps {
|
|
5
|
+
/** Full argv including the node + script entries, as `parseAsync` expects. */
|
|
6
|
+
argv: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the CLI version lazily. A thunk (not a plain string) so a throw from
|
|
9
|
+
* reading package.json happens *inside* `run()` — surfacing as a rejected promise
|
|
10
|
+
* bin.ts renders via `fail()`, not a raw stack trace before the promise exists.
|
|
11
|
+
*/
|
|
12
|
+
version: () => string;
|
|
13
|
+
fetchManifest: (baseUrl: string, apiKey: string) => Promise<Manifest>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the caller's granted permissions for the per-command gate. Never
|
|
16
|
+
* rejects — it resolves to `undefined` (fail-open) on any failure, so a
|
|
17
|
+
* permissions outage degrades to "gate nothing", never to a broken CLI.
|
|
18
|
+
*/
|
|
19
|
+
fetchPermissions: (baseUrl: string, apiKey: string) => Promise<GrantedPermissions>;
|
|
20
|
+
stdout: (text: string) => void;
|
|
21
|
+
/** Byte-preserving terminal output. Omitted by filesystem-less embedded callers. */
|
|
22
|
+
binaryStdout?: Writable;
|
|
23
|
+
stderr: (text: string) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Whether `scaffold`, `submit`, `skills`, `computes open`, and `computes tools`
|
|
26
|
+
* are registered at all, and whether request scope may fall back to the process
|
|
27
|
+
* environment. See `ProgramContext.localCheckout` — the terminal entrypoint
|
|
28
|
+
* passes `true`, an embedding server passes `false` so server configuration
|
|
29
|
+
* cannot become caller request scope.
|
|
30
|
+
*/
|
|
31
|
+
localCheckout: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve `--version` and the no-key branches with no network, then fetch the
|
|
35
|
+
* manifest and build + run the command tree.
|
|
36
|
+
*
|
|
37
|
+
* **Never throws and never exits.** It returns the exit code the caller should
|
|
38
|
+
* use, having already rendered any failure to `deps.stderr`. That contract is
|
|
39
|
+
* what lets the MCP endpoint run one `horizon` invocation in-process on behalf of an
|
|
40
|
+
* agent: a thrown error would become a 500 instead of a tool result, and a
|
|
41
|
+
* `process.exit` would take the API server down mid-request. `bin.ts` is the only
|
|
42
|
+
* caller that turns the returned code back into a real exit.
|
|
43
|
+
*/
|
|
44
|
+
export declare function run(deps: RunDeps): Promise<number>;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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 `horizon` 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, deps.localCheckout)
|
|
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
|
+
...(deps.binaryStdout === undefined ? {} : { binaryStdout: deps.binaryStdout }),
|
|
77
|
+
stderr: deps.stderr,
|
|
78
|
+
});
|
|
79
|
+
await program.parseAsync(deps.argv);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* The canonical Horizon skill installed when no name is given.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_SKILL_NAME = "horizon";
|
|
7
|
+
/** sha256 (hex) of the markdown with any injected `skill-version:` line removed. */
|
|
8
|
+
export declare function skillVersionHash(text: string): string;
|
|
9
|
+
/** The `skill-version` value stamped into a file's frontmatter, if present. */
|
|
10
|
+
export declare function stampedVersion(content: string): string | undefined;
|
|
11
|
+
export type SkillStatus = 'missing' | 'up-to-date' | 'out-of-date';
|
|
12
|
+
/** Compare an installed file (or its absence) against the latest published version. */
|
|
13
|
+
export declare function skillStatus(installed: string | undefined, latestVersion: string): SkillStatus;
|
|
14
|
+
/**
|
|
15
|
+
* True if the installed file looks locally modified: its recomputed body hash
|
|
16
|
+
* doesn't match its own `skill-version` stamp, or it carries no stamp at all
|
|
17
|
+
* (unknown provenance). Such a file is never overwritten without `--force`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isHandEdited(installed: string): boolean;
|
|
20
|
+
declare const SkillPayloadSchema: z.ZodObject<{
|
|
21
|
+
name: z.ZodString;
|
|
22
|
+
version: z.ZodString;
|
|
23
|
+
content: z.ZodString;
|
|
24
|
+
}, z.core.$strip>;
|
|
25
|
+
type SkillPayload = z.infer<typeof SkillPayloadSchema>;
|
|
26
|
+
declare const SkillSummarySchema: z.ZodObject<{
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
description: z.ZodString;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
type SkillSummary = z.infer<typeof SkillSummarySchema>;
|
|
31
|
+
/**
|
|
32
|
+
* Fetch `{ name, version, content }` for a skill from `GET /v1/skills/<name>`.
|
|
33
|
+
*
|
|
34
|
+
* Exported only so `support-urls.test.ts` can assert its hand-written URL join
|
|
35
|
+
* against the committed spec, alongside the other support endpoints.
|
|
36
|
+
*/
|
|
37
|
+
export declare function fetchSkill(name: string, apiKey: string, baseUrl: string): Promise<SkillPayload>;
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the catalog of installable skills from `GET /skills`. The endpoint is now
|
|
40
|
+
* gated (the CLI always has a key — `bin.ts` requires one before any command), so
|
|
41
|
+
* the key is sent when present; it stays optional here only so the function is
|
|
42
|
+
* reusable in contexts that legitimately have none.
|
|
43
|
+
*/
|
|
44
|
+
export declare function listSkills(opts: {
|
|
45
|
+
apiKey?: string | undefined;
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
}): Promise<SkillSummary[]>;
|
|
48
|
+
/** Programmatic core of `horizon skills check` — fetch latest, read installed, compare. */
|
|
49
|
+
export declare function checkSkill(name: string, opts: {
|
|
50
|
+
apiKey: string;
|
|
51
|
+
baseUrl: string;
|
|
52
|
+
skillFile?: string | undefined;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
status: SkillStatus;
|
|
55
|
+
latestVersion: string;
|
|
56
|
+
}>;
|
|
57
|
+
/** Raised when `install` refuses to clobber a locally-modified file (no `--force`). */
|
|
58
|
+
export declare class HandEditedError extends Error {
|
|
59
|
+
}
|
|
60
|
+
/** Programmatic core of `horizon skills install` — fetch latest and write it (guarded). */
|
|
61
|
+
export declare function installSkill(name: string, opts: {
|
|
62
|
+
apiKey: string;
|
|
63
|
+
baseUrl: string;
|
|
64
|
+
skillFile?: string | undefined;
|
|
65
|
+
force?: boolean | undefined;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
path: string;
|
|
68
|
+
action: 'installed' | 'updated' | 'unchanged';
|
|
69
|
+
version: string;
|
|
70
|
+
}>;
|
|
71
|
+
/** Register the `skills check` / `skills install` command group on `program`. */
|
|
72
|
+
export declare function addSkillsCommands(program: Command, docsHelpGroup: string): void;
|
|
73
|
+
export {};
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
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 { supportUrl } from './manifest.js';
|
|
8
|
+
import { commandApiKey, commandBaseUrl, resolveCommandAuth } from './resolve.js';
|
|
9
|
+
// ── bespoke `horizon 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 package defaults to the canonical `horizon` skill. Deliberately NOT an
|
|
15
|
+
// `@SdkRoute` endpoint — that would auto-generate a colliding `horizon skills` command.
|
|
16
|
+
/**
|
|
17
|
+
* The canonical Horizon skill installed when no name is given.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_SKILL_NAME = 'horizon';
|
|
20
|
+
// ── hash contract — MUST stay byte-identical to apps/horizon/api/src/skills/skill-hash.ts ──
|
|
21
|
+
// (the two packages share no published runtime dependency). A committed fixture
|
|
22
|
+
// pins the same hash in both test suites so the duplication can't drift.
|
|
23
|
+
/** A single `skill-version:` line (with its trailing newline), anywhere in the text. */
|
|
24
|
+
const SKILL_VERSION_LINE = /^skill-version:.*\r?\n/m;
|
|
25
|
+
/** sha256 (hex) of the markdown with any injected `skill-version:` line removed. */
|
|
26
|
+
export function skillVersionHash(text) {
|
|
27
|
+
return createHash('sha256').update(text.replace(SKILL_VERSION_LINE, ''), 'utf8').digest('hex');
|
|
28
|
+
}
|
|
29
|
+
/** The `skill-version` value stamped into a file's frontmatter, if present. */
|
|
30
|
+
export function stampedVersion(content) {
|
|
31
|
+
return /^skill-version:[ \t]*(\S+)[ \t]*\r?$/m.exec(content)?.[1];
|
|
32
|
+
}
|
|
33
|
+
/** Compare an installed file (or its absence) against the latest published version. */
|
|
34
|
+
export function skillStatus(installed, latestVersion) {
|
|
35
|
+
if (installed === undefined)
|
|
36
|
+
return 'missing';
|
|
37
|
+
return stampedVersion(installed) === latestVersion ? 'up-to-date' : 'out-of-date';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* True if the installed file looks locally modified: its recomputed body hash
|
|
41
|
+
* doesn't match its own `skill-version` stamp, or it carries no stamp at all
|
|
42
|
+
* (unknown provenance). Such a file is never overwritten without `--force`.
|
|
43
|
+
*/
|
|
44
|
+
export function isHandEdited(installed) {
|
|
45
|
+
const stamped = stampedVersion(installed);
|
|
46
|
+
if (stamped === undefined)
|
|
47
|
+
return true;
|
|
48
|
+
return skillVersionHash(installed) !== stamped;
|
|
49
|
+
}
|
|
50
|
+
// Validate every API response at the I/O boundary with Zod. The backend owns the
|
|
51
|
+
// authoritative 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
|
+
// Each `horizon skills` fetch is bounded for the same reason as the manifest fetch
|
|
62
|
+
// (see manifest.ts): a server that accepts the connection but never responds would
|
|
63
|
+
// otherwise hang `horizon skills check`/`install`/`list` indefinitely. The timeout makes
|
|
64
|
+
// `fetch` reject so the caller surfaces a clear error rather than blocking forever.
|
|
65
|
+
const SKILLS_FETCH_TIMEOUT_MS = 30_000;
|
|
66
|
+
/**
|
|
67
|
+
* Fetch `{ name, version, content }` for a skill from `GET /v1/skills/<name>`.
|
|
68
|
+
*
|
|
69
|
+
* Exported only so `support-urls.test.ts` can assert its hand-written URL join
|
|
70
|
+
* against the committed spec, alongside the other support endpoints.
|
|
71
|
+
*/
|
|
72
|
+
export async function fetchSkill(name, apiKey, baseUrl) {
|
|
73
|
+
const url = supportUrl(baseUrl, `/skills/${encodeURIComponent(name)}`);
|
|
74
|
+
const res = await fetch(url, {
|
|
75
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
76
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
77
|
+
signal: AbortSignal.timeout(SKILLS_FETCH_TIMEOUT_MS),
|
|
78
|
+
});
|
|
79
|
+
if (res.status === 404) {
|
|
80
|
+
throw new Error(`unknown skill "${name}" (the platform has no skill by that name)`);
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(`could not fetch skill "${name}" — HTTP ${res.status}`);
|
|
84
|
+
}
|
|
85
|
+
const parsed = SkillPayloadSchema.safeParse(await res.json());
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
throw new Error(`unexpected response shape from ${url}`);
|
|
88
|
+
}
|
|
89
|
+
return parsed.data;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Fetch the catalog of installable skills from `GET /skills`. The endpoint is now
|
|
93
|
+
* gated (the CLI always has a key — `bin.ts` requires one before any command), so
|
|
94
|
+
* the key is sent when present; it stays optional here only so the function is
|
|
95
|
+
* reusable in contexts that legitimately have none.
|
|
96
|
+
*/
|
|
97
|
+
export async function listSkills(opts) {
|
|
98
|
+
const url = supportUrl(opts.baseUrl, '/skills');
|
|
99
|
+
const res = await fetch(url, {
|
|
100
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
101
|
+
headers: opts.apiKey === undefined ? {} : { Authorization: `Bearer ${opts.apiKey}` },
|
|
102
|
+
signal: AbortSignal.timeout(SKILLS_FETCH_TIMEOUT_MS),
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
throw new Error(`could not list skills — HTTP ${res.status}`);
|
|
106
|
+
}
|
|
107
|
+
const parsed = z.array(SkillSummarySchema).safeParse(await res.json());
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
throw new Error(`unexpected response shape from ${url}`);
|
|
110
|
+
}
|
|
111
|
+
return parsed.data;
|
|
112
|
+
}
|
|
113
|
+
/** Global install path for a skill, unless `--skill-file` overrides it. */
|
|
114
|
+
function installedPath(name, skillFile) {
|
|
115
|
+
return skillFile ?? join(homedir(), '.claude', 'skills', name, 'SKILL.md');
|
|
116
|
+
}
|
|
117
|
+
function readInstalled(path) {
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(path, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Programmatic core of `horizon skills check` — fetch latest, read installed, compare. */
|
|
126
|
+
export async function checkSkill(name, opts) {
|
|
127
|
+
const latest = await fetchSkill(name, opts.apiKey, opts.baseUrl);
|
|
128
|
+
const installed = readInstalled(installedPath(name, opts.skillFile));
|
|
129
|
+
return { status: skillStatus(installed, latest.version), latestVersion: latest.version };
|
|
130
|
+
}
|
|
131
|
+
/** Raised when `install` refuses to clobber a locally-modified file (no `--force`). */
|
|
132
|
+
export class HandEditedError extends Error {
|
|
133
|
+
}
|
|
134
|
+
/** Programmatic core of `horizon skills install` — fetch latest and write it (guarded). */
|
|
135
|
+
export async function installSkill(name, opts) {
|
|
136
|
+
const latest = await fetchSkill(name, opts.apiKey, opts.baseUrl);
|
|
137
|
+
const path = installedPath(name, opts.skillFile);
|
|
138
|
+
const existing = readInstalled(path);
|
|
139
|
+
if (existing !== undefined && opts.force !== true && isHandEdited(existing)) {
|
|
140
|
+
throw new HandEditedError(`${path} looks hand-edited (its content no longer matches its skill-version stamp). ` +
|
|
141
|
+
'Refusing to overwrite — re-run with --force to replace it with the published version.');
|
|
142
|
+
}
|
|
143
|
+
// Already the published version — don't rewrite the file (no mtime churn) or
|
|
144
|
+
// claim a change happened; `install` run directly is then honest that nothing
|
|
145
|
+
// needs to take effect.
|
|
146
|
+
if (existing === latest.content) {
|
|
147
|
+
return { path, action: 'unchanged', version: latest.version };
|
|
148
|
+
}
|
|
149
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
150
|
+
writeFileSync(path, latest.content);
|
|
151
|
+
return {
|
|
152
|
+
path,
|
|
153
|
+
action: existing === undefined ? 'installed' : 'updated',
|
|
154
|
+
version: latest.version,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Register the `skills check` / `skills install` command group on `program`. */
|
|
158
|
+
export function addSkillsCommands(program, docsHelpGroup) {
|
|
159
|
+
const skills = program
|
|
160
|
+
.command('skills')
|
|
161
|
+
.helpGroup(docsHelpGroup)
|
|
162
|
+
.description('Install and update the downloadable Claude skills for this platform');
|
|
163
|
+
skills
|
|
164
|
+
.command('list')
|
|
165
|
+
.description('List the skills available to install')
|
|
166
|
+
.action(async () => {
|
|
167
|
+
try {
|
|
168
|
+
// The CLI always has a key by the time any command runs (bin.ts requires
|
|
169
|
+
// one), so pass it through; the endpoint is gated like everything else.
|
|
170
|
+
const available = await listSkills({
|
|
171
|
+
apiKey: commandApiKey(program),
|
|
172
|
+
baseUrl: commandBaseUrl(program),
|
|
173
|
+
});
|
|
174
|
+
if (available.length === 0) {
|
|
175
|
+
process.stdout.write('No skills are available.\n');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const lines = available.map((s) => s.description ? ` ${s.name} — ${s.description}` : ` ${s.name}`);
|
|
179
|
+
process.stdout.write(`Installable skills (run \`horizon skills install <name>\`):\n${lines.join('\n')}\n`);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
skills
|
|
187
|
+
.command('check [name]')
|
|
188
|
+
.description('Report whether the installed skill matches the latest published version')
|
|
189
|
+
.option('--skill-file <path>', 'Check this file instead of the global ~/.claude/skills path')
|
|
190
|
+
.action(async (name, opts) => {
|
|
191
|
+
const skill = name ?? DEFAULT_SKILL_NAME;
|
|
192
|
+
try {
|
|
193
|
+
const { apiKey, baseUrl } = resolveCommandAuth(program);
|
|
194
|
+
const { status } = await checkSkill(skill, { apiKey, baseUrl, skillFile: opts.skillFile });
|
|
195
|
+
if (status === 'up-to-date') {
|
|
196
|
+
process.stdout.write(`${skill} is up to date.\n`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const lead = status === 'missing' ? 'is not installed' : 'is out of date';
|
|
200
|
+
process.stdout.write(`${skill} ${lead} — run \`horizon skills install ${skill}\`.\n`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
skills
|
|
209
|
+
.command('install [name]')
|
|
210
|
+
.description('Install or update a skill to the latest published version')
|
|
211
|
+
.option('--force', 'Overwrite even if the installed file looks hand-edited')
|
|
212
|
+
.option('--skill-file <path>', 'Write to this file instead of the global ~/.claude/skills path')
|
|
213
|
+
.action(async (name, opts) => {
|
|
214
|
+
const skill = name ?? DEFAULT_SKILL_NAME;
|
|
215
|
+
try {
|
|
216
|
+
const { apiKey, baseUrl } = resolveCommandAuth(program);
|
|
217
|
+
const { path, action, version } = await installSkill(skill, {
|
|
218
|
+
apiKey,
|
|
219
|
+
baseUrl,
|
|
220
|
+
skillFile: opts.skillFile,
|
|
221
|
+
force: opts.force,
|
|
222
|
+
});
|
|
223
|
+
if (action === 'unchanged') {
|
|
224
|
+
process.stdout.write(`${skill} is already up to date (version ${version.slice(0, 12)}).\n`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write(`${action === 'installed' ? 'Installed' : 'Updated'} ${skill} → ${path} ` +
|
|
228
|
+
`(version ${version.slice(0, 12)}). Takes effect on the next /${skill}.\n`);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|