@cldmv/gh-broker 0.10.1 → 0.10.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
@@ -19,18 +19,18 @@ It runs as a dedicated `gh-broker` system user, holds a read-scoped and a write-
19
19
 
20
20
  ## ✨ What's New
21
21
 
22
- ### Latest: v0.10.1 (November 2026)
22
+ ### Latest: v0.10.2 (November 2026)
23
23
 
24
- - **The OTP gate says why it refused a code.** A wrong code now reads "not accepted", with the bad-code count and the lockout length, and a reused code says it's spent, so the agent can tell you what happened instead of just asking again.
25
- - **`report_dependents` lists dev branches on large orgs.** The GraphQL listing pages at 25 repos and backs off when GitHub times out, instead of failing with a raw 502.
26
- - [View full v0.10.1 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.1.md)
24
+ - **The MCP entry reaches every Claude config folder an agent uses.** Sessions started with `CLAUDE_CONFIG_DIR` (like `~/.claude-secondary`) were left on the removed shared socket. `install` and `agent sync` now update every `~/.claude*` folder in the agent's home, the folder of each running session, and folders saved with `--claude-config`.
25
+ - **Smaller package.** The 1.4 MB source map no longer ships, and the bundle is built minified with tsup, taking the package from 3.2 MB to about 780 kB unpacked. The Node.js floor is back to 22.0.0.
26
+ - [View full v0.10.2 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.2.md)
27
27
 
28
28
  ### Recent Releases
29
29
 
30
+ - **v0.10.1** (November 2026) — the OTP gate says why it refused a code, and `report_dependents` lists dev branches on large orgs without a 502 ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.1.md))
30
31
  - **v0.10.0** (November 2026) — OTP gate for risky write tools, one socket per agent with the `gh-broker` command, eleven new write tools and a built-in deny list ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.0.md))
31
32
  - **v0.9.0** (November 2026) — `report_alert_status`, `report_release_changes` and `report_dependents` for the release and fleet skills, `ciVerbosity` on `list_pull_requests`, and install from npm ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.9.0.md))
32
33
  - **v0.8.0** (November 2026) — `npm_scaffold` creates an npm package and its trusted publisher, `ghcr_push` pushes images without an agent-held token, and optional credentials hide their tools ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.8.0.md))
33
- - **v0.7.0** (October 2026) — `issue_labels` adds, removes or swaps single labels without replacing the set, and `issue_write` warns that its `labels` does ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.7.0.md))
34
34
 
35
35
  📚 For the complete version history, see the [docs/changelog/](https://github.com/CLDMV/gh-broker/tree/master/docs/changelog/) folder.
36
36
 
@@ -143,7 +143,7 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
143
143
  ### Requirements
144
144
 
145
145
  - **Linux with systemd.** The broker runs as a hardened systemd service and listens on a Unix socket.
146
- - **Node.js 22.12.0 or later** (`engines.node` is `>=22.12.0`). The installer points `/usr/local/bin/node` at the node it runs under.
146
+ - **Node.js 22.0.0 or later** (`engines.node` is `>=22.0.0`; development needs 22.22.0+). The installer points `/usr/local/bin/node` at the node it runs under.
147
147
  - **The agent runs as its own Linux user**, without passwordless sudo, so it can't read the broker's secrets. The model behind that is in [docs/agent-user-model.md](docs/agent-user-model.md).
148
148
  - **Two fine-grained PATs per org** (read and write). The scopes are listed in [deploy/README.md](deploy/README.md).
149
149
 
package/deploy/README.md CHANGED
@@ -148,7 +148,17 @@ sudo gh-broker agent sync [<user>] # re-apply the MCP config for one agent or
148
148
  }
149
149
  ```
150
150
 
151
- If an existing entry already mentions `/run/gh-broker/` (the old shared socket or the new one) that entry is updated in place and keeps its name; otherwise the entry is added as `github`, or `gh-broker` when that name is taken. Nothing else in the file changes. The file is parsed, edited, written to a temp file in the same directory and renamed, keeping its owner and mode, and `~/.claude.json.gh-broker.bak` keeps the previous copy (overwritten each time). A missing file is created `0600`; a file that doesn't parse is left alone and the entry is printed to add by hand. The home directory belongs to the agent user and the command runs as root, so it reads the file without following a symlink and only touches a plain file the user owns with a single link; anything else (a symlink, a hard link, someone else's file) is left alone with a warning, and the temp file is created with a random name that can't be pre-planted. Running Claude sessions for that user must be restarted to pick the change up, and `agent sync <user>` puts the entry back if a running session overwrote the file. `--no-mcp-config` (on `install`, `agent add` and `agent sync`) skips the write and prints the entry.
151
+ If an existing entry already mentions `/run/gh-broker/` (the old shared socket or the new one) that entry is updated in place and keeps its name; otherwise the entry is added as `github`, or `gh-broker` when that name is taken. Nothing else in the file changes. The file is parsed, edited, written to a temp file in the same directory and renamed, keeping its owner and mode, and `~/.claude.json.gh-broker.bak` keeps the previous copy (overwritten each time). A missing file is created `0600`; a file that doesn't parse is left alone and the entry is printed to add by hand. The home directory belongs to the agent user and the command runs as root, so it reads the file without following a symlink and only touches a plain file the user owns with a single link; anything else (a symlink, a hard link, someone else's file) is left alone with a warning, and the temp file is created with a random name that can't be pre-planted. Running Claude sessions for that user must be restarted to pick the change up, and `agent sync <user>` puts the entry back if a running session overwrote the file.
152
+
153
+ **Other Claude config folders.** A Claude session started with `CLAUDE_CONFIG_DIR` reads `$CLAUDE_CONFIG_DIR/.claude.json` and ignores `~/.claude.json`, so the entry also goes into the `.claude.json` of the agent's other Claude config folders. Only registered agents' folders are looked at:
154
+
155
+ - each `~/.claude*` directory in the agent's home;
156
+ - the `CLAUDE_CONFIG_DIR` of each `claude` process the agent is running, as long as it resolves inside the agent's home;
157
+ - folders saved for the agent with `--claude-config` (`sudo gh-broker agent add|sync <user> --claude-config <dir>`, or `sudo gh-broker install --claude-config <user>:<dir>`). They're kept in `claudeConfigDirs` in the broker config, so every later sync updates them. This is how a folder outside the home directory is added.
158
+
159
+ In these folders the file is only updated when it already exists (Claude Code creates it on first run). The folder must be a real directory the agent owns, not a symlink; anything else is skipped with a warning and doesn't fail the run. The folder is opened once and the file is edited through that open folder, so it can't be swapped mid-way. A session folder outside the home directory is skipped, because owning a folder doesn't mean the agent can reach it; save it with `--claude-config` if it's meant to be used. `agent remove` cleans the entry out of every folder it finds, and forgets the saved ones. A session folder that isn't running at that moment and isn't saved keeps its entry, which then points at a closed socket.
160
+
161
+ `--no-mcp-config` (on `install`, `agent add` and `agent sync`) skips the write and prints the entry.
152
162
 
153
163
  **Upgrading from the shared socket.** The first `gh-broker install` after the change registers every member of `gh-broker-clients` (other than `gh-broker`) as an agent, writes the new `listen` (`agents:/run/gh-broker/agents`), rewrites each agent's MCP config to its own socket and smoke-tests every socket. `gh-broker-clients` is left in place; nothing depends on it any more. Agents added later need a service restart, which `agent add` does.
154
164
 
@@ -88,7 +88,11 @@ Flags:
88
88
  Each agent gets its own socket and MCP config. Optional on an
89
89
  upgrade (registered agents are kept); a first install with none
90
90
  asks (never $SUDO_USER)
91
- --no-mcp-config do not write any agent's ~/.claude.json; print the entry instead
91
+ --no-mcp-config do not write any agent's Claude MCP config; print the entry instead
92
+ --claude-config <user>:<dir>
93
+ another Claude config folder (CLAUDE_CONFIG_DIR) that agent uses,
94
+ kept in the config so every sync updates it (repeatable). Folders
95
+ in the agent's home are found without it
92
96
  --secrets-provider <p> systemd-creds | file (skips the prompt)
93
97
  --secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
94
98
  --secret <name>=<tok> one token on the command line (repeatable). Visible in
@@ -16,26 +16,108 @@
16
16
  /**
17
17
  * `gh-broker agent add | remove | list | sync` (#143, contract v2). An agent is a Linux group
18
18
  * `gh-broker-agent-<u>` (the registry), one socket per agent in the daemon's agents directory,
19
- * and an entry in the agent user's Claude Code MCP config. Every effect is injected.
19
+ * and an entry in the agent user's Claude Code MCP config: `~/.claude.json`, plus the
20
+ * `.claude.json` in each other Claude config folder the user has (#163). Every effect is injected.
20
21
  */
21
22
  import { agentGroup, agentsDir, assertAgentName, registeredAgents, socketPath, versionWarning } from "./agents.mjs";
22
- import { applyMcpConfig, mcpEntry, mcpFileStatus, removeMcpConfig, MCP_BACKUP, MCP_FILE } from "./mcp-config.mjs";
23
+ import { applyMcpConfig, mcpEntry, mcpFileStatus, removeMcpConfig, SkippedFolder, MCP_BACKUP, MCP_FILE } from "./mcp-config.mjs";
23
24
  import { statePathOf } from "./otp-cli.mjs";
25
+ import { absoluteFolders, addClaudeConfigDirs } from "./config-merge.mjs";
24
26
  import { spendRecentCodes, withoutUser } from "./otp.mjs";
25
27
 
26
28
  /** Run one `agent` subcommand. Returns the exit code; throws on a failure the dispatcher reports. */
27
29
  export async function agentCommand(command, rest, config, d) {
28
- const flags = rest.filter((a) => a.startsWith("-"));
29
- const names = rest.filter((a) => !a.startsWith("-"));
30
- const noMcp = flags.includes("--no-mcp-config");
30
+ const { names, noMcp, claudeConfigs } = parseAgentArgs(rest);
31
+ if (claudeConfigs.length && !["add", "sync"].includes(command)) throw new Error("--claude-config only goes with agent add or agent sync");
31
32
  if (command === "list") return list(config, d);
32
- if (command === "sync") return sync(config, names[0], noMcp, d);
33
+ if (command === "sync") {
34
+ if (claudeConfigs.length && names.length !== 1) throw new Error("--claude-config needs the agent named: sudo gh-broker agent sync <user> --claude-config <dir>");
35
+ if (claudeConfigs.length) {
36
+ assertAgentName(names[0]);
37
+ if (!registry(d).some((a) => a.user === names[0])) throw new Error(`"${names[0]}" is not a registered agent`);
38
+ config = rememberFolders(config, names[0], claudeConfigs, d);
39
+ }
40
+ return sync(config, names[0], noMcp, d);
41
+ }
33
42
  if (names.length !== 1) throw new Error(`name the agent user: sudo gh-broker agent ${command} <user>`);
34
- return command === "add" ? add(config, names[0], noMcp, d) : remove(config, names[0], d);
43
+ if (command === "add") {
44
+ assertAgentName(names[0]);
45
+ if (!d.userInfo(names[0])) throw new Error(`user "${names[0]}" does not exist; create it first (the installer can: sudo gh-broker install --agent-user ${names[0]} --create-agent-user)`);
46
+ if (claudeConfigs.length) config = rememberFolders(config, names[0], claudeConfigs, d);
47
+ return add(config, names[0], noMcp, d);
48
+ }
49
+ return remove(config, names[0], d);
50
+ }
51
+
52
+ /** Split `agent` arguments into names and flags; `--claude-config <dir>` (or `=<dir>`) is repeatable. */
53
+ function parseAgentArgs(rest) {
54
+ const names = [];
55
+ const claudeConfigs = [];
56
+ let noMcp = false;
57
+ for (let i = 0; i < rest.length; i++) {
58
+ const a = rest[i];
59
+ if (a === "--no-mcp-config") noMcp = true;
60
+ else if (a === "--claude-config" || a.startsWith("--claude-config=")) {
61
+ const dir = a === "--claude-config" ? rest[++i] : a.slice("--claude-config=".length);
62
+ if (!dir?.startsWith("/")) throw new Error("--claude-config takes an absolute folder: --claude-config /path/to/claude-config");
63
+ claudeConfigs.push(dir.replace(/(.)\/+$/, "$1"));
64
+ } else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`);
65
+ else names.push(a);
66
+ }
67
+ return { names, noMcp, claudeConfigs };
68
+ }
69
+
70
+ /** Add `dirs` to `user`'s saved Claude config folders and write the broker config. Returns the new config. */
71
+ function rememberFolders(config, user, dirs, d) {
72
+ const next = { ...config, claudeConfigDirs: addClaudeConfigDirs(config.claudeConfigDirs, { [user]: dirs }) };
73
+ d.writeConfig(next);
74
+ d.log(`${user}: Claude config folders kept in the broker config: ${next.claudeConfigDirs[user].join(", ")}`);
75
+ return next;
35
76
  }
36
77
 
37
78
  const registry = (d) => registeredAgents(d.groups());
38
79
 
80
+ /**
81
+ * The agent's Claude config folders other than the home directory (#163): the ones saved for it
82
+ * with --claude-config, its `~/.claude*` folders, and the `CLAUDE_CONFIG_DIR` of its running
83
+ * `claude` sessions. Only these users' own folders are looked at, never anyone else's. Saved
84
+ * folders were named by root, so they may be outside the home directory; found ones may not.
85
+ * @returns {Array<{ folder: string, saved: boolean }>}
86
+ */
87
+ function extraFolders(info, d, config, user) {
88
+ const home = info.home.replace(/\/+$/, "");
89
+ const saved = absoluteFolders(config.claudeConfigDirs?.[user]);
90
+ const found = [...(d.mcpIO.scan?.(info.home) ?? []), ...(d.mcpIO.sessions?.(info.uid) ?? [])];
91
+ const seen = new Set([home, info.home]);
92
+ const out = [];
93
+ for (const [list, isSaved] of [[saved, true], [found, false]]) {
94
+ for (const folder of list) {
95
+ if (seen.has(folder)) continue;
96
+ seen.add(folder);
97
+ out.push({ folder, saved: isSaved });
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Run `fn(io)` for the files in `folder`. The home directory uses the io as is; any other folder is
105
+ * pinned first (it must be a real directory the agent owns, and unless it was saved with
106
+ * --claude-config, inside the agent's home). Returns undefined when the folder is gone.
107
+ */
108
+ function inFolder({ folder, saved }, info, d, fn) {
109
+ if (folder === info.home || !d.mcpIO.pin) return fn(d.mcpIO);
110
+ const pinned = d.mcpIO.pin(folder, info.uid, saved ? undefined : info.home);
111
+ if (!pinned) return undefined;
112
+ try {
113
+ return fn(pinned.io);
114
+ } finally {
115
+ pinned.close();
116
+ }
117
+ }
118
+
119
+ const BAD = ["failed", "unsafe", "unreadable"];
120
+
39
121
  /**
40
122
  * Write (or, with `noMcp`, just print) one agent's MCP config and explain the restart. Shared with
41
123
  * the installer. Returns the lines it logged through `d.log`.
@@ -49,16 +131,41 @@ export function configureMcp(user, config, d, { noMcp = false } = {}) {
49
131
  d.log(JSON.stringify({ github: entry }, null, "\t"));
50
132
  return "skipped";
51
133
  }
134
+ const home = configureFolder(user, { folder: info.home, saved: false }, info, dir, entry, d);
135
+ let worst = home;
136
+ for (const place of extraFolders(info, d, config, user)) {
137
+ const r = configureFolder(user, place, info, dir, entry, d);
138
+ if (BAD.includes(r) && !BAD.includes(worst)) worst = r;
139
+ }
140
+ d.log(`${user}: restart the user's running Claude sessions to pick it up. If a running session overwrites the file, "sudo gh-broker agent sync ${user}" puts it back.`);
141
+ return worst;
142
+ }
143
+
144
+ /**
145
+ * Write the entry in one folder's `.claude.json` and log the outcome. The home directory's file is
146
+ * created when missing; another folder is only updated when it already has one, since Claude
147
+ * Code writes that file itself in any folder it uses.
148
+ */
149
+ function configureFolder(user, place, info, dir, entry, d) {
150
+ const { folder } = place;
151
+ const isHome = folder === info.home;
152
+ const file = isHome ? "the user's ~/.claude.json" : `${folder}/${MCP_FILE}`;
153
+ const where = isHome ? "" : ` in ${folder}/${MCP_FILE}`;
52
154
  let r;
53
155
  try {
54
- r = applyMcpConfig({ home: info.home, user, dir, owner: { uid: info.uid, gid: info.gid } }, d.mcpIO);
156
+ r = inFolder(place, info, d, (io) => applyMcpConfig({ folder, user, dir, owner: { uid: info.uid, gid: info.gid }, create: isHome }, io)) ?? { status: "no-file" };
55
157
  } catch (err) {
56
- // The home directory is the agent's, so a write there can fail on anything it puts in the
57
- // way (a directory where the backup goes, say). Report it; it must not stop the caller.
58
- d.error(`${user}: could not write the MCP config: ${err.message}. Add this under "mcpServers" in the user's ~/.claude.json by hand:`);
158
+ if (err instanceof SkippedFolder) {
159
+ d.error(`${user}: skipped ${err.message}`);
160
+ return "skipped";
161
+ }
162
+ // Root is working in a folder the agent owns, so a write can fail on anything the agent puts
163
+ // in the way (a directory where the backup goes). Report it; it must not stop the caller.
164
+ d.error(`${user}: could not write the MCP config${where}: ${err.message}. Add this under "mcpServers" in ${file} by hand:`);
59
165
  d.log(JSON.stringify({ github: entry }, null, "\t"));
60
166
  return "failed";
61
167
  }
168
+ if (r.status === "no-file") return "no-file";
62
169
  if (r.status === "unsafe") {
63
170
  d.error(`${user}: ${r.reason}, so it was left alone (root won't read or replace it). Make it a plain file the user owns and run "sudo gh-broker agent sync ${user}", or add this under "mcpServers" by hand:`);
64
171
  d.log(JSON.stringify({ github: entry }, null, "\t"));
@@ -70,7 +177,6 @@ export function configureMcp(user, config, d, { noMcp = false } = {}) {
70
177
  return "unreadable";
71
178
  }
72
179
  d.log(`${user}: MCP config ${r.status === "unchanged" ? "already up to date" : r.status} in ${r.path} (entry "${r.key}")${r.status === "written" ? `; previous file saved as ${MCP_BACKUP}` : ""}`);
73
- d.log(`${user}: restart the user's running Claude sessions to pick it up. If a running session overwrites the file, "sudo gh-broker agent sync ${user}" puts it back.`);
74
180
  return r.status;
75
181
  }
76
182
 
@@ -87,9 +193,8 @@ export async function smokeAgent(user, config, d) {
87
193
  }
88
194
  }
89
195
 
196
+ /** `user` is a valid name and an existing user (agentCommand checks both before it saves anything). */
90
197
  async function add(config, user, noMcp, d) {
91
- assertAgentName(user);
92
- if (!d.userInfo(user)) throw new Error(`user "${user}" does not exist; create it first (the installer can: sudo gh-broker install --agent-user ${user} --create-agent-user)`);
93
198
  d.ensureGroup(user);
94
199
  d.log(`${user}: registered (group ${agentGroup(user)})`);
95
200
  configureMcp(user, config, d, { noMcp });
@@ -108,12 +213,31 @@ function remove(config, user, d) {
108
213
  d.log("service restarted");
109
214
  const info = d.userInfo(user);
110
215
  if (info) {
216
+ const dir = agentsDir(config.listen);
217
+ for (const place of [{ folder: info.home, saved: false }, ...extraFolders(info, d, config, user)]) {
218
+ const { folder } = place;
219
+ const isHome = folder === info.home;
220
+ try {
221
+ const r = inFolder(place, info, d, (io) => removeMcpConfig({ folder, dir, uid: info.uid }, io));
222
+ if (!r || (!isHome && r.status === "no-file")) continue;
223
+ const what = { removed: `removed the broker entry from ${r.path} (backup: ${MCP_BACKUP})`, absent: `no broker entry in ${r.path}`, "no-file": `no ${MCP_FILE} to edit`, unreadable: `${r.path} is not valid JSON, so it was left alone`, unsafe: `${r.reason}, so it was left alone` };
224
+ d.log(`${user}: ${what[r.status]}`);
225
+ } catch (err) {
226
+ if (err instanceof SkippedFolder) {
227
+ d.error(`${user}: skipped ${err.message}`);
228
+ continue;
229
+ }
230
+ d.error(`${user}: could not remove the broker entry from ${folder}/${MCP_FILE}: ${err.message}. The agent is unregistered anyway; remove the entry by hand.`);
231
+ }
232
+ }
233
+ }
234
+ if (config.claudeConfigDirs?.[user]) {
111
235
  try {
112
- const r = removeMcpConfig({ home: info.home, dir: agentsDir(config.listen), uid: info.uid }, d.mcpIO);
113
- const what = { removed: `removed the broker entry from ${r.path} (backup: ${MCP_BACKUP})`, absent: `no broker entry in ${r.path}`, "no-file": `no ${MCP_FILE} to edit`, unreadable: `${r.path} is not valid JSON, so it was left alone`, unsafe: `${r.reason}, so it was left alone` };
114
- d.log(`${user}: ${what[r.status]}`);
236
+ const { [user]: _gone, ...rest } = config.claudeConfigDirs;
237
+ d.writeConfig({ ...config, claudeConfigDirs: rest });
238
+ d.log(`${user}: saved Claude config folders removed from the broker config`);
115
239
  } catch (err) {
116
- d.error(`${user}: could not remove the broker entry from ${info.home}/${MCP_FILE}: ${err.message}. The agent is unregistered anyway; remove the entry by hand.`);
240
+ d.error(`${user}: could not update the broker config: ${err.message}. Remove claudeConfigDirs.${user} from it by hand.`);
117
241
  }
118
242
  }
119
243
  try {
@@ -142,10 +266,22 @@ function list(config, d) {
142
266
  let mcp = "no such user";
143
267
  if (info) {
144
268
  try {
145
- mcp = mcpFileStatus({ home: info.home, user: a.user, dir, uid: info.uid }, d.mcpIO);
269
+ mcp = mcpFileStatus({ folder: info.home, user: a.user, dir, uid: info.uid }, d.mcpIO);
146
270
  } catch (err) {
147
271
  mcp = `can't read (${err.message})`; // one agent's file must not hide the others
148
272
  }
273
+ const others = [];
274
+ for (const place of extraFolders(info, d, config, a.user)) {
275
+ const { folder } = place;
276
+ let status;
277
+ try {
278
+ status = inFolder(place, info, d, (io) => mcpFileStatus({ folder, user: a.user, dir, uid: info.uid, ifAbsent: null }, io));
279
+ } catch (err) {
280
+ status = err instanceof SkippedFolder ? `skipped (${err.message})` : `can't read (${err.message})`;
281
+ }
282
+ if (status) others.push(`${folder}: ${status}`);
283
+ }
284
+ if (others.length) mcp += ` (${others.join(", ")})`;
149
285
  }
150
286
  d.log(`${a.user} ${sock} socket: ${d.socketExists(sock) ? "yes" : "no"} mcp: ${mcp}${a.complete ? "" : " (group members incomplete; run agent add)"}`);
151
287
  }
@@ -161,6 +297,6 @@ function sync(config, only, noMcp, d) {
161
297
  if (!agents.includes(only)) throw new Error(`"${only}" is not a registered agent`);
162
298
  }
163
299
  let ok = true;
164
- for (const user of only ? [only] : agents) if (["failed", "unsafe", "unreadable"].includes(configureMcp(user, config, d, { noMcp }))) ok = false;
300
+ for (const user of only ? [only] : agents) if (BAD.includes(configureMcp(user, config, d, { noMcp }))) ok = false;
165
301
  return ok ? 0 : 1;
166
302
  }
@@ -26,6 +26,7 @@ export const USAGE = `Usage: sudo gh-broker <command>
26
26
  Commands:
27
27
  install [flags] install or upgrade the broker (see: gh-broker install --help)
28
28
  agent add <user> register an agent user: group, MCP config, restart, smoke test (--no-mcp-config to skip the config)
29
+ --claude-config <dir>: another Claude config folder it uses outside its home (repeatable, also on sync)
29
30
  agent remove <user> unregister an agent: empty its group, restart, MCP entry, lock state
30
31
  agent list each agent, its socket, and whether its MCP config points at it
31
32
  agent sync [<user>] re-apply the MCP config for one agent or all
@@ -28,7 +28,8 @@
28
28
  * exception is each deployed tool's boolean `otp` and `enabled`, which carry onto the same
29
29
  * tool in the new list (they never add a tool).
30
30
  * - Everything else is operator data and the deployed copy wins over the example: `listen`,
31
- * `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`, `otp`. The example ships
31
+ * `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`, `otp`,
32
+ * `claudeConfigDirs` (each agent's extra Claude config folders, #163; layers add to it). The example ships
32
33
  * none of the owner-specific ones, so a fresh install has no owners until the operator
33
34
  * supplies one (--owner, --config, or the interactive prompt).
34
35
  * - Layers 3 and 4 are merged on top of what is deployed rather than replacing it: scalars are
@@ -39,11 +40,11 @@
39
40
  * The result is validated by the real loader (`--check`) before anything is deployed, and a
40
41
  * --config file may hold only `cred:` references, never token values.
41
42
  */
42
- import { DEFAULT_LISTEN } from "./agents.mjs";
43
+ import { assertAgentName, DEFAULT_LISTEN } from "./agents.mjs";
43
44
 
44
45
  export const DEFAULT_GHCR_DROP_DIR = "/srv/gh-broker/ghcr-drop";
45
46
 
46
- const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr", "otp"];
47
+ const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr", "otp", "claudeConfigDirs"];
47
48
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
48
49
  /** Shapes of live GitHub / npm tokens (classic, fine-grained, app, npm granular). */
49
50
  const TOKEN_SHAPE = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{30,})\b/;
@@ -75,7 +76,7 @@ export function mergeConfig(example, current) {
75
76
  out.owners = { ...(example.owners ?? {}), ...(current.owners ?? {}) };
76
77
 
77
78
  // optional blocks: current wins if present.
78
- for (const k of ["app", "secrets", "npm", "ghcr"]) {
79
+ for (const k of ["app", "secrets", "npm", "ghcr", "claudeConfigDirs"]) {
79
80
  if (current[k] !== undefined) out[k] = structuredClone(current[k]);
80
81
  }
81
82
 
@@ -102,6 +103,7 @@ export function layerConfig(base, overlay) {
102
103
  out.owners = { ...(out.owners ?? {}) };
103
104
  for (const [name, creds] of Object.entries(overlay.owners)) out.owners[name] = { ...(out.owners[name] ?? {}), ...creds };
104
105
  }
106
+ if (overlay.claudeConfigDirs !== undefined) out.claudeConfigDirs = addClaudeConfigDirs(out.claudeConfigDirs, overlay.claudeConfigDirs);
105
107
  if (overlay.ghcr !== undefined) {
106
108
  const { owners: ghcrOwners, ...scalars } = overlay.ghcr;
107
109
  const merged = { ...(out.ghcr ?? {}), ...structuredClone(scalars) };
@@ -114,6 +116,34 @@ export function layerConfig(base, overlay) {
114
116
  return out;
115
117
  }
116
118
 
119
+ /**
120
+ * `claudeConfigDirs` with `more` added: per agent, the folders of both, each once. The result is
121
+ * a new object; neither argument is changed.
122
+ */
123
+ export function addClaudeConfigDirs(base, more) {
124
+ const out = structuredClone(isObject(base) ? base : {});
125
+ for (const [user, dirs] of Object.entries(more)) out[user] = [...new Set([...absoluteFolders(out[user]), ...absoluteFolders(dirs)])];
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * The absolute folder paths in one agent's saved list. The deployed config can be hand-edited, so
131
+ * a string instead of a list, or a relative entry, yields nothing rather than odd folders.
132
+ */
133
+ export function absoluteFolders(dirs) {
134
+ return Array.isArray(dirs) ? dirs.filter((d) => typeof d === "string" && d.startsWith("/")) : [];
135
+ }
136
+
137
+ /** Throw unless `v` is { "<agent user>": ["/absolute/dir", ...] }. `where` names it in the message. */
138
+ export function checkClaudeConfigDirs(v, where) {
139
+ if (!isObject(v)) throw new Error(`${where} must be an object of agent user -> list of folders`);
140
+ for (const [user, dirs] of Object.entries(v)) {
141
+ assertAgentName(user);
142
+ if (!Array.isArray(dirs) || !dirs.every((d) => typeof d === "string" && d.startsWith("/"))) throw new Error(`${where}.${user} must be a list of absolute paths`);
143
+ }
144
+ return v;
145
+ }
146
+
117
147
  /** The config fragment the CLI flags describe (same shape as a config file), or {} when none were given. */
118
148
  export function overlayFromFlags(opts) {
119
149
  const o = {};
@@ -132,6 +162,7 @@ export function overlayFromFlags(opts) {
132
162
  g.owners = { [opts.ghcrOwner]: { username: opts.ghcrUsername, credential: `cred:${opts.ghcrOwner}-packages`, images: [...opts.ghcrImages] } };
133
163
  }
134
164
  if (Object.keys(g).length) o.ghcr = g;
165
+ for (const { user, dir } of opts.claudeConfigs ?? []) ((o.claudeConfigDirs ??= {})[user] ??= []).push(dir);
135
166
  return o;
136
167
  }
137
168
 
@@ -151,6 +182,7 @@ export function readOperatorConfig(path, read) {
151
182
  if (raw.tools !== undefined) throw new Error("--config: `tools` is the shipped allowlist and cannot be set from a config file");
152
183
  const unknown = Object.keys(raw).filter((k) => !OPERATOR_KEYS.includes(k));
153
184
  if (unknown.length) throw new Error(`--config: unknown key(s) ${unknown.join(", ")} (allowed: ${OPERATOR_KEYS.join(", ")})`);
185
+ if (raw.claudeConfigDirs !== undefined) checkClaudeConfigDirs(raw.claudeConfigDirs, "--config: claudeConfigDirs");
154
186
  const hit = findTokenShape(raw, "");
155
187
  if (hit) throw new Error(`--config: ${hit} looks like a token. The config holds only "cred:<name>" references; pass the token with --secret, --secrets-file or env`);
156
188
  return raw;
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { assertSafeCredName } from "./secret-store.mjs";
23
+ import { assertAgentName } from "./agents.mjs";
23
24
 
24
25
  export const NPM_SCOPE = /^@[a-z0-9][a-z0-9._~-]*$/;
25
26
  export const NPM_ORG = /^[a-z0-9][a-z0-9._~-]*$/;
@@ -125,6 +126,12 @@ export function parseArgs(argv) {
125
126
  const n = value(++i, a);
126
127
  if (!/^[1-9][0-9]{0,15}$/.test(n) || !Number.isSafeInteger(Number(n))) throw new Error("--ghcr-max-archive-bytes must be a positive whole number of bytes");
127
128
  o.ghcrMaxArchiveBytes = Number(n);
129
+ } else if (a === "--claude-config") {
130
+ const pair = value(++i, a);
131
+ const colon = pair.indexOf(":");
132
+ const dir = pair.slice(colon + 1);
133
+ if (colon <= 0 || !dir.startsWith("/")) throw new Error(`--claude-config takes <agent user>:<absolute folder>, got "${pair}"`);
134
+ (o.claudeConfigs ??= []).push({ user: assertAgentName(pair.slice(0, colon)), dir: dir.replace(/(.)\/+$/, "$1") });
128
135
  } else if (a === "--protect-release-tags") o.protectReleaseTags = true;
129
136
  else if (a === "--no-protect-release-tags") o.protectReleaseTags = false;
130
137
  else if (a.startsWith("-")) throw new Error(`unknown flag: ${a} (see --help)`);