@drakon-systems/multi-clawd 2.6.0 → 2.6.2

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
@@ -416,7 +416,7 @@ openclaw plugins install (Get-Location).Path
416
416
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
417
417
  Code on the target machine already? Paste it this and go make coffee:
418
418
 
419
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.0/SETUP-AGENT.md
419
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.2/SETUP-AGENT.md
420
420
  > and follow it to set up multi-clawd on this machine. I own a second
421
421
  > Claude account — ask me when you need me to log in.
422
422
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.6.0",
4
+ "version": "2.6.2",
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.6.0",
3
+ "version": "2.6.2",
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",
@@ -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
@@ -189,6 +189,23 @@ function readOpenclawConfigSync() {
189
189
  }
190
190
  }
191
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
+
192
209
  /**
193
210
  * Everything the direct-route surfaces need, loaded from dist. None of these
194
211
  * modules import the `openclaw` peer, so they load from a global install.
@@ -268,11 +285,7 @@ async function loadDirect(config) {
268
285
  async function direct(args = []) {
269
286
  const { readFileSync: rf, mkdtempSync, writeFileSync, rmSync, statSync } = await import("node:fs");
270
287
  const { homedir, tmpdir } = await import("node:os");
271
- const config = readOpenclawConfigSync();
272
- if (!config) {
273
- console.error("direct: could not read your OpenClaw config");
274
- process.exit(1);
275
- }
288
+ const config = await requireOpenclawConfig("direct");
276
289
  const ctx = await loadDirect(config);
277
290
  const sub = args.find((a) => !a.startsWith("--")) ?? "status";
278
291
  console.log(`\n${BOLD}🦞 multi-clawd — direct anthropic/* route${RESET}\n`);
@@ -397,13 +410,7 @@ async function chain(args = []) {
397
410
  process.exit(1);
398
411
  }
399
412
 
400
- let config;
401
- try {
402
- config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
403
- } catch {
404
- console.error("chain: could not read ~/.openclaw/openclaw.json");
405
- process.exit(1);
406
- }
413
+ const config = await requireOpenclawConfig("chain");
407
414
 
408
415
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
409
416
  const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
@@ -731,13 +738,7 @@ async function explain(args = []) {
731
738
  console.error(distFailure("explain", "explain-core.js", err));
732
739
  process.exit(1);
733
740
  }
734
- let config = {};
735
- try {
736
- config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
737
- } catch {
738
- console.error("explain: could not read ~/.openclaw/openclaw.json");
739
- process.exit(1);
740
- }
741
+ const config = await requireOpenclawConfig("explain");
741
742
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
742
743
  const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
743
744
  const pool = pc.pool
@@ -844,11 +845,7 @@ async function usageCommand(args) {
844
845
  console.error(distFailure("usage", "usage-poll.js", err));
845
846
  process.exit(1);
846
847
  }
847
- const config = readOpenclawConfigSync();
848
- if (!config) {
849
- console.error("usage: could not read ~/.openclaw/openclaw.json");
850
- process.exit(1);
851
- }
848
+ const config = await requireOpenclawConfig("usage");
852
849
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
853
850
  const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
854
851
  const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
@@ -984,13 +981,7 @@ async function login() {
984
981
  console.error(distFailure("login", "login-plan.js", err));
985
982
  process.exit(1);
986
983
  }
987
- let config = {};
988
- try {
989
- config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
990
- } catch {
991
- console.error("login: could not read ~/.openclaw/openclaw.json — run `multi-clawd setup` first.");
992
- process.exit(1);
993
- }
984
+ const config = await requireOpenclawConfig("login");
994
985
  const accounts = config?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
995
986
  if (accounts.length === 0) {
996
987
  console.error("login: no multi-clawd accounts configured — run `multi-clawd setup` first.");
@@ -1080,6 +1071,9 @@ switch (cmd) {
1080
1071
  process.exitCode = 1;
1081
1072
  break;
1082
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");
1083
1077
  try {
1084
1078
  const { changeAccount } = await import("./account-config.mjs");
1085
1079
  const result = changeAccount(id, action === "disable", flags.includes("--dry-run"));
@@ -1115,7 +1109,8 @@ switch (cmd) {
1115
1109
  break;
1116
1110
  case "version":
1117
1111
  case "--version":
1118
- case "-v": {
1112
+ case "-v":
1113
+ case "-V": {
1119
1114
  const cliVersion = JSON.parse(
1120
1115
  readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
1121
1116
  ).version;
@@ -1128,7 +1123,14 @@ switch (cmd) {
1128
1123
  if (skewNote) console.log(`\n⚠️ ${skewNote}`);
1129
1124
  break;
1130
1125
  }
1131
- 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
+ }
1132
1133
  usage();
1133
- process.exit(cmd === undefined || cmd === "help" || cmd === "--help" ? 0 : 1);
1134
+ process.exit(wantsHelp ? 0 : 1);
1135
+ }
1134
1136
  }
@@ -1055,17 +1055,35 @@ if (accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) {
1055
1055
  // ── 9. watchdog ─────────────────────────────────────────────────────────────
1056
1056
  console.log("eviction watchdog");
1057
1057
  let watchdogFound = false;
1058
+ // A scheduler query that FAILS (no user session bus, no systemd, container)
1059
+ // says nothing about whether a watchdog exists. Only a query that succeeded
1060
+ // can confirm absence. Tool stderr is never inherited: it is raw noise.
1061
+ let watchdogQueried = false;
1062
+ const WATCHDOG_NAME = /multi-?clawd|eviction/;
1058
1063
  try {
1059
- const out = execFileSync("launchctl", ["list"], { encoding: "utf8" });
1064
+ const out = execFileSync("launchctl", ["list"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1065
+ watchdogQueried = true;
1060
1066
  if (out.includes("multiclawd")) watchdogFound = true;
1061
1067
  } catch {
1062
- /* not macOS */
1068
+ /* not macOS, or launchctl could not be queried */
1063
1069
  }
1064
1070
  try {
1065
- const out = execFileSync("systemctl", ["--user", "list-timers", "--all"], { encoding: "utf8" });
1066
- if (out.includes("multi-clawd") || out.includes("multiclawd") || out.includes("eviction")) watchdogFound = true;
1071
+ const out = execFileSync("systemctl", ["--user", "list-timers", "--all"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1072
+ watchdogQueried = true;
1073
+ if (WATCHDOG_NAME.test(out)) watchdogFound = true;
1067
1074
  } catch {
1068
- /* not systemd */
1075
+ /* not systemd, or the user manager could not be queried */
1076
+ }
1077
+ if (!watchdogFound && !watchdogQueried) {
1078
+ // Query unavailable: fall back to the unit files on disk, which need no
1079
+ // running manager.
1080
+ for (const d of [join(HOME, "Library", "LaunchAgents"), join(HOME, ".config", "systemd", "user")]) {
1081
+ try {
1082
+ if (readdirSync(d).some((f) => /\.(plist|service|timer)$/.test(f) && WATCHDOG_NAME.test(f))) watchdogFound = true;
1083
+ } catch {
1084
+ /* no such directory */
1085
+ }
1086
+ }
1069
1087
  }
1070
1088
  if (watchdogFound) {
1071
1089
  // "Scheduled" is not enough: the unit points at a script INSIDE an install
@@ -1108,7 +1126,11 @@ if (watchdogFound) {
1108
1126
  `watchdog unit ${orphan.file} points at a MISSING script (${orphan.target}) — it fails silently every tick. Repoint it at ${join(EXT_DIR, "scripts", "eviction-watchdog.mjs")} or run the setup wizard to repair.`,
1109
1127
  );
1110
1128
  } else ok("watchdog scheduled");
1111
- } else warn("no watchdog found (needed until openclaw#107596 ships — see README)");
1129
+ } else if (watchdogQueried) {
1130
+ warn("no watchdog found (needed until openclaw#107596 ships — see README)");
1131
+ } else {
1132
+ note("could not query the scheduler (no user session or no systemd/launchd) and found no watchdog unit file — watchdog state unknown. Run doctor from a normal login session to confirm.");
1133
+ }
1112
1134
 
1113
1135
  // ── 10. optional live probe ─────────────────────────────────────────────────
1114
1136
  if (args.has("--probe")) {
@@ -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
- const text = readLimited(configPath, MAX_CONFIG_BYTES, "OpenClaw config");
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("OpenClaw config is malformed JSON");
279
+ } catch (error) {
280
+ fail(describeOpenclawConfigError(error, configPath));
263
281
  }
264
282
  return config?.plugins?.entries?.["multi-clawd"]?.config;
265
283
  }