claude-multiacc 2.0.59 → 2.0.61

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.
Files changed (110) hide show
  1. package/README.md +19 -1
  2. package/bin/claude +78 -11
  3. package/bin/claude-accounts +3 -0
  4. package/bin/codex +65 -7
  5. package/bin/codex-accounts +2 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  41. package/lib/common.sh +15 -0
  42. package/lib/mcp_mirror.py +49 -0
  43. package/lib/mcp_mirror.sh +47 -0
  44. package/lib/mcp_registry.py +237 -10
  45. package/package.json +2 -2
  46. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  107. package/tests/run-tests.sh +146 -1
  108. package/tests/test_mcp_mirror.py +148 -0
  109. package/tests/test_mcp_mirror_args.py +129 -0
  110. package/tests/test_mcp_registry.py +234 -3
package/README.md CHANGED
@@ -568,7 +568,25 @@ claude-accounts mcp apply # re-apply now (repair drift)
568
568
  outcome into the registry and every other account (`learn`). This includes a command run
569
569
  from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
570
570
  an agent): the session's config dir is a pool account, so the same mirror applies. A
571
- config dir outside the pool is plain passthrough.
571
+ config dir outside the pool is plain passthrough. Global options in front of `mcp` are
572
+ looked past the way the real parser does (`alias claude='claude
573
+ --dangerously-skip-permissions'` puts one before every typed `claude mcp add`; until
574
+ 2026-10-22 that kept motionsites in 2 of 11 accounts). Mirroring is limited to the server
575
+ named by the command, including its project approvals: a concurrent runner update to
576
+ another server cannot become a synced override or tombstone. An unrecognized command
577
+ option leaves the native command intact and warns that its change was not mirrored.
578
+ Claude's desktop bulk import mirrors changes except names and retirements belonging to
579
+ another machine-local overlay owner; an explicit named command can still override one.
580
+ - **Adoption**: whatever reached an account by another route — the real binary run
581
+ directly, a hand edit, another tool's installer — is adopted by the next reconcile: a
582
+ server (user scope or a project entry) or a project's `.mcp.json` approval that no section
583
+ names, live or retired, joins the registry (a replica: its overlay) and so every account.
584
+ Several definitions of one name: the one most accounts hold wins, then the newest config
585
+ file. Every limits pass reconciles all accounts this way, so an idle account catches up too.
586
+ Never adopted: anything the pool itself once wrote into that account (`<acct>/.mcp-managed.json`
587
+ — so a stray synced entry dropped without a tombstone, or a vanished overlay owner's
588
+ server, stays out of the synced registry), tombstoned names, and nothing at all with
589
+ `CLAUDE_MULTIACC_MCP_ADOPT=0` / `CODEX_MULTIACC_MCP_ADOPT=0`.
572
590
  - **Runner-managed servers**: on app-robot fleet Macs the runner publishes `appinspire-mcp` into
573
591
  each Mac's machine-local overlay with that Mac's own paths. Never `mcp add` it to the synced
574
592
  registry (directly, through a mirrored `claude|codex mcp add`, or via `appinspire-mcp install`):
package/bin/claude CHANGED
@@ -167,15 +167,83 @@ mcp_oauth_ensure() { # $1 acct dir — silent, never changes the exit status
167
167
  # account against a snapshot taken just before). The real CLI's own parser and writer do
168
168
  # the work; the shim only copies the outcome. Exits with the real binary's status; a
169
169
  # mirror failure is one hint line. Returns 1 when this run is not such a command.
170
+ # The index of the first positional argument as the real CLI's parser (commander) finds
171
+ # it: global options may come first, in any number. The operator's `alias claude='claude
172
+ # --dangerously-skip-permissions'` puts one in front of every `claude mcp add` typed at a
173
+ # prompt, and the scoped-limit fallback prepends `--model X`; the mirror once looked at $1
174
+ # alone, so on 2026-10-22 motionsites (and playwright, inspo) sat in the one account a
175
+ # typed add ran under. A value option skips its value; an optional-value one the next
176
+ # word unless it is an option; a variadic one every word up to the next option. `--`, or
177
+ # a positional after `--` counts (commander's own rule). No positional: status 1. Options
178
+ # of claude 2.1.308 (`claude --help`; hidden ones left out on purpose): an unknown one
179
+ # counts as a flag — at worst its value is taken for the positional, which is then no
180
+ # `mcp`, the run is not mirrored, and the registry's adoption picks the server up instead.
181
+ CLI_VALUE_OPTS=" --agent --agents --append-system-prompt --append-system-prompt-file \
182
+ --autocompact --debug-file --effort --environment --fallback-model --input-format \
183
+ --json-schema --max-budget-usd --model -n --name --output-format --permission-mode \
184
+ --permission-prompts --permission-prompt-tool --plugin-dir --plugin-url \
185
+ --remote-control-session-name-prefix --session-id --setting-sources --settings \
186
+ --system-prompt --system-prompt-file --system-prompt-snapshot "
187
+ CLI_OPTIONAL_OPTS=" -d --debug --cloud --from-pr --prompt-suggestions --remote-control --rc \
188
+ --remote -r --resume --teleport -w --worktree "
189
+ CLI_VARIADIC_OPTS=" --add-dir --allowedTools --allowed-tools --betas --disallowedTools \
190
+ --disallowed-tools --file --mcp-config --tools "
191
+ cli_positional_at() { # argv -> prints the index of its first positional argument
192
+ local i=1 a nx
193
+ while [ "$i" -le "$#" ]; do
194
+ a="${!i}"
195
+ case "$a" in
196
+ --) i=$((i + 1)); [ "$i" -le "$#" ] || return 1; printf '%s\n' "$i"; return 0 ;;
197
+ --*=*) i=$((i + 1)); continue ;;
198
+ -?*) ;;
199
+ *) printf '%s\n' "$i"; return 0 ;;
200
+ esac
201
+ i=$((i + 1))
202
+ case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
203
+ case "$CLI_OPTIONAL_OPTS" in
204
+ *" $a "*) nx="${!i:-}"; case "$nx" in ''|-*) ;; *) i=$((i + 1)) ;; esac; continue ;;
205
+ esac
206
+ case "$CLI_VARIADIC_OPTS" in
207
+ *" $a "*)
208
+ while [ "$i" -le "$#" ]; do
209
+ nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
210
+ done ;;
211
+ esac
212
+ done
213
+ return 1
214
+ }
215
+
216
+ # Whether argv is `claude [global options] mcp add|add-json|add-from-claude-desktop|remove …`.
217
+ # The exact word -p/--print (or --bg/--background) anywhere before a `--` puts the client in
218
+ # print/background mode, which never runs the subcommand.
219
+ mcp_mutation_argv() {
220
+ local i j a
221
+ for a in "$@"; do
222
+ case "$a" in --) break ;; -p|--print|--bg|--background) return 1 ;; esac
223
+ done
224
+ i="$(cli_positional_at "$@")" || return 1
225
+ [ "${!i}" = "mcp" ] || return 1
226
+ j=$((i + 1))
227
+ case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) MCP_MUTATION_AT="$j"; return 0 ;; esac
228
+ return 1
229
+ }
230
+
170
231
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
171
- local d="$1" snap="" rc verb="${2:-}" sub="${3:-}"
232
+ local d="$1" snap="" rc parse_rc name="" mirror_args=()
172
233
  shift
173
- # The scoped-limit fallback may have prepended `--model X` (a global option the real
174
- # CLI accepts in front of any subcommand): look past it.
175
- if [ "$verb" = "--model" ]; then verb="${3:-}"; sub="${4:-}"; fi
176
- [ "$verb" = "mcp" ] || return 1
177
- case "$sub" in add|add-json|remove) ;; *) return 1 ;; esac
234
+ mcp_mutation_argv "$@" || return 1
178
235
  [ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
236
+ if [ "${!MCP_MUTATION_AT}" != add-from-claude-desktop ]; then
237
+ if name="$(. "$SELF_DIR/../lib/mcp_mirror.sh" && mcp_mutation_name claude "${@:$MCP_MUTATION_AT}")"; then
238
+ mirror_args=("--name=$name")
239
+ else
240
+ parse_rc=$?
241
+ "$REAL" "$@"; rc=$?
242
+ [ "$rc" -ne 0 ] || [ "$parse_rc" = 2 ] \
243
+ || printf 'claude-multiacc: could not identify the MCP target; change not mirrored\n' >&2
244
+ exit "$rc"
245
+ fi
246
+ fi
179
247
  mcp_ensure "$d"
180
248
  snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
181
249
  if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude snapshot \
@@ -186,7 +254,7 @@ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled
186
254
  rc=$?
187
255
  if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
188
256
  "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude learn --account-dir "$d" \
189
- --before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" </dev/null \
257
+ --before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" ${mirror_args[@]+"${mirror_args[@]}"} </dev/null \
190
258
  || printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
191
259
  elif [ "$rc" -eq 0 ]; then
192
260
  printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
@@ -207,9 +275,8 @@ mcp_nested_mirror() {
207
275
  [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] && return 1
208
276
  [ "${CLAUDE_MULTIACC_MCP:-1}" = "0" ] && return 1
209
277
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] || return 1
210
- local verb="${1:-}" cfg="${CLAUDE_CONFIG_DIR%/}" d=""
211
- if [ "$verb" = "--model" ]; then verb="${3:-}"; fi
212
- [ "$verb" = "mcp" ] || return 1
278
+ local cfg="${CLAUDE_CONFIG_DIR%/}" d=""
279
+ mcp_mutation_argv "$@" || return 1
213
280
  # A DIRECT child of the pool root, by its given spelling or its physical one (an
214
281
  # adopted account is a symlink whose target is the operator's ~/.claude).
215
282
  case "$cfg" in
@@ -2522,7 +2589,7 @@ export CLAUDE_SHIM_ACTIVE=1
2522
2589
 
2523
2590
  # A `claude mcp add|add-json|remove` is run as a child under the picked account and
2524
2591
  # mirrored to every other one (exits inside); anything else falls through to exec.
2525
- if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
2592
+ if mcp_mutation_argv "$@"; then
2526
2593
  export CLAUDE_CONFIG_DIR="$pick"
2527
2594
  tok="$PICK_TOKEN"
2528
2595
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
@@ -2963,6 +2963,9 @@ PYEOF
2963
2963
  # lock while a consumer reads its output.
2964
2964
  limits_lock_release
2965
2965
  trap - EXIT
2966
+ # Every account's MCP servers, adopting what one holds that the registry does not know
2967
+ # (lib/common.sh) — before the push below, which carries the registry to the peers.
2968
+ mcp_registry_pass
2966
2969
  # Whoever just refreshed is the only machine that CAN — pass it on before the
2967
2970
  # report, so a blind peer stops picking drained accounts within one cycle.
2968
2971
  limits_distribute
package/bin/codex CHANGED
@@ -119,12 +119,70 @@ mcp_ensure() { # $1 acct dir — silent, never changes the exit status
119
119
  # snapshot taken just before). The real CLI's own parser and writer do the work; the shim
120
120
  # only copies the outcome. Exits with the real binary's status; a mirror failure is one
121
121
  # hint line. Returns 1 when this run is not such a command.
122
+ # The index of the first positional argument as the real CLI's parser (clap) finds it:
123
+ # global options may come first, in any number. The operator's `alias codex='codex
124
+ # --dangerously-bypass-approvals-and-sandbox'` puts one in front of every `codex mcp add`
125
+ # typed at a prompt, and the mirror once looked at $1 alone (bin/claude met the same on
126
+ # 2026-10-22). A value option skips its value (attached `-mX`/`--model=X` is one word); a
127
+ # variadic one every word up to the next option. `--`, or no positional: status 1.
128
+ # Options of codex-cli 0.163 (`codex --help`); an unknown one counts as a flag.
129
+ CLI_VALUE_OPTS=" -c --config --enable --disable --remote --remote-auth-token-env -m --model \
130
+ --local-provider -p --profile -s --sandbox -C --cd --add-dir -a --ask-for-approval "
131
+ CLI_VARIADIC_OPTS=" -i --image "
132
+ cli_positional_at() { # argv -> prints the index of its first positional argument
133
+ local i=1 a nx
134
+ while [ "$i" -le "$#" ]; do
135
+ a="${!i}"
136
+ case "$a" in
137
+ --) return 1 ;;
138
+ --*=*) i=$((i + 1)); continue ;;
139
+ -?*) ;;
140
+ *) printf '%s\n' "$i"; return 0 ;;
141
+ esac
142
+ i=$((i + 1))
143
+ case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
144
+ case "$CLI_VARIADIC_OPTS" in
145
+ *" $a "*)
146
+ while [ "$i" -le "$#" ]; do
147
+ nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
148
+ done ;;
149
+ esac
150
+ done
151
+ return 1
152
+ }
153
+
154
+ # Whether argv is `codex [global options] mcp add|remove …`.
155
+ mcp_mutation_argv() {
156
+ local i j
157
+ i="$(cli_positional_at "$@")" || return 1
158
+ [ "${!i}" = "mcp" ] || return 1
159
+ j=$((i + 1))
160
+ # `mcp` takes options of its own before its subcommand: -c/--config, --enable, --disable
161
+ while :; do
162
+ case "${!j:-}" in
163
+ -c|--config|--enable|--disable) j=$((j + 2)) ;;
164
+ --*=*|-c?*) j=$((j + 1)) ;;
165
+ *) break ;;
166
+ esac
167
+ done
168
+ case "${!j:-}" in add|remove) MCP_MUTATION_AT="$j"; return 0 ;; esac
169
+ return 1
170
+ }
171
+
122
172
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
123
- local d="$1" snap="" rc
173
+ local d="$1" snap="" rc parse_rc name=""
124
174
  shift
125
- [ "${1:-}" = "mcp" ] || return 1
126
- case "${2:-}" in add|remove) ;; *) return 1 ;; esac
175
+ mcp_mutation_argv "$@" || return 1
127
176
  [ "${CODEX_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
177
+ if name="$(. "$SELF_DIR/../lib/mcp_mirror.sh" && mcp_mutation_name codex "${@:$MCP_MUTATION_AT}")"; then
178
+ :
179
+ else
180
+ parse_rc=$?
181
+ "$REAL" "$@"; rc=$?
182
+ [ "$rc" -ne 0 ] || [ "$parse_rc" = 2 ] \
183
+ || printf 'codex-multiacc: could not identify the MCP target; change not mirrored\n' >&2
184
+ exit "$rc"
185
+ fi
128
186
  mcp_ensure "$d"
129
187
  snap="$(mktemp "${TMPDIR:-/tmp}/codex-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
130
188
  if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex snapshot \
@@ -135,7 +193,7 @@ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled
135
193
  rc=$?
136
194
  if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
137
195
  "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex learn --account-dir "$d" \
138
- --before "$snap" </dev/null \
196
+ --before "$snap" "--name=$name" </dev/null \
139
197
  || printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
140
198
  elif [ "$rc" -eq 0 ]; then
141
199
  printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
@@ -156,8 +214,8 @@ mcp_nested_mirror() {
156
214
  [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] && return 1
157
215
  [ "${CODEX_MULTIACC_MCP:-1}" = "0" ] && return 1
158
216
  [ -n "${CODEX_HOME:-}" ] || return 1
159
- local verb="${1:-}" cfg="${CODEX_HOME%/}" d=""
160
- [ "$verb" = "mcp" ] || return 1
217
+ local cfg="${CODEX_HOME%/}" d=""
218
+ mcp_mutation_argv "$@" || return 1
161
219
  # A DIRECT child of the pool root, by its given spelling or its physical one (an
162
220
  # adopted account is a symlink whose target is the operator's ~/.claude).
163
221
  case "$cfg" in
@@ -1526,7 +1584,7 @@ export CODEX_SHIM_ACTIVE=1
1526
1584
 
1527
1585
  # A `codex mcp add|remove` is run as a child under the picked account and mirrored to
1528
1586
  # every other one (exits inside); anything else falls through to exec.
1529
- if [ "${1:-}" = "mcp" ]; then
1587
+ if mcp_mutation_argv "$@"; then
1530
1588
  share_state_index "$pick"
1531
1589
  export CODEX_HOME="$pick"
1532
1590
  mcp_mirror_run "$pick" "$@" || true
@@ -1846,6 +1846,8 @@ PYEOF
1846
1846
  # lock while a consumer reads its output.
1847
1847
  codex_limits_lock_release
1848
1848
  trap - EXIT
1849
+ # Every account's MCP servers, adopting what one holds that the registry does not know.
1850
+ mcp_registry_pass
1849
1851
  # This pool's model catalog, once its copy is an hour old (lib/common.sh).
1850
1852
  model_catalog_backstop
1851
1853
  # The refresher fails open per account; a NON-zero status means the pass itself
package/lib/common.sh CHANGED
@@ -771,6 +771,21 @@ seed_mcp_registry() { # $1 = acct dir
771
771
  return 0
772
772
  }
773
773
 
774
+ # Once per scheduled limits pass: reconcile EVERY account, which first adopts what any
775
+ # one of them holds that the registry does not know (lib/mcp_registry.py adopt) — the
776
+ # backstop for a server added where the shims' mirror never saw it, reaching accounts no
777
+ # session launched on since. Unlike the shims' pre-exec apply it also runs before this
778
+ # pool has a registry at all. Inline, quiet, fail-open: a pass never dies on it.
779
+ mcp_registry_pass() {
780
+ local off
781
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then off="${CODEX_MULTIACC_MCP:-1}"; else off="${CLAUDE_MULTIACC_MCP:-1}"; fi
782
+ [ "$off" = "0" ] && return 0
783
+ [ -f "$LIB_DIR/mcp_registry.py" ] || return 0
784
+ "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" apply \
785
+ --all --fail-open --quiet >/dev/null 2>&1 </dev/null || true
786
+ return 0
787
+ }
788
+
774
789
  seed_account_dir() { # $1 = acct dir
775
790
  local d="$1"
776
791
  mkdir -p "$d"
@@ -0,0 +1,49 @@
1
+ """Keep stock MCP mutations scoped to the server the command actually named."""
2
+
3
+ import copy
4
+
5
+
6
+ def _external_names(overlay, target_owner):
7
+ """Bulk imports cannot promote another local owner's machine-specific entries."""
8
+ user, everywhere, projects = set(), set(), {}
9
+ for owner, section in overlay.get("owners", {}).items():
10
+ if owner == target_owner:
11
+ continue
12
+ everywhere.update(section.get("retired") or [])
13
+ user.update(section.get("mcpServers") or {})
14
+ user.update(section.get("retiredUser") or [])
15
+ for path, entry in (section.get("projects") or {}).items():
16
+ names = projects.setdefault(path, set())
17
+ names.update(entry.get("mcpServers") or {})
18
+ names.update(entry.get("retired") or [])
19
+ names.update(entry.get("enabledMcpjsonServers") or [])
20
+ return user | everywhere, everywhere, projects
21
+
22
+
23
+ def _filtered_snapshot(snapshot, keep_user, keep_project):
24
+ result = copy.deepcopy(snapshot)
25
+ result["user"] = {name: block for name, block in (result.get("user") or {}).items()
26
+ if keep_user(name)}
27
+ for path, entry in (result.get("projects") or {}).items():
28
+ entry["mcpServers"] = {name: block for name, block in (entry.get("mcpServers") or {}).items()
29
+ if keep_project(path, name)}
30
+ for key in ("enabledMcpjsonServers", "disabledMcpjsonServers"):
31
+ entry[key] = [name for name in (entry.get(key) or []) if keep_project(path, name)]
32
+ return result
33
+
34
+
35
+ def scoped_snapshots(before, after, name, overlay, target_owner=None):
36
+ """Named commands mirror only their target, including project approvals.
37
+
38
+ Without a name (desktop bulk import), externally owned names are excluded even
39
+ if a delayed reconcile writes an older generation than the current overlay.
40
+ """
41
+ if name is not None:
42
+ keep_user = lambda candidate: candidate == name
43
+ keep_project = lambda _path, candidate: candidate == name
44
+ else:
45
+ user, everywhere, projects = _external_names(overlay, target_owner)
46
+ keep_user = lambda candidate: candidate not in user
47
+ keep_project = lambda path, candidate: candidate not in everywhere | projects.get(path, set())
48
+ return (_filtered_snapshot(before, keep_user, keep_project),
49
+ _filtered_snapshot(after, keep_user, keep_project))
@@ -0,0 +1,47 @@
1
+ # Target of a stock MCP mutation. Global CLI options are already skipped by the shim.
2
+ # Unknown options fail closed: the native command still runs, but is not mirrored.
3
+ # Status 2 is a help request before the target; pass it through without a warning.
4
+ mcp_mutation_name() { # provider, verb, then the verb's argv -> server name
5
+ local provider="$1" verb="$2" values="" variadic="" flags=" " a opt attached
6
+ shift 2
7
+ case "$provider:$verb" in
8
+ claude:add)
9
+ values=" -s --scope -t --transport --callback-port --client-id "
10
+ variadic=" -e --env -H --header "; flags="$flags--client-secret " ;;
11
+ claude:add-json) values=" -s --scope "; flags="$flags--client-secret " ;;
12
+ claude:remove) values=" -s --scope " ;;
13
+ codex:add)
14
+ values=" -c --config --env --enable --disable --url --bearer-token-env-var \
15
+ --oauth-client-id --oauth-client-secret --oauth-client-registration --oauth-resource " ;;
16
+ codex:remove) values=" -c --config --enable --disable " ;;
17
+ *) return 1 ;;
18
+ esac
19
+ while [ "$#" -gt 0 ]; do
20
+ a="$1"; shift
21
+ case "$a" in
22
+ --) [ "$#" -gt 0 ] || return 1; printf '%s\n' "$1"; return 0 ;;
23
+ -h|--help) return 2 ;;
24
+ -?*) ;;
25
+ *) printf '%s\n' "$a"; return 0 ;;
26
+ esac
27
+ opt="$a"; attached=0
28
+ case "$a" in
29
+ --*=*) opt="${a%%=*}"; attached=1 ;;
30
+ --*) ;;
31
+ -?*) opt="${a:0:2}"; [ "${#a}" -le 2 ] || attached=1 ;;
32
+ esac
33
+ case "$values" in
34
+ *" $opt "*)
35
+ if [ "$attached" = 0 ]; then [ "$#" -gt 0 ] || return 1; shift; fi
36
+ continue ;;
37
+ esac
38
+ case "$variadic" in
39
+ *" $opt "*)
40
+ while [ "$#" -gt 0 ]; do case "$1" in -*) break ;; esac; shift; done
41
+ continue ;;
42
+ esac
43
+ case "$flags" in *" $opt "*) [ "$attached" = 0 ] || return 1; continue ;; esac
44
+ return 1
45
+ done
46
+ return 1
47
+ }