claude-multiacc 1.0.0
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/CLAUDE_ACCS_TASK.md +236 -0
- package/README.md +263 -0
- package/bin/claude +359 -0
- package/bin/claude-accounts +1127 -0
- package/bin/cli.mjs +112 -0
- package/install.sh +277 -0
- package/lib/common.sh +200 -0
- package/package.json +55 -0
- package/scripts/postinstall.mjs +32 -0
- package/tests/run-tests.sh +643 -0
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# claude-accounts — manage the claude-multiacc account pool.
|
|
3
|
+
# Subcommands: list status add import remove mint sync verify limits post-sync health
|
|
4
|
+
set -u
|
|
5
|
+
|
|
6
|
+
_self="$0"
|
|
7
|
+
while [ -L "$_self" ]; do
|
|
8
|
+
_t="$(readlink "$_self")"
|
|
9
|
+
case "$_t" in /*) _self="$_t" ;; *) _self="$(dirname "$_self")/$_t" ;; esac
|
|
10
|
+
done
|
|
11
|
+
BIN_DIR="$(cd "$(dirname "$_self")" && pwd -P)"
|
|
12
|
+
REPO_DIR="$(dirname "$BIN_DIR")"
|
|
13
|
+
# shellcheck source=lib/common.sh
|
|
14
|
+
. "$REPO_DIR/lib/common.sh"
|
|
15
|
+
|
|
16
|
+
usage() {
|
|
17
|
+
cat <<'EOF'
|
|
18
|
+
claude-accounts — multi-account pool manager for claude-multiacc
|
|
19
|
+
|
|
20
|
+
USAGE
|
|
21
|
+
claude-accounts list brief account list
|
|
22
|
+
claude-accounts status full health: auth, per-bucket limits, markers
|
|
23
|
+
claude-accounts add <email> [--force|--tui]
|
|
24
|
+
login-FIRST: shows a sign-in link, you paste the browser code back, and the
|
|
25
|
+
account is registered only after auth is verified. Duplicate emails refused
|
|
26
|
+
(--force overrides). --tui uses interactive /login for full OAuth creds.
|
|
27
|
+
claude-accounts login <acct-NN> [--force] complete/refresh auth for an existing
|
|
28
|
+
account (same link+code flow)
|
|
29
|
+
claude-accounts import <email> [opts] register an account, optionally with credentials
|
|
30
|
+
--id acct-NN explicit id (default: next free)
|
|
31
|
+
--home mac|server which machine owns the OAuth grant (default: this one)
|
|
32
|
+
--creds PATH existing .credentials.json to adopt
|
|
33
|
+
--mode copy|move|link how to adopt --creds (default copy)
|
|
34
|
+
--token-file PATH|- long-lived token for server use (- reads stdin)
|
|
35
|
+
--no-sync skip the automatic server sync
|
|
36
|
+
claude-accounts adopt <acct-NN> make acct-NN THIS machine's existing
|
|
37
|
+
default ~/.claude login (dir symlink —
|
|
38
|
+
single credential file, no grant fork)
|
|
39
|
+
claude-accounts remove <acct-NN> [--yes] delete account (propagates to server)
|
|
40
|
+
claude-accounts mint <acct-NN> mint server token via `claude setup-token`
|
|
41
|
+
--paste paste an already-minted token instead of running setup-token
|
|
42
|
+
claude-accounts sync push manifest+tokens to the server (Mac only)
|
|
43
|
+
claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
|
|
44
|
+
claude-accounts limits [--quiet] [--force]
|
|
45
|
+
refresh usage buckets, apply >=90% markers. Skips accounts fetched in the
|
|
46
|
+
last 45s and honors 429 backoff; --force ignores both.
|
|
47
|
+
claude-accounts health limits + full verify; logs to health.log
|
|
48
|
+
claude-accounts self-update update the addon (npm i -g @latest, or git
|
|
49
|
+
pull + reinstall); logs to update.log
|
|
50
|
+
claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
|
|
51
|
+
|
|
52
|
+
ENV
|
|
53
|
+
CLAUDE_ACCOUNTS_DIR override ~/.claude-accounts
|
|
54
|
+
CLAUDE_ACCOUNT pin the shim to one account
|
|
55
|
+
CLAUDE_SHIM_RETRY=0 disable -p auto-retry
|
|
56
|
+
CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
|
|
57
|
+
EOF
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
require_manifest() { [ -f "$MANIFEST" ] || die "no manifest at $MANIFEST — run install.sh first"; }
|
|
61
|
+
|
|
62
|
+
next_id() {
|
|
63
|
+
local n=1 id
|
|
64
|
+
while :; do
|
|
65
|
+
id="$(printf 'acct-%02d' "$n")"
|
|
66
|
+
if [ ! -d "$ACC_ROOT/$id" ] && ! account_ids | grep -qx "$id"; then
|
|
67
|
+
printf '%s\n' "$id"
|
|
68
|
+
return 0
|
|
69
|
+
fi
|
|
70
|
+
n=$((n+1))
|
|
71
|
+
[ "$n" -gt 99 ] && die "no free account slot"
|
|
72
|
+
done
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
manifest_add_account() { # id email home
|
|
76
|
+
"$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
|
|
77
|
+
import json, sys, time
|
|
78
|
+
path, aid, email, home = sys.argv[1:5]
|
|
79
|
+
doc = json.load(open(path))
|
|
80
|
+
accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
|
|
81
|
+
accounts.append({
|
|
82
|
+
'id': aid,
|
|
83
|
+
'email': email,
|
|
84
|
+
'home': home,
|
|
85
|
+
'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
86
|
+
})
|
|
87
|
+
accounts.sort(key=lambda a: a['id'])
|
|
88
|
+
doc['accounts'] = accounts
|
|
89
|
+
import os
|
|
90
|
+
with open(path + '.tmp', 'w') as f:
|
|
91
|
+
json.dump(doc, f, indent=2)
|
|
92
|
+
f.write('\n')
|
|
93
|
+
os.replace(path + '.tmp', path)
|
|
94
|
+
PYEOF
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
email_owner() { # prints the id that owns <email>, empty if unregistered
|
|
98
|
+
[ -f "$MANIFEST" ] || return 0
|
|
99
|
+
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
|
|
100
|
+
import json, sys
|
|
101
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
102
|
+
if a.get('email', '').lower() == sys.argv[2].lower():
|
|
103
|
+
print(a['id'])
|
|
104
|
+
break
|
|
105
|
+
PYEOF
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
manifest_del_account() { # id
|
|
109
|
+
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
|
|
110
|
+
import json, sys
|
|
111
|
+
path, aid = sys.argv[1:3]
|
|
112
|
+
doc = json.load(open(path))
|
|
113
|
+
doc['accounts'] = [a for a in doc.get('accounts', []) if a['id'] != aid]
|
|
114
|
+
import os
|
|
115
|
+
with open(path + '.tmp', 'w') as f:
|
|
116
|
+
json.dump(doc, f, indent=2)
|
|
117
|
+
f.write('\n')
|
|
118
|
+
os.replace(path + '.tmp', path)
|
|
119
|
+
PYEOF
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
123
|
+
[ "$(machine_kind)" = "mac" ] || return 0
|
|
124
|
+
[ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
|
|
125
|
+
# subshell: cmd_sync exits on failure and must not take the CLI down with it
|
|
126
|
+
( cmd_sync ) || warn "server sync failed — run 'claude-accounts sync' manually"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cmd_list() {
|
|
130
|
+
require_manifest
|
|
131
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
|
|
132
|
+
import json, os, re, sys
|
|
133
|
+
doc = json.load(open(sys.argv[1]))
|
|
134
|
+
root = sys.argv[2]
|
|
135
|
+
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
|
|
136
|
+
accounts = [a for a in doc.get('accounts', [])
|
|
137
|
+
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
138
|
+
if not accounts:
|
|
139
|
+
print('(no accounts yet)')
|
|
140
|
+
for a in accounts:
|
|
141
|
+
d = os.path.join(root, a['id'])
|
|
142
|
+
auth = []
|
|
143
|
+
if os.path.isfile(os.path.join(d, '.credentials.json')):
|
|
144
|
+
auth.append('oauth')
|
|
145
|
+
if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
|
|
146
|
+
auth.append('token')
|
|
147
|
+
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
148
|
+
print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} auth={'+'.join(auth) or 'NONE':<11} {'LIMITED' if limited else ''}")
|
|
149
|
+
PYEOF
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
cmd_status() {
|
|
153
|
+
require_manifest
|
|
154
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
|
|
155
|
+
import json, os, sys, time
|
|
156
|
+
doc = json.load(open(sys.argv[1]))
|
|
157
|
+
root = sys.argv[2]
|
|
158
|
+
now = time.time()
|
|
159
|
+
|
|
160
|
+
def last_pick(aid):
|
|
161
|
+
path = os.path.join(root, 'selection.log')
|
|
162
|
+
if not os.path.isfile(path):
|
|
163
|
+
return '-'
|
|
164
|
+
last = '-'
|
|
165
|
+
try:
|
|
166
|
+
with open(path, errors='replace') as f:
|
|
167
|
+
for line in f:
|
|
168
|
+
parts = line.split()
|
|
169
|
+
if len(parts) >= 2 and parts[1] == aid:
|
|
170
|
+
last = parts[0]
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
return last
|
|
174
|
+
|
|
175
|
+
print(f"pool root : {root}")
|
|
176
|
+
print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
|
|
177
|
+
print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
|
|
178
|
+
print()
|
|
179
|
+
for a in doc.get('accounts', []):
|
|
180
|
+
aid = a['id']
|
|
181
|
+
d = os.path.join(root, aid)
|
|
182
|
+
print(f"{aid} {a['email']} [home={a.get('home','?')}]")
|
|
183
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
184
|
+
if os.path.isfile(cpath):
|
|
185
|
+
try:
|
|
186
|
+
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
187
|
+
exp = c.get('expiresAt', 0) / 1000.0
|
|
188
|
+
rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
|
|
189
|
+
state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
|
|
190
|
+
rstate = 'ok' if rexp > now else 'EXPIRED — re-login needed'
|
|
191
|
+
print(f" oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'})")
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f" oauth creds : unreadable ({e})")
|
|
194
|
+
else:
|
|
195
|
+
print(" oauth creds : none on this machine")
|
|
196
|
+
tpath = os.path.join(d, 'server.token')
|
|
197
|
+
if os.path.isfile(tpath) and os.path.getsize(tpath) > 0:
|
|
198
|
+
age_d = int((now - os.path.getmtime(tpath)) / 86400)
|
|
199
|
+
note = ' — NEARING 1y LIFETIME, re-mint soon' if age_d > 330 else ''
|
|
200
|
+
print(f" server token: present (minted ~{age_d}d ago){note}")
|
|
201
|
+
else:
|
|
202
|
+
print(" server token: none")
|
|
203
|
+
lpath = os.path.join(d, 'limits.json')
|
|
204
|
+
if os.path.isfile(lpath):
|
|
205
|
+
try:
|
|
206
|
+
lim = json.load(open(lpath))
|
|
207
|
+
age = int(now - lim.get('fetched_at', 0))
|
|
208
|
+
parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
|
|
209
|
+
print(f" limits : {' '.join(parts) or '(none)'} [{age}s old, max {lim.get('max_percent')}%]")
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f" limits : unreadable ({e})")
|
|
212
|
+
else:
|
|
213
|
+
print(" limits : never fetched")
|
|
214
|
+
mpath = os.path.join(d, '.limited')
|
|
215
|
+
if os.path.isfile(mpath):
|
|
216
|
+
try:
|
|
217
|
+
lines = open(mpath).read().splitlines()
|
|
218
|
+
reset = int(lines[0]) if lines and lines[0].isdigit() else 0
|
|
219
|
+
detail = lines[1] if len(lines) > 1 else ''
|
|
220
|
+
mins = max(0, int((reset - now) / 60))
|
|
221
|
+
print(f" marker : LIMITED ({detail}) — clears in ~{mins}m")
|
|
222
|
+
except Exception:
|
|
223
|
+
print(" marker : LIMITED (unreadable marker)")
|
|
224
|
+
else:
|
|
225
|
+
print(" marker : none (eligible)")
|
|
226
|
+
print(f" last picked : {last_pick(aid)}")
|
|
227
|
+
print()
|
|
228
|
+
PYEOF
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
cmd_add() {
|
|
232
|
+
# Login-FIRST: the account is registered (and synced) only after /login
|
|
233
|
+
# succeeded in the new dir and the authenticated email has been verified.
|
|
234
|
+
# An aborted or failed login leaves zero traces.
|
|
235
|
+
require_manifest
|
|
236
|
+
local email="${1:-}"
|
|
237
|
+
[ -n "$email" ] || die "usage: claude-accounts add <email> [--force]"
|
|
238
|
+
shift
|
|
239
|
+
local force=0 tui=0
|
|
240
|
+
while [ $# -gt 0 ]; do
|
|
241
|
+
case "$1" in
|
|
242
|
+
--force) force=1; shift ;;
|
|
243
|
+
--tui) tui=1; shift ;;
|
|
244
|
+
*) die "unknown option: $1" ;;
|
|
245
|
+
esac
|
|
246
|
+
done
|
|
247
|
+
# One account per email, always: a duplicate is never useful and silently splits the
|
|
248
|
+
# pool. There is no --force escape hatch here (use `login <acct-NN>` to re-auth).
|
|
249
|
+
local owner
|
|
250
|
+
owner="$(email_owner "$email")"
|
|
251
|
+
if [ -n "$owner" ]; then
|
|
252
|
+
die "$email is already registered as $owner — nothing created (run 'claude-accounts login $owner' to re-authenticate it)"
|
|
253
|
+
fi
|
|
254
|
+
if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
|
|
255
|
+
die "add is interactive (it completes sign-in before registering) — run it from a terminal"
|
|
256
|
+
fi
|
|
257
|
+
# Serialize the whole check->allocate->authenticate->register sequence: two concurrent
|
|
258
|
+
# adds must not race the duplicate check, collide on an id, or clobber the manifest.
|
|
259
|
+
local lock="$ACC_ROOT/.locks/mutate"
|
|
260
|
+
mkdir -p "$ACC_ROOT/.locks"
|
|
261
|
+
if ! mkdir "$lock" 2>/dev/null; then
|
|
262
|
+
if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 900 ]; then
|
|
263
|
+
rm -rf "$lock"; mkdir "$lock" 2>/dev/null || die "cannot acquire the account lock"
|
|
264
|
+
else
|
|
265
|
+
die "another claude-accounts add/login is in progress — try again when it finishes"
|
|
266
|
+
fi
|
|
267
|
+
fi
|
|
268
|
+
# shellcheck disable=SC2064
|
|
269
|
+
trap "rm -rf '$lock'" EXIT
|
|
270
|
+
local real
|
|
271
|
+
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
272
|
+
local id d got
|
|
273
|
+
id="$(next_id)"
|
|
274
|
+
d="$ACC_ROOT/$id"
|
|
275
|
+
seed_account_dir "$d"
|
|
276
|
+
if [ "$tui" = "1" ]; then
|
|
277
|
+
# Full-OAuth variant: interactive /login in the new dir (auto-refreshing creds).
|
|
278
|
+
cat <<EOF
|
|
279
|
+
Preparing $id for $email — NOTHING is registered until login succeeds.
|
|
280
|
+
An interactive claude session opens now with no account. In ONE incognito
|
|
281
|
+
window signed into $email:
|
|
282
|
+
1. type /login (press c to copy the URL into the incognito window)
|
|
283
|
+
2. /status must show $email
|
|
284
|
+
3. exit the session (/exit or ctrl+d) to finish registration
|
|
285
|
+
EOF
|
|
286
|
+
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" || true
|
|
287
|
+
if [ ! -f "$d/.credentials.json" ]; then
|
|
288
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
289
|
+
die "no login detected — nothing was created"
|
|
290
|
+
fi
|
|
291
|
+
got="$(CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
|
|
292
|
+
import json, sys
|
|
293
|
+
try:
|
|
294
|
+
doc = json.loads(sys.stdin.read())
|
|
295
|
+
print(doc.get("email", "") if doc.get("loggedIn") else "")
|
|
296
|
+
except Exception:
|
|
297
|
+
print("")')"
|
|
298
|
+
if [ -z "$got" ]; then
|
|
299
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
300
|
+
die "login could not be verified — nothing was created"
|
|
301
|
+
fi
|
|
302
|
+
else
|
|
303
|
+
# Default: sign-in link -> user pastes the code back here -> token captured.
|
|
304
|
+
# The token works on BOTH machines (no refresh rotation, ~1y lifetime).
|
|
305
|
+
echo "Preparing $id for $email — NOTHING is registered until sign-in completes."
|
|
306
|
+
if ! run_token_ceremony "$d"; then
|
|
307
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
308
|
+
die "sign-in failed or aborted — nothing was created"
|
|
309
|
+
fi
|
|
310
|
+
( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
|
|
311
|
+
chmod 600 "$d/server.token"
|
|
312
|
+
got="$(token_email "$CEREMONY_TOKEN")"
|
|
313
|
+
if [ -z "$got" ]; then
|
|
314
|
+
# Authentication produced a token, but we could not confirm WHICH account it
|
|
315
|
+
# belongs to. Registering on the claimed email could silently create a duplicate
|
|
316
|
+
# or mislabel the account, so refuse — the requirement is verified auth, not
|
|
317
|
+
# merely successful auth. --force accepts the claimed email deliberately.
|
|
318
|
+
if [ "$force" != "1" ]; then
|
|
319
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
320
|
+
die "signed in, but the account identity could not be verified — nothing was created (retry, or use --force to trust '$email')"
|
|
321
|
+
fi
|
|
322
|
+
warn "identity unverified — registering as $email because --force was given"
|
|
323
|
+
got="$email"
|
|
324
|
+
fi
|
|
325
|
+
fi
|
|
326
|
+
if [ "$got" != "$email" ]; then
|
|
327
|
+
owner="$(email_owner "$got")"
|
|
328
|
+
if [ -n "$owner" ]; then
|
|
329
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
330
|
+
die "you signed in as $got, which is already registered as $owner — nothing was created"
|
|
331
|
+
fi
|
|
332
|
+
warn "signed in as $got (expected $email) — registering the account that actually authenticated"
|
|
333
|
+
email="$got"
|
|
334
|
+
fi
|
|
335
|
+
manifest_add_account "$id" "$email" "$(machine_kind)"
|
|
336
|
+
log_to ops.log "add $id $email (auth-verified)"
|
|
337
|
+
auto_sync
|
|
338
|
+
cat <<EOF
|
|
339
|
+
Registered $id for $email (sign-in verified) — usable immediately.
|
|
340
|
+
Optional:
|
|
341
|
+
claude-accounts verify # confirm the 100% matrix
|
|
342
|
+
EOF
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
cmd_import() {
|
|
346
|
+
require_manifest
|
|
347
|
+
local email="${1:-}"
|
|
348
|
+
[ -n "$email" ] || die "usage: claude-accounts import <email> [--id acct-NN] [--home mac|server] [--creds PATH] [--mode copy|move|link] [--token-file PATH|-] [--no-sync]"
|
|
349
|
+
shift
|
|
350
|
+
local id="" home="" creds="" mode="copy" token_file="" no_sync=0 force=0
|
|
351
|
+
while [ $# -gt 0 ]; do
|
|
352
|
+
case "$1" in
|
|
353
|
+
--id) id="$2"; shift 2 ;;
|
|
354
|
+
--home) home="$2"; shift 2 ;;
|
|
355
|
+
--creds) creds="$2"; shift 2 ;;
|
|
356
|
+
--mode) mode="$2"; shift 2 ;;
|
|
357
|
+
--token-file) token_file="$2"; shift 2 ;;
|
|
358
|
+
--no-sync) no_sync=1; shift ;;
|
|
359
|
+
--force) force=1; shift ;;
|
|
360
|
+
*) die "unknown option: $1" ;;
|
|
361
|
+
esac
|
|
362
|
+
done
|
|
363
|
+
[ -n "$id" ] || id="$(next_id)"
|
|
364
|
+
[ -n "$home" ] || home="$(machine_kind)"
|
|
365
|
+
case "$id" in acct-[0-9][0-9]) ;; *) die "id must look like acct-NN" ;; esac
|
|
366
|
+
local owner
|
|
367
|
+
owner="$(email_owner "$email")"
|
|
368
|
+
if [ -n "$owner" ] && [ "$owner" != "$id" ] && [ "$force" != "1" ]; then
|
|
369
|
+
die "$email is already registered as $owner — use --id $owner to update it, or --force to register a duplicate"
|
|
370
|
+
fi
|
|
371
|
+
# Validate ALL inputs before creating anything, so a bad token/creds path leaves no
|
|
372
|
+
# half-made account dir behind.
|
|
373
|
+
[ -n "$creds" ] && { [ -f "$creds" ] || die "credentials file not found: $creds"; }
|
|
374
|
+
case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
|
|
375
|
+
local tok=""
|
|
376
|
+
if [ -n "$token_file" ]; then
|
|
377
|
+
if [ "$token_file" = "-" ]; then
|
|
378
|
+
tok="$(tr -d '[:space:]')"
|
|
379
|
+
else
|
|
380
|
+
[ -f "$token_file" ] || die "token file not found: $token_file"
|
|
381
|
+
tok="$(tr -d '[:space:]' < "$token_file")"
|
|
382
|
+
fi
|
|
383
|
+
valid_subscription_token "$tok" \
|
|
384
|
+
|| die "not a subscription setup-token (sk-ant-oat...). API keys are not supported."
|
|
385
|
+
fi
|
|
386
|
+
local d="$ACC_ROOT/$id"
|
|
387
|
+
seed_account_dir "$d"
|
|
388
|
+
if [ -n "$creds" ]; then
|
|
389
|
+
case "$mode" in
|
|
390
|
+
copy) cp "$creds" "$d/.credentials.json" ;;
|
|
391
|
+
move) mv "$creds" "$d/.credentials.json" ;;
|
|
392
|
+
link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
|
|
393
|
+
esac
|
|
394
|
+
chmod 600 "$d/.credentials.json" 2>/dev/null || true
|
|
395
|
+
fi
|
|
396
|
+
if [ -n "$tok" ]; then
|
|
397
|
+
( umask 077; printf '%s' "$tok" > "$d/server.token" )
|
|
398
|
+
chmod 600 "$d/server.token"
|
|
399
|
+
fi
|
|
400
|
+
manifest_add_account "$id" "$email" "$home"
|
|
401
|
+
log_to ops.log "import $id $email home=$home creds=${creds:+yes} mode=$mode token=${token_file:+yes}"
|
|
402
|
+
echo "Imported $id ($email, home=$home)."
|
|
403
|
+
[ "$no_sync" = "1" ] || auto_sync
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
cmd_adopt() {
|
|
407
|
+
# Make <acct-NN> THIS machine's existing default login (~/.claude) without
|
|
408
|
+
# forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
|
|
409
|
+
# there is exactly one credential file no matter which path refreshes it.
|
|
410
|
+
require_manifest
|
|
411
|
+
local id="${1:-}"
|
|
412
|
+
[ -n "$id" ] || die "usage: claude-accounts adopt <acct-NN>"
|
|
413
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
414
|
+
account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
|
|
415
|
+
local d="$ACC_ROOT/$id" default="$HOME/.claude"
|
|
416
|
+
[ -f "$default/.credentials.json" ] || die "no default login at $default to adopt"
|
|
417
|
+
# Seed the config-dir state file inside ~/.claude (CLAUDE_CONFIG_DIR mode reads
|
|
418
|
+
# <dir>/.claude.json, while default mode uses ~/.claude.json at HOME level).
|
|
419
|
+
if [ ! -f "$default/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
|
420
|
+
"$PYBIN" - "$HOME/.claude.json" "$default/.claude.json" <<'PYEOF'
|
|
421
|
+
import json, sys
|
|
422
|
+
doc = json.load(open(sys.argv[1]))
|
|
423
|
+
for k in ('oauthAccount', 'userID'):
|
|
424
|
+
doc.pop(k, None)
|
|
425
|
+
import os
|
|
426
|
+
with open(sys.argv[2] + '.tmp', 'w') as f:
|
|
427
|
+
json.dump(doc, f)
|
|
428
|
+
os.replace(sys.argv[2] + '.tmp', sys.argv[2])
|
|
429
|
+
PYEOF
|
|
430
|
+
fi
|
|
431
|
+
if [ -L "$d" ]; then
|
|
432
|
+
echo "$id already adopted ($(readlink "$d"))"
|
|
433
|
+
return 0
|
|
434
|
+
fi
|
|
435
|
+
if [ -d "$d" ]; then
|
|
436
|
+
[ -f "$d/.credentials.json" ] && die "$id already has its own credentials — refusing to replace with adopt"
|
|
437
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
438
|
+
fi
|
|
439
|
+
ln -s "$default" "$d"
|
|
440
|
+
log_to ops.log "adopt $id -> $default"
|
|
441
|
+
echo "$id now runs the default $default login (symlinked, single credential file)."
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
cmd_remove() {
|
|
445
|
+
require_manifest
|
|
446
|
+
local id="${1:-}" yes="${2:-}"
|
|
447
|
+
[ -n "$id" ] || die "usage: claude-accounts remove <acct-NN> [--yes]"
|
|
448
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
449
|
+
account_ids | grep -qx "$id" || die "unknown account: $id"
|
|
450
|
+
if [ "$yes" != "--yes" ]; then
|
|
451
|
+
printf 'Remove %s and propagate deletion to the server? [y/N] ' "$id"
|
|
452
|
+
read -r ans
|
|
453
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
454
|
+
fi
|
|
455
|
+
if [ -L "$ACC_ROOT/$id" ]; then
|
|
456
|
+
rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
|
|
457
|
+
else
|
|
458
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
459
|
+
fi
|
|
460
|
+
manifest_del_account "$id"
|
|
461
|
+
log_to ops.log "remove $id"
|
|
462
|
+
auto_sync
|
|
463
|
+
echo "Removed $id."
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# Interactive sign-in ceremony: runs `claude setup-token` attached to the user's
|
|
467
|
+
# terminal — it prints a clickable sign-in link, the user completes OAuth in a
|
|
468
|
+
# browser and pastes the code back HERE, and the resulting long-lived token is
|
|
469
|
+
# captured. Sets CEREMONY_TOKEN on success (no stdout capture: the UI must stay
|
|
470
|
+
# visible and interactive).
|
|
471
|
+
# A subscription setup-token, and nothing else. API keys (sk-ant-api...) are rejected
|
|
472
|
+
# everywhere: this addon is subscription-only by design (spec requirement).
|
|
473
|
+
valid_subscription_token() {
|
|
474
|
+
case "$1" in
|
|
475
|
+
sk-ant-oat[0-9][0-9]-*) [ "${#1}" -ge 50 ] ;;
|
|
476
|
+
*) return 1 ;;
|
|
477
|
+
esac
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
CEREMONY_TOKEN=""
|
|
481
|
+
run_token_ceremony() { # $1 = config dir
|
|
482
|
+
CEREMONY_TOKEN=""
|
|
483
|
+
local d="$1" real cap old_umask
|
|
484
|
+
real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
|
|
485
|
+
mkdir -p "$ACC_ROOT/tmp"
|
|
486
|
+
cap="$ACC_ROOT/tmp/mint.$$.log"
|
|
487
|
+
old_umask="$(umask)"
|
|
488
|
+
umask 077 # the capture file briefly holds the raw token (removed inline below).
|
|
489
|
+
# NB: no EXIT trap here — cmd_add owns the EXIT trap for its mutation lock, and a
|
|
490
|
+
# second EXIT trap would clobber it and leak the lock.
|
|
491
|
+
echo "A sign-in link will appear below — open it in a browser signed into the"
|
|
492
|
+
echo "RIGHT account, approve, then paste the code back here."
|
|
493
|
+
if [ -t 0 ]; then
|
|
494
|
+
if [ "$(machine_kind)" = "mac" ]; then
|
|
495
|
+
script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
|
|
496
|
+
else
|
|
497
|
+
script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' setup-token" "$cap"
|
|
498
|
+
fi
|
|
499
|
+
else
|
|
500
|
+
# Headless (tests / piped code): capture into the 0600 file only. Never tee the
|
|
501
|
+
# raw token to stdout — a redirected run would write the secret to a plain log.
|
|
502
|
+
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token > "$cap" 2>&1
|
|
503
|
+
sed -E 's/sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]*/sk-ant-oat**-<redacted>/g' "$cap"
|
|
504
|
+
fi
|
|
505
|
+
umask "$old_umask"
|
|
506
|
+
CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" | tail -1)"
|
|
507
|
+
rm -f "$cap"
|
|
508
|
+
[ -n "$CEREMONY_TOKEN" ] || return 1
|
|
509
|
+
valid_subscription_token "$CEREMONY_TOKEN" || {
|
|
510
|
+
CEREMONY_TOKEN=""
|
|
511
|
+
warn "captured credential is not a subscription setup-token"
|
|
512
|
+
return 1
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
token_email() { # $1 = token; prints the authenticated email (best effort)
|
|
517
|
+
local real
|
|
518
|
+
real="$(find_real_claude "$_self")" || { echo ""; return 0; }
|
|
519
|
+
CLAUDE_CODE_OAUTH_TOKEN="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
|
|
520
|
+
import json, sys
|
|
521
|
+
try:
|
|
522
|
+
doc = json.loads(sys.stdin.read())
|
|
523
|
+
print(doc.get("email", "") if doc.get("loggedIn") else "")
|
|
524
|
+
except Exception:
|
|
525
|
+
print("")'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
cmd_mint() {
|
|
529
|
+
require_manifest
|
|
530
|
+
local id="${1:-}" paste="${2:-}"
|
|
531
|
+
[ -n "$id" ] || die "usage: claude-accounts mint <acct-NN> [--paste]"
|
|
532
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
533
|
+
local d="$ACC_ROOT/$id"
|
|
534
|
+
[ -d "$d" ] || die "unknown account dir: $d"
|
|
535
|
+
local tok=""
|
|
536
|
+
if [ "$paste" = "--paste" ]; then
|
|
537
|
+
printf 'Paste the sk-ant-oat... token: '
|
|
538
|
+
read -r tok
|
|
539
|
+
tok="$(printf '%s' "$tok" | tr -d '[:space:]')"
|
|
540
|
+
else
|
|
541
|
+
echo "Running 'claude setup-token' for $id — approve in a browser signed into THIS account."
|
|
542
|
+
run_token_ceremony "$d" || die "no token captured — mint failed"
|
|
543
|
+
tok="$CEREMONY_TOKEN"
|
|
544
|
+
fi
|
|
545
|
+
[ -n "$tok" ] || die "no token captured — mint failed"
|
|
546
|
+
valid_subscription_token "$tok" \
|
|
547
|
+
|| die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
|
|
548
|
+
( umask 077; printf '%s' "$tok" > "$d/server.token" )
|
|
549
|
+
chmod 600 "$d/server.token"
|
|
550
|
+
log_to ops.log "mint $id"
|
|
551
|
+
echo "Token saved to $d/server.token"
|
|
552
|
+
auto_sync
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
cmd_login() {
|
|
556
|
+
# Complete (or refresh) auth for an EXISTING account: sign-in link -> paste
|
|
557
|
+
# code -> token saved. Verifies the authenticated email matches the manifest.
|
|
558
|
+
require_manifest
|
|
559
|
+
local id="${1:-}" force="${2:-}"
|
|
560
|
+
[ -n "$id" ] || die "usage: claude-accounts login <acct-NN> [--force]"
|
|
561
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
562
|
+
account_ids | grep -qx "$id" || die "unknown account: $id"
|
|
563
|
+
local d="$ACC_ROOT/$id" email got
|
|
564
|
+
seed_account_dir "$d"
|
|
565
|
+
email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
|
|
566
|
+
import json, sys
|
|
567
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
568
|
+
if a['id'] == sys.argv[2]:
|
|
569
|
+
print(a.get('email', ''))
|
|
570
|
+
break
|
|
571
|
+
PYEOF
|
|
572
|
+
)"
|
|
573
|
+
echo "Sign in as $email for $id."
|
|
574
|
+
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
|
|
575
|
+
got="$(token_email "$CEREMONY_TOKEN")"
|
|
576
|
+
if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "--force" ]; then
|
|
577
|
+
die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
|
|
578
|
+
fi
|
|
579
|
+
printf '%s' "$CEREMONY_TOKEN" > "$d/server.token"
|
|
580
|
+
chmod 600 "$d/server.token"
|
|
581
|
+
log_to ops.log "login $id verified=${got:-unverified}"
|
|
582
|
+
echo "$id auth saved (token, works on Mac and server)."
|
|
583
|
+
echo "Optional, for auto-refreshing OAuth creds on this machine instead:"
|
|
584
|
+
echo " CLAUDE_ACCOUNT=$id claude # then /login"
|
|
585
|
+
auto_sync
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
cmd_limits() {
|
|
589
|
+
require_manifest
|
|
590
|
+
local quiet=0 force=0
|
|
591
|
+
while [ $# -gt 0 ]; do
|
|
592
|
+
case "$1" in
|
|
593
|
+
--quiet) quiet=1; shift ;;
|
|
594
|
+
--force) force=1; shift ;; # ignore freshness/backoff (manual override)
|
|
595
|
+
*) die "unknown option: $1" ;;
|
|
596
|
+
esac
|
|
597
|
+
done
|
|
598
|
+
local lock="$ACC_ROOT/.locks/limits"
|
|
599
|
+
mkdir -p "$ACC_ROOT/.locks"
|
|
600
|
+
if ! mkdir "$lock" 2>/dev/null; then
|
|
601
|
+
local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
|
|
602
|
+
if [ "$age" -lt 120 ]; then
|
|
603
|
+
[ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
|
|
604
|
+
return 0
|
|
605
|
+
fi
|
|
606
|
+
rm -rf "$lock"
|
|
607
|
+
mkdir "$lock" 2>/dev/null || return 0
|
|
608
|
+
fi
|
|
609
|
+
# shellcheck disable=SC2064
|
|
610
|
+
trap "rm -rf '$lock'" EXIT
|
|
611
|
+
rotate_log limits.log
|
|
612
|
+
# Expired-bearer self-heal: on a quiet machine nothing refreshes OAuth creds,
|
|
613
|
+
# which would silently stall limits telemetry (fail-open keeps selection working,
|
|
614
|
+
# but data goes stale). `claude auth status` refreshes creds without inference.
|
|
615
|
+
local real="" d
|
|
616
|
+
real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
|
|
617
|
+
if [ -n "$real" ]; then
|
|
618
|
+
for d in "$ACC_ROOT"/acct-*; do
|
|
619
|
+
[ -d "$d" ] || continue
|
|
620
|
+
[ -f "$d/.credentials.json" ] || continue
|
|
621
|
+
[ -s "$d/server.token" ] && continue
|
|
622
|
+
if "$PYBIN" -c '
|
|
623
|
+
import json, sys, time
|
|
624
|
+
c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
|
|
625
|
+
sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
|
|
626
|
+
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
|
|
627
|
+
fi
|
|
628
|
+
done
|
|
629
|
+
fi
|
|
630
|
+
# The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
|
|
631
|
+
# never loosen it, or an account could sit at 95% and still be selected.
|
|
632
|
+
local threshold
|
|
633
|
+
threshold="$(manifest_get threshold 90)"
|
|
634
|
+
case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
|
|
635
|
+
[ "$threshold" -gt 90 ] && threshold=90
|
|
636
|
+
[ "$threshold" -lt 1 ] && threshold=90
|
|
637
|
+
"$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
|
|
638
|
+
import json, os, sys, time, urllib.request
|
|
639
|
+
|
|
640
|
+
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
641
|
+
now = time.time()
|
|
642
|
+
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
643
|
+
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
|
|
644
|
+
|
|
645
|
+
def say(msg):
|
|
646
|
+
if not quiet:
|
|
647
|
+
print(msg)
|
|
648
|
+
with open(os.path.join(root, 'limits.log'), 'a') as f:
|
|
649
|
+
f.write(time.strftime('%Y-%m-%dT%H:%M:%SZ ', time.gmtime()) + msg + '\n')
|
|
650
|
+
|
|
651
|
+
def parse_iso(s):
|
|
652
|
+
if not s:
|
|
653
|
+
return None
|
|
654
|
+
import datetime
|
|
655
|
+
try:
|
|
656
|
+
return datetime.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
|
|
657
|
+
except Exception:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
662
|
+
except Exception as e:
|
|
663
|
+
sys.exit(f'cannot read manifest: {e}')
|
|
664
|
+
|
|
665
|
+
for acct in manifest.get('accounts', []):
|
|
666
|
+
aid = acct['id']
|
|
667
|
+
d = os.path.join(root, aid)
|
|
668
|
+
if not os.path.isdir(d):
|
|
669
|
+
continue
|
|
670
|
+
bearer = None
|
|
671
|
+
source = None
|
|
672
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
673
|
+
if os.path.isfile(cpath):
|
|
674
|
+
try:
|
|
675
|
+
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
676
|
+
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
677
|
+
bearer, source = c['accessToken'], 'oauth'
|
|
678
|
+
except Exception:
|
|
679
|
+
pass
|
|
680
|
+
tpath = os.path.join(d, 'server.token')
|
|
681
|
+
if not bearer and os.path.isfile(tpath):
|
|
682
|
+
t = open(tpath).read().strip()
|
|
683
|
+
if t:
|
|
684
|
+
bearer, source = t, 'token'
|
|
685
|
+
if not bearer:
|
|
686
|
+
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
687
|
+
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
688
|
+
continue
|
|
689
|
+
|
|
690
|
+
# The usage endpoint rate-limits per account. Several callers can fire at once
|
|
691
|
+
# (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
|
|
692
|
+
# this account's data is already fresh, and honor any backoff a 429 set earlier.
|
|
693
|
+
lpath = os.path.join(d, 'limits.json')
|
|
694
|
+
prev = {}
|
|
695
|
+
if os.path.isfile(lpath):
|
|
696
|
+
try:
|
|
697
|
+
prev = json.load(open(lpath))
|
|
698
|
+
except Exception:
|
|
699
|
+
prev = {}
|
|
700
|
+
if not force:
|
|
701
|
+
age = now - prev.get('fetched_at', 0)
|
|
702
|
+
if age < MIN_FETCH_INTERVAL:
|
|
703
|
+
continue
|
|
704
|
+
retry_at = prev.get('retry_after', 0)
|
|
705
|
+
if retry_at > now:
|
|
706
|
+
say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
|
|
707
|
+
continue
|
|
708
|
+
|
|
709
|
+
req = urllib.request.Request(url, headers={
|
|
710
|
+
'Authorization': 'Bearer ' + bearer,
|
|
711
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
712
|
+
'Content-Type': 'application/json',
|
|
713
|
+
'User-Agent': 'claude-multiacc/1.0',
|
|
714
|
+
})
|
|
715
|
+
try:
|
|
716
|
+
resp = urllib.request.urlopen(req, timeout=15)
|
|
717
|
+
data = json.loads(resp.read().decode())
|
|
718
|
+
except urllib.error.HTTPError as e:
|
|
719
|
+
if e.code == 429:
|
|
720
|
+
# Respect Retry-After; otherwise exponential backoff capped at 30 min.
|
|
721
|
+
try:
|
|
722
|
+
wait = int(e.headers.get('Retry-After') or 0)
|
|
723
|
+
except (TypeError, ValueError):
|
|
724
|
+
wait = 0
|
|
725
|
+
if wait <= 0:
|
|
726
|
+
wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
|
|
727
|
+
prev['retry_after'] = int(now + wait)
|
|
728
|
+
prev['backoff'] = wait
|
|
729
|
+
tmp = lpath + '.tmp'
|
|
730
|
+
with open(tmp, 'w') as f:
|
|
731
|
+
json.dump(prev, f, indent=1)
|
|
732
|
+
os.replace(tmp, lpath)
|
|
733
|
+
say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
|
|
734
|
+
else:
|
|
735
|
+
say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
|
|
736
|
+
continue
|
|
737
|
+
except Exception as e:
|
|
738
|
+
say(f'{aid}: usage fetch failed ({e}); failing open')
|
|
739
|
+
continue
|
|
740
|
+
|
|
741
|
+
def pct_of(v):
|
|
742
|
+
try:
|
|
743
|
+
return max(0, min(100, int(round(float(v)))))
|
|
744
|
+
except (TypeError, ValueError):
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
def classify_group(kind):
|
|
748
|
+
# session (5h, self-healing) vs weekly (multi-day, expensive) vs monthly.
|
|
749
|
+
# Unknown durable buckets default to 'weekly' so they are never under-weighted.
|
|
750
|
+
k = kind.lower()
|
|
751
|
+
if k.startswith('session') or k in ('five_hour', 'fivehour', '5h'):
|
|
752
|
+
return 'session'
|
|
753
|
+
if 'month' in k:
|
|
754
|
+
return 'monthly'
|
|
755
|
+
return 'weekly'
|
|
756
|
+
|
|
757
|
+
# Shape-agnostic bucket extraction: every entry in limits[] becomes a bucket
|
|
758
|
+
# named kind[:model]. If Anthropic drops the per-model (Fable) separation,
|
|
759
|
+
# renames kinds, or reshapes scope, whatever buckets remain are still tracked
|
|
760
|
+
# and the >=90% rule keeps working. Entries we cannot parse are skipped, and
|
|
761
|
+
# any per-account surprise degrades that account only (fail open), never the run.
|
|
762
|
+
buckets = []
|
|
763
|
+
try:
|
|
764
|
+
for lim in (data.get('limits') or []):
|
|
765
|
+
if not isinstance(lim, dict):
|
|
766
|
+
continue
|
|
767
|
+
pct = pct_of(lim.get('percent'))
|
|
768
|
+
if pct is None:
|
|
769
|
+
continue
|
|
770
|
+
kind = str(lim.get('kind') or 'unknown')
|
|
771
|
+
name = kind
|
|
772
|
+
scope = lim.get('scope') or {}
|
|
773
|
+
model = None
|
|
774
|
+
if isinstance(scope, dict):
|
|
775
|
+
m = scope.get('model')
|
|
776
|
+
if isinstance(m, dict):
|
|
777
|
+
model = m.get('display_name') or m.get('id')
|
|
778
|
+
if model:
|
|
779
|
+
name = f'{name}:{model}'
|
|
780
|
+
resets = lim.get('resets_at')
|
|
781
|
+
buckets.append({
|
|
782
|
+
'name': name,
|
|
783
|
+
'kind': kind,
|
|
784
|
+
'group': str(lim.get('group') or classify_group(kind)),
|
|
785
|
+
'percent': pct,
|
|
786
|
+
'resets_at': resets,
|
|
787
|
+
'resets_epoch': int(parse_iso(resets) or now + 3600),
|
|
788
|
+
})
|
|
789
|
+
if not buckets:
|
|
790
|
+
# Fallback for a payload with no limits[] array: scan EVERY top-level object
|
|
791
|
+
# carrying a utilization, so per-model buckets (seven_day_opus,
|
|
792
|
+
# seven_day_fable, ...) are picked up too — not just five_hour/seven_day.
|
|
793
|
+
# Missing one would let an exhausted model bucket go unnoticed.
|
|
794
|
+
for k, b in sorted(data.items()):
|
|
795
|
+
if not isinstance(b, dict) or k == 'extra_usage':
|
|
796
|
+
continue
|
|
797
|
+
pct = pct_of(b.get('utilization'))
|
|
798
|
+
if pct is None:
|
|
799
|
+
continue
|
|
800
|
+
buckets.append({
|
|
801
|
+
'name': k,
|
|
802
|
+
'kind': k,
|
|
803
|
+
'group': classify_group(k),
|
|
804
|
+
'percent': pct,
|
|
805
|
+
'resets_at': b.get('resets_at'),
|
|
806
|
+
'resets_epoch': int(parse_iso(b.get('resets_at')) or now + 3600),
|
|
807
|
+
})
|
|
808
|
+
except Exception as e:
|
|
809
|
+
say(f'{aid}: unexpected usage payload shape ({e}); failing open')
|
|
810
|
+
continue
|
|
811
|
+
# Successful fetch: fresh buckets replace everything, backoff state is dropped.
|
|
812
|
+
# THREE selection signals, per the documented reset asymmetry:
|
|
813
|
+
# max_percent — peak of ALL buckets; drives >=90% EXCLUSION (a full session
|
|
814
|
+
# bucket really does block, but its marker expires in ~5h).
|
|
815
|
+
# weekly_percent — peak of the durable (weekly/monthly) buckets; the PRIMARY
|
|
816
|
+
# ranking signal, because weekly headroom only returns on the
|
|
817
|
+
# account's fixed weekly reset (days away).
|
|
818
|
+
# session_percent— peak of the self-healing 5h bucket; a soft tiebreaker only.
|
|
819
|
+
maxp = max([b['percent'] for b in buckets] or [0])
|
|
820
|
+
weekly = [b['percent'] for b in buckets if b['group'] != 'session']
|
|
821
|
+
session = [b['percent'] for b in buckets if b['group'] == 'session']
|
|
822
|
+
weeklyp = max(weekly) if weekly else maxp
|
|
823
|
+
sessionp = max(session) if session else 0
|
|
824
|
+
out = {'fetched_at': int(now), 'source': source, 'max_percent': maxp,
|
|
825
|
+
'weekly_percent': weeklyp, 'session_percent': sessionp, 'buckets': buckets}
|
|
826
|
+
tmp = lpath + '.tmp'
|
|
827
|
+
with open(tmp, 'w') as f:
|
|
828
|
+
json.dump(out, f, indent=1)
|
|
829
|
+
os.replace(tmp, lpath)
|
|
830
|
+
offenders = [b for b in buckets if b['percent'] >= threshold]
|
|
831
|
+
mpath = os.path.join(d, '.limited')
|
|
832
|
+
if offenders:
|
|
833
|
+
worst = max(offenders, key=lambda b: b['percent'])
|
|
834
|
+
reset_epoch = max(int(b['resets_epoch']) for b in offenders)
|
|
835
|
+
# Atomic: a concurrent shim must never read a half-written marker.
|
|
836
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
837
|
+
f.write(f'{reset_epoch}\n')
|
|
838
|
+
f.write(f"bucket={worst['name']} percent={worst['percent']} "
|
|
839
|
+
f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
|
|
840
|
+
f"reason=limits resets_at={worst['resets_at']}\n")
|
|
841
|
+
os.replace(mpath + '.tmp', mpath)
|
|
842
|
+
say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
|
|
843
|
+
else:
|
|
844
|
+
if os.path.exists(mpath):
|
|
845
|
+
# A shim-written error-cooldown marker outlives a clean limits pass:
|
|
846
|
+
# the account failed a real call moments ago; give the cooldown its window.
|
|
847
|
+
keep = False
|
|
848
|
+
try:
|
|
849
|
+
txt = open(mpath).read()
|
|
850
|
+
first = txt.splitlines()[0] if txt else ''
|
|
851
|
+
if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
|
|
852
|
+
keep = True
|
|
853
|
+
except Exception:
|
|
854
|
+
pass
|
|
855
|
+
if not keep:
|
|
856
|
+
os.remove(mpath)
|
|
857
|
+
say(f'{aid}: marker cleared (max {maxp}%)')
|
|
858
|
+
if not quiet:
|
|
859
|
+
detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
|
|
860
|
+
print(f'{aid}: ok {detail}')
|
|
861
|
+
PYEOF
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
cmd_verify() {
|
|
865
|
+
require_manifest
|
|
866
|
+
local quick=0
|
|
867
|
+
[ "${1:-}" = "--quick" ] && quick=1
|
|
868
|
+
local real=""
|
|
869
|
+
if [ "$quick" = "0" ]; then
|
|
870
|
+
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
871
|
+
fi
|
|
872
|
+
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" <<'PYEOF'
|
|
873
|
+
import json, os, subprocess, sys, time
|
|
874
|
+
|
|
875
|
+
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
876
|
+
now = time.time()
|
|
877
|
+
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
878
|
+
failures = 0
|
|
879
|
+
tested = 0
|
|
880
|
+
|
|
881
|
+
for acct in manifest.get('accounts', []):
|
|
882
|
+
aid = acct['id']
|
|
883
|
+
d = os.path.join(root, aid)
|
|
884
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
885
|
+
tpath = os.path.join(d, 'server.token')
|
|
886
|
+
has_creds = os.path.isfile(cpath)
|
|
887
|
+
has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
|
|
888
|
+
if not has_creds and not has_token:
|
|
889
|
+
print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
|
|
890
|
+
continue
|
|
891
|
+
tested += 1
|
|
892
|
+
if has_creds:
|
|
893
|
+
try:
|
|
894
|
+
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
895
|
+
rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
|
|
896
|
+
if rexp and rexp < now:
|
|
897
|
+
print(f'{aid} {acct["email"]}: FAIL (refresh token expired — re-login)')
|
|
898
|
+
failures += 1
|
|
899
|
+
continue
|
|
900
|
+
except Exception as e:
|
|
901
|
+
print(f'{aid} {acct["email"]}: FAIL (unreadable credentials: {e})')
|
|
902
|
+
failures += 1
|
|
903
|
+
continue
|
|
904
|
+
if quick:
|
|
905
|
+
kind = 'oauth' if has_creds else 'token'
|
|
906
|
+
print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
|
|
907
|
+
continue
|
|
908
|
+
env = dict(os.environ)
|
|
909
|
+
env['CLAUDE_CONFIG_DIR'] = d
|
|
910
|
+
env.pop('ANTHROPIC_API_KEY', None)
|
|
911
|
+
env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
|
|
912
|
+
env.pop('CLAUDE_ACCOUNT', None)
|
|
913
|
+
env['CLAUDE_SHIM_ACTIVE'] = '1'
|
|
914
|
+
if not has_creds:
|
|
915
|
+
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
916
|
+
t0 = time.time()
|
|
917
|
+
try:
|
|
918
|
+
r = subprocess.run([real, '-p', 'Reply with exactly: OK'],
|
|
919
|
+
env=env, capture_output=True, text=True, timeout=240,
|
|
920
|
+
stdin=subprocess.DEVNULL, cwd=root)
|
|
921
|
+
except subprocess.TimeoutExpired:
|
|
922
|
+
print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
|
|
923
|
+
failures += 1
|
|
924
|
+
continue
|
|
925
|
+
dt = time.time() - t0
|
|
926
|
+
out = (r.stdout or '').strip()
|
|
927
|
+
if r.returncode == 0 and 'ok' in out.lower():
|
|
928
|
+
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
929
|
+
else:
|
|
930
|
+
err = (r.stderr or '').strip()[:200]
|
|
931
|
+
print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} out={out[:120]!r} err={err!r}')
|
|
932
|
+
failures += 1
|
|
933
|
+
|
|
934
|
+
print()
|
|
935
|
+
print(f'verified {tested} account(s), {failures} failure(s)')
|
|
936
|
+
sys.exit(1 if failures else 0)
|
|
937
|
+
PYEOF
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
cmd_sync() {
|
|
941
|
+
require_manifest
|
|
942
|
+
[ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
|
|
943
|
+
local allow_empty=0
|
|
944
|
+
[ "${1:-}" = "--allow-empty" ] && allow_empty=1
|
|
945
|
+
local server sroot srepo rc=0
|
|
946
|
+
server="$(manifest_get server "$DEFAULT_SERVER")"
|
|
947
|
+
sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
|
|
948
|
+
srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
|
|
949
|
+
# These land inside a remote shell command — anything but a plain target/path is a
|
|
950
|
+
# command-injection vector from a corrupted or hand-edited manifest.
|
|
951
|
+
valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
|
|
952
|
+
valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
|
|
953
|
+
valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
|
|
954
|
+
rotate_log sync.log
|
|
955
|
+
slog() { log_to sync.log "$*"; }
|
|
956
|
+
fail() { slog "FAIL: $*"; printf 'claude-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
|
|
957
|
+
slog "sync start -> $server:$sroot"
|
|
958
|
+
|
|
959
|
+
# Hard validation before anything destructive: a corrupt or accountless manifest must
|
|
960
|
+
# never be pushed (it would blank the server's pool), and must never make the removal
|
|
961
|
+
# loop below wipe the server's credentials. Emptying the pool on purpose is possible
|
|
962
|
+
# via `sync --allow-empty`, so this can never happen by accident.
|
|
963
|
+
"$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
|
|
964
|
+
import json, re, sys
|
|
965
|
+
doc = json.load(open(sys.argv[1]))
|
|
966
|
+
accounts = doc.get('accounts')
|
|
967
|
+
if not isinstance(accounts, list):
|
|
968
|
+
sys.exit(1)
|
|
969
|
+
for a in accounts:
|
|
970
|
+
if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
|
|
971
|
+
or not str(a.get('email', '')).strip():
|
|
972
|
+
sys.exit(1)
|
|
973
|
+
PYEOF
|
|
974
|
+
if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
|
|
975
|
+
fail "manifest has zero accounts — refusing to blank the server pool (use 'sync --allow-empty' if that is really intended)"
|
|
976
|
+
fi
|
|
977
|
+
|
|
978
|
+
ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
979
|
+
|| fail "cannot reach $server"
|
|
980
|
+
rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
981
|
+
|| fail "manifest push failed"
|
|
982
|
+
|
|
983
|
+
local id d
|
|
984
|
+
for id in $(account_ids); do
|
|
985
|
+
d="$ACC_ROOT/$id"
|
|
986
|
+
[ -d "$d" ] || continue
|
|
987
|
+
ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
988
|
+
|| fail "mkdir $id failed"
|
|
989
|
+
if [ -s "$d/server.token" ]; then
|
|
990
|
+
rsync -az --chmod=F600 "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
991
|
+
|| fail "token push for $id failed"
|
|
992
|
+
fi
|
|
993
|
+
local seed
|
|
994
|
+
for seed in .claude.json settings.json; do
|
|
995
|
+
if [ -f "$d/$seed" ]; then
|
|
996
|
+
rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
997
|
+
|| fail "seed push for $id/$seed failed"
|
|
998
|
+
fi
|
|
999
|
+
done
|
|
1000
|
+
# Advisory limit state for accounts the server may lack a bearer for.
|
|
1001
|
+
local extra
|
|
1002
|
+
for extra in limits.json .limited; do
|
|
1003
|
+
if [ -f "$d/$extra" ]; then
|
|
1004
|
+
rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
|
|
1005
|
+
fi
|
|
1006
|
+
done
|
|
1007
|
+
done
|
|
1008
|
+
|
|
1009
|
+
# Removal propagation: any server acct dir not in the manifest gets deleted.
|
|
1010
|
+
# Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
|
|
1011
|
+
# wiping every server credential must be an explicit manual act, not a side effect.
|
|
1012
|
+
local ids_list ids_spaced
|
|
1013
|
+
ids_list="$(account_ids)"
|
|
1014
|
+
if [ -z "$ids_list" ]; then
|
|
1015
|
+
slog "removal propagation skipped: empty account list (safety guard)"
|
|
1016
|
+
else
|
|
1017
|
+
ids_spaced=" $(printf '%s' "$ids_list" | tr '\n' ' ') "
|
|
1018
|
+
ssh -o BatchMode=yes "$server" "cd '$sroot' 2>/dev/null || exit 0
|
|
1019
|
+
for dd in acct-*; do
|
|
1020
|
+
[ -d \"\$dd\" ] || continue
|
|
1021
|
+
case '$ids_spaced' in
|
|
1022
|
+
*\" \$dd \"*) ;;
|
|
1023
|
+
*) rm -rf -- \"\$dd\" ;;
|
|
1024
|
+
esac
|
|
1025
|
+
done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
|
|
1026
|
+
fi
|
|
1027
|
+
|
|
1028
|
+
# Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
|
|
1029
|
+
ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/claude-accounts' ] && '$srepo/bin/claude-accounts' post-sync" \
|
|
1030
|
+
>>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook not available yet (server not bootstrapped?)"
|
|
1031
|
+
|
|
1032
|
+
slog "sync ok"
|
|
1033
|
+
echo "sync ok -> $server:$sroot"
|
|
1034
|
+
return $rc
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
cmd_post_sync() {
|
|
1038
|
+
require_manifest
|
|
1039
|
+
local id d
|
|
1040
|
+
for id in $(account_ids); do
|
|
1041
|
+
d="$ACC_ROOT/$id"
|
|
1042
|
+
seed_account_dir "$d"
|
|
1043
|
+
[ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
|
|
1044
|
+
[ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
|
|
1045
|
+
done
|
|
1046
|
+
log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
|
|
1047
|
+
( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
|
|
1048
|
+
cmd_verify --quick
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
cmd_self_update() {
|
|
1052
|
+
# Update the addon in place. npm global install => npm i -g @latest (its postinstall
|
|
1053
|
+
# re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
|
|
1054
|
+
# no-op with a hint. Best-effort and fully logged; never disrupts a running claude.
|
|
1055
|
+
local quiet=0
|
|
1056
|
+
[ "${1:-}" = "--quiet" ] && quiet=1
|
|
1057
|
+
rotate_log update.log
|
|
1058
|
+
ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
|
|
1059
|
+
case "$REPO_DIR" in
|
|
1060
|
+
*/node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
|
|
1061
|
+
command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
|
|
1062
|
+
local cur lat
|
|
1063
|
+
cur="$(npm ls -g --depth=0 claude-multiacc 2>/dev/null | sed -n 's/.*claude-multiacc@//p' | head -1)"
|
|
1064
|
+
lat="$(npm view claude-multiacc version 2>/dev/null)"
|
|
1065
|
+
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
1066
|
+
ulog "self-update: already latest ($cur)"
|
|
1067
|
+
return 0
|
|
1068
|
+
fi
|
|
1069
|
+
ulog "self-update: npm $cur -> ${lat:-latest}"
|
|
1070
|
+
if npm install -g claude-multiacc@latest >>"$ACC_ROOT/update.log" 2>&1; then
|
|
1071
|
+
ulog "self-update: npm update ok"
|
|
1072
|
+
else
|
|
1073
|
+
ulog "self-update: npm update FAILED (see update.log)"
|
|
1074
|
+
return 1
|
|
1075
|
+
fi
|
|
1076
|
+
;;
|
|
1077
|
+
*)
|
|
1078
|
+
if [ -d "$REPO_DIR/.git" ] && command -v git >/dev/null 2>&1; then
|
|
1079
|
+
ulog "self-update: git pull in $REPO_DIR"
|
|
1080
|
+
if git -C "$REPO_DIR" pull --ff-only >>"$ACC_ROOT/update.log" 2>&1; then
|
|
1081
|
+
"$REPO_DIR/install.sh" >>"$ACC_ROOT/update.log" 2>&1 \
|
|
1082
|
+
&& ulog "self-update: git update + reinstall ok" \
|
|
1083
|
+
|| { ulog "self-update: reinstall FAILED"; return 1; }
|
|
1084
|
+
else
|
|
1085
|
+
ulog "self-update: git pull FAILED (local changes? see update.log)"
|
|
1086
|
+
return 1
|
|
1087
|
+
fi
|
|
1088
|
+
else
|
|
1089
|
+
ulog "self-update: not an npm or git install ($REPO_DIR) — update manually"
|
|
1090
|
+
fi
|
|
1091
|
+
;;
|
|
1092
|
+
esac
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
cmd_health() {
|
|
1096
|
+
require_manifest
|
|
1097
|
+
rotate_log health.log
|
|
1098
|
+
local out rc=0
|
|
1099
|
+
# verify first: its real claude runs refresh any expired OAuth creds, so the
|
|
1100
|
+
# limits pass that follows always has fresh bearers.
|
|
1101
|
+
out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
|
|
1102
|
+
printf '%s\n' "$out"
|
|
1103
|
+
printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
|
|
1104
|
+
if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
|
|
1105
|
+
osascript -e 'display notification "claude-multiacc health check FAILED — run claude-accounts status" with title "claude-multiacc"' 2>/dev/null || true
|
|
1106
|
+
fi
|
|
1107
|
+
return $rc
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
case "${1:-help}" in
|
|
1111
|
+
list) shift; cmd_list "$@" ;;
|
|
1112
|
+
status) shift; cmd_status "$@" ;;
|
|
1113
|
+
add) shift; cmd_add "$@" ;;
|
|
1114
|
+
import) shift; cmd_import "$@" ;;
|
|
1115
|
+
adopt) shift; cmd_adopt "$@" ;;
|
|
1116
|
+
remove) shift; cmd_remove "$@" ;;
|
|
1117
|
+
mint) shift; cmd_mint "$@" ;;
|
|
1118
|
+
login) shift; cmd_login "$@" ;;
|
|
1119
|
+
sync) shift; cmd_sync "$@" ;;
|
|
1120
|
+
verify) shift; cmd_verify "$@" ;;
|
|
1121
|
+
limits) shift; cmd_limits "$@" ;;
|
|
1122
|
+
post-sync) shift; cmd_post_sync "$@" ;;
|
|
1123
|
+
health) shift; cmd_health "$@" ;;
|
|
1124
|
+
self-update) shift; cmd_self_update "$@" ;;
|
|
1125
|
+
help|--help|-h) usage ;;
|
|
1126
|
+
*) usage; exit 1 ;;
|
|
1127
|
+
esac
|