@fulldivevr/workcord-cli 1.1.1 → 1.2.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.
package/README.md CHANGED
@@ -281,6 +281,115 @@ workcord --as $U dm send-text --to $OTHER --text "hey"
281
281
  # → composite: opens (or finds) the DM room, sends the message, returns both
282
282
  ```
283
283
 
284
+ ## Hermes Hub (`hermes` namespace)
285
+
286
+ WorkCord provisions a **Hermes Hub tenant** for each room-agent and each Hermes
287
+ custom agent. That tenant is where the agent actually runs: its chats, runs,
288
+ files, skills, flavor pin, A2A peers and eval runs all live on the Hub, not in
289
+ WorkCord. The `hermes` namespace drives that tenant directly.
290
+
291
+ This is the one namespace that talks to a **second origin**. Every other command
292
+ goes to the WorkCord API; `hermes` uses the WorkCord API only to reveal the
293
+ tenant key, then calls the Hub's `/v1/tenant-api/*` routes with it.
294
+
295
+ ### Target selection
296
+
297
+ Every `hermes` command takes exactly one of:
298
+
299
+ | Flag | Meaning |
300
+ | --- | --- |
301
+ | `--agent <id>` | a custom agent id, used verbatim |
302
+ | `--room-agent <id>` | a room agent id, resolved as `workcord-room-agent:<id>` |
303
+
304
+ Passing both, or neither, is an error.
305
+
306
+ ### Credentials
307
+
308
+ The tenant key is resolved in one place (`resolveHermesKey` in `src/auth.ts`)
309
+ and is **never** accepted as a flag:
310
+
311
+ | Env var | Meaning |
312
+ | --- | --- |
313
+ | `WORKCORD_HERMES_KEY` | tenant key, used verbatim; requires `WORKCORD_HERMES_HUB_URL` |
314
+ | `WORKCORD_HERMES_HUB_URL` | Hub origin (bare origin, no path); requires `WORKCORD_HERMES_KEY` |
315
+ | `WORKCORD_HERMES_TENANT_ID` | tenant id for the tenant-scoped routes, alongside the two above |
316
+
317
+ Setting only one of `WORKCORD_HERMES_KEY` / `WORKCORD_HERMES_HUB_URL` is an
318
+ error — the CLI will not half-guess a hub.
319
+
320
+ With none of them set, the CLI calls
321
+ `POST /api/v1/agents/{id}/hermes-cli-key/reveal` on WorkCord (as the acting
322
+ user, so ownership is enforced server-side) and gets back the key, tenant id and
323
+ hub origin.
324
+
325
+ Revealed credentials are cached per agent ref in `~/.workcord/config.json`
326
+ under `hermes`, alongside the saved login. The file is written `0600` inside a
327
+ `0700` directory. Two flags control it:
328
+
329
+ | Flag | Effect |
330
+ | --- | --- |
331
+ | `--refresh` | ignore the cached entry, re-reveal, rewrite the cache |
332
+ | `--no-cache` | neither read nor write the cache for this invocation |
333
+
334
+ Env credentials always win over the cache. `hermes key` prints the resolved
335
+ credentials on stdout — that is its whole job; no other command emits key
336
+ material.
337
+
338
+ ### Commands
339
+
340
+ | Group | What |
341
+ | --- | --- |
342
+ | `hermes key` / `whoami` / `runtime` | reveal credentials, show the tenant, show its runtime |
343
+ | `hermes agent status\|start\|stop\|restart\|card` | tenant agent lifecycle |
344
+ | `hermes chat list\|create\|get\|messages\|send\|message\|stream\|stop\|approve` | chats and chat messages (`stream` is SSE) |
345
+ | `hermes run create\|get\|stream\|stop\|approve` | runs |
346
+ | `hermes files ls\|cat\|download` | tenant-visible files |
347
+ | `hermes share groups\|ls\|read\|stat\|grep\|find\|write\|mkdir\|move\|rm` | share-group filesystem |
348
+ | `hermes skills list\|enable\|disable\|files\|cat` | skills installed on this tenant |
349
+ | `hermes flavor current\|catalog\|get\|set\|subscribe\|checkout\|publish\|rollout` | flavor marketplace and this tenant's pin |
350
+ | `hermes skill catalog\|get\|checkout\|publish` | skill marketplace |
351
+ | `hermes eval suites\|run\|list\|get\|cancel` | flavor eval suites and runs |
352
+ | `hermes peers list\|get\|call\|find` | A2A peers |
353
+ | `hermes conversations list\|get\|messages` | A2A conversations |
354
+ | `hermes sessions list`, `hermes artifacts` | agent sessions and artifacts |
355
+
356
+ ```bash
357
+ workcord hermes whoami --room-agent $RA
358
+ workcord hermes chat send --room-agent $RA --chat $CHAT --text "status?"
359
+ workcord hermes flavor current --agent $AGENT
360
+ workcord hermes peers find --agent $AGENT --query "translation" --limit 5
361
+ ```
362
+
363
+ Version flags are named `--flavor-version` and `--skill-version`, not
364
+ `--version`: commander's program-level `.version()` matches `--version`
365
+ anywhere in argv, prints the CLI version and exits before the subcommand runs.
366
+
367
+ ### Authoring flavors and skills
368
+
369
+ `flavor publish`, `flavor rollout` and `skill publish` are **owner-gated on the
370
+ Hub**. A WorkCord agent tenant is provisioned as a *consumer* and owns no flavor
371
+ or skill namespace, so these will 403 out of the box. Ownership is
372
+ admin-assigned only (`PUT /v1/flavors/{id}/owner`, likewise for skills) and
373
+ there is **no self-service bootstrap** — a hub operator has to assign the
374
+ namespace to your tenant first. The CLI turns that 403 into an explicit error
375
+ naming the tenant and the namespace so you know exactly what to ask for.
376
+
377
+ The CLI does **not** pack or untar archives. The authoring loop is:
378
+
379
+ ```bash
380
+ workcord hermes flavor checkout --agent $AGENT --flavor-version 1.4.0 --out flavor.tgz
381
+ # unpack, edit, and repack with hermes-hub's own tooling
382
+ workcord hermes flavor publish --agent $AGENT --archive flavor.tgz
383
+ ```
384
+
385
+ `--archive` must already be a gzipped package built beforehand; anything over
386
+ 32 MiB is rejected locally before upload.
387
+
388
+ `flavor set` warns (and refuses without `--force`) on a downgrade or a
389
+ deprecated target. `flavor rollout` defaults to the policy-respecting automatic
390
+ rollout; `--all-matching` re-pins every tenant on the Hub pinned to that flavor
391
+ id regardless of their own update policy, and therefore requires `--force`.
392
+
284
393
  ## Development
285
394
 
286
395
  ```bash
@@ -310,8 +419,12 @@ without running `regen` if you don't need the latest server changes.
310
419
  ```
311
420
  src/
312
421
  ├── cli.ts commander entry, global --as flag
313
- ├── auth.ts auth resolution: WORKCORD_TOKEN vs minted --as JWT
314
- ├── client.ts fetch wrapper (JSON + multipart)
422
+ ├── auth.ts auth resolution: WORKCORD_TOKEN vs minted --as JWT,
423
+ │ plus Hermes Hub key resolution
424
+ ├── http.ts shared fetch primitives (retry, errors, query)
425
+ ├── client.ts WorkCord fetch wrapper (JSON + multipart)
426
+ ├── hermes-client.ts Hermes Hub fetch wrapper (JSON, upload, SSE, binary)
427
+ ├── hermes-guards.ts rollout/ownership/archive guards, version compare
315
428
  ├── commands/ hand-written semantic commands
316
429
  └── generated/ auto-generated from openapi.json
317
430
  ├── index.ts
package/dist/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import jwt from "jsonwebtoken";
2
+ import { readConfig, saveHermesEntry } from "./config.js";
2
3
  export function actingUserId(program) {
3
4
  return program.opts().as;
4
5
  }
@@ -27,6 +28,48 @@ export function resolveAuth(asUserId, env = process.env, config = {}) {
27
28
  }
28
29
  throw new Error("No credentials. Run `workcord auth login --email <address> --save`, or set WORKCORD_TOKEN. For privileged impersonation, set WORKCORD_JWT_SECRET and pass --as <userId>.");
29
30
  }
31
+ async function revealHermesKey(agentRef, asUserId) {
32
+ const { call } = await import("./client.js");
33
+ const res = (await call({
34
+ method: "POST",
35
+ path: `/api/v1/agents/${encodeURIComponent(agentRef)}/hermes-cli-key/reveal`,
36
+ asUserId,
37
+ }));
38
+ if (!res?.key) {
39
+ throw new Error(`Reveal for ${agentRef} returned no Hermes key`);
40
+ }
41
+ if (!res.hubBaseUrl) {
42
+ throw new Error(`Reveal for ${agentRef} returned no hubBaseUrl. Set WORKCORD_HERMES_HUB_URL (with WORKCORD_HERMES_KEY) to target the hub explicitly.`);
43
+ }
44
+ return { key: res.key, tenantId: res.tenantId ?? "", hubBaseUrl: res.hubBaseUrl };
45
+ }
46
+ export async function resolveHermesKey(agentRef, opts = {}) {
47
+ const env = opts.env ?? process.env;
48
+ const envKey = env.WORKCORD_HERMES_KEY?.trim() || undefined;
49
+ const envHub = env.WORKCORD_HERMES_HUB_URL?.trim() || undefined;
50
+ if (envKey) {
51
+ if (!envHub) {
52
+ throw new Error("WORKCORD_HERMES_KEY is set but WORKCORD_HERMES_HUB_URL is not. Set both, or unset the key to reveal it from WorkCord.");
53
+ }
54
+ return { key: envKey, tenantId: env.WORKCORD_HERMES_TENANT_ID?.trim() || "", hubBaseUrl: envHub, source: "env" };
55
+ }
56
+ if (envHub && !envKey) {
57
+ throw new Error("WORKCORD_HERMES_HUB_URL is set but WORKCORD_HERMES_KEY is not. Set both, or unset the hub URL to reveal both from WorkCord.");
58
+ }
59
+ const useCache = opts.cache !== false;
60
+ const config = opts.config ?? (await readConfig());
61
+ if (useCache && !opts.refresh) {
62
+ const cached = config.hermes?.[agentRef];
63
+ if (cached?.key && cached.hubBaseUrl) {
64
+ return { key: cached.key, tenantId: cached.tenantId, hubBaseUrl: cached.hubBaseUrl, source: "cache" };
65
+ }
66
+ }
67
+ const revealed = await (opts.reveal ?? revealHermesKey)(agentRef, opts.asUserId);
68
+ if (useCache) {
69
+ await (opts.save ?? saveHermesEntry)(agentRef, { ...revealed, revealedAt: new Date().toISOString() });
70
+ }
71
+ return { ...revealed, source: "reveal" };
72
+ }
30
73
  export function decodeJwtPayload(token) {
31
74
  const parts = token.split(".");
32
75
  if (parts.length !== 3)
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { registerRoomAgents } from "./commands/room-agents.js";
12
12
  import { registerCustomAgents } from "./commands/custom-agents.js";
13
13
  import { registerChallenges } from "./commands/challenges.js";
14
14
  import { registerHealth } from "./commands/health.js";
15
+ import { registerHermes } from "./commands/hermes.js";
15
16
  import { registerAll as registerGenerated } from "./generated/index.js";
16
17
  const program = new Command();
17
18
  const require = createRequire(import.meta.url);
@@ -35,6 +36,7 @@ registerRoomAgents(program);
35
36
  registerCustomAgents(program);
36
37
  registerChallenges(program); // adds regenerate-cards (not in the OpenAPI spec)
37
38
  registerHealth(program);
39
+ registerHermes(program);
38
40
  registerGenerated(program);
39
41
  program.parseAsync().catch((err) => {
40
42
  const out = { error: err.message ?? String(err) };
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveAuth } from "./auth.js";
2
2
  import { DEFAULT_BASE_URL, readConfig } from "./config.js";
3
+ import { applyQuery, fetchWithRetry, httpError, normalizeBaseUrl, safeJson } from "./http.js";
3
4
  let baseUrlOverride;
4
5
  export function setBaseUrlOverride(url) {
5
6
  baseUrlOverride = url;
@@ -8,50 +9,6 @@ export async function getBaseUrl() {
8
9
  const config = await readConfig();
9
10
  return normalizeBaseUrl(baseUrlOverride || process.env.WORKCORD_API_URL || config.baseUrl || DEFAULT_BASE_URL);
10
11
  }
11
- function normalizeBaseUrl(raw) {
12
- const trimmed = raw.replace(/\/+$/, "");
13
- let parsed;
14
- try {
15
- parsed = new URL(trimmed);
16
- }
17
- catch {
18
- throw new Error(`Invalid API base URL: ${raw}`);
19
- }
20
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
21
- throw new Error(`API base URL must be http or https: ${raw}`);
22
- }
23
- if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
24
- throw new Error(`API base URL must be an origin with no path, query or fragment: ${raw}`);
25
- }
26
- return parsed.origin;
27
- }
28
- const RETRY_MAX = Number(process.env.WORKCORD_RETRY_MAX ?? 3);
29
- const RETRY_BACKOFF_MS = Number(process.env.WORKCORD_RETRY_BACKOFF_MS ?? 200);
30
- const RETRY_STATUSES = new Set([408, 429, 502, 503, 504]);
31
- async function fetchWithRetry(build) {
32
- let lastErr;
33
- for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
34
- try {
35
- const { url, init } = build();
36
- const res = await fetch(url, init);
37
- if (!RETRY_STATUSES.has(res.status))
38
- return res;
39
- lastErr = res;
40
- if (attempt >= RETRY_MAX)
41
- return res;
42
- }
43
- catch (err) {
44
- lastErr = err;
45
- if (attempt >= RETRY_MAX)
46
- throw err;
47
- }
48
- const delay = RETRY_BACKOFF_MS * Math.pow(3, attempt);
49
- await new Promise((r) => setTimeout(r, delay));
50
- }
51
- if (lastErr instanceof Response)
52
- return lastErr;
53
- throw lastErr;
54
- }
55
12
  export async function call({ method = "GET", path, query, body, headers, asUserId }) {
56
13
  const config = await readConfig();
57
14
  const { bearer } = resolveAuth(asUserId, process.env, config);
@@ -62,13 +19,7 @@ export async function callPublic({ method = "GET", path, query, body, headers })
62
19
  }
63
20
  async function request({ method = "GET", path, query, body, headers, bearer }) {
64
21
  const base = await getBaseUrl();
65
- const url = new URL(base + path);
66
- if (query) {
67
- for (const [k, v] of Object.entries(query)) {
68
- if (v !== undefined && v !== null)
69
- url.searchParams.set(k, String(v));
70
- }
71
- }
22
+ const url = applyQuery(new URL(base + path), query);
72
23
  const res = await fetchWithRetry(() => ({
73
24
  url,
74
25
  init: {
@@ -83,22 +34,10 @@ async function request({ method = "GET", path, query, body, headers, bearer }) {
83
34
  }));
84
35
  const text = await res.text();
85
36
  const data = text ? safeJson(text) : null;
86
- if (!res.ok) {
87
- const err = new Error(`${method} ${path} -> ${res.status}`);
88
- err.status = res.status;
89
- err.body = data;
90
- throw err;
91
- }
37
+ if (!res.ok)
38
+ throw httpError(`${method} ${path}`, res.status, data);
92
39
  return data;
93
40
  }
94
- function safeJson(s) {
95
- try {
96
- return JSON.parse(s);
97
- }
98
- catch {
99
- return s;
100
- }
101
- }
102
41
  export async function callMultipart({ path, asUserId, fields, file }) {
103
42
  const config = await readConfig();
104
43
  const { bearer } = resolveAuth(asUserId, process.env, config);
@@ -123,12 +62,8 @@ export async function callMultipart({ path, asUserId, fields, file }) {
123
62
  });
124
63
  const text = await res.text();
125
64
  const data = text ? safeJson(text) : null;
126
- if (!res.ok) {
127
- const err = new Error(`POST ${path} -> ${res.status}`);
128
- err.status = res.status;
129
- err.body = data;
130
- throw err;
131
- }
65
+ if (!res.ok)
66
+ throw httpError(`POST ${path}`, res.status, data);
132
67
  return data;
133
68
  }
134
69
  //# sourceMappingURL=client.js.map