@drakon-systems/multi-clawd 1.7.2 → 1.7.3

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
@@ -54,6 +54,136 @@ install anything? Every command also runs as
54
54
 
55
55
  Then `multi-clawd explain` shows you exactly what you built.
56
56
 
57
+ ## Hermes Agent integration
58
+
59
+ multi-clawd can also import the configured Claude subscriptions into Hermes
60
+ Agent's native Anthropic credential pool. Install and set up
61
+ [Hermes Agent](https://hermes-agent.nousresearch.com/docs/) first, keep
62
+ `hermes` on `PATH`, then preview and apply the import:
63
+
64
+ ```bash
65
+ multi-clawd hermes sync --dry-run
66
+ multi-clawd hermes sync --strategy round_robin
67
+ multi-clawd hermes doctor
68
+
69
+ # An existing Hermes profile (create it first: hermes profile create work):
70
+ multi-clawd hermes sync --profile work --strategy least_used
71
+
72
+ # Read a non-default OpenClaw configuration:
73
+ multi-clawd hermes doctor --config ~/configs/openclaw.json
74
+ ```
75
+
76
+ Validated against Hermes Agent **0.19.1**.
77
+
78
+ ### Only stable setup tokens are imported
79
+
80
+ The one credential this adapter will copy is an account's `oauthTokenFile` — a
81
+ plain `claude setup-token` value, the same string multi-clawd already passes to
82
+ Claude Code as `CLAUDE_CODE_OAUTH_TOKEN`. It carries no refresh token and
83
+ nothing rotates it, so a second copy in Hermes stays valid.
84
+
85
+ Everything else is refused, on purpose:
86
+
87
+ - **`native` logins are not importable, but need nothing imported.** A native
88
+ login is a `.credentials.json` file holding a *rotating* OAuth grant whose
89
+ refresh token is single-use, so copying it into Hermes and the next refresh —
90
+ by Claude Code, or by Hermes itself — would invalidate the other copy, which
91
+ Hermes then marks dead and drops from rotation. It doesn't need copying
92
+ anyway: Hermes' own `claude_code` credential source already reads that exact
93
+ same native `~/.claude/.credentials.json` directly, so leave a native account
94
+ on that source instead of duplicating the grant.
95
+ - **`configDir` logins are not importable, and Hermes cannot be pointed at
96
+ them either.** The same single-use-refresh-token problem applies, and unlike
97
+ a native login there is no Hermes-side fallback: as of Hermes Agent 0.19.1,
98
+ its `claude_code` credential source reads only the native path above, never
99
+ an arbitrary `configDir`. A `configDir` account can only reach Hermes' pool
100
+ by getting its own `oauthTokenFile` (a `claude setup-token`, same as above),
101
+ or it stays OpenClaw-only.
102
+ - **`oauthTokenRef` is never resolved.** Reading a gateway secret reference
103
+ here would turn a reference into a copied plaintext secret in a second store;
104
+ the adapter fails closed and says so. An account may carry a `configDir`
105
+ alongside its `oauthTokenFile` — it is simply ignored for Hermes.
106
+
107
+ `doctor` names every account it cannot import and why; `sync` refuses to write
108
+ anything until they are fixed or removed. **Re-run `sync` whenever a
109
+ setup-token file changes** — nothing propagates a new token automatically.
110
+
111
+ ### What sync does
112
+
113
+ Accounts are read from `plugins.entries["multi-clawd"].config.accounts` in
114
+ `~/.openclaw/openclaw.json` by default. Every account is validated and every
115
+ token file read and parsed before Hermes is asked to write anything.
116
+
117
+ - Only deterministic rows labelled `multi-clawd:<account-id>` are added or
118
+ updated, and only those rows are sent to Hermes — it merges them into the
119
+ pool under its own lock, so unrelated credentials are never read, copied, or
120
+ rewritten. The operation is idempotent.
121
+ - Row priority follows your `pool.accounts` preference order (home account
122
+ first), which is the order Hermes' `fill_first` drains in. Accounts absent
123
+ from `pool.accounts` follow in `accounts[]` order.
124
+ - `--strategy` sets Hermes' Anthropic pool strategy to one of `fill_first`,
125
+ `round_robin`, `random`, `least_used`. **Omit it and your configured strategy
126
+ is left alone** (`fill_first` only when nothing is set at all).
127
+ - `--dry-run` runs the same validation and planning and writes nothing.
128
+ - Planning always reads the target home's *own* pool. A Hermes profile with no
129
+ Anthropic rows yet falls back to reading the global pool — `doctor` labels
130
+ that as an effective-only view, and sync never copies those rows into the
131
+ profile.
132
+ - Named profiles must already exist; the adapter never creates one, so a
133
+ deleted profile is not resurrected as an empty skeleton. Run
134
+ `hermes profile create <name>` first. The target home is whatever Hermes
135
+ itself reports as its root (via its own `hermes_constants` module) — never
136
+ reimplemented here — which is `~/.hermes` on POSIX, `%LOCALAPPDATA%\hermes`
137
+ on native Windows, or a Docker/custom `HERMES_HOME` root; a named profile is
138
+ `<root>/profiles/<name>`. `--profile default` always targets that root, even
139
+ when the active `HERMES_HOME` currently points at another profile.
140
+ - Only multi-clawd's own broken state (duplicate or malformed managed rows)
141
+ blocks a sync. Several `claude_code` rows, or another tool's malformed row,
142
+ are reported as warnings — they are legitimate states and not this plugin's
143
+ to fix.
144
+
145
+ The pool and the config are two separate Hermes files. Each write is atomic on
146
+ its own and both are verified by re-reading afterwards; the pair is not atomic,
147
+ so the pool is written first and both are idempotent — an interruption between
148
+ them leaves a state that re-running `sync` repairs, rather than one needing a
149
+ rollback.
150
+
151
+ ### What this integration is (and is not)
152
+
153
+ Hermes talks to the **native Anthropic Messages API** with Hermes' own tool
154
+ system. It does not run the OpenClaw plugin backend or the Claude Code harness,
155
+ so OpenClaw skills/MCP wiring and Claude Code's built-in harness are not copied
156
+ across. multi-clawd supplies the account credentials and the configured Hermes
157
+ pool distribution only. Hermes then provides native reactive failover and the
158
+ selected distribution strategy. Proactive near-limit telemetry/rotation from
159
+ multi-clawd's OpenClaw pool is **not implemented for Hermes yet**.
160
+
161
+ ### Security model
162
+
163
+ Tokens are read from real files with strict size limits, parsed locally, and
164
+ sent to the bundled Python bridge only as one JSON document on **stdin**. They
165
+ are never placed in argv, exported as environment variables, or printed to
166
+ stdout/stderr — no token, no prefix, no fingerprint — and command errors use
167
+ fixed safe messages. On POSIX, a token source file must be private —
168
+ group/other-readable or -writable (any `chmod` bits beyond `600`) is refused
169
+ outright, not just warned about, checked via `fstat` on the already-open file
170
+ descriptor so there is no gap between the check and the read (Windows has no
171
+ equivalent bit layout, so the check is a no-op there; the config JSON is read
172
+ through the same code path but never needs to be private). The bridge uses
173
+ Hermes' installed Python APIs directly (no shell-string execution), writes
174
+ only inside the selected `HERMES_HOME`, and never echoes identifiers or other
175
+ fields belonging to a row it doesn't manage — an unrelated row is reported by
176
+ position only, even when its own source/label/status fields are stuffed with
177
+ a token-like string. Paths reject NUL bytes, expand only a leading `~/`, and
178
+ profile names cannot contain traversal or separators.
179
+
180
+ `doctor` validates files, account configuration, and the shape of Hermes' pool
181
+ and config on disk — it makes **no live request to Anthropic** and cannot
182
+ prove a setup token is still accepted. A token can pass every doctor check and
183
+ still be expired or revoked; that is discovered at runtime, where Hermes' own
184
+ native 401/429 handling takes over exactly as it does for any other credential
185
+ in its pool.
186
+
57
187
  ## Why
58
188
 
59
189
  OpenClaw's bundled `claude-cli` backend runs Claude Code on a **single**
@@ -234,7 +364,7 @@ openclaw plugins install (Get-Location).Path
234
364
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
235
365
  Code on the target machine already? Paste it this and go make coffee:
236
366
 
237
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.7.2/SETUP-AGENT.md
367
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.7.3/SETUP-AGENT.md
238
368
  > and follow it to set up multi-clawd on this machine. I own a second
239
369
  > Claude account — ask me when you need me to log in.
240
370
 
@@ -310,9 +440,8 @@ dir* — a separate Claude "app" — so the logins can never clobber each other.
310
440
 
311
441
  ```bash
312
442
  mkdir -p ~/.claw2 && chmod 700 ~/.claw2
313
- CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token # log in as the 2nd account
314
- # store the token where the plugin can read it (0600):
315
- # ~/.claw2/oauth-token
443
+ CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token > ~/.claw2/oauth-token # log in as the 2nd account
444
+ chmod 600 ~/.claw2/oauth-token
316
445
  ```
317
446
 
318
447
  **Windows (native, PowerShell):**
@@ -533,6 +662,14 @@ Housekeeping:
533
662
  - Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
534
663
  file; when a file is used, keep it `0600` (POSIX) or locked to your user
535
664
  with `icacls` (Windows).
665
+ - **Credential resolution fails closed (v1.7.3).** An account that declares
666
+ `oauthTokenRef` or `oauthTokenFile` is authenticated by that token. If it
667
+ resolves to nothing — provider briefly unavailable, empty secret, truncated
668
+ file — and the account has no `configDir` to fall back on, the launch is
669
+ refused with `declares a token source but none resolved` rather than
670
+ allowed to proceed on the machine's default login and spend a different
671
+ account's quota under this account's name. Native accounts are exempt: the
672
+ default login *is* their credential.
536
673
  - Migrating a token file into a vault? `op read` (and most secret CLIs)
537
674
  append a trailing newline on output — resolution trims the resolved
538
675
  value (guaranteed in `token-resolution.ts`), so a file-vs-vault diff
@@ -12,11 +12,17 @@ export function buildAccountChildEnv(account, token, stateFile) {
12
12
  MULTI_CLAWD_ACCOUNT_ID: account.id,
13
13
  MULTI_CLAWD_STATE_FILE: stateFile,
14
14
  };
15
- if (token)
16
- env.CLAUDE_CODE_OAUTH_TOKEN = token;
15
+ const usableToken = token?.trim() ? token : undefined;
16
+ if (usableToken)
17
+ env.CLAUDE_CODE_OAUTH_TOKEN = usableToken;
17
18
  if (!account.native && account.configDir) {
18
19
  env.CLAUDE_CONFIG_DIR = expandHomePath(account.configDir);
19
20
  }
21
+ const declaresToken = Boolean(account.oauthTokenFile) || Boolean(account.oauthTokenRef);
22
+ if (!account.native && declaresToken && !usableToken && !account.configDir) {
23
+ throw new Error(`[multi-clawd] account "${account.id}" declares a token source but none resolved — ` +
24
+ `refusing to launch on the default login`);
25
+ }
20
26
  return env;
21
27
  }
22
28
  export function tokenFileModeWarning(path, mode) {
@@ -25,7 +25,7 @@ function parseRef(ref) {
25
25
  const idx = ref.indexOf("/");
26
26
  if (idx < 0)
27
27
  return { modelId: ref };
28
- return { provider: ref.slice(0, idx), modelId: ref.slice(idx + 1) };
28
+ return { provider: ref.slice(0, idx).trim().toLowerCase(), modelId: ref.slice(idx + 1) };
29
29
  }
30
30
  export function offPoolClaudeRef(ref, poolId) {
31
31
  if (typeof ref !== "string" || ref.length === 0)
@@ -35,7 +35,7 @@ export function offPoolClaudeRef(ref, poolId) {
35
35
  return null;
36
36
  if (provider === undefined)
37
37
  return null;
38
- if (provider === poolId)
38
+ if (provider === poolId.trim().toLowerCase())
39
39
  return null;
40
40
  if (provider === "anthropic" || provider === "claude-cli")
41
41
  return "strong";
package/dist/health.js CHANGED
@@ -62,7 +62,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
62
62
  if (!fresh)
63
63
  continue;
64
64
  hasLiveEvidence = true;
65
- if (w.status === "rejected" && resetBearing) {
65
+ if (w.status === "rejected" && resetBearing && isPeriodWindow(window)) {
66
66
  return {
67
67
  verdict: "exhausted",
68
68
  resumeAt: resetMs,
@@ -0,0 +1,258 @@
1
+ import { createHash } from "node:crypto";
2
+ export const HERMES_MANAGED_ID_PREFIX = "multi-clawd-";
3
+ export const HERMES_MANAGED_SOURCE = "manual:multi-clawd";
4
+ export const HERMES_SUPPORTED_STRATEGIES = [
5
+ "fill_first",
6
+ "round_robin",
7
+ "random",
8
+ "least_used",
9
+ ];
10
+ export const HERMES_DEFAULT_STRATEGY = "fill_first";
11
+ export const HERMES_MAX_SETUP_TOKEN_BYTES = 8 * 1024;
12
+ export class HermesAdapterError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = "HermesAdapterError";
18
+ }
19
+ }
20
+ const PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
21
+ const ACCOUNT_RE = PROFILE_RE;
22
+ const RESERVED_PROFILES = new Set(["hermes", "test", "tmp", "root", "sudo"]);
23
+ const SETUP_TOKEN_RE = /^sk-ant-oat\d{2,}-[!-~]+$/;
24
+ const API_KEY_PREFIX = "sk-ant-api";
25
+ const NATIVE_SETUP_TOKEN_GUIDANCE = "add an oauthTokenFile holding a `claude setup-token` value, or let Hermes read the native " +
26
+ "login itself — its own claude_code credential source reads ~/.claude/.credentials.json directly";
27
+ const CONFIGDIR_SETUP_TOKEN_GUIDANCE = "Hermes' claude_code credential source only reads the native ~/.claude/.credentials.json, never " +
28
+ "an arbitrary configDir, so this account cannot be pointed at Hermes directly — add its own " +
29
+ "oauthTokenFile holding a `claude setup-token` value, or leave it OpenClaw-only";
30
+ function fail(code, message) {
31
+ throw new HermesAdapterError(code, message);
32
+ }
33
+ function asRecord(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value)
35
+ ? value
36
+ : undefined;
37
+ }
38
+ function optionalNonEmptyString(value, code) {
39
+ if (value === undefined || value === null)
40
+ return undefined;
41
+ if (typeof value !== "string" || !value.trim())
42
+ fail(code, "account configuration is malformed");
43
+ return value.trim();
44
+ }
45
+ export function validateHermesStrategy(value) {
46
+ if (typeof value !== "string") {
47
+ fail("invalid_strategy", `strategy must be one of: ${HERMES_SUPPORTED_STRATEGIES.join(", ")}`);
48
+ }
49
+ const normalized = value.trim().toLowerCase();
50
+ if (!HERMES_SUPPORTED_STRATEGIES.includes(normalized)) {
51
+ fail("invalid_strategy", `strategy must be one of: ${HERMES_SUPPORTED_STRATEGIES.join(", ")}`);
52
+ }
53
+ return normalized;
54
+ }
55
+ export function validateHermesProfileName(value) {
56
+ if (typeof value !== "string")
57
+ fail("invalid_profile", "profile name is invalid");
58
+ const normalized = value.trim().toLowerCase();
59
+ if (!PROFILE_RE.test(normalized) || (normalized !== "default" && RESERVED_PROFILES.has(normalized))) {
60
+ fail("invalid_profile", "profile name must be a safe Hermes profile identifier");
61
+ }
62
+ return normalized;
63
+ }
64
+ export function validateHermesAccount(value) {
65
+ const row = asRecord(value);
66
+ if (!row || typeof row.id !== "string" || !row.id.trim()) {
67
+ fail("malformed_account", "account configuration is malformed");
68
+ }
69
+ const oauthTokenRef = asRecord(row.oauthTokenRef);
70
+ if (row.oauthTokenRef !== undefined && !oauthTokenRef) {
71
+ fail("malformed_account", "account configuration is malformed");
72
+ }
73
+ return {
74
+ id: row.id.trim().toLowerCase(),
75
+ label: optionalNonEmptyString(row.label, "malformed_account"),
76
+ native: row.native === true || undefined,
77
+ configDir: optionalNonEmptyString(row.configDir, "malformed_account"),
78
+ oauthTokenFile: optionalNonEmptyString(row.oauthTokenFile, "malformed_account"),
79
+ oauthTokenRef,
80
+ };
81
+ }
82
+ export function describeHermesAccountSupport(account) {
83
+ if (!ACCOUNT_RE.test(account.id)) {
84
+ return {
85
+ supported: false,
86
+ code: "unsupported_account_id",
87
+ reason: "account id must be lowercase letters, digits, '_' or '-' (max 64 chars) to become a Hermes credential id",
88
+ };
89
+ }
90
+ if (account.oauthTokenFile)
91
+ return { supported: true };
92
+ if (account.native) {
93
+ return {
94
+ supported: false,
95
+ code: "native_not_supported",
96
+ reason: "a native Claude login is a rotating single-use grant, unsafe to duplicate — " +
97
+ NATIVE_SETUP_TOKEN_GUIDANCE,
98
+ };
99
+ }
100
+ if (account.oauthTokenRef) {
101
+ return {
102
+ supported: false,
103
+ code: "secret_ref_not_supported",
104
+ reason: "oauthTokenRef is never resolved here — a secret reference must not be turned into a " +
105
+ `copied plaintext secret; ${NATIVE_SETUP_TOKEN_GUIDANCE}`,
106
+ };
107
+ }
108
+ if (account.configDir) {
109
+ return {
110
+ supported: false,
111
+ code: "setup_token_file_required",
112
+ reason: "a configDir login is a rotating single-use grant, unsafe to duplicate, and " +
113
+ CONFIGDIR_SETUP_TOKEN_GUIDANCE,
114
+ };
115
+ }
116
+ return {
117
+ supported: false,
118
+ code: "setup_token_file_required",
119
+ reason: `no importable Hermes credential source — ${NATIVE_SETUP_TOKEN_GUIDANCE}`,
120
+ };
121
+ }
122
+ export function collectHermesAccounts(values) {
123
+ if (!Array.isArray(values) || values.length === 0) {
124
+ fail("no_accounts", "no multi-clawd accounts are configured");
125
+ }
126
+ const accounts = [];
127
+ const unsupported = [];
128
+ const seen = new Set();
129
+ for (const value of values) {
130
+ const account = validateHermesAccount(value);
131
+ if (seen.has(account.id)) {
132
+ unsupported.push({
133
+ id: account.id,
134
+ code: "duplicate_account",
135
+ reason: "duplicate account id — remove the repeated entry from the OpenClaw config",
136
+ });
137
+ continue;
138
+ }
139
+ seen.add(account.id);
140
+ const support = describeHermesAccountSupport(account);
141
+ if (support.supported)
142
+ accounts.push(account);
143
+ else
144
+ unsupported.push({ id: account.id, code: support.code, reason: support.reason });
145
+ }
146
+ return { accounts, unsupported };
147
+ }
148
+ export function hermesAccountPriorities(pluginConfig, accounts) {
149
+ const known = new Set(accounts.map((account) => account.id));
150
+ const ordered = [];
151
+ const push = (raw) => {
152
+ if (typeof raw !== "string")
153
+ return;
154
+ const id = raw.trim().toLowerCase();
155
+ if (!known.has(id) || ordered.includes(id))
156
+ return;
157
+ ordered.push(id);
158
+ };
159
+ const config = asRecord(pluginConfig);
160
+ const pools = Array.isArray(config?.pool) ? config.pool : [config?.pool];
161
+ for (const entry of pools) {
162
+ const preference = asRecord(entry)?.accounts;
163
+ if (Array.isArray(preference))
164
+ for (const id of preference)
165
+ push(id);
166
+ }
167
+ for (const account of accounts)
168
+ push(account.id);
169
+ return new Map(ordered.map((id, index) => [id, index]));
170
+ }
171
+ export function chooseHermesCredentialSource(accountValue, candidates) {
172
+ const account = validateHermesAccount(accountValue);
173
+ const support = describeHermesAccountSupport(account);
174
+ if (!support.supported)
175
+ fail(support.code, support.reason);
176
+ if (!candidates.oauthTokenFilePath) {
177
+ fail("setup_token_file_required", `no importable Hermes credential source — ${NATIVE_SETUP_TOKEN_GUIDANCE}`);
178
+ }
179
+ if (!candidates.existingPaths.includes(candidates.oauthTokenFilePath)) {
180
+ fail("setup_token_file_missing", "the configured oauthTokenFile does not exist");
181
+ }
182
+ return { kind: "oauthTokenFile", path: candidates.oauthTokenFilePath };
183
+ }
184
+ export function parseClaudeSetupToken(text) {
185
+ if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > HERMES_MAX_SETUP_TOKEN_BYTES) {
186
+ fail("malformed_setup_token", "the setup-token file is unreadable or too large");
187
+ }
188
+ const lines = text.split("\n");
189
+ if (lines.length > 1 && lines[lines.length - 1] === "")
190
+ lines.pop();
191
+ if (lines.length !== 1) {
192
+ fail("malformed_setup_token", "the setup-token file must hold exactly one line");
193
+ }
194
+ let token = lines[0];
195
+ if (token.endsWith("\r"))
196
+ token = token.slice(0, -1);
197
+ if (!token)
198
+ fail("malformed_setup_token", "the setup-token file is empty");
199
+ if (token.startsWith("{") || token.startsWith("[")) {
200
+ fail("rotating_grant_not_supported", "that file holds a JSON Claude credentials grant, not a setup token — rotating grants are " +
201
+ "single-use and unsafe to duplicate. If this is your native ~/.claude login, it needs no " +
202
+ "copy at all — Hermes' own claude_code credential source already reads that file directly. " +
203
+ "A configDir login has no such fallback (claude_code cannot be pointed at a configDir) and " +
204
+ "needs its own oauthTokenFile setup token instead");
205
+ }
206
+ if (token.startsWith(API_KEY_PREFIX)) {
207
+ fail("malformed_setup_token", "that looks like a Claude API key, not a setup token — setup tokens start with sk-ant-oat");
208
+ }
209
+ if (!SETUP_TOKEN_RE.test(token)) {
210
+ fail("malformed_setup_token", "the setup token does not match the expected sk-ant-oat… shape");
211
+ }
212
+ return token;
213
+ }
214
+ export function stableHermesCredentialId(accountId) {
215
+ const account = validateHermesAccount({ id: accountId });
216
+ if (!ACCOUNT_RE.test(account.id)) {
217
+ fail("unsupported_account_id", "account id cannot be turned into a Hermes credential id");
218
+ }
219
+ const digest = createHash("sha256").update(`multi-clawd/hermes/${account.id}`, "utf8").digest("hex");
220
+ return `${HERMES_MANAGED_ID_PREFIX}${digest.slice(0, 16)}`;
221
+ }
222
+ export function buildHermesManagedCredential(accountValue, setupToken, priority) {
223
+ const account = validateHermesAccount(accountValue);
224
+ if (typeof setupToken !== "string" || !setupToken) {
225
+ fail("malformed_setup_token", "the setup token is missing");
226
+ }
227
+ if (!Number.isSafeInteger(priority) || priority < 0) {
228
+ fail("invalid_priority", "credential priority must be a non-negative integer");
229
+ }
230
+ return {
231
+ accountId: account.id,
232
+ id: stableHermesCredentialId(account.id),
233
+ label: `multi-clawd:${account.id}`,
234
+ source: HERMES_MANAGED_SOURCE,
235
+ authType: "oauth",
236
+ accessToken: setupToken,
237
+ priority,
238
+ };
239
+ }
240
+ export function buildHermesBridgeRequest(input) {
241
+ if (!["probe", "doctor", "apply"].includes(input.operation)) {
242
+ fail("unsupported_operation", "operation must be probe, doctor, or apply");
243
+ }
244
+ if (typeof input.targetHome !== "string" || !input.targetHome) {
245
+ fail("invalid_target_home", "the target Hermes home is invalid");
246
+ }
247
+ const request = {
248
+ operation: input.operation,
249
+ targetHome: input.targetHome,
250
+ };
251
+ if (input.operation !== "apply")
252
+ return request;
253
+ request.dryRun = input.dryRun === true;
254
+ request.credentials = (input.credentials ?? []).map((credential) => ({ ...credential }));
255
+ if (input.strategy !== undefined)
256
+ request.strategy = validateHermesStrategy(input.strategy);
257
+ return request;
258
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.7.2",
4
+ "version": "1.7.3",
5
5
  "description": "Register additional Claude Code logins (Max/Pro accounts) as first-class OpenClaw CLI backends for cross-account failover, keeping the full skills/MCP harness on every account.",
6
6
  "cliBackends": [
7
7
  "claw1",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.7.2",
4
- "description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account.",
3
+ "version": "1.7.3",
4
+ "description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account. Also imports those accounts' setup tokens into Hermes Agent's Anthropic credential pool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Drakon Systems Ltd",
@@ -44,6 +44,8 @@
44
44
  "files": [
45
45
  "dist",
46
46
  "scripts",
47
+ "!scripts/__pycache__",
48
+ "!**/*.py[cod]",
47
49
  "openclaw.plugin.json",
48
50
  "README.md",
49
51
  "SECURITY.md",
package/scripts/cli.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * npx @drakon-systems/multi-clawd setup guided setup wizard
6
6
  * npx @drakon-systems/multi-clawd update update to the latest version
7
7
  * npx @drakon-systems/multi-clawd doctor health check
8
+ * npx @drakon-systems/multi-clawd hermes sync/diagnose Hermes credentials
8
9
  * npx @drakon-systems/multi-clawd version versions (CLI + installed plugin)
9
10
  *
10
11
  * (Installed globally via `npm i -g @drakon-systems/multi-clawd`, the same
@@ -38,6 +39,7 @@ ${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
38
39
  ${BOLD}chain${RESET} audit your model routing — what actually serves each turn
39
40
  ${BOLD}update${RESET} update the plugin to the latest version
40
41
  ${BOLD}doctor${RESET} health check (add --probe for a live turn)
42
+ ${BOLD}hermes${RESET} sync or diagnose Hermes Agent's Anthropic credential pool
41
43
  ${BOLD}version${RESET} show CLI + installed plugin versions
42
44
 
43
45
  Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
@@ -554,6 +556,9 @@ switch (cmd) {
554
556
  case "doctor":
555
557
  runSibling("doctor.mjs", rest);
556
558
  break;
559
+ case "hermes":
560
+ runSibling("hermes.mjs", rest);
561
+ break;
557
562
  case "update":
558
563
  await update();
559
564
  break;