claude-multiacc 1.0.7 → 1.0.8

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/install.sh CHANGED
@@ -11,6 +11,9 @@ set -u
11
11
  REPO_DIR="$(cd "$(dirname "$0")" && pwd -P)"
12
12
  # shellcheck source=lib/common.sh
13
13
  . "$REPO_DIR/lib/common.sh"
14
+ # common.sh is sourced in claude mode (ACC_ROOT = the claude pool); the codex pool
15
+ # root is needed here too for uninstall/purge messaging.
16
+ CODEX_ACC_ROOT="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
14
17
 
15
18
  MARK_BEGIN="# >>> claude-multiacc >>>"
16
19
  MARK_END="# <<< claude-multiacc <<<"
@@ -18,6 +21,8 @@ CRON_TAG="# claude-multiacc"
18
21
  PLIST_LIMITS="$HOME/Library/LaunchAgents/com.claude-multiacc.limits.plist"
19
22
  PLIST_HEALTH="$HOME/Library/LaunchAgents/com.claude-multiacc.health.plist"
20
23
  PLIST_UPDATE="$HOME/Library/LaunchAgents/com.claude-multiacc.update.plist"
24
+ PLIST_CODEX_LIMITS="$HOME/Library/LaunchAgents/com.claude-multiacc.codex-limits.plist"
25
+ PLIST_CODEX_HEALTH="$HOME/Library/LaunchAgents/com.claude-multiacc.codex-health.plist"
21
26
  PROFILED="/etc/profile.d/claude-multiacc.sh"
22
27
 
23
28
  UNINSTALL=0
@@ -122,21 +127,63 @@ EOF
122
127
  <key>StandardOutPath</key><string>/dev/null</string>
123
128
  <key>StandardErrorPath</key><string>/dev/null</string>
124
129
  </dict></plist>
130
+ EOF
131
+ # Codex pool: its own limits/health agents, so either provider's telemetry can
132
+ # fail or be removed without touching the other's.
133
+ cat > "$PLIST_CODEX_LIMITS" <<EOF
134
+ <?xml version="1.0" encoding="UTF-8"?>
135
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
136
+ <plist version="1.0"><dict>
137
+ <key>Label</key><string>com.claude-multiacc.codex-limits</string>
138
+ <key>ProgramArguments</key><array>
139
+ <string>$REPO_DIR/bin/codex-accounts</string>
140
+ <string>limits</string>
141
+ <string>--quiet</string>
142
+ </array>
143
+ <key>StartInterval</key><integer>60</integer>
144
+ <key>RunAtLoad</key><true/>
145
+ <key>StandardOutPath</key><string>/dev/null</string>
146
+ <key>StandardErrorPath</key><string>/dev/null</string>
147
+ </dict></plist>
148
+ EOF
149
+ cat > "$PLIST_CODEX_HEALTH" <<EOF
150
+ <?xml version="1.0" encoding="UTF-8"?>
151
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
152
+ <plist version="1.0"><dict>
153
+ <key>Label</key><string>com.claude-multiacc.codex-health</string>
154
+ <key>ProgramArguments</key><array>
155
+ <string>$REPO_DIR/bin/codex-accounts</string>
156
+ <string>health</string>
157
+ </array>
158
+ <key>StartCalendarInterval</key><dict>
159
+ <key>Weekday</key><integer>1</integer>
160
+ <key>Hour</key><integer>9</integer>
161
+ <key>Minute</key><integer>37</integer>
162
+ </dict>
163
+ <key>StandardOutPath</key><string>/dev/null</string>
164
+ <key>StandardErrorPath</key><string>/dev/null</string>
165
+ </dict></plist>
125
166
  EOF
126
167
  launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
127
168
  launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
128
169
  launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
170
+ launchctl unload "$PLIST_CODEX_LIMITS" 2>/dev/null || true
171
+ launchctl unload "$PLIST_CODEX_HEALTH" 2>/dev/null || true
129
172
  launchctl load -w "$PLIST_LIMITS" 2>/dev/null || echo " (launchctl load limits agent failed — limits refresh will rely on the shim's opportunistic kick)"
130
173
  launchctl load -w "$PLIST_HEALTH" 2>/dev/null || true
174
+ launchctl load -w "$PLIST_CODEX_LIMITS" 2>/dev/null || true
175
+ launchctl load -w "$PLIST_CODEX_HEALTH" 2>/dev/null || true
131
176
  [ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && launchctl load -w "$PLIST_UPDATE" 2>/dev/null || true
132
- echo " launchd: limits refresh every 60s + weekly health check + weekly auto-update"
177
+ echo " launchd: limits refresh every 60s (claude + codex) + weekly health checks + weekly auto-update"
133
178
  }
134
179
 
135
180
  mac_schedule_remove() {
136
181
  launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
137
182
  launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
138
183
  launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
139
- rm -f "$PLIST_LIMITS" "$PLIST_HEALTH" "$PLIST_UPDATE"
184
+ launchctl unload "$PLIST_CODEX_LIMITS" 2>/dev/null || true
185
+ launchctl unload "$PLIST_CODEX_HEALTH" 2>/dev/null || true
186
+ rm -f "$PLIST_LIMITS" "$PLIST_HEALTH" "$PLIST_UPDATE" "$PLIST_CODEX_LIMITS" "$PLIST_CODEX_HEALTH"
140
187
  }
141
188
 
142
189
  linux_schedule_install() {
@@ -145,13 +192,15 @@ linux_schedule_install() {
145
192
  { crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true; } > "$tmp"
146
193
  {
147
194
  echo "* * * * * $REPO_DIR/bin/claude-accounts limits --quiet >/dev/null 2>&1 $CRON_TAG"
195
+ echo "* * * * * $REPO_DIR/bin/codex-accounts limits --quiet >/dev/null 2>&1 $CRON_TAG"
148
196
  echo "17 9 * * 1 $REPO_DIR/bin/claude-accounts health >/dev/null 2>&1 $CRON_TAG"
197
+ echo "37 9 * * 1 $REPO_DIR/bin/codex-accounts health >/dev/null 2>&1 $CRON_TAG"
149
198
  [ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && \
150
199
  echo "7 4 * * 0 $REPO_DIR/bin/claude-accounts self-update --quiet >/dev/null 2>&1 $CRON_TAG"
151
200
  } >> "$tmp"
152
201
  crontab "$tmp"
153
202
  rm -f "$tmp"
154
- echo " cron: limits refresh every 60s + weekly health check + weekly auto-update"
203
+ echo " cron: limits refresh every 60s (claude + codex) + weekly health checks + weekly auto-update"
155
204
  }
156
205
 
157
206
  linux_schedule_remove() {
@@ -179,14 +228,22 @@ do_uninstall() {
179
228
  rm -f /usr/local/bin/claude
180
229
  echo " removed /usr/local/bin/claude shim symlink"
181
230
  fi
231
+ if [ -L /usr/local/bin/codex ] && [ "$(canon_path /usr/local/bin/codex)" = "$(canon_path "$REPO_DIR/bin/codex")" ]; then
232
+ rm -f /usr/local/bin/codex
233
+ echo " removed /usr/local/bin/codex shim symlink"
234
+ fi
182
235
  fi
183
- if [ "$PURGE" = "1" ]; then
184
- case "$ACC_ROOT" in
185
- */.claude-accounts) rm -rf "$ACC_ROOT"; echo " purged $ACC_ROOT" ;;
186
- *) echo " refusing to purge unusual ACC_ROOT: $ACC_ROOT" >&2 ;;
187
- esac
188
- else
189
- echo " account data kept at $ACC_ROOT (use --purge-data to remove)"
236
+ local root
237
+ for root in "$ACC_ROOT" "$CODEX_ACC_ROOT"; do
238
+ if [ "$PURGE" = "1" ]; then
239
+ case "$root" in
240
+ */.claude-accounts|*/.codex-accounts) rm -rf "$root"; echo " purged $root" ;;
241
+ *) echo " refusing to purge unusual accounts root: $root" >&2 ;;
242
+ esac
243
+ fi
244
+ done
245
+ if [ "$PURGE" != "1" ]; then
246
+ echo " account data kept at $ACC_ROOT and $CODEX_ACC_ROOT (use --purge-data to remove)"
190
247
  fi
191
248
  echo "uninstall complete — stock claude behavior restored"
192
249
  }
@@ -198,6 +255,8 @@ do_install() {
198
255
 
199
256
  [ -x "$REPO_DIR/bin/claude" ] || chmod +x "$REPO_DIR/bin/claude" 2>/dev/null || true
200
257
  [ -x "$REPO_DIR/bin/claude-accounts" ] || chmod +x "$REPO_DIR/bin/claude-accounts" 2>/dev/null || true
258
+ [ -x "$REPO_DIR/bin/codex" ] || chmod +x "$REPO_DIR/bin/codex" 2>/dev/null || true
259
+ [ -x "$REPO_DIR/bin/codex-accounts" ] || chmod +x "$REPO_DIR/bin/codex-accounts" 2>/dev/null || true
201
260
 
202
261
  local real
203
262
  if real="$(find_real_claude "$REPO_DIR/bin/claude")"; then
@@ -205,6 +264,12 @@ do_install() {
205
264
  else
206
265
  echo " WARNING: no real claude binary found yet — install Claude Code first (https://claude.com/claude-code)" >&2
207
266
  fi
267
+ local real_codex
268
+ if real_codex="$(find_real_codex "$REPO_DIR/bin/codex")"; then
269
+ echo " real codex binary: $real_codex ($("$real_codex" --version 2>/dev/null | head -1 || echo 'version unknown'))"
270
+ else
271
+ echo " note: no codex binary found — the codex pool stays idle until Codex CLI is installed (npm i -g @openai/codex)"
272
+ fi
208
273
 
209
274
  # Keychain-mode detection: per-dir /login isolation needs file-based credentials.
210
275
  if [ "$kind" = "mac" ] && [ ! -f "$HOME/.claude/.credentials.json" ] \
@@ -226,6 +291,12 @@ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
226
291
  PYEOF
227
292
  fi
228
293
  echo " account pool: $ACC_ROOT (manifest ready)"
294
+ # The codex pool gets its own skeleton + manifest (same schema, separate root).
295
+ if ! "$REPO_DIR/bin/codex-accounts" init-pool "${SERVER_OVERRIDE:-}" >/dev/null 2>&1; then
296
+ echo " WARNING: codex pool init failed (codex-accounts init-pool)" >&2
297
+ else
298
+ echo " codex account pool: $CODEX_ACC_ROOT (manifest ready)"
299
+ fi
229
300
 
230
301
  if [ "$kind" = "mac" ]; then
231
302
  # .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
@@ -260,14 +331,21 @@ PYEOF
260
331
  ln -sfn "$REPO_DIR/bin/claude" /usr/local/bin/claude
261
332
  echo " shim: /usr/local/bin/claude -> $REPO_DIR/bin/claude (systemd-PATH compatible)"
262
333
  fi
334
+ if [ -e /usr/local/bin/codex ] && [ ! -L /usr/local/bin/codex ]; then
335
+ echo " WARNING: /usr/local/bin/codex exists and is a real file — NOT overwriting." >&2
336
+ else
337
+ ln -sfn "$REPO_DIR/bin/codex" /usr/local/bin/codex
338
+ echo " shim: /usr/local/bin/codex -> $REPO_DIR/bin/codex (systemd-PATH compatible)"
339
+ fi
263
340
  fi
264
341
  [ "$NO_SCHEDULE" = "1" ] || linux_schedule_install
265
342
  fi
266
343
 
267
344
  echo
268
345
  echo "install complete. Open a new shell (or 'export PATH=\"$REPO_DIR/bin:\$PATH\"'), then:"
269
- echo " claude-accounts list # see the pool"
270
- echo " claude-accounts status # auth + limits detail"
346
+ echo " claude-accounts list # the Claude Code pool"
347
+ echo " codex-accounts list # the Codex pool"
348
+ echo " claude-accounts status | codex-accounts status # auth + limits detail"
271
349
  }
272
350
 
273
351
  if [ "$UNINSTALL" = "1" ]; then
@@ -0,0 +1,238 @@
1
+ """Shared codex account-auth audit — the ONE place that decides whether a codex
2
+ account's login still works, so `expired`, `relogin`, `list` and `status` can never
3
+ disagree with the shim's selection rule (bin/codex: expired_marked / auth_dead).
4
+
5
+ Codex auth material is a single $CODEX_HOME/auth.json written by `codex login`:
6
+ {"auth_mode": "chatgpt", "OPENAI_API_KEY": null,
7
+ "tokens": {"id_token": JWT, "access_token": JWT, "refresh_token": str,
8
+ "account_id": str},
9
+ "last_refresh": ISO}
10
+ The access token is a JWT whose `exp` claim is readable offline; the refresh token
11
+ is opaque (no readable expiry), so a dead refresh grant is only ever known from a
12
+ marker written by a failed refresh/verify — exactly like the shim assumes.
13
+
14
+ States:
15
+ ok usable right now (live access token, or expired-but-refreshable)
16
+ expired has auth material that CANNOT authenticate — needs `codex-accounts relogin`
17
+ blocked authenticates, but Codex access is disabled for it by a workspace admin —
18
+ a re-login usually re-issues the grant; if it stays blocked, an admin must act
19
+ missing no auth on this machine, and this machine is supposed to own the grant
20
+ remote no auth here, but the manifest says another machine owns it (informational)
21
+
22
+ Only 'ok' accounts are selectable; everything else is excluded by the shim.
23
+
24
+ Run directly for a TSV dump: python3 lib/codex_audit.py <acc-root> [mac|linux]
25
+ Columns: id, email, home, state, label, reason, fix
26
+ """
27
+
28
+ import base64
29
+ import json
30
+ import os
31
+ import re
32
+ import sys
33
+ import time
34
+
35
+ VALID_ID = re.compile(r'acct-\d{2}')
36
+
37
+
38
+ def _mtime(path):
39
+ try:
40
+ return os.path.getmtime(path)
41
+ except OSError:
42
+ return 0.0
43
+
44
+
45
+ def _nonempty(path):
46
+ try:
47
+ return os.path.getsize(path) > 0
48
+ except OSError:
49
+ return False
50
+
51
+
52
+ def marker_reason(mpath):
53
+ """Second line of a marker file, as a human string ('' when absent/unreadable)."""
54
+ try:
55
+ lines = open(mpath, errors='replace').read().splitlines()
56
+ except OSError:
57
+ return ''
58
+ return lines[1].strip() if len(lines) > 1 else ''
59
+
60
+
61
+ def marker_slug(detail):
62
+ """The reason=<slug> field of a marker line ('' when absent)."""
63
+ m = re.search(r'reason=([A-Za-z0-9._-]+)', detail or '')
64
+ return m.group(1) if m else ''
65
+
66
+
67
+ def expired_marked(d, now=None):
68
+ """True when <d>/.expired is still in force. Mirrors the shim exactly:
69
+ a CREDENTIAL-scoped park clears as soon as a newer auth.json lands (re-login, or a
70
+ refresh by another process); a POLICY park (org-blocked) does not, because a token
71
+ refresh says nothing about whether the workspace re-enabled Codex. Either kind also
72
+ expires at its own soft_until when the shim wrote it from a single failed run."""
73
+ now = time.time() if now is None else now
74
+ mpath = os.path.join(d, '.expired')
75
+ if not os.path.isfile(mpath):
76
+ return False
77
+ detail = marker_reason(mpath)
78
+ if marker_slug(detail) != 'org-blocked':
79
+ mt = _mtime(mpath)
80
+ p = os.path.join(d, 'auth.json')
81
+ if os.path.isfile(p) and _mtime(p) > mt:
82
+ return False
83
+ m = re.search(r'soft_until=(\d+)', detail)
84
+ if m and int(m.group(1)) <= now:
85
+ return False
86
+ return True
87
+
88
+
89
+ def jwt_claims(token):
90
+ """Decode a JWT payload without verification (identity/expiry live client-side
91
+ anyway). Returns {} for anything that is not a well-formed JWT."""
92
+ try:
93
+ payload = str(token).split('.')[1]
94
+ payload += '=' * (-len(payload) % 4)
95
+ claims = json.loads(base64.urlsafe_b64decode(payload))
96
+ return claims if isinstance(claims, dict) else {}
97
+ except Exception:
98
+ return {}
99
+
100
+
101
+ def auth_email(cpath):
102
+ """The signed-in identity, read offline from the id_token ('' when unknown)."""
103
+ try:
104
+ doc = json.load(open(cpath))
105
+ tokens = doc.get('tokens') or {}
106
+ return str(jwt_claims(tokens.get('id_token')).get('email') or '')
107
+ except Exception:
108
+ return ''
109
+
110
+
111
+ def creds_state(cpath, now):
112
+ """(state, reason) for an on-disk auth.json."""
113
+ try:
114
+ doc = json.load(open(cpath))
115
+ if not isinstance(doc, dict):
116
+ raise ValueError('auth.json is not an object')
117
+ except Exception as e:
118
+ # Unparseable (truncated / mid-write): the shim still selects this account
119
+ # (it only checks for a non-empty access_token string), so fall back to the
120
+ # same lenient rule — a scrape-able access token keeps it in the pool.
121
+ try:
122
+ txt = open(cpath, errors='replace').read()
123
+ except OSError:
124
+ return 'expired', f'auth.json unreadable ({str(e)[:60]})'
125
+ if re.search(r'"access_token"\s*:\s*"[^"]', txt):
126
+ return 'ok', 'auth.json unreadable but carries a token — left in the pool'
127
+ return 'expired', f'auth.json unreadable ({str(e)[:60]})'
128
+ tokens = doc.get('tokens')
129
+ if not isinstance(tokens, dict) or not tokens.get('access_token'):
130
+ if doc.get('OPENAI_API_KEY'):
131
+ # Subscription-only by design (same rule as the claude pool: no API keys).
132
+ return 'expired', ('signed in with an API key — not supported; '
133
+ 'use the ChatGPT subscription login')
134
+ return 'expired', 'auth.json has no ChatGPT tokens'
135
+ exp = jwt_claims(tokens['access_token']).get('exp')
136
+ try:
137
+ exp = float(exp)
138
+ except (TypeError, ValueError):
139
+ exp = 0.0
140
+ if exp > now:
141
+ return 'ok', 'chatgpt access token valid'
142
+ if not tokens.get('refresh_token'):
143
+ return 'expired', 'access token expired and there is no refresh token'
144
+ # The refresh token is opaque (no readable expiry): assume refreshable. A dead
145
+ # grant is detected by the limits refresher / verify, which write `.expired`.
146
+ return 'ok', 'access token stale but auto-refreshes'
147
+
148
+
149
+ LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED',
150
+ 'missing': 'NO LOGIN', 'remote': 'ELSEWHERE'}
151
+
152
+ # `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
153
+ # two vocabularies — normalize, or an un-authenticated account on the very machine that
154
+ # owns it would be filed as "lives elsewhere" and silently drop off the worklist.
155
+ _MACHINE_ALIASES = {'mac': 'mac', 'macos': 'mac', 'darwin': 'mac', 'osx': 'mac',
156
+ 'server': 'linux', 'linux': 'linux', 'ubuntu': 'linux',
157
+ 'debian': 'linux', 'remote': 'linux'}
158
+
159
+
160
+ def _norm_machine(name):
161
+ return _MACHINE_ALIASES.get(str(name or '').strip().lower(), '')
162
+
163
+
164
+ def _elsewhere(home, machine):
165
+ """True only when the manifest names a DIFFERENT machine as the grant's owner.
166
+ An unknown/blank home is never treated as 'elsewhere' — that would hide a real gap."""
167
+ h, m = _norm_machine(home), _norm_machine(machine)
168
+ return bool(h) and bool(m) and h != m
169
+
170
+
171
+ def _fix_for(state, aid):
172
+ if state == 'blocked':
173
+ return (f'codex-accounts relogin {aid} '
174
+ f'(if it stays BLOCKED, a workspace admin must enable Codex for it)')
175
+ if state in ('expired', 'missing'):
176
+ return f'codex-accounts relogin {aid}'
177
+ return ''
178
+
179
+
180
+ def audit_account(root, acct, now=None, machine=None):
181
+ now = time.time() if now is None else now
182
+ aid = acct.get('id', '')
183
+ d = os.path.join(root, aid)
184
+ cpath = os.path.join(d, 'auth.json')
185
+ has_creds = _nonempty(cpath)
186
+ home = acct.get('home', '?')
187
+ row = {'id': aid, 'email': acct.get('email', ''), 'home': home,
188
+ 'state': 'ok', 'reason': ''}
189
+
190
+ def done(r):
191
+ r['label'] = LABELS.get(r['state'], r['state'].upper())
192
+ r['fix'] = _fix_for(r['state'], aid)
193
+ return r
194
+
195
+ if expired_marked(d, now=now):
196
+ detail = marker_reason(os.path.join(d, '.expired'))
197
+ slug = marker_slug(detail)
198
+ if slug == 'org-blocked':
199
+ row['state'] = 'blocked'
200
+ row['reason'] = ('Codex access is disabled for this account by a '
201
+ 'workspace admin')
202
+ else:
203
+ row['state'] = 'expired'
204
+ row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
205
+ return done(row)
206
+ if has_creds:
207
+ state, reason = creds_state(cpath, now)
208
+ row['state'] = state
209
+ row['reason'] = reason
210
+ return done(row)
211
+ if machine and _elsewhere(home, machine):
212
+ row['state'] = 'remote'
213
+ row['reason'] = f'no auth here — the grant lives on the {home} machine'
214
+ else:
215
+ row['state'] = 'missing'
216
+ row['reason'] = 'no credentials on this machine'
217
+ return done(row)
218
+
219
+
220
+ def audit_all(root, machine=None, now=None):
221
+ try:
222
+ doc = json.load(open(os.path.join(root, 'accounts.json')))
223
+ except Exception:
224
+ return []
225
+ out = []
226
+ for a in doc.get('accounts', []):
227
+ if isinstance(a, dict) and VALID_ID.fullmatch(str(a.get('id', ''))):
228
+ out.append(audit_account(root, a, now=now, machine=machine))
229
+ return out
230
+
231
+
232
+ if __name__ == '__main__':
233
+ root = sys.argv[1]
234
+ machine = sys.argv[2] if len(sys.argv) > 2 else None
235
+ for r in audit_all(root, machine=machine):
236
+ # Tabs are the field separator, so no field may contain one.
237
+ print('\t'.join(str(r[k]).replace('\t', ' ')
238
+ for k in ('id', 'email', 'home', 'state', 'label', 'reason', 'fix')))
package/lib/common.sh CHANGED
@@ -1,18 +1,37 @@
1
1
  #!/usr/bin/env bash
2
2
  # lib/common.sh — claude-multiacc shared helpers.
3
- # Sourced by bin/claude-accounts and install.sh. Bash 3.2 compatible (macOS stock).
3
+ # Sourced by bin/claude-accounts, bin/codex-accounts and install.sh.
4
+ # Bash 3.2 compatible (macOS stock).
5
+ #
6
+ # PROVIDER SUPPORT: the same pool machinery serves two independent providers —
7
+ # claude (Claude Code, the default) and codex (OpenAI Codex CLI). A caller opts
8
+ # into codex by exporting MULTIACC_PROVIDER=codex BEFORE sourcing this file; every
9
+ # path/endpoint global below then points at the codex pool. The two pools are
10
+ # completely separate on disk (~/.claude-accounts vs ~/.codex-accounts), so
11
+ # nothing an operation does on one provider can ever touch the other's accounts.
4
12
  # shellcheck disable=SC2034
5
13
 
6
- ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}"
7
- MANIFEST="$ACC_ROOT/accounts.json"
14
+ MULTIACC_PROVIDER="${MULTIACC_PROVIDER:-claude}"
8
15
  PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
9
16
  DEFAULT_SERVER="root@138.197.36.107"
10
- DEFAULT_SERVER_ROOT="/root/.claude-accounts"
11
17
  DEFAULT_SERVER_REPO="/root/claude-multiacc"
12
- USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
13
18
 
14
19
  LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"
15
- AUDIT_PY="$LIB_DIR/audit.py"
20
+
21
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then
22
+ ACC_ROOT="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
23
+ DEFAULT_SERVER_ROOT="/root/.codex-accounts"
24
+ USAGE_URL="${CODEX_MULTIACC_USAGE_URL:-https://chatgpt.com/backend-api/codex/usage}"
25
+ AUDIT_PY="$LIB_DIR/codex_audit.py"
26
+ PROVIDER_CLI="codex-accounts"
27
+ else
28
+ ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}"
29
+ DEFAULT_SERVER_ROOT="/root/.claude-accounts"
30
+ USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
31
+ AUDIT_PY="$LIB_DIR/audit.py"
32
+ PROVIDER_CLI="claude-accounts"
33
+ fi
34
+ MANIFEST="$ACC_ROOT/accounts.json"
16
35
 
17
36
  ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
18
37
  epoch_now() { date +%s; }
@@ -23,8 +42,8 @@ log_to() { # log_to <file-in-acc-root> <msg...>
23
42
  printf '%s %s\n' "$(ts_utc)" "$*" >> "$ACC_ROOT/$f" 2>/dev/null || true
24
43
  }
25
44
 
26
- die() { printf 'claude-accounts: error: %s\n' "$*" >&2; exit 1; }
27
- warn() { printf 'claude-accounts: warning: %s\n' "$*" >&2; }
45
+ die() { printf '%s: error: %s\n' "$PROVIDER_CLI" "$*" >&2; exit 1; }
46
+ warn() { printf '%s: warning: %s\n' "$PROVIDER_CLI" "$*" >&2; }
28
47
 
29
48
  machine_kind() { case "$(uname -s)" in Darwin) echo mac ;; *) echo linux ;; esac; }
30
49
 
@@ -47,14 +66,16 @@ canon_path() { # resolve symlinks (loop-guarded) and normalize; always prints so
47
66
  if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
48
67
  }
49
68
 
50
- is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q claude-multiacc-shim; }
69
+ # Matches both shims: bin/claude ("claude-multiacc-shim") and bin/codex
70
+ # ("codex-multiacc-shim") — find_real_bin must skip either, whatever it scans for.
71
+ is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q multiacc-shim; }
51
72
 
52
- # Prints an INVOCABLE path to the real claude binary (kept as the symlink path,
73
+ # Prints an INVOCABLE path to the real <name> binary (kept as the symlink path,
53
74
  # not the canonical target, so process cmdlines keep matching monitors like
54
- # `pgrep -f '.local/bin/claude'`). $1 = path of the caller to exclude (optional).
55
- find_real_claude() {
56
- local self_c cand c d
57
- self_c="$(canon_path "${1:-${BASH_SOURCE[0]}}")"
75
+ # `pgrep -f '.local/bin/claude'`). $1 = binary name, $2 = caller path to exclude.
76
+ find_real_bin() {
77
+ local name="$1" self_c cand c d
78
+ self_c="$(canon_path "${2:-${BASH_SOURCE[0]}}")"
58
79
  local oldifs="$IFS"
59
80
  IFS=':'; set -f
60
81
  # shellcheck disable=SC2086
@@ -62,7 +83,7 @@ find_real_claude() {
62
83
  IFS="$oldifs"; set +f
63
84
  for d in "$@"; do
64
85
  [ -n "$d" ] || continue
65
- cand="$d/claude"
86
+ cand="$d/$name"
66
87
  [ -f "$cand" ] && [ -x "$cand" ] || continue
67
88
  c="$(canon_path "$cand")"
68
89
  [ "$c" = "$self_c" ] && continue
@@ -70,7 +91,7 @@ find_real_claude() {
70
91
  is_shim_file "$c" && continue
71
92
  printf '%s\n' "$cand"; return 0
72
93
  done
73
- for cand in "$HOME/.local/bin/claude" /usr/local/bin/claude /opt/homebrew/bin/claude /usr/bin/claude; do
94
+ for cand in "$HOME/.local/bin/$name" "/usr/local/bin/$name" "/opt/homebrew/bin/$name" "/usr/bin/$name"; do
74
95
  [ -f "$cand" ] && [ -x "$cand" ] || continue
75
96
  c="$(canon_path "$cand")"
76
97
  [ "$c" = "$self_c" ] && continue
@@ -80,6 +101,9 @@ find_real_claude() {
80
101
  return 1
81
102
  }
82
103
 
104
+ find_real_claude() { find_real_bin claude "${1:-}"; }
105
+ find_real_codex() { find_real_bin codex "${1:-}"; }
106
+
83
107
  manifest_get() { # manifest_get <dot.path> [default]
84
108
  local val
85
109
  if [ -f "$MANIFEST" ]; then
@@ -136,7 +160,11 @@ valid_ssh_target() {
136
160
  acct_dir() { printf '%s/%s\n' "$ACC_ROOT" "$1"; }
137
161
 
138
162
  has_local_auth() { # $1 = acct dir
139
- [ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
163
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then
164
+ [ -s "$1/auth.json" ]
165
+ else
166
+ [ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
167
+ fi
140
168
  }
141
169
 
142
170
  # ---- expired-login bookkeeping -------------------------------------------------
@@ -173,7 +201,7 @@ account_audit() {
173
201
  out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" 2>"$err")"
174
202
  rc=$?
175
203
  if [ "$rc" -ne 0 ]; then
176
- printf 'claude-accounts: cannot audit accounts (%s)\n' "$(tail -1 "$err" 2>/dev/null)" >&2
204
+ printf '%s: cannot audit accounts (%s)\n' "$PROVIDER_CLI" "$(tail -1 "$err" 2>/dev/null)" >&2
177
205
  rm -f "$err" 2>/dev/null
178
206
  return 1
179
207
  fi
@@ -196,10 +224,13 @@ accounts_needing_login() {
196
224
  | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
197
225
  }
198
226
 
199
- # True when <acct dir>'s credential can authenticate right now (audit.py's rule).
227
+ # True when <acct dir>'s credential can authenticate right now (the audit module's
228
+ # rule — audit.py for claude, codex_audit.py for codex; both expose creds_state).
200
229
  creds_alive() { # $1 = acct dir
201
- [ -s "$1/.credentials.json" ] || return 1
202
- "$PYBIN" - "$AUDIT_PY" "$1/.credentials.json" <<'PYEOF' 2>/dev/null
230
+ local cred
231
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then cred="$1/auth.json"; else cred="$1/.credentials.json"; fi
232
+ [ -s "$cred" ] || return 1
233
+ "$PYBIN" - "$AUDIT_PY" "$cred" <<'PYEOF' 2>/dev/null
203
234
  import importlib.util, sys, time
204
235
  spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
205
236
  mod = importlib.util.module_from_spec(spec)
@@ -230,13 +261,28 @@ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
230
261
  PYEOF
231
262
  }
232
263
 
233
- # Seed an account config dir so headless runs never prompt: stripped .claude.json,
234
- # copied settings.json, shared projects/ symlink (shared history => --continue/--resume
235
- # work regardless of which account was picked).
264
+ # Seed an account config dir so headless runs never prompt.
265
+ # claude: stripped .claude.json, copied settings.json, shared projects/ symlink
266
+ # (shared history => --continue/--resume work regardless of picked account).
267
+ # codex: copied config.toml (carries project trust + settings, no identity),
268
+ # shared sessions/ symlink (=> `codex resume` finds every session), and a
269
+ # shared AGENTS.md symlink so global instructions apply under any account.
236
270
  seed_account_dir() { # $1 = acct dir
237
271
  local d="$1"
238
272
  mkdir -p "$d"
239
273
  chmod 700 "$d"
274
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then
275
+ if [ ! -f "$d/config.toml" ] && [ -f "$HOME/.codex/config.toml" ]; then
276
+ cp "$HOME/.codex/config.toml" "$d/config.toml" 2>/dev/null || true
277
+ fi
278
+ if [ ! -e "$d/sessions" ] && [ -d "$HOME/.codex/sessions" ]; then
279
+ ln -s "$HOME/.codex/sessions" "$d/sessions" 2>/dev/null || true
280
+ fi
281
+ if [ ! -e "$d/AGENTS.md" ] && [ -f "$HOME/.codex/AGENTS.md" ]; then
282
+ ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
283
+ fi
284
+ return 0
285
+ fi
240
286
  if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
241
287
  "$PYBIN" - "$HOME/.claude.json" "$d/.claude.json" <<'PYEOF' 2>/dev/null || true
242
288
  import json, sys
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.7",
4
- "description": "Multi-account addon for Claude Code: every claude / claude -p runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
3
+ "version": "1.0.8",
4
+ "description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-multiacc": "bin/cli.mjs"
@@ -18,6 +18,10 @@
18
18
  "keywords": [
19
19
  "claude",
20
20
  "claude-code",
21
+ "codex",
22
+ "codex-cli",
23
+ "openai",
24
+ "chatgpt",
21
25
  "anthropic",
22
26
  "multi-account",
23
27
  "subscription",