@fulldivevr/workcord-cli 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,15 +30,9 @@ Base URL must be a bare origin (scheme + host, no path). The `/api/v1` prefix
30
30
  is added by the CLI. Resolution order is `--base-url`, `WORKCORD_API_URL`,
31
31
  saved configuration, then the WorkCord production default.
32
32
 
33
- | Deployment | `WORKCORD_API_URL` |
34
- | --- | --- |
35
- | WorkCord prod | `https://work-rooms-api.wizeup.app` |
36
- | VIA | `https://via-rooms-api.wizeup.app` |
37
- | RoomCord prod | `https://rooms-api.wizeup.app` |
38
- | RoomCord dev | `https://rooms-api-dev.wizeup.app` |
39
- | JesusCord | `https://jesuscord-api.wizeup.app` |
40
- | Local WorkCord | `http://localhost:3001` |
41
- | Local RoomCord / JesusCord | `http://localhost:3000` |
33
+ Your deployment's origin comes from whoever operates it. A local server is
34
+ `http://localhost:3001` (WorkCord) or `http://localhost:3000` (RoomCord /
35
+ JesusCord).
42
36
 
43
37
  ```bash
44
38
  workcord auth login --email you@company.com --save
@@ -49,20 +43,17 @@ workcord rooms list
49
43
  Use another server for one command:
50
44
 
51
45
  ```bash
52
- workcord --base-url https://via-rooms-api.wizeup.app rooms list
46
+ workcord --base-url https://your-server.example.com rooms list
53
47
  ```
54
48
 
55
49
  Save another server and login for future commands:
56
50
 
57
51
  ```bash
58
- workcord --base-url https://via-rooms-api.wizeup.app auth login --email you@via.com --save
52
+ workcord --base-url https://your-server.example.com auth login --email you@company.com --save
59
53
  ```
60
54
 
61
- Reset the saved server to WorkCord by logging in and saving again:
62
-
63
- ```bash
64
- workcord --base-url https://work-rooms-api.wizeup.app auth login --email you@company.com --save
65
- ```
55
+ Reset the saved server to the built-in default by clearing `baseUrl` from
56
+ `~/.workcord/config.json` and logging in again.
66
57
 
67
58
  Output is JSON on stdout, errors on stderr, non-zero exit on failure.
68
59
 
@@ -281,6 +272,115 @@ workcord --as $U dm send-text --to $OTHER --text "hey"
281
272
  # → composite: opens (or finds) the DM room, sends the message, returns both
282
273
  ```
283
274
 
275
+ ## Hermes Hub (`hermes` namespace)
276
+
277
+ WorkCord provisions a **Hermes Hub tenant** for each room-agent and each Hermes
278
+ custom agent. That tenant is where the agent actually runs: its chats, runs,
279
+ files, skills, flavor pin, A2A peers and eval runs all live on the Hub, not in
280
+ WorkCord. The `hermes` namespace drives that tenant directly.
281
+
282
+ This is the one namespace that talks to a **second origin**. Every other command
283
+ goes to the WorkCord API; `hermes` uses the WorkCord API only to reveal the
284
+ tenant key, then calls the Hub's `/v1/tenant-api/*` routes with it.
285
+
286
+ ### Target selection
287
+
288
+ Every `hermes` command takes exactly one of:
289
+
290
+ | Flag | Meaning |
291
+ | --- | --- |
292
+ | `--agent <id>` | a custom agent id, used verbatim |
293
+ | `--room-agent <id>` | a room agent id, resolved as `workcord-room-agent:<id>` |
294
+
295
+ Passing both, or neither, is an error.
296
+
297
+ ### Credentials
298
+
299
+ The tenant key is resolved in one place (`resolveHermesKey` in `src/auth.ts`)
300
+ and is **never** accepted as a flag:
301
+
302
+ | Env var | Meaning |
303
+ | --- | --- |
304
+ | `WORKCORD_HERMES_KEY` | tenant key, used verbatim; requires `WORKCORD_HERMES_HUB_URL` |
305
+ | `WORKCORD_HERMES_HUB_URL` | Hub origin (bare origin, no path); requires `WORKCORD_HERMES_KEY` |
306
+ | `WORKCORD_HERMES_TENANT_ID` | tenant id for the tenant-scoped routes, alongside the two above |
307
+
308
+ Setting only one of `WORKCORD_HERMES_KEY` / `WORKCORD_HERMES_HUB_URL` is an
309
+ error — the CLI will not half-guess a hub.
310
+
311
+ With none of them set, the CLI calls
312
+ `POST /api/v1/agents/{id}/hermes-cli-key/reveal` on WorkCord (as the acting
313
+ user, so ownership is enforced server-side) and gets back the key, tenant id and
314
+ hub origin.
315
+
316
+ Revealed credentials are cached per agent ref in `~/.workcord/config.json`
317
+ under `hermes`, alongside the saved login. The file is written `0600` inside a
318
+ `0700` directory. Two flags control it:
319
+
320
+ | Flag | Effect |
321
+ | --- | --- |
322
+ | `--refresh` | ignore the cached entry, re-reveal, rewrite the cache |
323
+ | `--no-cache` | neither read nor write the cache for this invocation |
324
+
325
+ Env credentials always win over the cache. `hermes key` prints the resolved
326
+ credentials on stdout — that is its whole job; no other command emits key
327
+ material.
328
+
329
+ ### Commands
330
+
331
+ | Group | What |
332
+ | --- | --- |
333
+ | `hermes key` / `whoami` / `runtime` | reveal credentials, show the tenant, show its runtime |
334
+ | `hermes agent status\|start\|stop\|restart\|card` | tenant agent lifecycle |
335
+ | `hermes chat list\|create\|get\|messages\|send\|message\|stream\|stop\|approve` | chats and chat messages (`stream` is SSE) |
336
+ | `hermes run create\|get\|stream\|stop\|approve` | runs |
337
+ | `hermes files ls\|cat\|download` | tenant-visible files |
338
+ | `hermes share groups\|ls\|read\|stat\|grep\|find\|write\|mkdir\|move\|rm` | share-group filesystem |
339
+ | `hermes skills list\|enable\|disable\|files\|cat` | skills installed on this tenant |
340
+ | `hermes flavor current\|catalog\|get\|set\|subscribe\|checkout\|publish\|rollout` | flavor marketplace and this tenant's pin |
341
+ | `hermes skill catalog\|get\|checkout\|publish` | skill marketplace |
342
+ | `hermes eval suites\|run\|list\|get\|cancel` | flavor eval suites and runs |
343
+ | `hermes peers list\|get\|call\|find` | A2A peers |
344
+ | `hermes conversations list\|get\|messages` | A2A conversations |
345
+ | `hermes sessions list`, `hermes artifacts` | agent sessions and artifacts |
346
+
347
+ ```bash
348
+ workcord hermes whoami --room-agent $RA
349
+ workcord hermes chat send --room-agent $RA --chat $CHAT --text "status?"
350
+ workcord hermes flavor current --agent $AGENT
351
+ workcord hermes peers find --agent $AGENT --query "translation" --limit 5
352
+ ```
353
+
354
+ Version flags are named `--flavor-version` and `--skill-version`, not
355
+ `--version`: commander's program-level `.version()` matches `--version`
356
+ anywhere in argv, prints the CLI version and exits before the subcommand runs.
357
+
358
+ ### Authoring flavors and skills
359
+
360
+ `flavor publish`, `flavor rollout` and `skill publish` are **owner-gated on the
361
+ Hub**. A WorkCord agent tenant is provisioned as a *consumer* and owns no flavor
362
+ or skill namespace, so these will 403 out of the box. Ownership is
363
+ admin-assigned only (`PUT /v1/flavors/{id}/owner`, likewise for skills) and
364
+ there is **no self-service bootstrap** — a hub operator has to assign the
365
+ namespace to your tenant first. The CLI turns that 403 into an explicit error
366
+ naming the tenant and the namespace so you know exactly what to ask for.
367
+
368
+ The CLI does **not** pack or untar archives. The authoring loop is:
369
+
370
+ ```bash
371
+ workcord hermes flavor checkout --agent $AGENT --flavor-version 1.4.0 --out flavor.tgz
372
+ # unpack, edit, and repack with hermes-hub's own tooling
373
+ workcord hermes flavor publish --agent $AGENT --archive flavor.tgz
374
+ ```
375
+
376
+ `--archive` must already be a gzipped package built beforehand; anything over
377
+ 32 MiB is rejected locally before upload.
378
+
379
+ `flavor set` warns (and refuses without `--force`) on a downgrade or a
380
+ deprecated target. `flavor rollout` defaults to the policy-respecting automatic
381
+ rollout; `--all-matching` re-pins every tenant on the Hub pinned to that flavor
382
+ id regardless of their own update policy, and therefore requires `--force`.
383
+
284
384
  ## Development
285
385
 
286
386
  ```bash
@@ -310,8 +410,12 @@ without running `regen` if you don't need the latest server changes.
310
410
  ```
311
411
  src/
312
412
  ├── 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)
413
+ ├── auth.ts auth resolution: WORKCORD_TOKEN vs minted --as JWT,
414
+ │ plus Hermes Hub key resolution
415
+ ├── http.ts shared fetch primitives (retry, errors, query)
416
+ ├── client.ts WorkCord fetch wrapper (JSON + multipart)
417
+ ├── hermes-client.ts Hermes Hub fetch wrapper (JSON, upload, SSE, binary)
418
+ ├── hermes-guards.ts rollout/ownership/archive guards, version compare
315
419
  ├── commands/ hand-written semantic commands
316
420
  └── generated/ auto-generated from openapi.json
317
421
  ├── 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