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.
@@ -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, fast-fail on a duplicate now. If not, we learn (and dedup)
253
- # the real email from the sign-in below one account per email either way.
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" ] && die "$email is already registered as $owner — nothing created (run 'claude-accounts login $owner' to re-authenticate it)"
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
- if ! run_token_ceremony "$d"; then
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 (the flow that just works — full scopes, no
294
- # long-lived-token step-up). Writes auto-refreshing .credentials.json, valid on
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
- if ! run_login_ceremony "$d" "$email"; then
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
- # `got` is now the authoritative signed-in email. Dedup against it, warn on mismatch.
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
- rm -rf "${ACC_ROOT:?}/${id:?}"
319
- die "you signed in as $got, which is already registered as $owner — nothing was created"
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
- email="$got"
325
- manifest_add_account "$id" "$email" "$(machine_kind)"
326
- log_to ops.log "add $id $email (auth-verified)"
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 $email (sign-in verified) — usable immediately.
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",
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": {
@@ -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 refuses duplicate email" "already registered as acct-01" "$out"
359
- [ "$rc" != "0" ] && t_ok "duplicate add exits nonzero" || t_fail "duplicate add rc" "rc=0"
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 refuses a duplicate signed-in email" "already registered as acct-01" "$out"
395
- [ "$rc" != "0" ] && t_ok "email-less duplicate exits nonzero" || t_fail "email-less dup rc" "rc=0"
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 rejected" "already registered as acct-01" "$out"
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":[