claude-multiacc 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -163,25 +163,37 @@ acct-01 = the Mac's login, acct-02 = the server's login (adopted server-side).
163
163
  ### Add a fresh account (~1 min, one browser sign-in)
164
164
 
165
165
  ```bash
166
+ claude-accounts add # no email needed — read back from the sign-in
167
+ # or name it explicitly (fast-fails on a duplicate, and pre-fills the login page):
166
168
  claude-accounts add new@example.com
167
169
  ```
168
170
 
169
- It prints a **clickable sign-in link**. Open it in a browser signed into that account,
170
- approve, and paste the code back into the same prompt. The account is registered **only
171
- after the sign-in is verified** an aborted or failed sign-in leaves nothing behind, and
172
- a duplicate email is refused outright (`--force` overrides). The captured token has a
173
- ~1-year lifetime, is inference-only, and does not rotate → it works on the Mac **and**
174
- the server with no cross-machine refresh races, so the account is usable everywhere the
175
- moment `add` returns.
171
+ This runs the **normal full Claude Code login** the exact same
172
+ `claude auth login` flow the app itself uses (`claude.com/cai/oauth/authorize`, full
173
+ Claude Code scopes). A browser opens (or a sign-in link is printed); sign in to the
174
+ account you're adding, approve, and if a code is shown, paste it back. It draws on the
175
+ subscription never API keys.
176
+
177
+ Using the full login on purpose: it does **not** hit the *"Sign in again to continue"*
178
+ step-up wall that the narrow long-lived-token grant triggers, and it stores an
179
+ auto-refreshing `.credentials.json`. That credential is **machine-local** (never synced),
180
+ which is what keeps two machines from invalidating each other's refresh token — so an
181
+ account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
182
+ you want it there too. The account is registered **only after the signed-in email is read
183
+ back**; an aborted login leaves nothing behind, and duplicate emails are refused.
176
184
 
177
185
  ```bash
178
- claude-accounts login acct-NN # re-run the same flow for an existing account
179
- # (complete missing auth, or refresh an aging token)
180
- claude-accounts add x@y.com --tui # alternative: interactive /login instead, storing
181
- # auto-refreshing OAuth creds (that machine only)
186
+ claude-accounts login acct-NN # complete/refresh an existing account (full login)
187
+ claude-accounts add new@example.com --token # portable setup-token instead: works on BOTH
188
+ # machines and syncs to the server, but needs a
189
+ # recent sign-in (may show the step-up screen)
190
+ claude-accounts login acct-NN --token # same, for an existing account
182
191
  ```
183
192
 
184
- `status` warns when a token nears end of life; re-run `login` for that account.
193
+ Use `--token` when you specifically want one account usable on the server via the Mac's
194
+ sync (the ~1-year inference-only token that mirrors over). For everyday adds, the default
195
+ full login is smoother. `status` warns when a token nears end of life; re-run `login`.
196
+
185
197
  Both accounts currently in the pool are live and verified: acct-01 (support@gowalkae.com,
186
198
  Mac) and acct-02 (hasan@gowalkqa.com, server) — each adopted from its machine's existing
187
199
  login, so each runs on the machine that holds its credential.
@@ -248,6 +260,11 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
248
260
  - **A service bypasses the shim** — it spawns an absolute path. Point its env
249
261
  (`CLAUDE_BIN` etc.) at `/usr/local/bin/claude`. Docker-internal `claude` installs
250
262
  (e.g. openclaw live-test containers) are out of scope for a host shim.
263
+ - **`add` sign-in says "Sign in again to continue"** — that's Claude's step-up
264
+ authentication, not a tool error: granting a long-lived token needs a *recent* sign-in.
265
+ Open a fresh incognito window, sign in to the target account at claude.ai **first**,
266
+ then paste the link and approve. The approval scope is `user:inference`
267
+ (*"Contribute to your Claude subscription usage"*) — that's correct and minimal.
251
268
  - **Limits look stale** — launchd: `launchctl list | grep claude-multiacc`;
252
269
  cron: `crontab -l | grep claude-multiacc`. The shim also kicks a refresh when data
253
270
  is >3 min old.
@@ -20,12 +20,15 @@ claude-accounts — multi-account pool manager for claude-multiacc
20
20
  USAGE
21
21
  claude-accounts list brief account list
22
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)
23
+ claude-accounts add [email] [--token] [--force]
24
+ login-FIRST: runs the full Claude Code login (`claude auth login` the normal
25
+ browser sign-in, no long-lived-token step-up), then registers only after the
26
+ signed-in email is read back. Email is OPTIONAL (derived from the sign-in).
27
+ Stores auto-refreshing .credentials.json valid on THIS machine. Duplicates refused.
28
+ --token instead mints a portable setup-token (works on Mac AND server; needs a
29
+ recent sign-in — use it when you want the account usable on the server too).
30
+ claude-accounts login <acct-NN> [--token] [--force]
31
+ complete/refresh auth for an existing account (full login, or --token)
29
32
  claude-accounts import <email> [opts] register an account, optionally with credentials
30
33
  --id acct-NN explicit id (default: next free)
31
34
  --home mac|server which machine owns the OAuth grant (default: this one)
@@ -229,27 +232,29 @@ PYEOF
229
232
  }
230
233
 
231
234
  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
+ # Login-FIRST: the account is registered (and synced) only after sign-in succeeds
236
+ # and the authenticated email is read back from the account itself. The email
237
+ # argument is OPTIONAL omit it and it's derived from whoever you sign in as.
238
+ # An aborted or failed sign-in leaves zero traces.
235
239
  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
+ local email="" force=0 token=0
240
241
  while [ $# -gt 0 ]; do
241
242
  case "$1" in
242
243
  --force) force=1; shift ;;
243
- --tui) tui=1; shift ;;
244
- *) die "unknown option: $1" ;;
244
+ --token) token=1; shift ;; # portable setup-token instead of full login (server use)
245
+ --tui) shift ;; # accepted for compatibility; full login is now the default
246
+ --*) die "unknown option: $1" ;;
247
+ *)
248
+ if [ -z "$email" ]; then email="$1"; shift
249
+ else die "unexpected argument: $1 (usage: claude-accounts add [email] [--token] [--force])"; fi ;;
245
250
  esac
246
251
  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).
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.
249
254
  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)"
255
+ if [ -n "$email" ]; then
256
+ 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)"
253
258
  fi
254
259
  if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
255
260
  die "add is interactive (it completes sign-in before registering) — run it from a terminal"
@@ -269,40 +274,14 @@ cmd_add() {
269
274
  trap "rm -rf '$lock'" EXIT
270
275
  local real
271
276
  real="$(find_real_claude "$_self")" || die "real claude binary not found"
272
- local id d got
277
+ local id d got elabel
278
+ elabel="${email:-the account you sign in as}"
273
279
  id="$(next_id)"
274
280
  d="$ACC_ROOT/$id"
275
281
  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."
282
+ if [ "$token" = "1" ]; then
283
+ # Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
284
+ echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
306
285
  if ! run_token_ceremony "$d"; then
307
286
  rm -rf "${ACC_ROOT:?}/${id:?}"
308
287
  die "sign-in failed or aborted — nothing was created"
@@ -310,28 +289,39 @@ except Exception:
310
289
  ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
311
290
  chmod 600 "$d/server.token"
312
291
  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"
292
+ 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).
296
+ 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"
324
300
  fi
301
+ got="$(config_dir_email "$d")"
325
302
  fi
326
- if [ "$got" != "$email" ]; then
327
- owner="$(email_owner "$got")"
328
- if [ -n "$owner" ]; then
303
+ 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
+ if [ -n "$email" ] && [ "$force" = "1" ]; then
308
+ warn "identity unverified — registering as $email because --force was given"
309
+ got="$email"
310
+ else
329
311
  rm -rf "${ACC_ROOT:?}/${id:?}"
330
- die "you signed in as $got, which is already registered as $owner — nothing was created"
312
+ 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)"
331
313
  fi
332
- warn "signed in as $got (expected $email) — registering the account that actually authenticated"
333
- email="$got"
334
314
  fi
315
+ # `got` is now the authoritative signed-in email. Dedup against it, warn on mismatch.
316
+ owner="$(email_owner "$got")"
317
+ 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"
320
+ fi
321
+ if [ -n "$email" ] && [ "$got" != "$email" ]; then
322
+ warn "signed in as $got (you named $email) — registering the account that actually authenticated"
323
+ fi
324
+ email="$got"
335
325
  manifest_add_account "$id" "$email" "$(machine_kind)"
336
326
  log_to ops.log "add $id $email (auth-verified)"
337
327
  auto_sync
@@ -488,8 +478,17 @@ run_token_ceremony() { # $1 = config dir
488
478
  umask 077 # the capture file briefly holds the raw token (removed inline below).
489
479
  # NB: no EXIT trap here — cmd_add owns the EXIT trap for its mutation lock, and a
490
480
  # 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."
481
+ cat <<'TIP'
482
+ A sign-in link will appear below. For a smooth grant (avoids the "Sign in again
483
+ to continue" step-up screen):
484
+ 1. Open a fresh incognito/private window.
485
+ 2. Sign in at claude.ai to the EXACT account you're adding — do this FIRST.
486
+ 3. THEN paste the link below into that same window and approve
487
+ ("Contribute to your Claude subscription usage" is the correct permission).
488
+ 4. Copy the code it shows and paste it back here.
489
+ If you do see "Sign in again to continue", that is Claude's security step, not an
490
+ error — just sign in to that account and approve; the code still appears.
491
+ TIP
493
492
  if [ -t 0 ]; then
494
493
  if [ "$(machine_kind)" = "mac" ]; then
495
494
  script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
@@ -525,6 +524,56 @@ except Exception:
525
524
  print("")'
526
525
  }
527
526
 
527
+ config_dir_email() { # $1 = config dir; prints the logged-in email or empty
528
+ local real
529
+ real="$(find_real_claude "$_self")" || { echo ""; return 0; }
530
+ CLAUDE_CONFIG_DIR="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
531
+ import json, sys
532
+ try:
533
+ doc = json.loads(sys.stdin.read())
534
+ print(doc.get("email", "") if doc.get("loggedIn") else "")
535
+ except Exception:
536
+ print("")'
537
+ }
538
+
539
+ # Full subscription login: `claude auth login --claudeai`, the SAME flow the
540
+ # interactive app uses (claude.com/cai/oauth/authorize, full Claude Code scopes,
541
+ # normal sign-in — no long-lived-token step-up wall). Writes <dir>/.credentials.json
542
+ # (auto-refreshing). Returns success iff the credential landed. $2 = optional email hint.
543
+ run_login_ceremony() { # $1 = config dir, $2 = email hint
544
+ local d="$1" hint="${2:-}" real
545
+ real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
546
+ cat <<'TIP'
547
+ A browser will open (or a sign-in link will be printed) for the FULL Claude Code
548
+ login. Sign in as the account you want to add and approve; if a code is shown,
549
+ paste it back here. This is the normal login — it draws on the subscription, no
550
+ API keys, and refreshes itself over time.
551
+ TIP
552
+ if [ -t 0 ]; then
553
+ if [ "$(machine_kind)" = "mac" ]; then
554
+ if [ -n "$hint" ]; then
555
+ script -q /dev/null env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai --email "$hint"
556
+ else
557
+ script -q /dev/null env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai
558
+ fi
559
+ else
560
+ if [ -n "$hint" ]; then
561
+ script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' auth login --claudeai --email '$hint'" /dev/null
562
+ else
563
+ script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' auth login --claudeai" /dev/null
564
+ fi
565
+ fi
566
+ else
567
+ # Headless (tests / automation): no PTY.
568
+ if [ -n "$hint" ]; then
569
+ CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai --email "$hint" || true
570
+ else
571
+ CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
572
+ fi
573
+ fi
574
+ [ -f "$d/.credentials.json" ]
575
+ }
576
+
528
577
  cmd_mint() {
529
578
  require_manifest
530
579
  local id="${1:-}" paste="${2:-}"
@@ -553,11 +602,20 @@ cmd_mint() {
553
602
  }
554
603
 
555
604
  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.
605
+ # Complete (or refresh) auth for an EXISTING account. Default: full Claude Code login
606
+ # (auto-refreshing .credentials.json, this machine). --token: portable setup-token
607
+ # (Mac + server). Verifies the signed-in email matches the manifest.
558
608
  require_manifest
559
- local id="${1:-}" force="${2:-}"
560
- [ -n "$id" ] || die "usage: claude-accounts login <acct-NN> [--force]"
609
+ local id="" force=0 token=0
610
+ while [ $# -gt 0 ]; do
611
+ case "$1" in
612
+ --force) force=1; shift ;;
613
+ --token) token=1; shift ;;
614
+ --*) die "unknown option: $1" ;;
615
+ *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
616
+ esac
617
+ done
618
+ [ -n "$id" ] || die "usage: claude-accounts login <acct-NN> [--token] [--force]"
561
619
  valid_acct_id "$id" || die "not a valid account id: $id"
562
620
  account_ids | grep -qx "$id" || die "unknown account: $id"
563
621
  local d="$ACC_ROOT/$id" email got
@@ -571,17 +629,24 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
571
629
  PYEOF
572
630
  )"
573
631
  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)"
632
+ if [ "$token" = "1" ]; then
633
+ run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
634
+ got="$(token_email "$CEREMONY_TOKEN")"
635
+ if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
636
+ die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
637
+ fi
638
+ ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
639
+ chmod 600 "$d/server.token"
640
+ echo "$id token saved (portable — works on Mac and server)."
641
+ else
642
+ run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing changed"
643
+ got="$(config_dir_email "$d")"
644
+ if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
645
+ die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
646
+ fi
647
+ echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
578
648
  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"
649
+ log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
585
650
  auto_sync
586
651
  }
587
652
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": {
@@ -29,9 +29,17 @@ cat > "$FAKEBIN/claude" <<'EOF'
29
29
  #!/usr/bin/env bash
30
30
  # fake real claude for tests (not a multiacc shim)
31
31
  if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then
32
+ if [ -n "${FAKE_AUTH_FAIL:-}" ]; then echo '{"loggedIn": false}'; exit 0; fi
32
33
  printf '{"loggedIn": true, "email": "%s"}\n' "${FAKE_EMAIL:-fake@test}"
33
34
  exit 0
34
35
  fi
36
+ if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
37
+ [ -n "${FAKE_LOGIN_FAIL:-}" ] && { echo "login aborted" >&2; exit 1; }
38
+ # simulate a completed full-scope login: write auto-refreshing creds to the config dir
39
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' > "${CLAUDE_CONFIG_DIR:-/dev/null}/.credentials.json"
40
+ echo "Logged in."
41
+ exit 0
42
+ fi
35
43
  if [ "${1:-}" = "setup-token" ]; then
36
44
  echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
37
45
  [ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "sign-in aborted" >&2; exit 1; }
@@ -357,28 +365,59 @@ check "import refuses duplicate email" "already registered as acct-01" "$out"
357
365
  out="$(claude-accounts import a@test --id acct-01 --no-sync 2>&1)"
358
366
  check "import same-id update allowed" "Imported acct-01" "$out"
359
367
 
360
- # ---- 15c. login-first add (default link+code flow): registers after verified auth --
368
+ # ---- 15c. login-first add (DEFAULT full-login flow): registers after verified auth --
361
369
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=new@test claude-accounts add new@test 2>&1)"
362
- check "add shows sign-in link" "sign-in link" "$out"
363
- check "add registers after verified sign-in" "Registered acct-04 for new@test" "$out"
364
- [ -s "$ACC/acct-04/server.token" ] && t_ok "token landed in the new dir" || t_fail "add token" "missing"
370
+ check "add registers after verified login" "Registered acct-04 for new@test" "$out"
371
+ [ -f "$ACC/acct-04/.credentials.json" ] && t_ok "default add writes .credentials.json (full login)" || t_fail "add creds" "missing"
372
+ [ ! -f "$ACC/acct-04/server.token" ] && t_ok "default add does NOT mint a setup-token" || t_fail "add token" "unexpected server.token"
373
+ out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
374
+ check "new account usable via pin (oauth creds)" "CFG=acct-04" "$out"
375
+ claude-accounts remove acct-04 --yes >/dev/null 2>&1
376
+
377
+ # ---- 15c1. add --token: portable setup-token instead of creds ------------------------
378
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=tok@test claude-accounts add tok@test --token 2>&1)"
379
+ check "add --token registers via portable token" "Registered acct-04 for tok@test" "$out"
380
+ [ -s "$ACC/acct-04/server.token" ] && t_ok "add --token writes server.token" || t_fail "add --token" "missing"
365
381
  out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
366
- check "new account usable via pin (token auth)" "TOK=sk-ant-oat01-FAKE" "$out"
382
+ check "token account exports CLAUDE_CODE_OAUTH_TOKEN" "TOK=sk-ant-oat01-FAKE" "$out"
367
383
  claude-accounts remove acct-04 --yes >/dev/null 2>&1
368
384
 
369
- # ---- 15c2. --tui variant registers via /login creds --------------------------------
370
- out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_DO_LOGIN=1 FAKE_EMAIL=tui@test claude-accounts add tui@test --tui 2>&1)"
371
- check "add --tui registers after verified login" "Registered acct-04 for tui@test" "$out"
372
- [ -f "$ACC/acct-04/.credentials.json" ] && t_ok "tui login creds landed in the new dir" || t_fail "tui creds" "missing"
385
+ # ---- 15c0. add with NO email: derives it from the verified sign-in -------------------
386
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=derived@test claude-accounts add 2>&1)"
387
+ check "add without email registers the signed-in address" "Registered acct-04 for derived@test" "$out"
388
+ claude-accounts list 2>&1 | grep -q "derived@test" && t_ok "email-less add lands in the manifest" \
389
+ || t_fail "email-less add" "derived@test not registered"
373
390
  claude-accounts remove acct-04 --yes >/dev/null 2>&1
391
+ # add with no email, but the signed-in email is already registered => refuse + clean up
392
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add 2>&1)"
393
+ 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
+ [ ! -d "$ACC/acct-04" ] && t_ok "email-less duplicate cleaned up" || t_fail "email-less dup cleanup" "dir left"
397
+ # add with no email but identity can't be read back => refuse (can't label the account)
398
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_AUTH_FAIL=1 claude-accounts add 2>&1)"
399
+ rc=$?
400
+ check "email-less add needs a readable identity" "identity could not be read back" "$out"
401
+ [ ! -d "$ACC/acct-04" ] && t_ok "unverifiable email-less add cleaned up" || t_fail "unverifiable cleanup" "dir left"
374
402
 
375
- # ---- 15d. aborted sign-in leaves zero traces ----------------------------------------
376
- out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_TOKEN_FAIL=1 claude-accounts add ghost@test 2>&1)"
403
+ # ---- 15c2. --tui is accepted as a no-op alias (full login is the default now) --------
404
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=tui@test claude-accounts add tui@test --tui 2>&1)"
405
+ check "add --tui still works (alias for default login)" "Registered acct-04 for tui@test" "$out"
406
+ [ -f "$ACC/acct-04/.credentials.json" ] && t_ok "--tui writes creds like the default" || t_fail "tui creds" "missing"
407
+ claude-accounts remove acct-04 --yes >/dev/null 2>&1
408
+
409
+ # ---- 15d. aborted login leaves zero traces ------------------------------------------
410
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_FAIL=1 claude-accounts add ghost@test 2>&1)"
377
411
  rc=$?
378
- check "aborted sign-in detected" "sign-in failed or aborted" "$out"
412
+ check "aborted login detected" "login failed or aborted" "$out"
379
413
  [ "$rc" != "0" ] && t_ok "aborted add exits nonzero" || t_fail "aborted add rc" "rc=0"
380
414
  [ ! -d "$ACC/acct-04" ] && t_ok "aborted add cleaned up its dir" || t_fail "aborted add cleanup" "dir left behind"
381
415
  claude-accounts list 2>&1 | grep -q ghost@test && t_fail "aborted add not in manifest" "ghost@test registered" || t_ok "aborted add not in manifest"
416
+ # aborted --token path too
417
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_TOKEN_FAIL=1 claude-accounts add ghost2@test --token 2>&1)"
418
+ rc=$?
419
+ check "aborted --token add detected" "sign-in failed or aborted" "$out"
420
+ [ ! -d "$ACC/acct-04" ] && t_ok "aborted --token add cleaned up" || t_fail "aborted token cleanup" "dir left"
382
421
 
383
422
  # ---- 15e. sign-in as an already-registered email is rejected + cleaned ---------------
384
423
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add brand@test 2>&1)"
@@ -389,12 +428,16 @@ check "wrong-account sign-in rejected" "already registered as acct-01" "$out"
389
428
  # ---- 15f. login command completes auth for an existing auth-less account -------------
390
429
  claude-accounts import pending@test --id acct-08 --no-sync >/dev/null 2>&1
391
430
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 2>&1)"
392
- check "login completes existing account" "acct-08 auth saved" "$out"
393
- [ -s "$ACC/acct-08/server.token" ] && t_ok "login saved token" || t_fail "login token" "missing"
431
+ check "login completes existing account (full login)" "acct-08 login saved" "$out"
432
+ [ -f "$ACC/acct-08/.credentials.json" ] && t_ok "login writes .credentials.json" || t_fail "login creds" "missing"
394
433
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=other@test claude-accounts login acct-08 2>&1)"
395
434
  rc=$?
396
435
  check "login email mismatch refused" "nothing saved" "$out"
397
436
  [ "$rc" != "0" ] && t_ok "mismatched login exits nonzero" || t_fail "mismatched login rc" "rc=0"
437
+ # login --token writes a portable token for the same account
438
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 --token 2>&1)"
439
+ check "login --token saves a portable token" "acct-08 token saved" "$out"
440
+ [ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
398
441
  claude-accounts remove acct-08 --yes >/dev/null 2>&1
399
442
 
400
443
  # ---- 16. CLI: limits marking via fixture endpoint ------------------------------