claude-multiacc 1.0.3 → 1.0.4
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/bin/claude-accounts +118 -38
- package/lib/common.sh +28 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +64 -5
package/bin/claude-accounts
CHANGED
|
@@ -40,6 +40,8 @@ USAGE
|
|
|
40
40
|
default ~/.claude login (dir symlink —
|
|
41
41
|
single credential file, no grant fork)
|
|
42
42
|
claude-accounts remove <acct-NN> [--yes] delete account (propagates to server)
|
|
43
|
+
claude-accounts dedupe [--yes] remove any account registered twice
|
|
44
|
+
(same email), keeping one per email
|
|
43
45
|
claude-accounts mint <acct-NN> mint server token via `claude setup-token`
|
|
44
46
|
--paste paste an already-minted token instead of running setup-token
|
|
45
47
|
claude-accounts sync push manifest+tokens to the server (Mac only)
|
|
@@ -149,6 +151,14 @@ for a in accounts:
|
|
|
149
151
|
auth.append('token')
|
|
150
152
|
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
151
153
|
print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} auth={'+'.join(auth) or 'NONE':<11} {'LIMITED' if limited else ''}")
|
|
154
|
+
seen = {}
|
|
155
|
+
for a in accounts:
|
|
156
|
+
seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
157
|
+
dups = {e: ids for e, ids in seen.items() if len(ids) > 1}
|
|
158
|
+
if dups:
|
|
159
|
+
print()
|
|
160
|
+
for e, ids in dups.items():
|
|
161
|
+
print(f"WARNING: {e} is registered {len(ids)}x ({', '.join(ids)}) — run 'claude-accounts dedupe'")
|
|
152
162
|
PYEOF
|
|
153
163
|
}
|
|
154
164
|
|
|
@@ -249,89 +259,94 @@ cmd_add() {
|
|
|
249
259
|
else die "unexpected argument: $1 (usage: claude-accounts add [email] [--token] [--force])"; fi ;;
|
|
250
260
|
esac
|
|
251
261
|
done
|
|
252
|
-
# If an email was named
|
|
253
|
-
#
|
|
262
|
+
# If an email was named and it's already in the pool, SKIP before any sign-in — no
|
|
263
|
+
# duplicate is ever created. (A graceful skip, exit 0: adding an existing account is
|
|
264
|
+
# a no-op, not an error. Re-auth an existing account with 'login'.)
|
|
254
265
|
local owner
|
|
255
266
|
if [ -n "$email" ]; then
|
|
256
267
|
owner="$(email_owner "$email")"
|
|
257
|
-
[ -n "$owner" ]
|
|
268
|
+
if [ -n "$owner" ]; then
|
|
269
|
+
echo "$email is already added as $owner — skipping (nothing to do; run 'claude-accounts login $owner' to re-authenticate it)."
|
|
270
|
+
return 0
|
|
271
|
+
fi
|
|
258
272
|
fi
|
|
259
273
|
if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
|
|
260
274
|
die "add is interactive (it completes sign-in before registering) — run it from a terminal"
|
|
261
275
|
fi
|
|
262
|
-
# Serialize the whole check->allocate->authenticate->register sequence: two concurrent
|
|
263
|
-
# adds must not race the duplicate check, collide on an id, or clobber the manifest.
|
|
264
|
-
local lock="$ACC_ROOT/.locks/mutate"
|
|
265
|
-
mkdir -p "$ACC_ROOT/.locks"
|
|
266
|
-
if ! mkdir "$lock" 2>/dev/null; then
|
|
267
|
-
if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 900 ]; then
|
|
268
|
-
rm -rf "$lock"; mkdir "$lock" 2>/dev/null || die "cannot acquire the account lock"
|
|
269
|
-
else
|
|
270
|
-
die "another claude-accounts add/login is in progress — try again when it finishes"
|
|
271
|
-
fi
|
|
272
|
-
fi
|
|
273
|
-
# shellcheck disable=SC2064
|
|
274
|
-
trap "rm -rf '$lock'" EXIT
|
|
275
276
|
local real
|
|
276
277
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
278
|
+
|
|
279
|
+
# Reserve a unique id under a BRIEF lock (creating the dir claims the id, so a parallel
|
|
280
|
+
# add gets the next one). The long browser sign-in below runs WITHOUT the lock, so
|
|
281
|
+
# several `add` in different terminals proceed in parallel. RESERVED_DIR drives a trap
|
|
282
|
+
# that removes the half-made dir on any failure/abort BEFORE registration.
|
|
277
283
|
local id d got elabel
|
|
278
284
|
elabel="${email:-the account you sign in as}"
|
|
285
|
+
RESERVED_DIR=""
|
|
286
|
+
trap 'add_cleanup_reserved; exit 130' INT TERM
|
|
287
|
+
trap 'add_cleanup_reserved' EXIT
|
|
288
|
+
mutate_lock || die "could not acquire the account lock (another op is stuck?) — try again"
|
|
279
289
|
id="$(next_id)"
|
|
280
290
|
d="$ACC_ROOT/$id"
|
|
281
291
|
seed_account_dir "$d"
|
|
292
|
+
RESERVED_DIR="$d"
|
|
293
|
+
mutate_unlock
|
|
294
|
+
|
|
282
295
|
if [ "$token" = "1" ]; then
|
|
283
296
|
# Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
|
|
284
297
|
echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
|
|
285
|
-
|
|
286
|
-
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
287
|
-
die "sign-in failed or aborted — nothing was created"
|
|
288
|
-
fi
|
|
298
|
+
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
|
|
289
299
|
( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
|
|
290
300
|
chmod 600 "$d/server.token"
|
|
291
301
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
292
302
|
else
|
|
293
|
-
# Default: full Claude Code login (
|
|
294
|
-
#
|
|
295
|
-
# THIS machine (creds are never synced, so no cross-machine refresh races).
|
|
303
|
+
# Default: full Claude Code login (full scopes, no long-lived-token step-up).
|
|
304
|
+
# Writes auto-refreshing .credentials.json, valid on THIS machine.
|
|
296
305
|
echo "Preparing $id for $elabel — NOTHING is registered until login completes."
|
|
297
|
-
|
|
298
|
-
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
299
|
-
die "login failed or aborted — nothing was created"
|
|
300
|
-
fi
|
|
306
|
+
run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
|
|
301
307
|
got="$(config_dir_email "$d")"
|
|
302
308
|
fi
|
|
303
309
|
if [ -z "$got" ]; then
|
|
304
|
-
# Signed in, but we could not read back WHICH account. Without a verified email we
|
|
305
|
-
# cannot label or dedup the account, so refuse — unless an email was named AND
|
|
306
|
-
# --force was given (then we trust the named email).
|
|
307
310
|
if [ -n "$email" ] && [ "$force" = "1" ]; then
|
|
308
311
|
warn "identity unverified — registering as $email because --force was given"
|
|
309
312
|
got="$email"
|
|
310
313
|
else
|
|
311
|
-
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
312
314
|
die "signed in, but the account identity could not be read back — nothing was created (retry; or 'add <email> --force' to trust a named email)"
|
|
313
315
|
fi
|
|
314
316
|
fi
|
|
315
|
-
#
|
|
317
|
+
# Dedup + register under a BRIEF lock, re-reading the manifest (a parallel add may have
|
|
318
|
+
# registered the same email meanwhile — the loser skips gracefully). This is the
|
|
319
|
+
# guarantee that a code pasted for an already-added account never yields a second entry.
|
|
320
|
+
mutate_lock || die "could not acquire the account lock — try again"
|
|
316
321
|
owner="$(email_owner "$got")"
|
|
317
322
|
if [ -n "$owner" ]; then
|
|
318
|
-
|
|
319
|
-
|
|
323
|
+
mutate_unlock
|
|
324
|
+
echo "$got is already added as $owner — skipping (nothing added)."
|
|
325
|
+
return 0 # RESERVED_DIR still set -> trap removes the temp dir
|
|
320
326
|
fi
|
|
321
327
|
if [ -n "$email" ] && [ "$got" != "$email" ]; then
|
|
322
328
|
warn "signed in as $got (you named $email) — registering the account that actually authenticated"
|
|
323
329
|
fi
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
330
|
+
manifest_add_account "$id" "$got" "$(machine_kind)"
|
|
331
|
+
mutate_unlock
|
|
332
|
+
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
333
|
+
trap - EXIT INT TERM
|
|
334
|
+
log_to ops.log "add $id $got (auth-verified)"
|
|
327
335
|
auto_sync
|
|
328
336
|
cat <<EOF
|
|
329
|
-
Registered $id for $
|
|
337
|
+
Registered $id for $got (sign-in verified) — usable immediately.
|
|
330
338
|
Optional:
|
|
331
339
|
claude-accounts verify # confirm the 100% matrix
|
|
332
340
|
EOF
|
|
333
341
|
}
|
|
334
342
|
|
|
343
|
+
add_cleanup_reserved() {
|
|
344
|
+
# EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
|
|
345
|
+
# we died mid-critical-section, release the lock).
|
|
346
|
+
[ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
|
|
347
|
+
mutate_unlock 2>/dev/null || true
|
|
348
|
+
}
|
|
349
|
+
|
|
335
350
|
cmd_import() {
|
|
336
351
|
require_manifest
|
|
337
352
|
local email="${1:-}"
|
|
@@ -393,6 +408,70 @@ cmd_import() {
|
|
|
393
408
|
[ "$no_sync" = "1" ] || auto_sync
|
|
394
409
|
}
|
|
395
410
|
|
|
411
|
+
# Prints duplicate account ids to REMOVE, one per line: for every email that appears
|
|
412
|
+
# more than once, keep exactly one (prefer an account that has auth on this machine,
|
|
413
|
+
# then the lowest id) and list the rest. Empty output => pool is already clean.
|
|
414
|
+
dup_ids_to_remove() {
|
|
415
|
+
[ -f "$MANIFEST" ] || return 0
|
|
416
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
|
|
417
|
+
import json, os, re, sys
|
|
418
|
+
manifest, root = sys.argv[1], sys.argv[2]
|
|
419
|
+
accts = [a for a in json.load(open(manifest)).get('accounts', [])
|
|
420
|
+
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
421
|
+
def has_auth(aid):
|
|
422
|
+
d = os.path.join(root, aid)
|
|
423
|
+
c = os.path.join(d, '.credentials.json')
|
|
424
|
+
t = os.path.join(d, 'server.token')
|
|
425
|
+
return (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0)
|
|
426
|
+
by_email = {}
|
|
427
|
+
for a in accts:
|
|
428
|
+
by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
429
|
+
for email, ids in by_email.items():
|
|
430
|
+
if len(ids) < 2:
|
|
431
|
+
continue
|
|
432
|
+
# keep: authed first, then lowest id
|
|
433
|
+
keep = sorted(ids, key=lambda i: (not has_auth(i), i))[0]
|
|
434
|
+
for i in ids:
|
|
435
|
+
if i != keep:
|
|
436
|
+
print(i)
|
|
437
|
+
PYEOF
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
cmd_dedupe() {
|
|
441
|
+
require_manifest
|
|
442
|
+
local yes=0
|
|
443
|
+
[ "${1:-}" = "--yes" ] && yes=1
|
|
444
|
+
local dups
|
|
445
|
+
dups="$(dup_ids_to_remove)"
|
|
446
|
+
if [ -z "$dups" ]; then
|
|
447
|
+
echo "No duplicate accounts — every email appears once."
|
|
448
|
+
return 0
|
|
449
|
+
fi
|
|
450
|
+
echo "Duplicate accounts (same email registered more than once):"
|
|
451
|
+
local id email
|
|
452
|
+
for id in $dups; do
|
|
453
|
+
email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
|
|
454
|
+
import json, sys
|
|
455
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
456
|
+
if a.get('id') == sys.argv[2]: print(a.get('email', '')); break
|
|
457
|
+
PYEOF
|
|
458
|
+
)"
|
|
459
|
+
echo " will remove $id ($email)"
|
|
460
|
+
done
|
|
461
|
+
if [ "$yes" != "1" ]; then
|
|
462
|
+
printf 'Remove these duplicates (keeps one per email)? [y/N] '
|
|
463
|
+
read -r ans
|
|
464
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
465
|
+
fi
|
|
466
|
+
for id in $dups; do
|
|
467
|
+
if [ -L "$ACC_ROOT/$id" ]; then rm -f "${ACC_ROOT:?}/${id:?}"; else rm -rf "${ACC_ROOT:?}/${id:?}"; fi
|
|
468
|
+
manifest_del_account "$id"
|
|
469
|
+
log_to ops.log "dedupe removed $id"
|
|
470
|
+
done
|
|
471
|
+
echo "Removed $(printf '%s\n' "$dups" | grep -c .) duplicate account(s)."
|
|
472
|
+
auto_sync
|
|
473
|
+
}
|
|
474
|
+
|
|
396
475
|
cmd_adopt() {
|
|
397
476
|
# Make <acct-NN> THIS machine's existing default login (~/.claude) without
|
|
398
477
|
# forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
|
|
@@ -1165,6 +1244,7 @@ case "${1:-help}" in
|
|
|
1165
1244
|
add) shift; cmd_add "$@" ;;
|
|
1166
1245
|
import) shift; cmd_import "$@" ;;
|
|
1167
1246
|
adopt) shift; cmd_adopt "$@" ;;
|
|
1247
|
+
dedupe) shift; cmd_dedupe "$@" ;;
|
|
1168
1248
|
remove) shift; cmd_remove "$@" ;;
|
|
1169
1249
|
mint) shift; cmd_mint "$@" ;;
|
|
1170
1250
|
login) shift; cmd_login "$@" ;;
|
package/lib/common.sh
CHANGED
|
@@ -188,6 +188,34 @@ PYEOF
|
|
|
188
188
|
fi
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
# Short-lived mutation lock: serialize only the brief critical sections (id reservation,
|
|
192
|
+
# manifest write), NOT long interactive work like a browser sign-in — so several
|
|
193
|
+
# `add`/`login` in different terminals run in parallel and only briefly wait on each other.
|
|
194
|
+
# Held for milliseconds; a lock older than 30s is assumed abandoned (holder died) and reclaimed.
|
|
195
|
+
MUTATE_LOCK_HELD=0
|
|
196
|
+
mutate_lock() {
|
|
197
|
+
local lock="$ACC_ROOT/.locks/mutate" tries=0
|
|
198
|
+
mkdir -p "$ACC_ROOT/.locks" 2>/dev/null
|
|
199
|
+
while ! mkdir "$lock" 2>/dev/null; do
|
|
200
|
+
if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 30 ]; then
|
|
201
|
+
rm -rf "$lock" 2>/dev/null
|
|
202
|
+
continue
|
|
203
|
+
fi
|
|
204
|
+
tries=$((tries + 1))
|
|
205
|
+
[ "$tries" -gt 600 ] && return 1 # ~60s ceiling; contention should clear in ms
|
|
206
|
+
sleep 0.1
|
|
207
|
+
done
|
|
208
|
+
MUTATE_LOCK_HELD=1
|
|
209
|
+
return 0
|
|
210
|
+
}
|
|
211
|
+
# Only removes the lock dir when THIS process holds it — so a normal release, or a
|
|
212
|
+
# cleanup trap firing after release, can never delete a lock a parallel process just took.
|
|
213
|
+
mutate_unlock() {
|
|
214
|
+
[ "${MUTATE_LOCK_HELD:-0}" = 1 ] || return 0
|
|
215
|
+
rm -rf "$ACC_ROOT/.locks/mutate" 2>/dev/null
|
|
216
|
+
MUTATE_LOCK_HELD=0
|
|
217
|
+
}
|
|
218
|
+
|
|
191
219
|
rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~256KB)
|
|
192
220
|
local f="$ACC_ROOT/$1"
|
|
193
221
|
[ -f "$f" ] || return 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/tests/run-tests.sh
CHANGED
|
@@ -353,11 +353,13 @@ check "remove account" "Removed acct-04" "$out"
|
|
|
353
353
|
[ ! -d "$ACC/acct-04" ] && t_ok "remove deleted dir" || t_fail "remove deleted dir" "dir still there"
|
|
354
354
|
|
|
355
355
|
# ---- 15b. duplicate-email guards ------------------------------------------------
|
|
356
|
+
# add of a named, already-present email => graceful SKIP before any sign-in (exit 0)
|
|
356
357
|
out="$(claude-accounts add a@test 2>&1)"
|
|
357
358
|
rc=$?
|
|
358
|
-
check "add
|
|
359
|
-
[ "$rc"
|
|
359
|
+
check "add skips a duplicate email with a message" "already added as acct-01 — skipping" "$out"
|
|
360
|
+
[ "$rc" = "0" ] && t_ok "duplicate add exits 0 (graceful skip)" || t_fail "duplicate add rc" "rc=$rc"
|
|
360
361
|
[ ! -d "$ACC/acct-04" ] && t_ok "duplicate add created nothing" || t_fail "duplicate add" "dir created"
|
|
362
|
+
# import stays strict (it's the lower-level command): refuses a duplicate
|
|
361
363
|
out="$(claude-accounts import a@test --id acct-09 --no-sync 2>&1)"
|
|
362
364
|
rc=$?
|
|
363
365
|
check "import refuses duplicate email" "already registered as acct-01" "$out"
|
|
@@ -391,8 +393,8 @@ claude-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
|
391
393
|
# add with no email, but the signed-in email is already registered => refuse + clean up
|
|
392
394
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add 2>&1)"
|
|
393
395
|
rc=$?
|
|
394
|
-
check "email-less add
|
|
395
|
-
[ "$rc"
|
|
396
|
+
check "email-less add skips a duplicate signed-in email" "a@test is already added as acct-01 — skipping" "$out"
|
|
397
|
+
[ "$rc" = "0" ] && t_ok "email-less duplicate skip exits 0" || t_fail "email-less dup rc" "rc=$rc"
|
|
396
398
|
[ ! -d "$ACC/acct-04" ] && t_ok "email-less duplicate cleaned up" || t_fail "email-less dup cleanup" "dir left"
|
|
397
399
|
# add with no email but identity can't be read back => refuse (can't label the account)
|
|
398
400
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_AUTH_FAIL=1 claude-accounts add 2>&1)"
|
|
@@ -422,7 +424,8 @@ check "aborted --token add detected" "sign-in failed or aborted" "$out"
|
|
|
422
424
|
# ---- 15e. sign-in as an already-registered email is rejected + cleaned ---------------
|
|
423
425
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add brand@test 2>&1)"
|
|
424
426
|
rc=$?
|
|
425
|
-
check "wrong-account sign-in
|
|
427
|
+
check "wrong-account sign-in skips (already added)" "a@test is already added as acct-01 — skipping" "$out"
|
|
428
|
+
[ "$rc" = "0" ] && t_ok "wrong-account skip exits 0" || t_fail "wrong-account rc" "rc=$rc"
|
|
426
429
|
[ ! -d "$ACC/acct-04" ] && t_ok "wrong-account sign-in cleaned up" || t_fail "wrong-account cleanup" "dir left behind"
|
|
427
430
|
|
|
428
431
|
# ---- 15f. login command completes auth for an existing auth-less account -------------
|
|
@@ -440,6 +443,62 @@ check "login --token saves a portable token" "acct-08 token saved" "$out"
|
|
|
440
443
|
[ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
|
|
441
444
|
claude-accounts remove acct-08 --yes >/dev/null 2>&1
|
|
442
445
|
|
|
446
|
+
# ---- 15g. parallel adds (different terminals) get distinct ids, no lock-busy error ----
|
|
447
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par1@test claude-accounts add >"$WORK/p1.out" 2>&1 ) &
|
|
448
|
+
pA=$!
|
|
449
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par2@test claude-accounts add >"$WORK/p2.out" 2>&1 ) &
|
|
450
|
+
pB=$!
|
|
451
|
+
wait "$pA"; wait "$pB"
|
|
452
|
+
both="$(cat "$WORK/p1.out" "$WORK/p2.out" 2>/dev/null)"
|
|
453
|
+
printf '%s' "$both" | grep -q "in progress" \
|
|
454
|
+
&& t_fail "parallel add: no lock-busy error" "got the 'another add in progress' error" \
|
|
455
|
+
|| t_ok "parallel add: neither reports 'another add in progress'"
|
|
456
|
+
regA="$(claude-accounts list 2>&1 | grep par1@test | awk '{print $1}')"
|
|
457
|
+
regB="$(claude-accounts list 2>&1 | grep par2@test | awk '{print $1}')"
|
|
458
|
+
{ [ -n "$regA" ] && [ -n "$regB" ] && [ "$regA" != "$regB" ]; } \
|
|
459
|
+
&& t_ok "parallel add: both registered on distinct ids ($regA, $regB)" \
|
|
460
|
+
|| t_fail "parallel add ids" "regA=$regA regB=$regB"
|
|
461
|
+
# and no duplicate/second entry crept in for either email
|
|
462
|
+
{ [ "$(claude-accounts list 2>&1 | grep -c par1@test)" = "1" ] && [ "$(claude-accounts list 2>&1 | grep -c par2@test)" = "1" ]; } \
|
|
463
|
+
&& t_ok "parallel add: exactly one entry per email" || t_fail "parallel add dup" "duplicate created"
|
|
464
|
+
[ -n "$regA" ] && claude-accounts remove "$regA" --yes >/dev/null 2>&1
|
|
465
|
+
[ -n "$regB" ] && claude-accounts remove "$regB" --yes >/dev/null 2>&1
|
|
466
|
+
|
|
467
|
+
# ---- 15h. two parallel adds signing into the SAME email: exactly one wins ------------
|
|
468
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s1.out" 2>&1 ) &
|
|
469
|
+
sA=$!
|
|
470
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s2.out" 2>&1 ) &
|
|
471
|
+
sB=$!
|
|
472
|
+
wait "$sA"; wait "$sB"
|
|
473
|
+
n="$(claude-accounts list 2>&1 | grep -c same@test)"
|
|
474
|
+
[ "$n" = "1" ] && t_ok "same-email parallel add: exactly one registered (other skipped)" \
|
|
475
|
+
|| t_fail "same-email parallel add" "count=$n (expected 1)"
|
|
476
|
+
cat "$WORK/s1.out" "$WORK/s2.out" 2>/dev/null | grep -q "already added as\|skipping" \
|
|
477
|
+
&& t_ok "same-email parallel add: loser skipped gracefully" || t_fail "same-email skip msg" "no skip message"
|
|
478
|
+
regS="$(claude-accounts list 2>&1 | grep same@test | awk '{print $1}')"
|
|
479
|
+
[ -n "$regS" ] && claude-accounts remove "$regS" --yes >/dev/null 2>&1
|
|
480
|
+
|
|
481
|
+
# ---- 15i. dedupe removes accounts registered twice (keeps one per email) --------------
|
|
482
|
+
out="$(claude-accounts dedupe 2>&1)"
|
|
483
|
+
check "dedupe on a clean pool is a no-op" "No duplicate accounts" "$out"
|
|
484
|
+
# manufacture a duplicate directly in the manifest (a pre-fix leftover) + a dir for it
|
|
485
|
+
claude-accounts import twin@test --id acct-06 --creds "$WORK/import-creds.json" --mode copy --no-sync >/dev/null 2>&1
|
|
486
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
487
|
+
import json, os, sys
|
|
488
|
+
d = json.load(open(sys.argv[1]))
|
|
489
|
+
d['accounts'].append({'id': 'acct-07', 'email': 'twin@test', 'home': 'mac'})
|
|
490
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
491
|
+
EOF
|
|
492
|
+
mkdir -p "$ACC/acct-07"
|
|
493
|
+
out="$(claude-accounts list 2>&1)"
|
|
494
|
+
check "list warns about a duplicate email" "twin@test is registered 2x" "$out"
|
|
495
|
+
out="$(claude-accounts dedupe --yes 2>&1)"
|
|
496
|
+
check "dedupe reports removal" "Removed 1 duplicate" "$out"
|
|
497
|
+
[ "$(claude-accounts list 2>&1 | grep -c twin@test)" = "1" ] && t_ok "dedupe keeps exactly one per email" || t_fail "dedupe count" "not 1"
|
|
498
|
+
# it kept the authed one (acct-06 has creds), removed the bare acct-07
|
|
499
|
+
claude-accounts list 2>&1 | grep -q "acct-06.*twin@test" && t_ok "dedupe kept the authenticated account" || t_fail "dedupe keep-authed" "kept the wrong one"
|
|
500
|
+
claude-accounts remove acct-06 --yes >/dev/null 2>&1
|
|
501
|
+
|
|
443
502
|
# ---- 16. CLI: limits marking via fixture endpoint ------------------------------
|
|
444
503
|
cat > "$WORK/usage-high.json" <<'EOF'
|
|
445
504
|
{"limits":[
|