@azx-pbc/helix-cli 0.5.0 → 0.6.0

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.
Files changed (3) hide show
  1. package/README.md +50 -9
  2. package/dist/helix.js +148 -22
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -66,6 +66,7 @@ helix deploy [--dir <dir>] [--bundle <zip>] [--promote] # upload a version
66
66
  helix versions # list versions
67
67
  helix promote <number> # make a version live
68
68
  helix rollback [number] # revert the live pointer
69
+ helix capabilities [--json] # what this deployment offers
69
70
  helix skill [--path <file>] # write this deployment's agent skill to disk
70
71
  helix ci init [--repo <owner/name>] [--branch <b>] [--reusable] [--force] # deploy from GitHub Actions
71
72
  helix ci show | helix ci remove # the app's repo binding
@@ -75,12 +76,38 @@ helix ci show | helix ci remove # the app's repo bi
75
76
  same step (architecture §5.1). `visibility` is `internal | group:<id>[,<id>…] | password
76
77
  | public`.
77
78
 
78
- `helix skill` fetches this deployment's rendered agent skill (`GET /api/v1/skill`,
79
- ADR-0036) — the same document the portal's **How to develop** modal hands out, with
80
- this deployment's hosts, servable models, and approval baselines already substituted
81
- in. It needs no app slug or `helix.json`, only a portal URL and a token. Defaults to
82
- `./SKILL.md`; `--path .claude/skills/helix/SKILL.md` drops it where a Claude Code
83
- agent reads it off disk.
79
+ `helix capabilities` prints the deployment capability catalogue
80
+ (`GET /api/v1/capabilities`, ADR-0036) in readable form:
81
+
82
+ - servable models;
83
+ - approval baselines;
84
+ - named connections and OAuth providers;
85
+ - visibility modes;
86
+ - deploy caps;
87
+ - the dev gateway;
88
+ - which requests queue for an admin.
89
+
90
+ `--json` prints the raw catalogue. If the portal sends a shape this CLI can't
91
+ format, it prints the JSON instead of failing.
92
+
93
+ `helix skill` fetches this deployment's rendered agent skill (`GET /api/v1/skill`).
94
+ It is the same document the portal's **How to develop** modal hands out: this
95
+ deployment's hosts, plus pointers to `helix capabilities` and the public docs.
96
+
97
+ Neither command needs an app slug or `helix.json`, only a portal URL and a token.
98
+ `helix skill` writes `./SKILL.md` by default; `--path .claude/skills/helix/SKILL.md`
99
+ puts it where a Claude Code agent reads it from disk.
100
+
101
+ ### Compatibility with the portal
102
+
103
+ Every command except `logout` first reads the public `GET /api/v1/config`. If
104
+ the portal states a `cli.minVersion` newer than this CLI, the CLI prints a
105
+ warning to stderr suggesting `@latest`. The warning never fails the command.
106
+
107
+ A 404 from a missing _route_ (the portal's not-found handler, as opposed to "app
108
+ not found") means the portal is older than the CLI, and the error says so. The
109
+ floor is `MIN_CLI_VERSION` in `@azx-pbc/shared`. Bump it by hand when the portal
110
+ removes or changes something an older CLI relies on (ADR-0052).
84
111
 
85
112
  ### Deploying from GitHub Actions
86
113
 
@@ -138,15 +165,29 @@ Two paths, in precedence order:
138
165
 
139
166
  ## Running it
140
167
 
141
- ### Installed from npm
168
+ ### From npm
169
+
170
+ App authors and agents run it with `npx`, so it is always the current release
171
+ and there is nothing to keep updated (ADR-0052):
142
172
 
143
173
  ```bash
144
- npm i -g @azx-pbc/helix-cli
145
174
  cd my-app
146
- export HELIX_TOKEN="…"
175
+ npx -y @azx-pbc/helix-cli@latest deploy --promote
176
+ ```
177
+
178
+ Write `@latest` explicitly, because a bare package name lets `npx` reuse a copy
179
+ it cached earlier. `-y` skips the install prompt.
180
+
181
+ To install it once instead:
182
+
183
+ ```bash
184
+ npm i -g @azx-pbc/helix-cli # update with: npm i -g @azx-pbc/helix-cli@latest
147
185
  helix deploy --promote
148
186
  ```
149
187
 
188
+ CI workflows pin an exact version; `helix ci init` writes the one it was run
189
+ with.
190
+
150
191
  Needs **Node 22.12+** (or 24+). The engines range and the bundle's esbuild
151
192
  target move together (see `scripts/build.mjs`), so the declared floor is real:
152
193
  older runtimes may not merely warn, they may fail to parse it.
package/dist/helix.js CHANGED
@@ -17,6 +17,7 @@ var options = {
17
17
  branch: { type: "string" },
18
18
  reusable: { type: "boolean" },
19
19
  force: { type: "boolean" },
20
+ json: { type: "boolean" },
20
21
  help: { type: "boolean" }
21
22
  };
22
23
  function parseCliArgs(argv) {
@@ -233,7 +234,7 @@ var HEADER_VALUE_SAFE = /^[\t\x20-\x7e]*$/;
233
234
  var HEADER_VALUE_WIRE = /^[\t\x20-\x7e\x80-\xff]*$/;
234
235
  var headerName = (from) => {
235
236
  const base = z6.string().min(1);
236
- return (from === "request" ? base.max(64).regex(HEADER_TOKEN, "must be an RFC 7230 token") : base).transform((n) => n.trim().toLowerCase()).refine((n) => !FORBIDDEN_HEADER_NAMES.has(n), "reserved header name").refine((n) => !n.startsWith("x-helix-"), "reserved header prefix");
237
+ return (from === "request" ? base.max(64).regex(HEADER_TOKEN, "must be an RFC 7230 token") : base).transform((n2) => n2.trim().toLowerCase()).refine((n2) => !FORBIDDEN_HEADER_NAMES.has(n2), "reserved header name").refine((n2) => !n2.startsWith("x-helix-"), "reserved header prefix");
237
238
  };
238
239
  var headerTemplate = (from) => from === "request" ? z6.string().max(512).regex(HEADER_VALUE_SAFE) : z6.string().regex(HEADER_VALUE_WIRE);
239
240
  var injectionRecipe = (from) => z6.discriminatedUnion("kind", [
@@ -1285,7 +1286,16 @@ var DeploymentConfigResponseSchema = z13.object({
1285
1286
  enabled: z13.boolean(),
1286
1287
  audience: z13.url(),
1287
1288
  serverUrl: z13.url()
1288
- }).optional()
1289
+ }).optional(),
1290
+ /**
1291
+ * The CLI compatibility floor. A CLI older than `minVersion` warns and tells
1292
+ * the user to run `@latest`; it does not refuse to run. Absent on a portal
1293
+ * that predates the handshake, so a CLI treats absence as "no floor stated",
1294
+ * not as an error. `minVersion` is a plain string rather than a validated
1295
+ * semver: the CLI parses this whole response, so a stricter schema would turn
1296
+ * an unexpected format into a failed command instead of a skipped check.
1297
+ */
1298
+ cli: z13.object({ minVersion: z13.string() }).optional()
1289
1299
  });
1290
1300
 
1291
1301
  // ../shared/src/catalogue.ts
@@ -2309,6 +2319,9 @@ var SessionRevokeResultSchema = z28.object({
2309
2319
  apps: z28.array(z28.string())
2310
2320
  });
2311
2321
 
2322
+ // src/version.ts
2323
+ var CLI_VERSION = true ? "0.6.0" : "latest";
2324
+
2312
2325
  // src/client.ts
2313
2326
  var CliError = class extends Error {
2314
2327
  code;
@@ -2330,6 +2343,7 @@ var VersionListSchema = z29.array(VersionSchema);
2330
2343
  var PortalClient = class {
2331
2344
  #baseUrl;
2332
2345
  #tokenProvider;
2346
+ #deploymentConfig;
2333
2347
  /** `token` may be a static string (dev token) or an async provider (OIDC). */
2334
2348
  constructor(baseUrl, token) {
2335
2349
  this.#baseUrl = baseUrl.replace(/\/+$/, "");
@@ -2352,9 +2366,22 @@ var PortalClient = class {
2352
2366
  getAuthConfig() {
2353
2367
  return this.#json(AuthConfigResponseSchema, "GET", "/api/v1/auth/config", {});
2354
2368
  }
2355
- /** Public deployment topology (`GET /api/v1/config`), incl. the CI token audience. */
2369
+ /**
2370
+ * Public deployment topology (`GET /api/v1/config`), incl. the CI token
2371
+ * audience and the CLI version floor. Fetched once per client: the
2372
+ * compatibility check and the command that follows it share one request.
2373
+ */
2356
2374
  getDeploymentConfig() {
2357
- return this.#json(DeploymentConfigResponseSchema, "GET", "/api/v1/config", {});
2375
+ this.#deploymentConfig ??= this.#json(
2376
+ DeploymentConfigResponseSchema,
2377
+ "GET",
2378
+ "/api/v1/config",
2379
+ {}
2380
+ ).catch((err) => {
2381
+ this.#deploymentConfig = void 0;
2382
+ throw err;
2383
+ });
2384
+ return this.#deploymentConfig;
2358
2385
  }
2359
2386
  /** The app's GitHub binding (ADR-0051), or null when it has none. */
2360
2387
  async getRepoBinding(slug) {
@@ -2386,13 +2413,20 @@ var PortalClient = class {
2386
2413
  }
2387
2414
  /**
2388
2415
  * The deployment-rendered agent skill (`GET /api/v1/skill`, ADR-0036) —
2389
- * `text/markdown`, already substituted with this deployment's hosts, servable
2390
- * models, and approval baselines. Authed like every other `/api/v1` read.
2391
- * Powers `helix skill`.
2416
+ * `text/markdown`, with this deployment's hosts filled in. Authed like every
2417
+ * other `/api/v1` read. Powers `helix skill`.
2392
2418
  */
2393
2419
  getSkill() {
2394
2420
  return this.#text("/api/v1/skill", { auth: true });
2395
2421
  }
2422
+ /**
2423
+ * The deployment capability catalogue (`GET /api/v1/capabilities`), unparsed.
2424
+ * `helix capabilities` validates it when formatting, and falls back to the raw
2425
+ * JSON when a portal of a different version sends a shape this CLI doesn't know.
2426
+ */
2427
+ getCapabilities() {
2428
+ return this.#json(z29.unknown(), "GET", "/api/v1/capabilities", { auth: true });
2429
+ }
2396
2430
  listVersions(slug) {
2397
2431
  return this.#json(VersionListSchema, "GET", `/api/v1/apps/${enc(slug)}/versions`, {
2398
2432
  auth: true
@@ -2458,12 +2492,7 @@ var PortalClient = class {
2458
2492
  const text = await res.text();
2459
2493
  const data = text ? JSON.parse(text) : void 0;
2460
2494
  if (!res.ok) {
2461
- const parsed = ApiErrorSchema.safeParse(data);
2462
- if (parsed.success) {
2463
- const { message, code, details } = parsed.data.error;
2464
- throw new CliError(message, code, details);
2465
- }
2466
- throw new CliError(`request failed (HTTP ${res.status})`);
2495
+ throw errorFromResponse(res.status, data);
2467
2496
  }
2468
2497
  return schema.parse(data);
2469
2498
  }
@@ -2474,16 +2503,24 @@ var PortalClient = class {
2474
2503
  const body = await res.text();
2475
2504
  if (!res.ok) {
2476
2505
  const data = body ? safeJsonParse(body) : void 0;
2477
- const parsed = ApiErrorSchema.safeParse(data);
2478
- if (parsed.success) {
2479
- const { message, code, details } = parsed.data.error;
2480
- throw new CliError(message, code, details);
2481
- }
2482
- throw new CliError(`request failed (HTTP ${res.status})`);
2506
+ throw errorFromResponse(res.status, data);
2483
2507
  }
2484
2508
  return body;
2485
2509
  }
2486
2510
  };
2511
+ function errorFromResponse(status, data) {
2512
+ const parsed = ApiErrorSchema.safeParse(data);
2513
+ if (!parsed.success) return new CliError(`request failed (HTTP ${status})`);
2514
+ const { message, code, details } = parsed.data.error;
2515
+ if (status === 404 && code === "not_found" && message.startsWith("route ")) {
2516
+ return new CliError(
2517
+ `${message}. This portal is probably older than this CLI (${CLI_VERSION}); ask your operator to update Helix, or run an older @azx-pbc/helix-cli`,
2518
+ code,
2519
+ details
2520
+ );
2521
+ }
2522
+ return new CliError(message, code, details);
2523
+ }
2487
2524
  function enc(segment) {
2488
2525
  return encodeURIComponent(segment);
2489
2526
  }
@@ -2516,6 +2553,60 @@ function zipDirectory(dir) {
2516
2553
  });
2517
2554
  }
2518
2555
 
2556
+ // src/capabilities.ts
2557
+ var TRIGGER_LABELS = {
2558
+ externalOrigins: "any externalOrigins entry",
2559
+ fetchOrigins: "any fetch.origins entry",
2560
+ mcp: "any mcp server",
2561
+ publicVisibility: "public visibility",
2562
+ budgetAboveBaseline: "any budget above its baseline",
2563
+ uncuratedLlmModel: "an LLM model not in the list above"
2564
+ };
2565
+ var n = (value) => value.toLocaleString("en-US");
2566
+ function formatCapabilities(c) {
2567
+ const b = c.approval.baselines;
2568
+ const lines = [
2569
+ "Helix capabilities on this deployment",
2570
+ "",
2571
+ `Visibility modes: ${c.visibility.modes.join(", ")}`,
2572
+ "",
2573
+ "LLM (capabilities.llm)",
2574
+ ` models: ${c.llm.models.length ? c.llm.models.join(", ") : "(none servable)"}`,
2575
+ ` baseline: $${n(b.dollarsPerDay)}/day`,
2576
+ "",
2577
+ "App data (capabilities.data)",
2578
+ ` available: ${c.data.provisioned ? "yes" : "no"}`,
2579
+ ` baselines: ${n(b.writesPerDay)} writes/day, ${n(b.bytesPerDay)} bytes/day`,
2580
+ "",
2581
+ "Outbound HTTP (capabilities.fetch, externalOrigins)",
2582
+ ` connections: ${c.fetch.connections.length ? c.fetch.connections.map((x) => x.name).join(", ") : "(none)"}`,
2583
+ ...c.fetch.providers.length ? [
2584
+ " providers:",
2585
+ ...c.fetch.providers.map(
2586
+ (p) => ` ${p.ref} (${p.displayName}, ${p.env}) \u2192 ${p.apiOrigins.join(", ")}`
2587
+ )
2588
+ ] : [" providers: (none)"],
2589
+ ` externalOrigins: ${c.fetch.externalOriginsPermitted ? "may be requested" : "not permitted"}`,
2590
+ ` baseline: ${n(b.fetchRequestsPerDay)} proxied requests/day`,
2591
+ "",
2592
+ `MCP: ${c.mcp.enforced ? "enforced" : "carried in the manifest, not enforced"}`,
2593
+ `Offline: ${c.offline.available ? `available \u2014 ${c.offline.scopeRule}` : "not available"}`,
2594
+ `Deploy limits: ${n(c.deploy.maxFileMb)} MB per file, ${n(c.deploy.maxBundleMb)} MB per bundle`,
2595
+ `Dev gateway: ${c.devApiBase ? `${c.devApiBase}/<slug>` : "not deployed"}`,
2596
+ "",
2597
+ "Needs admin approval:",
2598
+ ...c.approval.elevationTriggers.map((t) => ` - ${TRIGGER_LABELS[t]}`),
2599
+ "Everything else applies immediately."
2600
+ ];
2601
+ return lines.join("\n");
2602
+ }
2603
+ function renderCapabilities(raw, opts) {
2604
+ if (opts.json) return JSON.stringify(raw, null, 2);
2605
+ const parsed = CapabilityCatalogueSchema.safeParse(raw);
2606
+ if (parsed.success) return formatCapabilities(parsed.data);
2607
+ return "(this CLI could not format the portal's response; showing it as JSON)\n" + JSON.stringify(raw, null, 2);
2608
+ }
2609
+
2519
2610
  // src/auth/deviceFlow.ts
2520
2611
  import * as oidc from "openid-client";
2521
2612
  var DEVICE_SCOPES = "openid profile email offline_access";
@@ -2820,6 +2911,9 @@ async function skillCommand(client, _config, opts) {
2820
2911
  await writeFile2(outPath, skill, "utf8");
2821
2912
  console.log(`Wrote skill to ${outPath}`);
2822
2913
  }
2914
+ async function capabilitiesCommand(client, opts) {
2915
+ console.log(renderCapabilities(await client.getCapabilities(), opts));
2916
+ }
2823
2917
 
2824
2918
  // src/auth/session.ts
2825
2919
  var REFRESH_MARGIN_MS = 6e4;
@@ -2897,9 +2991,6 @@ async function resolveConfig(flags, env = process.env, cwd = process.cwd()) {
2897
2991
  };
2898
2992
  }
2899
2993
 
2900
- // src/version.ts
2901
- var CLI_VERSION = true ? "0.5.0" : "latest";
2902
-
2903
2994
  // src/ci/remote.ts
2904
2995
  function parseGithubRemote(remote, githubHost = "github.com") {
2905
2996
  const url = remote.trim();
@@ -3232,9 +3323,39 @@ function isLocalPortal(url) {
3232
3323
  return host === "localhost" || host === "127.0.0.1" || host.endsWith(".local") || host === "::1";
3233
3324
  }
3234
3325
 
3326
+ // src/compat.ts
3327
+ var NPX_LATEST = "npx -y @azx-pbc/helix-cli@latest";
3328
+ function parseVersion(v) {
3329
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
3330
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
3331
+ }
3332
+ function isOlderThan(version, floor) {
3333
+ const a = parseVersion(version);
3334
+ const b = parseVersion(floor);
3335
+ if (!a || !b) return false;
3336
+ for (let i = 0; i < 3; i++) {
3337
+ if (a[i] !== b[i]) return a[i] < b[i];
3338
+ }
3339
+ return false;
3340
+ }
3341
+ async function warnIfCliTooOld(client, cliVersion, warn = (msg) => console.error(msg)) {
3342
+ try {
3343
+ const floor = (await client.getDeploymentConfig()).cli?.minVersion;
3344
+ if (floor && isOlderThan(cliVersion, floor)) {
3345
+ warn(
3346
+ `warning: this portal needs helix-cli ${floor} or newer (you have ${cliVersion}). Run \`${NPX_LATEST} \u2026\`, or update your global install.`
3347
+ );
3348
+ }
3349
+ } catch {
3350
+ }
3351
+ }
3352
+
3235
3353
  // src/bin.ts
3236
3354
  var USAGE = `helix \u2014 Helix deploy CLI
3237
3355
 
3356
+ Run it with \`npx -y @azx-pbc/helix-cli@latest <command>\` to always use the newest
3357
+ version, or install it with \`npm i -g @azx-pbc/helix-cli\`.
3358
+
3238
3359
  Usage:
3239
3360
  helix login # sign in via the browser (OIDC device flow)
3240
3361
  helix logout
@@ -3244,6 +3365,7 @@ Usage:
3244
3365
  helix versions
3245
3366
  helix promote <number>
3246
3367
  helix rollback [number]
3368
+ helix capabilities [--json] # what this deployment offers: models, baselines, connections\u2026
3247
3369
  helix skill [--path <file>] # write this deployment's agent skill to disk
3248
3370
  helix ci init [--repo <owner/name>] [--branch <b>] [--reusable] [--force]
3249
3371
  # connect the app to its GitHub repo and write the workflow
@@ -3273,6 +3395,7 @@ async function main() {
3273
3395
  config.portalUrl,
3274
3396
  makeTokenProvider({ portalUrl: config.portalUrl, staticToken: config.token })
3275
3397
  );
3398
+ if (command !== "logout") await warnIfCliTooOld(client, CLI_VERSION);
3276
3399
  switch (command) {
3277
3400
  case "login":
3278
3401
  await loginCommand(client, config);
@@ -3283,6 +3406,9 @@ async function main() {
3283
3406
  case "whoami":
3284
3407
  await whoamiCommand(client);
3285
3408
  break;
3409
+ case "capabilities":
3410
+ await capabilitiesCommand(client, { json: values.json });
3411
+ break;
3286
3412
  case "skill":
3287
3413
  await skillCommand(client, config, { path: values.path });
3288
3414
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azx-pbc/helix-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deploy CLI for Helix, the AZX App Platform",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,9 +32,9 @@
32
32
  "esbuild": "^0.28.2",
33
33
  "tsx": "^4.22.4",
34
34
  "typescript": "^6.0.3",
35
- "@azx-pbc/deploy-skill": "0.0.0",
35
+ "@azx-pbc/dev-idp": "0.0.0",
36
36
  "@azx-pbc/shared": "0.0.0",
37
- "@azx-pbc/dev-idp": "0.0.0"
37
+ "@azx-pbc/deploy-skill": "0.0.0"
38
38
  },
39
39
  "scripts": {
40
40
  "helix": "tsx src/bin.ts",