claude-multiacc 2.0.26 → 2.0.28

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.
@@ -0,0 +1,91 @@
1
+ # Install / update / uninstall
2
+
3
+ ### npm (recommended)
4
+
5
+ ```bash
6
+ npm install -g claude-multiacc # postinstall wires up the shim, CLI, and scheduled jobs
7
+ # or run it once without installing globally:
8
+ npx claude-multiacc
9
+ ```
10
+
11
+ `claude-multiacc` then wraps everything:
12
+
13
+ ```bash
14
+ claude-multiacc # install or update the addon (idempotent)
15
+ claude-multiacc self-update # update the running installation + re-install
16
+ claude-multiacc list # Claude pool, via claude-accounts
17
+ claude-multiacc codex list # Codex pool, via codex-accounts
18
+ claude-multiacc doctor # status for both pools
19
+ claude-multiacc install --help # installer options
20
+ claude-multiacc uninstall # keep both account pools
21
+ # To delete both pools as well: claude-multiacc uninstall --purge-data
22
+ ```
23
+
24
+ After `claude-multiacc uninstall`, `npm uninstall -g claude-multiacc` also removes
25
+ the npm package and its command links.
26
+
27
+ npm links `claude-multiacc`, `claude-accounts`, `codex-accounts`, and `multiacc-select`
28
+ into its global bin directory. If `claude-multiacc` works, both account commands work
29
+ in that same shell, even when postinstall was skipped. The `claude` and `codex` shims
30
+ still need the installer's PATH setup: open a new shell after installation.
31
+
32
+ If an older installation says `codex-accounts: command not found`, update it:
33
+
34
+ ```bash
35
+ npm install -g claude-multiacc@latest
36
+ codex-accounts verify
37
+ ```
38
+
39
+ Before updating, `claude-multiacc codex verify` runs the same verification through
40
+ the existing npm wrapper. For git/npx installs, open a new shell or run the exact
41
+ `export PATH=...` command printed by the installer. Run `claude-multiacc install`
42
+ if auto-setup was skipped and the pool has no manifest yet.
43
+
44
+ **Automatic updates:** a globally-installed package re-runs `install.sh` from its own
45
+ postinstall, so `npm i -g claude-multiacc@latest` fully updates the shim, CLI, and jobs.
46
+ A daily job (launchd on macOS, cron on Linux, 04:07) runs `claude-accounts self-update`
47
+ to pull the latest release on its own — `npm i -g claude-multiacc@latest` for npm installs,
48
+ `git pull && ./install.sh` for git checkouts; set `CLAUDE_MULTIACC_AUTOUPDATE=0` before install to
49
+ opt out. `update-notifier` also prints a one-line nudge when a newer version is on npm.
50
+ (Note: newer npm versions gate install scripts; if postinstall is blocked, just run
51
+ `claude-multiacc install` once.)
52
+
53
+ New versions publish to npm automatically from `main` via GitHub Actions
54
+ (`.github/workflows/publish.yml`, OIDC Trusted Publishing — no long-lived token after the
55
+ first bootstrap publish; `scripts/auto-version.mjs` bumps the patch above the last release).
56
+
57
+ ### From a git checkout
58
+
59
+ ```bash
60
+ git clone https://github.com/gowalk-public/claude-multiacc.git
61
+ cd claude-multiacc
62
+ ./install.sh # macOS or Linux, idempotent
63
+ ./install.sh --server root@1.2.3.4 # override sync target
64
+ git pull && ./install.sh # update (data untouched)
65
+ ./install.sh --uninstall # restores stock behavior, keeps account data
66
+ ./install.sh --uninstall --purge-data # also deletes both account pools
67
+ ```
68
+
69
+ `self-update` auto-detects which of these you used (npm global vs git checkout).
70
+
71
+ **Servers / machines without repo access:** install via **npm** — the registry is
72
+ public, so the daily self-update needs no credentials at all (no rsync, no keys).
73
+ A git checkout self-updates too, but only if `git pull` can authenticate; for a
74
+ private repo on a server that means a read-only deploy key (the 138 server runs
75
+ this way: `core.sshCommand` pinned to its deploy key). A plain copied tree is the
76
+ one layout that can NOT self-update — don't ship the addon that way.
77
+
78
+ What install does (all reversible, nothing else):
79
+
80
+ - **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
81
+ (+ bash rc files if present) — end-of-file placement matters because those files
82
+ re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
83
+ `.codex-limits` (15m Claude / 5m Codex), `.health` + `.codex-health` (weekly Mon morning), and
84
+ `.update` (daily 04:07). Notes when this Mac keeps Claude Code logins in the
85
+ Keychain (the pool reads them; ssh sessions cannot — mint portable tokens for
86
+ accounts that must work from everywhere).
87
+ - **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`,
88
+ shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
89
+ PATH order — on the systemd default PATH too; the original binaries are untouched),
90
+ cron entries for limits/health (both providers) + the daily auto-update.
91
+ - Both: `~/.claude-accounts/` and `~/.codex-accounts/`, each with an `accounts.json` manifest.
@@ -13,6 +13,8 @@ multiacc-select --request-json - --response-json -
13
13
 
14
14
  The command reads one UTF-8 JSON object from stdin and writes one JSON object to
15
15
  stdout. `multiacc-select --version` reports the selector contract version.
16
+ `multiacc-select --help` (or `-h`) prints usage and exits successfully without
17
+ reading stdin. All three forms also work through `claude-multiacc select`.
16
18
 
17
19
  The request schema is `claude-multiacc/pool-selection.v2` and contains:
18
20
 
@@ -0,0 +1,68 @@
1
+ # Verification and troubleshooting
2
+
3
+ ## Verification
4
+
5
+ ```bash
6
+ tests/run-tests.sh # sandboxed compatibility tests, no quota
7
+ python3 tests/test_selector.py # unified selector contract
8
+ npm run test:commands # packed npm commands; run npm install --ignore-scripts first
9
+ claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
10
+ claude-accounts verify --quick # auth presence/expiry only, no inference
11
+ codex-accounts verify # real codex exec call per account
12
+ codex-accounts verify --quick # local Codex auth check, no inference
13
+ claude-accounts limits # live per-bucket usage incl. the Fable bucket
14
+ claude-accounts health # limits + full verify, logs to health.log, notifies on failure (Mac)
15
+ ```
16
+
17
+ Verified end-to-end on both machines (2026-07-13): full matrix PASS, 20-invocation shim
18
+ spread clean, real project `-p` through the shim OK on Mac (`~/pipeline`) and server
19
+ (`/opt/inapp-onboarding`), uninstall→stock→reinstall roundtrip byte-identical, three
20
+ server systemd services (`vpn-server-management`, `inapp-onboarding`, `vpn-seo-panel`)
21
+ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
22
+
23
+ ## Troubleshooting
24
+
25
+ - **`claude` resolves to the real binary, not the shim** — open a new shell, or check
26
+ that the marked block is the LAST PATH manipulation in your rc file
27
+ (`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
28
+ - **An account never gets picked** — `claude-accounts status`: no auth on this machine,
29
+ a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
30
+ marker (shows bucket + minutes to reset).
31
+ - **"Failed to authenticate: OAuth session expired and could not be refreshed"** — the
32
+ login behind that run is dead. Run `claude-accounts expired` to see which, then
33
+ `claude-accounts relogin`. Accounts in that state are excluded from selection
34
+ automatically, so this only surfaces when *every* account needs a re-login (the shim
35
+ then falls back to the machine's own `~/.claude` login) or when it was pinned with
36
+ `CLAUDE_ACCOUNT`.
37
+ - **"Please run /login · API Error: 401 OAuth access token is invalid"** — the portable
38
+ setup-token is rejected even though its presence and `claude auth status` look healthy.
39
+ The shim now runs a private first-use inference, parks a rejected token, and reselects
40
+ before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
41
+ check every token immediately; `claude-accounts expired` reports token-only accounts
42
+ as `UNVERIFIED` until that proof exists.
43
+ - **Everything marked limited** — the shim still runs: the still-serving limited accounts
44
+ go through the same two cuts (session gate, then strict best-weekly) and one is handed
45
+ out anyway; check `selection.log` for `all-limited fallback=` lines.
46
+ - **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
47
+ `server` (BatchMode — needs key auth).
48
+ - **A service bypasses the shim** — it spawns an absolute path. Point its env
49
+ (`CLAUDE_BIN` etc.) at `/usr/local/bin/claude`. Docker-internal `claude` installs
50
+ (e.g. openclaw live-test containers) are out of scope for a host shim.
51
+ - **`add` sign-in says "Sign in again to continue"** — that's Claude's step-up
52
+ authentication, not a tool error: granting a long-lived token needs a *recent* sign-in.
53
+ Open a fresh incognito window, sign in to the target account at claude.ai **first**,
54
+ then paste the link and approve. The approval scope is `user:inference`
55
+ (*"Contribute to your Claude subscription usage"*) — that's correct and minimal.
56
+ - **Limits look stale** — launchd: `launchctl list | grep claude-multiacc`;
57
+ cron: `crontab -l | grep claude-multiacc`. The shim also kicks a refresh when data
58
+ is >3 min old.
59
+
60
+ ## Security notes & known risks
61
+
62
+ - Tokens and credentials live as 0600 files under `~/.claude-accounts` (and
63
+ `/root/.claude-accounts` on the server). Server compromise = account access.
64
+ - `selection.log` records timestamps/account/cwd only — never prompt text.
65
+ - Rotating multiple subscriptions to spread usage may be flagged by anti-abuse systems;
66
+ accounts can be banned for limit circumvention. Known and accepted by the operator.
67
+ - The usage endpoint is the internal one `/usage` consumes; if it changes shape, limit
68
+ tracking fails open (accounts stay available, error-retry is the backstop).
package/install.sh CHANGED
@@ -20,6 +20,7 @@ set -u
20
20
  REPO_DIR="$(cd "$(dirname "$0")" && pwd -P)"
21
21
  # shellcheck source=lib/common.sh
22
22
  . "$REPO_DIR/lib/common.sh"
23
+ . "$REPO_DIR/lib/install_actions.sh"
23
24
  # common.sh is sourced in claude mode (ACC_ROOT = the claude pool); the codex pool
24
25
  # root is needed here too for the codex agents and uninstall/purge messaging.
25
26
  CODEX_ACC_ROOT="${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
@@ -31,6 +32,7 @@ NO_SCHEDULE=0
31
32
  INSTANCE_OVERRIDE=""
32
33
  while [ $# -gt 0 ]; do
33
34
  case "$1" in
35
+ --help|-h) cat "$REPO_DIR/lib/install-help.txt"; exit 0 ;;
34
36
  --uninstall) UNINSTALL=1; shift ;;
35
37
  --purge-data) PURGE=1; shift ;;
36
38
  --server) SERVER_OVERRIDE="${2:?--server requires a value}"; shift 2 ;;
@@ -127,50 +129,9 @@ cron_env_prefix() {
127
129
  printf "CLAUDE_ACCOUNTS_ROOT='%s' CODEX_ACCOUNTS_ROOT='%s' " "$ACC_ROOT" "$CODEX_ACC_ROOT"
128
130
  }
129
131
 
130
- is_pool_root() { # true when $1 is safe to delete as an account pool
131
- case "$1" in
132
- ''|/|"$HOME") return 1 ;;
133
- */.claude-accounts|*/.codex-accounts) return 0 ;;
134
- /*) [ -f "$1/accounts.json" ] ;;
135
- *) return 1 ;;
136
- esac
137
- }
138
-
139
- strip_block() { # remove our marked block from a file (portable, no sed -i)
140
- local f="$1"
141
- [ -f "$f" ] || return 0
142
- # Unmatched begin marker (end line hand-deleted): stripping would eat the rest
143
- # of the rc file. Leave it alone and say so.
144
- if grep -qF "$MARK_BEGIN" "$f" && ! grep -qF "$MARK_END" "$f"; then
145
- echo " WARNING: $f has an unterminated claude-multiacc block — fix it by hand; not touching this file" >&2
146
- return 1
147
- fi
148
- awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
149
- $0 == b { skip = 1; next }
150
- $0 == e { skip = 0; next }
151
- !skip { print }
152
- ' "$f" > "$f.claude-multiacc.tmp" && mv "$f.claude-multiacc.tmp" "$f"
153
- }
154
-
155
- path_block_body() {
156
- # Move (not just add) the shim dir to the front: later rc lines prepend
157
- # ~/.local/bin, so a plain add-once guard would leave the real binary first.
158
- printf 'PATH="$(printf %%s ":$PATH:" | sed '\''s|:%s/bin:|:|g; s|^:||; s|:$||'\'')"\n' "$REPO_DIR"
159
- printf 'export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
160
- }
161
-
162
- append_block() { # strip then append our PATH block to a file
163
- local f="$1"
164
- strip_block "$f" || return 1 # never create a second block next to a broken one
165
- {
166
- printf '%s\n' "$MARK_BEGIN"
167
- path_block_body
168
- printf '%s\n' "$MARK_END"
169
- } >> "$f"
170
- }
171
-
172
132
  mac_schedule_install() {
173
133
  mkdir -p "$HOME/Library/LaunchAgents"
134
+ . "$REPO_DIR/lib/install_calendar.sh"
174
135
  cat > "$PLIST_LIMITS" <<EOF
175
136
  <?xml version="1.0" encoding="UTF-8"?>
176
137
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -183,52 +144,12 @@ mac_schedule_install() {
183
144
  </array>
184
145
  $(plist_env_block) <key>StartInterval</key><integer>900</integer>
185
146
  <key>RunAtLoad</key><true/>
186
- <!-- The pass hands its readings to the peers from a detached push; launchd
187
- must not kill that push with the job's process group. -->
147
+ <!-- Preserve the detached telemetry push after this job exits. -->
188
148
  <key>AbandonProcessGroup</key><true/>
189
149
  <key>StandardOutPath</key><string>/dev/null</string>
190
150
  <key>StandardErrorPath</key><string>/dev/null</string>
191
151
  </dict></plist>
192
152
  EOF
193
- cat > "$PLIST_HEALTH" <<EOF
194
- <?xml version="1.0" encoding="UTF-8"?>
195
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
196
- <plist version="1.0"><dict>
197
- <key>Label</key><string>$LABEL.health</string>
198
- <key>ProgramArguments</key><array>
199
- <string>$REPO_DIR/bin/claude-accounts</string>
200
- <string>health</string>
201
- </array>
202
- $(plist_env_block) <key>StartCalendarInterval</key><dict>
203
- <key>Weekday</key><integer>1</integer>
204
- <key>Hour</key><integer>9</integer>
205
- <key>Minute</key><integer>17</integer>
206
- </dict>
207
- <key>StandardOutPath</key><string>/dev/null</string>
208
- <key>StandardErrorPath</key><string>/dev/null</string>
209
- </dict></plist>
210
- EOF
211
- # Daily auto-update (04:07). No-op unless this is an npm or git install.
212
- cat > "$PLIST_UPDATE" <<EOF
213
- <?xml version="1.0" encoding="UTF-8"?>
214
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
215
- <plist version="1.0"><dict>
216
- <key>Label</key><string>$LABEL.update</string>
217
- <key>ProgramArguments</key><array>
218
- <string>$REPO_DIR/bin/claude-accounts</string>
219
- <string>self-update</string>
220
- <string>--quiet</string>
221
- </array>
222
- $(plist_env_block) <key>StartCalendarInterval</key><dict>
223
- <key>Hour</key><integer>4</integer>
224
- <key>Minute</key><integer>7</integer>
225
- </dict>
226
- <key>StandardOutPath</key><string>/dev/null</string>
227
- <key>StandardErrorPath</key><string>/dev/null</string>
228
- </dict></plist>
229
- EOF
230
- # Codex pool: its own limits/health agents, so either provider's telemetry can
231
- # fail or be removed without touching the other's.
232
153
  cat > "$PLIST_CODEX_LIMITS" <<EOF
233
154
  <?xml version="1.0" encoding="UTF-8"?>
234
155
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -246,30 +167,11 @@ $(plist_env_block) <key>StartInterval</key><integer>300</integer>
246
167
  <key>StandardErrorPath</key><string>/dev/null</string>
247
168
  </dict></plist>
248
169
  EOF
249
- cat > "$PLIST_CODEX_HEALTH" <<EOF
250
- <?xml version="1.0" encoding="UTF-8"?>
251
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
252
- <plist version="1.0"><dict>
253
- <key>Label</key><string>$LABEL.codex-health</string>
254
- <key>ProgramArguments</key><array>
255
- <string>$REPO_DIR/bin/codex-accounts</string>
256
- <string>health</string>
257
- </array>
258
- $(plist_env_block) <key>StartCalendarInterval</key><dict>
259
- <key>Weekday</key><integer>1</integer>
260
- <key>Hour</key><integer>9</integer>
261
- <key>Minute</key><integer>37</integer>
262
- </dict>
263
- <key>StandardOutPath</key><string>/dev/null</string>
264
- <key>StandardErrorPath</key><string>/dev/null</string>
265
- </dict></plist>
266
- EOF
267
- launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
268
- launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
269
- launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
270
- launchctl unload "$PLIST_CODEX_LIMITS" 2>/dev/null || true
271
- launchctl unload "$PLIST_CODEX_HEALTH" 2>/dev/null || true
272
- launchctl load -w "$PLIST_LIMITS" 2>/dev/null || echo " (launchctl load limits agent failed — limits refresh will rely on the shim's opportunistic kick)"
170
+ local plist
171
+ for plist in "$PLIST_LIMITS" "$PLIST_HEALTH" "$PLIST_UPDATE" "$PLIST_CODEX_LIMITS" "$PLIST_CODEX_HEALTH"; do
172
+ launchctl unload "$plist" 2>/dev/null || true
173
+ done
174
+ launchctl load -w "$PLIST_LIMITS" 2>/dev/null || echo " (limits agent failed — the shim will refresh on demand)"
273
175
  launchctl load -w "$PLIST_HEALTH" 2>/dev/null || true
274
176
  launchctl load -w "$PLIST_CODEX_LIMITS" 2>/dev/null || true
275
177
  launchctl load -w "$PLIST_CODEX_HEALTH" 2>/dev/null || true
@@ -317,183 +219,6 @@ linux_schedule_remove() {
317
219
  rm -f "$tmp"
318
220
  }
319
221
 
320
- do_uninstall() {
321
- echo "claude-multiacc: uninstalling (restoring stock behavior)"
322
- # An instance install never wrote a shell rc block, a /etc/profile.d file or the
323
- # /usr/local/bin shims — those belong to the DEFAULT install and are shared with it.
324
- # Removing them here would disable the operator's shim while only one instance was
325
- # being uninstalled, so an instance removes exactly its own agents.
326
- if [ -n "$INSTANCE" ]; then
327
- echo " instance $INSTANCE: removing its agents only (PATH block and shims belong to the default install)"
328
- else
329
- strip_block "$HOME/.zshenv"
330
- strip_block "$HOME/.zprofile"
331
- strip_block "$HOME/.zshrc"
332
- strip_block "$HOME/.bashrc"
333
- strip_block "$HOME/.bash_profile"
334
- strip_block "$HOME/.profile"
335
- fi
336
- if [ "$(machine_kind)" = "mac" ]; then
337
- mac_schedule_remove
338
- else
339
- linux_schedule_remove
340
- if [ -z "$INSTANCE" ]; then
341
- [ -f "$PROFILED" ] && rm -f "$PROFILED"
342
- if [ -L /usr/local/bin/claude ] && [ "$(canon_path /usr/local/bin/claude)" = "$(canon_path "$REPO_DIR/bin/claude")" ]; then
343
- rm -f /usr/local/bin/claude
344
- echo " removed /usr/local/bin/claude shim symlink"
345
- fi
346
- if [ -L /usr/local/bin/codex ] && [ "$(canon_path /usr/local/bin/codex)" = "$(canon_path "$REPO_DIR/bin/codex")" ]; then
347
- rm -f /usr/local/bin/codex
348
- echo " removed /usr/local/bin/codex shim symlink"
349
- fi
350
- fi
351
- fi
352
- local root
353
- for root in "$ACC_ROOT" "$CODEX_ACC_ROOT"; do
354
- if [ "$PURGE" = "1" ]; then
355
- # Either the conventional location, or a directory that is provably a pool (it
356
- # holds a manifest) — an instance root lives anywhere, so the manifest is what
357
- # makes `rm -rf` safe. Anything else is left alone.
358
- if is_pool_root "$root"; then
359
- rm -rf "$root"; echo " purged $root"
360
- else
361
- echo " refusing to purge unusual accounts root: $root" >&2
362
- fi
363
- fi
364
- done
365
- if [ "$PURGE" != "1" ]; then
366
- echo " account data kept at $ACC_ROOT and $CODEX_ACC_ROOT (use --purge-data to remove)"
367
- fi
368
- echo "uninstall complete — stock claude behavior restored"
369
- }
370
-
371
- do_install() {
372
- local kind
373
- kind="$(machine_kind)"
374
- echo "claude-multiacc: installing (mode: $kind, repo: $REPO_DIR)"
375
- if [ -n "$INSTANCE" ]; then
376
- echo " instance: $INSTANCE (agents labelled $LABEL.*, pools $ACC_ROOT + $CODEX_ACC_ROOT)"
377
- fi
378
-
379
- [ -x "$REPO_DIR/bin/claude" ] || chmod +x "$REPO_DIR/bin/claude" 2>/dev/null || true
380
- [ -x "$REPO_DIR/bin/claude-accounts" ] || chmod +x "$REPO_DIR/bin/claude-accounts" 2>/dev/null || true
381
- [ -x "$REPO_DIR/bin/codex" ] || chmod +x "$REPO_DIR/bin/codex" 2>/dev/null || true
382
- [ -x "$REPO_DIR/bin/codex-accounts" ] || chmod +x "$REPO_DIR/bin/codex-accounts" 2>/dev/null || true
383
-
384
- local real
385
- if real="$(find_real_claude "$REPO_DIR/bin/claude")"; then
386
- echo " real claude binary: $real ($("$real" --version 2>/dev/null | head -1 || echo 'version unknown'))"
387
- else
388
- echo " WARNING: no real claude binary found yet — install Claude Code first (https://claude.com/claude-code)" >&2
389
- fi
390
- local real_codex
391
- if real_codex="$(find_real_codex "$REPO_DIR/bin/codex")"; then
392
- echo " real codex binary: $real_codex ($("$real_codex" --version 2>/dev/null | head -1 || echo 'version unknown'))"
393
- else
394
- echo " note: no codex binary found — the codex pool stays idle until Codex CLI is installed (npm i -g @openai/codex)"
395
- fi
396
-
397
- # Keychain-mode note: Claude Code keeps per-config-dir logins in the login Keychain
398
- # (one item per dir) whenever the session can open it, and only sessions without
399
- # keychain access (ssh, launchd background jobs) fall back to .credentials.json. The
400
- # pool reads both (lib/keychain.py); what an operator has to know is that a login
401
- # made from a GUI session is invisible to their ssh sessions — mint a portable
402
- # token for anything that must work from everywhere.
403
- if [ "$kind" = "mac" ] && [ ! -f "$HOME/.claude/.credentials.json" ] \
404
- && security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
405
- echo " note: this Mac keeps Claude Code logins in the Keychain. Per-dir logins made here"
406
- echo " are read from it; ssh/background sessions cannot open it (they see 'locked') —"
407
- echo " use 'claude-accounts mint <acct-NN>' for accounts that must work from everywhere."
408
- fi
409
-
410
- manifest_init "${SERVER_OVERRIDE:-$DEFAULT_SERVER}"
411
- if [ -n "$SERVER_OVERRIDE" ]; then
412
- "$PYBIN" - "$MANIFEST" "$SERVER_OVERRIDE" <<'PYEOF'
413
- import json, os, sys
414
- doc = json.load(open(sys.argv[1]))
415
- doc['server'] = sys.argv[2]
416
- with open(sys.argv[1] + '.tmp', 'w') as f:
417
- json.dump(doc, f, indent=2)
418
- f.write('\n')
419
- os.replace(sys.argv[1] + '.tmp', sys.argv[1])
420
- PYEOF
421
- fi
422
- if sync_target_is_local "$(sync_target)"; then
423
- echo " account pool: $ACC_ROOT (manifest ready; sync target: none — local-only)"
424
- else
425
- echo " account pool: $ACC_ROOT (manifest ready; sync target: $(sync_target))"
426
- fi
427
- # The codex pool gets its own skeleton + manifest (same schema, separate root).
428
- if ! "$REPO_DIR/bin/codex-accounts" init-pool "${SERVER_OVERRIDE:-}" >/dev/null 2>&1; then
429
- echo " WARNING: codex pool init failed (codex-accounts init-pool)" >&2
430
- else
431
- echo " codex account pool: $CODEX_ACC_ROOT (manifest ready)"
432
- fi
433
-
434
- # An INSTANCE install never rewrites the shell rc blocks: one interactive PATH
435
- # cannot serve two pools, and clobbering the default install's block would point
436
- # the operator's shell at an instance pool. Its agents (below) carry the roots
437
- # instead, and the runner daemon invokes the shims by absolute path.
438
- if [ -n "$INSTANCE" ]; then
439
- echo " PATH block: skipped (instance install) — for a shell against this pool:"
440
- echo " export CLAUDE_ACCOUNTS_ROOT='$ACC_ROOT' CODEX_ACCOUNTS_ROOT='$CODEX_ACC_ROOT'"
441
- echo " export PATH=\"$REPO_DIR/bin:\$PATH\""
442
- fi
443
- if [ "$kind" = "mac" ]; then
444
- # .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
445
- # over ~/.local/bin re-prepends done earlier in .zshrc/.zprofile.
446
- # zsh reads: .zshenv always; .zprofile for login; .zshrc for interactive.
447
- # The block must end each file that later re-prepends ~/.local/bin.
448
- if [ -z "$INSTANCE" ]; then
449
- touch "$HOME/.zshenv"
450
- append_block "$HOME/.zshenv"
451
- touch "$HOME/.zprofile"
452
- append_block "$HOME/.zprofile"
453
- touch "$HOME/.zshrc"
454
- append_block "$HOME/.zshrc"
455
- [ -f "$HOME/.bash_profile" ] && append_block "$HOME/.bash_profile"
456
- [ -f "$HOME/.bashrc" ] && append_block "$HOME/.bashrc"
457
- echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present)"
458
- fi
459
- [ "$NO_SCHEDULE" = "1" ] || mac_schedule_install
460
- else
461
- if [ -z "$INSTANCE" ]; then
462
- touch "$HOME/.bashrc"
463
- append_block "$HOME/.bashrc"
464
- if [ -w /etc/profile.d ] 2>/dev/null || [ "$(id -u)" = "0" ]; then
465
- {
466
- printf '%s\n' "$MARK_BEGIN"
467
- path_block_body
468
- printf '%s\n' "$MARK_END"
469
- } > "$PROFILED"
470
- echo " PATH block: ~/.bashrc + $PROFILED"
471
- fi
472
- fi
473
- if [ "$(id -u)" = "0" ]; then
474
- if [ -e /usr/local/bin/claude ] && [ ! -L /usr/local/bin/claude ]; then
475
- echo " WARNING: /usr/local/bin/claude exists and is a real file — NOT overwriting." >&2
476
- else
477
- ln -sfn "$REPO_DIR/bin/claude" /usr/local/bin/claude
478
- echo " shim: /usr/local/bin/claude -> $REPO_DIR/bin/claude (systemd-PATH compatible)"
479
- fi
480
- if [ -e /usr/local/bin/codex ] && [ ! -L /usr/local/bin/codex ]; then
481
- echo " WARNING: /usr/local/bin/codex exists and is a real file — NOT overwriting." >&2
482
- else
483
- ln -sfn "$REPO_DIR/bin/codex" /usr/local/bin/codex
484
- echo " shim: /usr/local/bin/codex -> $REPO_DIR/bin/codex (systemd-PATH compatible)"
485
- fi
486
- fi
487
- [ "$NO_SCHEDULE" = "1" ] || linux_schedule_install
488
- fi
489
-
490
- echo
491
- echo "install complete. Open a new shell (or 'export PATH=\"$REPO_DIR/bin:\$PATH\"'), then:"
492
- echo " claude-accounts list # the Claude Code pool"
493
- echo " codex-accounts list # the Codex pool"
494
- echo " claude-accounts status | codex-accounts status # auth + limits detail"
495
- }
496
-
497
222
  if [ "$UNINSTALL" = "1" ]; then
498
223
  do_uninstall
499
224
  else
package/lib/common.sh CHANGED
@@ -588,12 +588,101 @@ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
588
588
  PYEOF
589
589
  }
590
590
 
591
+ # ---- one index for the shared session tree -----------------------------------------
592
+ # codex refuses to start until its rollout index (state_<schema>.sqlite) has been
593
+ # backfilled from every rollout under $CODEX_HOME/sessions. The codex layout shares that
594
+ # tree across accounts (<acct>/sessions -> ~/.codex/sessions), so the index is shared
595
+ # the same way: <acct>/state_N.sqlite -> ~/.codex/state_N.sqlite. Otherwise every new
596
+ # account re-reads the whole tree (10 GB on a busy Mac) before its first launch, and a
597
+ # launch that meets another process's unfinished scan dies after 30 s with the CLI's
598
+ # "local database appears to be damaged" message. bin/codex applies the same at every
599
+ # launch (and pre-links the name the installed binary will create); seeding covers a
600
+ # CODEX_HOME handed straight to the real binary. The two CORE functions below are
601
+ # byte-identical with bin/codex — tests/run-tests.sh diffs them.
602
+ # CORE — byte-identical in bin/codex; tests diff them.
603
+ state_index_names() { # $1 acct dir, $2 home dir -> the index file names either side holds
604
+ local f name seen=" "
605
+ for f in "$2"/state_[0-9]*.sqlite "$1"/state_[0-9]*.sqlite; do
606
+ [ -e "$f" ] || [ -L "$f" ] || continue # an unmatched glob is the pattern itself
607
+ name="${f##*/}"
608
+ case "$seen" in *" $name "*) continue ;; esac
609
+ seen="$seen$name "
610
+ printf '%s\n' "$name"
611
+ done
612
+ }
613
+
614
+ # CORE — byte-identical in bin/codex; tests diff them.
615
+ shared_index_rejected() { # $1 acct dir, $2 index name, $3 shared target
616
+ # True when codex's own corruption recovery moved THIS account's link out of the way:
617
+ # it renames the database it judged damaged (and its -wal/-shm) into
618
+ # <CODEX_HOME>/db-backups/sqlite-<ts>-<n>/ and rebuilds, and under a link that renames
619
+ # the LINK. A link sitting in there pointing at the shared file is codex's verdict on
620
+ # that file, so this account keeps the index codex rebuilt for it and this name is
621
+ # left alone — handing the link back would hand the damage back.
622
+ local marker
623
+ for marker in "$1"/db-backups/*/"$2"; do
624
+ [ -L "$marker" ] || continue
625
+ [ "$(readlink "$marker" 2>/dev/null)" = "$3" ] && return 0
626
+ done
627
+ return 1
628
+ }
629
+
630
+ # CORE — byte-identical in bin/codex; tests diff them.
631
+ share_state_index_links() { # $1 acct dir, $2 home dir, $3 extra index name ('' for none)
632
+ local d="$1" home="$2" name names target link retired n
633
+ [ -L "$d" ] && return 0 # adopted: the dir IS the home
634
+ [ -L "$d/sessions" ] || return 0 # a private tree keeps its private index
635
+ [ "$(readlink "$d/sessions" 2>/dev/null)" = "$home/sessions" ] || return 0
636
+ [ -d "$home" ] || return 0
637
+ names="$(state_index_names "$d" "$home")"
638
+ case "${3:-}" in ''|*[!A-Za-z0-9_.]*) ;; *) names="$names $3" ;; esac
639
+ for name in $names; do
640
+ target="$home/$name"; link="$d/$name"
641
+ [ -L "$link" ] && continue # already shared (or pointed elsewhere on purpose)
642
+ shared_index_rejected "$d" "$name" "$target" && continue
643
+ if [ ! -e "$link" ]; then
644
+ ln -s "$target" "$link" 2>/dev/null || true # a new account: the whole point
645
+ continue
646
+ fi
647
+ # This account has an index of its own, and MOVING one that a process can still open
648
+ # is unsafe. codex holds the state database through an sqlx pool that opens its
649
+ # connections lazily and BY PATH (max_connections(5), create_if_missing(true) —
650
+ # codex-rs/state/src/sqlite.rs open_read_write_pool), so a rename under a live holder
651
+ # leaves connection 1 on the old inode while every connection the pool opens
652
+ # afterwards follows the new link: one process, two databases. A -wal or a -shm beside
653
+ # the file is that proof. A codex killed mid-write leaves them behind too, and the
654
+ # next clean session on the account removes them, so this heals itself in time.
655
+ { [ -e "$link-wal" ] || [ -e "$link-shm" ]; } && continue
656
+ if [ -e "$target" ]; then
657
+ retired="$link.private"; n=0 # never overwrite an earlier copy
658
+ while [ -e "$retired" ] && [ "$n" -lt 100 ]; do n=$((n+1)); retired="$link.private.$n"; done
659
+ [ -e "$retired" ] && continue
660
+ mv "$link" "$retired" 2>/dev/null || continue
661
+ else
662
+ # Atomic or nothing: link(2) refuses an existing target, so two shims racing to be
663
+ # the first to promote cannot rename one's fresh symlink onto the file the other
664
+ # just promoted — which is how a self-referential shared index (ELOOP, and every
665
+ # account on the Mac unable to start) could appear. A cross-device link simply
666
+ # fails and this account keeps its own index.
667
+ ln "$link" "$target" 2>/dev/null || continue
668
+ [ -L "$link" ] || rm -f "$link"
669
+ fi
670
+ ln -s "$target" "$link" 2>/dev/null || true
671
+ done
672
+ return 0
673
+ }
674
+
675
+ codex_share_state_index() { # $1 acct dir — seed time: whatever index names exist already
676
+ share_state_index_links "$1" "${HOME:-/nonexistent}/.codex" ""
677
+ }
678
+
591
679
  # Seed an account config dir so headless runs never prompt.
592
680
  # claude: stripped .claude.json, copied settings.json, shared projects/ symlink
593
681
  # (shared history => --continue/--resume work regardless of picked account).
594
682
  # codex: copied config.toml (carries project trust + settings, no identity),
595
- # shared sessions/ symlink (=> `codex resume` finds every session), and a
596
- # shared AGENTS.md symlink so global instructions apply under any account.
683
+ # shared sessions/ symlink (=> `codex resume` finds every session) with the
684
+ # shared rollout index that tree needs, and a shared AGENTS.md symlink so
685
+ # global instructions apply under any account.
597
686
  seed_account_dir() { # $1 = acct dir
598
687
  local d="$1"
599
688
  mkdir -p "$d"
@@ -608,6 +697,7 @@ seed_account_dir() { # $1 = acct dir
608
697
  if [ ! -e "$d/AGENTS.md" ] && [ -f "$HOME/.codex/AGENTS.md" ]; then
609
698
  ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
610
699
  fi
700
+ codex_share_state_index "$d"
611
701
  return 0
612
702
  fi
613
703
  if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
@@ -0,0 +1,20 @@
1
+ USAGE
2
+ claude-multiacc install [options]
3
+ ./install.sh [options]
4
+ claude-multiacc uninstall [--purge-data]
5
+ ./install.sh --uninstall [--purge-data]
6
+
7
+ OPTIONS
8
+ --server user@host set the sync target
9
+ --no-server use local-only pools
10
+ --instance NAME label this instance's scheduled jobs
11
+ --no-schedule install without scheduled jobs
12
+ --uninstall remove shell setup and jobs; keep account data
13
+ --purge-data also delete both account pools when uninstalling
14
+ -h, --help show this help without installing or uninstalling
15
+
16
+ Use CLAUDE_ACCOUNTS_ROOT and CODEX_ACCOUNTS_ROOT to select isolated pools.
17
+ After npm install -g claude-multiacc, both claude-accounts and codex-accounts
18
+ are available alongside claude-multiacc in npm's bin directory immediately.
19
+ Git/npx installs add the package's bin directory through shell startup files;
20
+ open a new shell or use the export PATH command printed by the installer.