@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 +115 -2
- package/dist/auth.js +43 -0
- package/dist/cli.js +2 -0
- package/dist/client.js +6 -71
- package/dist/commands/hermes.js +644 -0
- package/dist/config.js +24 -0
- package/dist/generated/commands/custom-agents.js +14 -0
- package/dist/generated/commands/health.js +29 -0
- package/dist/generated/commands/live-kit-webhook.js +20 -0
- package/dist/generated/commands/room-call.js +33 -0
- package/dist/generated/commands/rooms.js +24 -0
- package/dist/generated/index.js +4 -0
- package/dist/hermes-client.js +87 -0
- package/dist/hermes-guards.js +70 -0
- package/dist/http.js +68 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|