@drakon-systems/multi-clawd 2.5.1 → 2.6.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 +2 -1
- package/dist/explain-core.js +46 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/_shared.mjs +55 -1
- package/scripts/cli.mjs +68 -36
- package/scripts/hermes.mjs +22 -4
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ multi-clawd setup # guided wizard: accounts, isolated second login, pool, wa
|
|
|
31
31
|
multi-clawd login claw2 # launch the right Claude sign-in for an account (or re-auth it)
|
|
32
32
|
multi-clawd explain # your whole setup in plain English — accounts, chain, live health
|
|
33
33
|
multi-clawd explain --history --since 7d # per-account launches, retries, rotations
|
|
34
|
+
multi-clawd explain --json # live health plus a rough weekly pace estimate, machine-readable
|
|
34
35
|
multi-clawd account disable claw2 --dry-run # preview benching an account
|
|
35
36
|
multi-clawd chain # audit your model routing — what actually serves each turn
|
|
36
37
|
multi-clawd direct # the direct anthropic/* route: status, or `direct sync` (v1.9, optional)
|
|
@@ -415,7 +416,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
415
416
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
416
417
|
Code on the target machine already? Paste it this and go make coffee:
|
|
417
418
|
|
|
418
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.
|
|
419
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.1/SETUP-AGENT.md
|
|
419
420
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
420
421
|
> Claude account — ask me when you need me to log in.
|
|
421
422
|
|
package/dist/explain-core.js
CHANGED
|
@@ -30,6 +30,49 @@ export function renderUsageLine(usage, nowMs) {
|
|
|
30
30
|
})
|
|
31
31
|
.join(" · ");
|
|
32
32
|
}
|
|
33
|
+
const WEEK_MS = 7 * 86_400_000;
|
|
34
|
+
const MIN_PACE_ELAPSED_MS = WEEK_MS / 10;
|
|
35
|
+
const PACE_RESET_SLACK_MS = 3_600_000;
|
|
36
|
+
export function computeWeeklyPace(usage, nowMs) {
|
|
37
|
+
const u = ["usage:seven_day", "seven_day"]
|
|
38
|
+
.map((w) => usage?.find((x) => x.window === w))
|
|
39
|
+
.find((x) => x !== undefined);
|
|
40
|
+
if (!u || u.resetsAt === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (!Number.isFinite(u.utilization) || u.utilization < 0 || u.utilization > 1)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (!Number.isFinite(u.resetsAt) || !Number.isFinite(nowMs))
|
|
45
|
+
return undefined;
|
|
46
|
+
const remaining = u.resetsAt - nowMs;
|
|
47
|
+
if (remaining <= 0 || remaining > WEEK_MS + PACE_RESET_SLACK_MS)
|
|
48
|
+
return undefined;
|
|
49
|
+
const elapsed = Math.max(0, WEEK_MS - remaining);
|
|
50
|
+
if (elapsed < MIN_PACE_ELAPSED_MS)
|
|
51
|
+
return undefined;
|
|
52
|
+
const expectedPct = elapsed / WEEK_MS;
|
|
53
|
+
const projectedExhaustionAt = u.utilization >= 1
|
|
54
|
+
? nowMs
|
|
55
|
+
: u.utilization > 0 ? nowMs + ((1 - u.utilization) * elapsed) / u.utilization : undefined;
|
|
56
|
+
return {
|
|
57
|
+
window: u.window,
|
|
58
|
+
expectedPct,
|
|
59
|
+
aheadOfPace: u.utilization - expectedPct,
|
|
60
|
+
projectedExhaustionAt,
|
|
61
|
+
willLastToReset: projectedExhaustionAt === undefined || projectedExhaustionAt >= u.resetsAt,
|
|
62
|
+
resetsAt: u.resetsAt,
|
|
63
|
+
estimate: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function renderPaceLine(pace, nowMs) {
|
|
67
|
+
if (!pace)
|
|
68
|
+
return undefined;
|
|
69
|
+
const pts = Math.round(pace.aheadOfPace * 100);
|
|
70
|
+
const where = pts === 0 ? "on pace" : pts > 0 ? `${pts} points ahead of pace` : `${-pts} points behind pace`;
|
|
71
|
+
const verdict = pace.willLastToReset
|
|
72
|
+
? "should last to the reset"
|
|
73
|
+
: `could run out in ${relativeUntil(pace.projectedExhaustionAt ?? nowMs, nowMs)}, before the reset in ${relativeUntil(pace.resetsAt, nowMs)}`;
|
|
74
|
+
return `weekly pace (rough estimate): ~${Math.round(pace.expectedPct * 100)}% of the week gone, ${where}; ${verdict}`;
|
|
75
|
+
}
|
|
33
76
|
export function describeAccount(acc) {
|
|
34
77
|
if (acc.native) {
|
|
35
78
|
return "the machine's main `claude` login (default config dir; OS keychain on macOS)";
|
|
@@ -145,6 +188,9 @@ export function renderExplanation(model) {
|
|
|
145
188
|
const word = VERDICT_WORDS[h.verdict] ?? h.verdict;
|
|
146
189
|
lines.push(` ${h.id}: ${model.accounts.find((a) => a.id === h.id)?.disabled ? "disabled by operator" : word}${h.detail ? ` — ${h.detail}` : ""}`);
|
|
147
190
|
lines.push(` usage: ${h.usage?.length ? renderUsageLine(h.usage, nowMs) : "no live telemetry"}`);
|
|
191
|
+
const paceLine = renderPaceLine(computeWeeklyPace(h.usage, nowMs), nowMs);
|
|
192
|
+
if (paceLine)
|
|
193
|
+
lines.push(` ${paceLine}`);
|
|
148
194
|
}
|
|
149
195
|
if (model.pool) {
|
|
150
196
|
const strategy = poolStrategy(model.pool.strategy);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.6.1",
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
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",
|
package/scripts/_shared.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
14
|
/** Where the plugin is installed: path install wins, else newest npm-project install. */
|
|
15
15
|
export function resolveInstallDir() {
|
|
@@ -50,3 +50,57 @@ export const WATCHDOG_LAUNCHER = join(
|
|
|
50
50
|
"multi-clawd",
|
|
51
51
|
"watchdog-launcher.mjs",
|
|
52
52
|
);
|
|
53
|
+
|
|
54
|
+
/** The default OpenClaw config path, resolved for display (never a literal `~`). */
|
|
55
|
+
export function openclawConfigPath() {
|
|
56
|
+
return join(homedir(), ".openclaw", "openclaw.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One diagnostic for every command that needs the OpenClaw config (#106).
|
|
61
|
+
*
|
|
62
|
+
* `error` is whatever reading or parsing threw. The message names the resolved
|
|
63
|
+
* path and the next step, and keeps the real causes apart: the file is missing
|
|
64
|
+
* (and whether OpenClaw itself looks installed), it is unreadable for this
|
|
65
|
+
* user, it is not a regular file, or it is present but not valid JSON. It never
|
|
66
|
+
* repeats raw Node error text: an fs message is noise to a first-time user, and
|
|
67
|
+
* a JSON SyntaxError quotes a slice of the file, which may hold credentials.
|
|
68
|
+
*
|
|
69
|
+
* A `configPath` other than the default (hermes `--config`) gets a "check the
|
|
70
|
+
* path" step instead of `multi-clawd setup`, which only writes the default;
|
|
71
|
+
* that split applies to the missing and the not-a-regular-file cases alike.
|
|
72
|
+
*/
|
|
73
|
+
export function describeOpenclawConfigError(error, configPath = openclawConfigPath()) {
|
|
74
|
+
const code = typeof error?.code === "string" ? error.code : undefined;
|
|
75
|
+
const isDefault = configPath === openclawConfigPath();
|
|
76
|
+
const at = `OpenClaw config at ${configPath}`;
|
|
77
|
+
if (code === "ENOENT") {
|
|
78
|
+
const dir = dirname(configPath);
|
|
79
|
+
if (isDefault && !existsSync(dir)) {
|
|
80
|
+
return `OpenClaw config not found at ${configPath} (no ${dir} directory). ` +
|
|
81
|
+
"Install and start OpenClaw first, then run `multi-clawd setup`.";
|
|
82
|
+
}
|
|
83
|
+
return `OpenClaw config not found at ${configPath}. ` + (isDefault
|
|
84
|
+
? "Run `multi-clawd setup` to create it."
|
|
85
|
+
: "Check the --config path, or omit it to use the default location.");
|
|
86
|
+
}
|
|
87
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
88
|
+
return `${at} is not readable: permission denied. Run multi-clawd as the user that ` +
|
|
89
|
+
"owns the OpenClaw install, or fix the file's ownership and mode; `multi-clawd setup` cannot fix this.";
|
|
90
|
+
}
|
|
91
|
+
const notRegular = { EISDIR: "it is a directory", ELOOP: "it is a symbolic-link loop",
|
|
92
|
+
ENOTDIR: "a parent in the path is not a directory" }[code];
|
|
93
|
+
if (notRegular) {
|
|
94
|
+
// `multi-clawd setup` writes only the default path, so for a custom
|
|
95
|
+
// `--config` the fix is the named path (or the option), never setup.
|
|
96
|
+
return `${at} is not a regular file (${notRegular}). ` + (isDefault
|
|
97
|
+
? "Move whatever is at that path aside, then run `multi-clawd setup` or restore the OpenClaw config file."
|
|
98
|
+
: "Restore the OpenClaw config file at that path, or correct the --config path (omit it to use the " +
|
|
99
|
+
"default location); `multi-clawd setup` only writes the default path.");
|
|
100
|
+
}
|
|
101
|
+
if (error instanceof SyntaxError) {
|
|
102
|
+
return `${at} is malformed JSON. Fix the file by hand (a JSON validator helps) and retry; ` +
|
|
103
|
+
"`multi-clawd setup` does not repair it.";
|
|
104
|
+
}
|
|
105
|
+
return `${at} could not be read${code ? ` (${code})` : ""}.`;
|
|
106
|
+
}
|
package/scripts/cli.mjs
CHANGED
|
@@ -50,6 +50,27 @@ Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
|
|
|
50
50
|
`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Per-command usage, printed for `<command> --help` / `-h` before anything is
|
|
54
|
+
// dispatched, so asking for help never reads config, runs checks, or starts the
|
|
55
|
+
// setup wizard. `hermes` is absent on purpose: it handles its own help.
|
|
56
|
+
const COMMAND_HELP = {
|
|
57
|
+
setup: "multi-clawd setup [--dry-run]\n\n Guided setup wizard (accounts, pool, watchdog).\n --dry-run preview the changes without writing anything",
|
|
58
|
+
login: "multi-clawd login [<id>]\n\n Log a configured account in (or re-auth it). Without <id>, lists the\n configured accounts to choose from.",
|
|
59
|
+
account: "multi-clawd account <enable|disable> <id> [--dry-run]\n\n Disable or enable a pool account.\n --dry-run preview the change without writing it",
|
|
60
|
+
explain: "multi-clawd explain [--json | --history [--since 7d|24h]]\n\n Your setup in plain English: accounts, pool, fallback chain.\n --json machine-readable output\n --history recent pool events; --since limits the window",
|
|
61
|
+
chain: "multi-clawd chain [--raw]\n\n Audit your model routing: what actually serves each turn.\n --raw include raw routing detail",
|
|
62
|
+
direct: "multi-clawd direct [status | sync [--dry-run] [--yes] [--resync]]\n\n The direct anthropic/* route. Default is status.\n sync store profiles for the direct route\n --dry-run preview without writing\n --yes skip the confirmation prompt\n --resync refresh profiles that are already stored",
|
|
63
|
+
usage: "multi-clawd usage [--json]\n\n Live usage per account from the provider: what is left, and what the\n pool will do.\n --json machine-readable output",
|
|
64
|
+
update: "multi-clawd update\n\n Update the plugin to the latest version.",
|
|
65
|
+
doctor: "multi-clawd doctor [--probe] [--probe-pool] [--verbose] [--raw]\n\n Health check.\n --probe run a live turn per account plus the pool\n --probe-pool with --probe: probe the pool ref only (one turn)\n --verbose show more detail\n --raw show raw output",
|
|
66
|
+
version: "multi-clawd version\n\n Show CLI and installed plugin versions.",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (Object.hasOwn(COMMAND_HELP, cmd) && rest.some((a) => a === "--help" || a === "-h")) {
|
|
70
|
+
console.log(`\n${COMMAND_HELP[cmd]}\n`);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const { resolveInstallDir } = await import(join(__dirname, "_shared.mjs"));
|
|
54
75
|
|
|
55
76
|
function installedVersion(installPath) {
|
|
@@ -168,6 +189,23 @@ function readOpenclawConfigSync() {
|
|
|
168
189
|
}
|
|
169
190
|
}
|
|
170
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The OpenClaw config for a command that cannot run without it (#106). On any
|
|
194
|
+
* read or parse failure, prints the one shared diagnostic — resolved path,
|
|
195
|
+
* what is wrong, next step — on stderr and exits 1. Read-only: never creates
|
|
196
|
+
* or repairs the file.
|
|
197
|
+
*/
|
|
198
|
+
async function requireOpenclawConfig(command) {
|
|
199
|
+
const { describeOpenclawConfigError, openclawConfigPath } = await import(join(__dirname, "_shared.mjs"));
|
|
200
|
+
const path = openclawConfigPath();
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(`${command}: ${describeOpenclawConfigError(err, path)}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
171
209
|
/**
|
|
172
210
|
* Everything the direct-route surfaces need, loaded from dist. None of these
|
|
173
211
|
* modules import the `openclaw` peer, so they load from a global install.
|
|
@@ -247,11 +285,7 @@ async function loadDirect(config) {
|
|
|
247
285
|
async function direct(args = []) {
|
|
248
286
|
const { readFileSync: rf, mkdtempSync, writeFileSync, rmSync, statSync } = await import("node:fs");
|
|
249
287
|
const { homedir, tmpdir } = await import("node:os");
|
|
250
|
-
const config =
|
|
251
|
-
if (!config) {
|
|
252
|
-
console.error("direct: could not read your OpenClaw config");
|
|
253
|
-
process.exit(1);
|
|
254
|
-
}
|
|
288
|
+
const config = await requireOpenclawConfig("direct");
|
|
255
289
|
const ctx = await loadDirect(config);
|
|
256
290
|
const sub = args.find((a) => !a.startsWith("--")) ?? "status";
|
|
257
291
|
console.log(`\n${BOLD}🦞 multi-clawd — direct anthropic/* route${RESET}\n`);
|
|
@@ -376,13 +410,7 @@ async function chain(args = []) {
|
|
|
376
410
|
process.exit(1);
|
|
377
411
|
}
|
|
378
412
|
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
382
|
-
} catch {
|
|
383
|
-
console.error("chain: could not read ~/.openclaw/openclaw.json");
|
|
384
|
-
process.exit(1);
|
|
385
|
-
}
|
|
413
|
+
const config = await requireOpenclawConfig("chain");
|
|
386
414
|
|
|
387
415
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
388
416
|
const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
|
|
@@ -677,11 +705,12 @@ async function healWatchdogUnit() {
|
|
|
677
705
|
/** `explain` — gather config + live health, render the plain-English view. */
|
|
678
706
|
async function explain(args = []) {
|
|
679
707
|
let historySince;
|
|
680
|
-
|
|
708
|
+
const asJson = args.length === 1 && args[0] === "--json";
|
|
709
|
+
if (args.length && !asJson) {
|
|
681
710
|
const allowed = args[0] === "--history" && (args.length === 1 ||
|
|
682
711
|
(args.length === 3 && args[1] === "--since"));
|
|
683
712
|
if (!allowed) {
|
|
684
|
-
console.error("explain: usage: multi-clawd explain [--history [--since 7d|24h]]");
|
|
713
|
+
console.error("explain: usage: multi-clawd explain [--json | --history [--since 7d|24h]]");
|
|
685
714
|
process.exitCode = 1;
|
|
686
715
|
return;
|
|
687
716
|
}
|
|
@@ -709,13 +738,7 @@ async function explain(args = []) {
|
|
|
709
738
|
console.error(distFailure("explain", "explain-core.js", err));
|
|
710
739
|
process.exit(1);
|
|
711
740
|
}
|
|
712
|
-
|
|
713
|
-
try {
|
|
714
|
-
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
715
|
-
} catch {
|
|
716
|
-
console.error("explain: could not read ~/.openclaw/openclaw.json");
|
|
717
|
-
process.exit(1);
|
|
718
|
-
}
|
|
741
|
+
const config = await requireOpenclawConfig("explain");
|
|
719
742
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
720
743
|
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
721
744
|
const pool = pc.pool
|
|
@@ -775,6 +798,14 @@ async function explain(args = []) {
|
|
|
775
798
|
/* no journal yet: every account counts as never served */
|
|
776
799
|
}
|
|
777
800
|
}
|
|
801
|
+
if (asJson) {
|
|
802
|
+
// #94: machine-readable health with the display-only weekly pace estimate.
|
|
803
|
+
console.log(JSON.stringify({
|
|
804
|
+
at: new Date(now).toISOString(),
|
|
805
|
+
accounts: healthRows.map((r) => ({ ...r, weeklyPace: ec.computeWeeklyPace(r.usage, now) ?? null })),
|
|
806
|
+
}, null, 2));
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
778
809
|
// v1.9: the direct anthropic/* route, when any account opted in.
|
|
779
810
|
let direct;
|
|
780
811
|
const dctx = await loadDirect(config);
|
|
@@ -814,11 +845,7 @@ async function usageCommand(args) {
|
|
|
814
845
|
console.error(distFailure("usage", "usage-poll.js", err));
|
|
815
846
|
process.exit(1);
|
|
816
847
|
}
|
|
817
|
-
const config =
|
|
818
|
-
if (!config) {
|
|
819
|
-
console.error("usage: could not read ~/.openclaw/openclaw.json");
|
|
820
|
-
process.exit(1);
|
|
821
|
-
}
|
|
848
|
+
const config = await requireOpenclawConfig("usage");
|
|
822
849
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
823
850
|
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
824
851
|
const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
|
|
@@ -954,13 +981,7 @@ async function login() {
|
|
|
954
981
|
console.error(distFailure("login", "login-plan.js", err));
|
|
955
982
|
process.exit(1);
|
|
956
983
|
}
|
|
957
|
-
|
|
958
|
-
try {
|
|
959
|
-
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
960
|
-
} catch {
|
|
961
|
-
console.error("login: could not read ~/.openclaw/openclaw.json — run `multi-clawd setup` first.");
|
|
962
|
-
process.exit(1);
|
|
963
|
-
}
|
|
984
|
+
const config = await requireOpenclawConfig("login");
|
|
964
985
|
const accounts = config?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
|
|
965
986
|
if (accounts.length === 0) {
|
|
966
987
|
console.error("login: no multi-clawd accounts configured — run `multi-clawd setup` first.");
|
|
@@ -1050,6 +1071,9 @@ switch (cmd) {
|
|
|
1050
1071
|
process.exitCode = 1;
|
|
1051
1072
|
break;
|
|
1052
1073
|
}
|
|
1074
|
+
// Same missing/unreadable/malformed diagnostic as every other config
|
|
1075
|
+
// reader (#106), instead of the raw fs error changeAccount would throw.
|
|
1076
|
+
await requireOpenclawConfig("account");
|
|
1053
1077
|
try {
|
|
1054
1078
|
const { changeAccount } = await import("./account-config.mjs");
|
|
1055
1079
|
const result = changeAccount(id, action === "disable", flags.includes("--dry-run"));
|
|
@@ -1085,7 +1109,8 @@ switch (cmd) {
|
|
|
1085
1109
|
break;
|
|
1086
1110
|
case "version":
|
|
1087
1111
|
case "--version":
|
|
1088
|
-
case "-v":
|
|
1112
|
+
case "-v":
|
|
1113
|
+
case "-V": {
|
|
1089
1114
|
const cliVersion = JSON.parse(
|
|
1090
1115
|
readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
|
|
1091
1116
|
).version;
|
|
@@ -1098,7 +1123,14 @@ switch (cmd) {
|
|
|
1098
1123
|
if (skewNote) console.log(`\n⚠️ ${skewNote}`);
|
|
1099
1124
|
break;
|
|
1100
1125
|
}
|
|
1101
|
-
default:
|
|
1126
|
+
default: {
|
|
1127
|
+
const wantsHelp = cmd === undefined || cmd === "help" || cmd === "--help" || cmd === "-h";
|
|
1128
|
+
if (!wantsHelp) {
|
|
1129
|
+
// Say what went wrong on stderr, then show the list and exit non-zero.
|
|
1130
|
+
console.error(`multi-clawd: unknown command '${cmd}'`);
|
|
1131
|
+
console.error("Run 'multi-clawd --help' for the list of commands.");
|
|
1132
|
+
}
|
|
1102
1133
|
usage();
|
|
1103
|
-
process.exit(
|
|
1134
|
+
process.exit(wantsHelp ? 0 : 1);
|
|
1135
|
+
}
|
|
1104
1136
|
}
|
package/scripts/hermes.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { describeOpenclawConfigError } from "./_shared.mjs";
|
|
18
19
|
|
|
19
20
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const BRIDGE = join(HERE, "hermes_bridge.py");
|
|
@@ -56,6 +57,17 @@ function safePath(raw, label) {
|
|
|
56
57
|
|
|
57
58
|
class ReadLimitedError extends Error {}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A directory at the path. Carries EISDIR, the code `readFileSync` raises for
|
|
62
|
+
* it, so a caller's `describe` (the shared #106 diagnostic) names the cause
|
|
63
|
+
* and the next step instead of the generic size-cap line; without `describe`
|
|
64
|
+
* it still fails closed with that line. Other non-regular files keep the
|
|
65
|
+
* generic guard: nothing is read from them either way.
|
|
66
|
+
*/
|
|
67
|
+
class NotRegularFileError extends ReadLimitedError {
|
|
68
|
+
code = "EISDIR";
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
/**
|
|
60
72
|
* `requirePrivate` rejects a POSIX file readable or writable by group/other
|
|
61
73
|
* (mode & 0o077 != 0) — checked via fstat on the already-open fd, so nothing
|
|
@@ -63,11 +75,12 @@ class ReadLimitedError extends Error {}
|
|
|
63
75
|
* bit layout, so the check is skipped there; the config JSON is read through
|
|
64
76
|
* here too but never with this flag, since it holds no secret.
|
|
65
77
|
*/
|
|
66
|
-
function readLimited(path, limit, label, { requirePrivate = false } = {}) {
|
|
78
|
+
function readLimited(path, limit, label, { requirePrivate = false, describe } = {}) {
|
|
67
79
|
let fd;
|
|
68
80
|
try {
|
|
69
81
|
fd = openSync(path, "r");
|
|
70
82
|
const stat = fstatSync(fd);
|
|
83
|
+
if (stat.isDirectory()) throw new NotRegularFileError(`${label} is unavailable or too large`);
|
|
71
84
|
if (!stat.isFile() || stat.size > limit) throw new ReadLimitedError(`${label} is unavailable or too large`);
|
|
72
85
|
if (requirePrivate && process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
|
73
86
|
throw new ReadLimitedError(
|
|
@@ -78,7 +91,9 @@ function readLimited(path, limit, label, { requirePrivate = false } = {}) {
|
|
|
78
91
|
if (data.byteLength > limit) throw new ReadLimitedError(`${label} is unavailable or too large`);
|
|
79
92
|
return data.toString("utf8");
|
|
80
93
|
} catch (error) {
|
|
94
|
+
if (error instanceof NotRegularFileError && describe) fail(describe(error));
|
|
81
95
|
if (error instanceof ReadLimitedError) fail(error.message);
|
|
96
|
+
if (describe) fail(describe(error));
|
|
82
97
|
fail(`${label} could not be read`);
|
|
83
98
|
} finally {
|
|
84
99
|
if (fd !== undefined) closeSync(fd);
|
|
@@ -254,12 +269,15 @@ function invokeBridge(hermes, home, request) {
|
|
|
254
269
|
}
|
|
255
270
|
|
|
256
271
|
function loadPluginConfig(configPath) {
|
|
257
|
-
|
|
272
|
+
// Same resolved-path + next-step diagnostic as the cli.mjs commands (#106).
|
|
273
|
+
const text = readLimited(configPath, MAX_CONFIG_BYTES, "OpenClaw config", {
|
|
274
|
+
describe: (error) => describeOpenclawConfigError(error, configPath),
|
|
275
|
+
});
|
|
258
276
|
let config;
|
|
259
277
|
try {
|
|
260
278
|
config = JSON.parse(text);
|
|
261
|
-
} catch {
|
|
262
|
-
fail(
|
|
279
|
+
} catch (error) {
|
|
280
|
+
fail(describeOpenclawConfigError(error, configPath));
|
|
263
281
|
}
|
|
264
282
|
return config?.plugins?.entries?.["multi-clawd"]?.config;
|
|
265
283
|
}
|