claudehop-cli 1.4.0__py3-none-any.whl

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.
claudehop.py ADDED
@@ -0,0 +1,1491 @@
1
+ #!/usr/bin/env python3
2
+ """claudehop - hop Claude Code between several Claude accounts.
3
+
4
+ To switch accounts, run `hop` and pick one. That is the whole tool.
5
+
6
+ hop show your accounts, pick one to hop to
7
+ hop <name> hop straight to that one
8
+ hop add <name> log in as a new account and save it
9
+
10
+ A hop takes effect for the next `claude` you start. Sessions already running
11
+ keep the account they began with.
12
+
13
+ Now and then:
14
+
15
+ hop whoami who is logged in right now (asks the API)
16
+ hop save <name> save a login you did by hand, under a name
17
+ hop list --long token expiry and when each account was saved
18
+ hop rm <name> delete a saved account (does not log you out)
19
+ hop rename <old> <new>
20
+ hop doctor [--fix] check the setup, repair what it can
21
+ hop shell-init shell glue for a pip install: eval "$(claudehop shell-init)"
22
+
23
+ Flags:
24
+
25
+ -y, --yes never prompt; also overrides the running-session stop
26
+ --verify check saved tokens against the API
27
+ --long more columns in the listing
28
+ --json machine-readable output
29
+ --no-color plain text
30
+ -V, --version print the version
31
+
32
+ Also accepted, for scripts and old habits: `use`/`switch` (what `hop <name>`
33
+ does), `ls`, `active` (print the active name, no network), `sync` (write the live
34
+ login back to its file), `--no-sync`, and the pre-1.2.0 names `claude-acct` and
35
+ `cacct`.
36
+
37
+ How it works: Claude Code keeps the live login under the "claudeAiOauth" key of
38
+ its credential store - ~/.claude/.credentials.json on Linux, the login keychain
39
+ on macOS. This tool keeps one saved copy of that block per account in
40
+ ~/.claude/accounts/<name>.json and swaps the active one in and out. Everything
41
+ else in the store, including your MCP server logins, is left alone. Email and
42
+ plan are re-fetched from the API by Claude Code at startup, so swapping the
43
+ credential is the entire job.
44
+
45
+ Environment: CLAUDE_CONFIG_DIR (default ~/.claude), CLAUDE_ACCOUNTS_DIR
46
+ (default <config>/accounts), CLAUDE_HOP_BACKEND (auto|file|keychain),
47
+ CLAUDE_HOP_OFFLINE=1 to never call the API.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import binascii
53
+ import getpass
54
+ import json
55
+ import os
56
+ import re
57
+ import shutil
58
+ import signal
59
+ import subprocess
60
+ import sys
61
+ import textwrap
62
+ import time
63
+
64
+ VERSION = "1.4.0"
65
+ PROG = "claudehop"
66
+
67
+
68
+ def env(*names: str) -> str | None:
69
+ """First of these environment variables that is set and non-empty.
70
+
71
+ The tool was called claude-acct before 1.2.0, so the old CLAUDE_ACCT_*
72
+ names are still honoured everywhere the new ones are.
73
+ """
74
+ for n in names:
75
+ v = os.environ.get(n)
76
+ if v:
77
+ return v
78
+ return None
79
+
80
+
81
+ HOME = os.path.expanduser("~")
82
+ CLAUDE_DIR = env("CLAUDE_CONFIG_DIR") or os.path.join(HOME, ".claude")
83
+ CREDS = os.path.join(CLAUDE_DIR, ".credentials.json")
84
+ ACCOUNTS = env("CLAUDE_ACCOUNTS_DIR") or os.path.join(CLAUDE_DIR, "accounts")
85
+ ACTIVE_PTR = os.path.join(ACCOUNTS, "active")
86
+ LOCK_PATH = os.path.join(ACCOUNTS, ".lock")
87
+ PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"
88
+
89
+ # What Claude Code calls its keychain item on macOS.
90
+ KEYCHAIN_SERVICE = "Claude Code-credentials"
91
+
92
+ OAUTH_KEY = "claudeAiOauth"
93
+ API_TIMEOUT = float(env("CLAUDE_HOP_TIMEOUT", "CLAUDE_ACCT_TIMEOUT") or 10.0)
94
+ OFFLINE = bool(env("CLAUDE_HOP_OFFLINE", "CLAUDE_ACCT_OFFLINE"))
95
+
96
+ # Temp files this tool writes; the second is what versions before 1.2.0 left.
97
+ TMP_MARKERS = (".tmp-claudehop", ".tmp-claude-acct")
98
+
99
+ USAGE_EXIT = 2
100
+
101
+ _ANSI = re.compile(r"\033\[[0-9;]*m")
102
+
103
+
104
+ def _use_color() -> bool:
105
+ if "--no-color" in sys.argv or os.environ.get("NO_COLOR"):
106
+ return False
107
+ if os.environ.get("CLICOLOR_FORCE"):
108
+ return True
109
+ return sys.stdout.isatty()
110
+
111
+
112
+ BOLD, DIM, GREEN, YELLOW, RED, CYAN, OFF = (
113
+ ("\033[1m", "\033[2m", "\033[32m", "\033[33m", "\033[31m", "\033[36m", "\033[0m")
114
+ if _use_color()
115
+ else ("", "", "", "", "", "", "")
116
+ )
117
+
118
+
119
+ def die(msg: str, code: int = 1):
120
+ print(f"{RED}error:{OFF} {msg}", file=sys.stderr)
121
+ sys.exit(code)
122
+
123
+
124
+ def info(msg: str):
125
+ print(msg, file=sys.stderr)
126
+
127
+
128
+ def visible_len(s: str) -> int:
129
+ """Width of a string once the colour escapes are stripped."""
130
+ return len(_ANSI.sub("", s))
131
+
132
+
133
+ def pad(s: str, width: int) -> str:
134
+ return s + " " * max(0, width - visible_len(s))
135
+
136
+
137
+ # ------------------------------------------------------------------ json files
138
+
139
+
140
+ def read_json(path: str) -> dict:
141
+ try:
142
+ with open(path) as f:
143
+ return json.load(f)
144
+ except FileNotFoundError:
145
+ return {}
146
+ except json.JSONDecodeError as e:
147
+ die(f"{path} is not valid JSON ({e}). Refusing to touch it.")
148
+
149
+
150
+ def write_json_secure(path: str, data: dict, keep_backup: bool = False):
151
+ """Atomic, 0600, fsynced. Optionally keeps a .bak of the previous file."""
152
+ tmp = f"{path}{TMP_MARKERS[0]}.{os.getpid()}"
153
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
154
+ try:
155
+ with os.fdopen(fd, "w") as f:
156
+ json.dump(data, f, indent=2)
157
+ f.flush()
158
+ os.fsync(f.fileno())
159
+ if keep_backup and os.path.exists(path):
160
+ shutil.copy2(path, path + ".bak")
161
+ os.chmod(path + ".bak", 0o600)
162
+ os.replace(tmp, path)
163
+ os.chmod(path, 0o600)
164
+ except BaseException:
165
+ if os.path.exists(tmp):
166
+ os.remove(tmp)
167
+ raise
168
+
169
+
170
+ # ------------------------------------------------------------ credential store
171
+ #
172
+ # Two backends. On Linux the live login is a JSON file; on macOS it is normally
173
+ # a generic password in the login keychain, and Claude Code falls back to the
174
+ # file when the keychain is unavailable (ssh, no GUI session). We read and write
175
+ # whichever one currently holds the login so we never split it across both.
176
+
177
+
178
+ class FileStore:
179
+ name = "file"
180
+
181
+ def __init__(self, path: str = CREDS):
182
+ self.path = path
183
+ self.where = path
184
+
185
+ def exists(self) -> bool:
186
+ return os.path.exists(self.path)
187
+
188
+ def read(self) -> dict:
189
+ return read_json(self.path)
190
+
191
+ def write(self, doc: dict):
192
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
193
+ write_json_secure(self.path, doc, keep_backup=True)
194
+
195
+
196
+ class KeychainStore:
197
+ """macOS login keychain, the same item Claude Code writes itself."""
198
+
199
+ name = "keychain"
200
+
201
+ def __init__(self, service: str = KEYCHAIN_SERVICE, account: str | None = None):
202
+ self.service = service
203
+ self.account = account or os.environ.get("USER") or getpass.getuser()
204
+ self.where = f"keychain item {service!r} (account {self.account})"
205
+
206
+ def _security(self, args: list[str]) -> tuple[int, str, str]:
207
+ p = subprocess.run(
208
+ ["security", *args],
209
+ capture_output=True,
210
+ text=True,
211
+ timeout=30,
212
+ )
213
+ return p.returncode, p.stdout, p.stderr
214
+
215
+ def exists(self) -> bool:
216
+ if not shutil.which("security"):
217
+ return False
218
+ rc, _, _ = self._security(
219
+ ["find-generic-password", "-a", self.account, "-s", self.service]
220
+ )
221
+ return rc == 0
222
+
223
+ def read(self) -> dict:
224
+ rc, out, err = self._security(
225
+ ["find-generic-password", "-a", self.account, "-s", self.service, "-w"]
226
+ )
227
+ if rc != 0:
228
+ if "could not be found" in err:
229
+ return {}
230
+ die(f"could not read the keychain item: {err.strip() or f'security exited {rc}'}")
231
+ raw = out.strip()
232
+ if not raw:
233
+ return {}
234
+ # `security` prints the secret as text, but as hex if it is not printable.
235
+ try:
236
+ return json.loads(raw)
237
+ except json.JSONDecodeError:
238
+ try:
239
+ return json.loads(binascii.unhexlify(raw).decode())
240
+ except Exception:
241
+ die("the keychain item does not contain the JSON Claude Code expects.")
242
+
243
+ def write(self, doc: dict):
244
+ blob = json.dumps(doc).encode()
245
+ rc, _, err = self._security(
246
+ [
247
+ "add-generic-password",
248
+ "-U", # update in place if it already exists
249
+ "-a", self.account,
250
+ "-s", self.service,
251
+ "-X", binascii.hexlify(blob).decode(),
252
+ ]
253
+ )
254
+ if rc != 0:
255
+ die(f"could not write the keychain item: {err.strip() or f'security exited {rc}'}")
256
+
257
+
258
+ def pick_store():
259
+ want = (env("CLAUDE_HOP_BACKEND", "CLAUDE_ACCT_BACKEND") or "auto").lower()
260
+ file_store = FileStore()
261
+ if want == "file":
262
+ return file_store
263
+ if want == "keychain":
264
+ if not shutil.which("security"):
265
+ die("CLAUDE_HOP_BACKEND=keychain but the `security` command is not available.")
266
+ return KeychainStore()
267
+ if want != "auto":
268
+ die(f"CLAUDE_HOP_BACKEND must be auto, file or keychain (got {want!r})")
269
+ if sys.platform != "darwin" or not shutil.which("security"):
270
+ return file_store
271
+ kc = KeychainStore()
272
+ # Whichever one actually holds a login wins; a stale empty file must not
273
+ # shadow a real keychain login, and vice versa.
274
+ if kc.exists():
275
+ return kc
276
+ if file_store.exists() and file_store.read().get(OAUTH_KEY):
277
+ return file_store
278
+ return kc
279
+
280
+
281
+ STORE = pick_store()
282
+
283
+
284
+ def live_oauth() -> dict | None:
285
+ return STORE.read().get(OAUTH_KEY) or None
286
+
287
+
288
+ def set_live_oauth(block: dict):
289
+ doc = STORE.read()
290
+ doc[OAUTH_KEY] = block
291
+ STORE.write(doc)
292
+
293
+
294
+ def clear_live_oauth():
295
+ doc = STORE.read()
296
+ doc.pop(OAUTH_KEY, None)
297
+ STORE.write(doc)
298
+
299
+
300
+ # ---------------------------------------------------------------------- lock
301
+ #
302
+ # Two `claudehop` runs at once could interleave read-modify-write on the same
303
+ # credential store and lose an account. Cheap to prevent.
304
+
305
+ try:
306
+ import fcntl
307
+ except ImportError: # pragma: no cover - POSIX only in practice
308
+ fcntl = None
309
+
310
+
311
+ class _Lock:
312
+ def __init__(self, path: str):
313
+ self.path = path
314
+ self.fd = None
315
+
316
+ def __enter__(self):
317
+ if fcntl is None:
318
+ return self
319
+ profiles_dir()
320
+ self.fd = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
321
+ try:
322
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
323
+ except OSError:
324
+ info(f"{DIM}waiting for another {PROG} to finish...{OFF}")
325
+ fcntl.flock(self.fd, fcntl.LOCK_EX)
326
+ return self
327
+
328
+ def __exit__(self, *exc):
329
+ if self.fd is not None:
330
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
331
+ os.close(self.fd)
332
+ self.fd = None
333
+ return False
334
+
335
+
336
+ def lock():
337
+ return _Lock(LOCK_PATH)
338
+
339
+
340
+ # ------------------------------------------------------------------- profiles
341
+
342
+
343
+ def profiles_dir() -> str:
344
+ os.makedirs(ACCOUNTS, mode=0o700, exist_ok=True)
345
+ if os.stat(ACCOUNTS).st_mode & 0o077:
346
+ os.chmod(ACCOUNTS, 0o700)
347
+ return ACCOUNTS
348
+
349
+
350
+ def profile_path(name: str) -> str:
351
+ return os.path.join(profiles_dir(), f"{name}.json")
352
+
353
+
354
+ VALID_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
355
+
356
+
357
+ def check_name(name: str) -> str:
358
+ if not VALID_NAME.match(name) or name in (".", ".."):
359
+ die(
360
+ f"'{name}' is not a usable account name. Use letters, digits, dot, "
361
+ f"dash or underscore, starting with a letter or digit."
362
+ )
363
+ return name
364
+
365
+
366
+ def list_profiles() -> list[str]:
367
+ if not os.path.isdir(ACCOUNTS):
368
+ return []
369
+ return sorted(
370
+ f[:-5] for f in os.listdir(ACCOUNTS) if f.endswith(".json") and not f.startswith(".")
371
+ )
372
+
373
+
374
+ def load_profile(name: str) -> dict:
375
+ p = profile_path(name)
376
+ if not os.path.exists(p):
377
+ known = ", ".join(list_profiles()) or "none saved yet"
378
+ die(f"no saved account called '{name}'. Saved: {known}")
379
+ return read_json(p)
380
+
381
+
382
+ def save_profile(name: str, block: dict, meta: dict | None = None):
383
+ data = read_json(profile_path(name))
384
+ data.update(meta or {})
385
+ data["name"] = name
386
+ data[OAUTH_KEY] = block
387
+ data["savedAt"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
388
+ write_json_secure(profile_path(name), data)
389
+
390
+
391
+ def read_ptr() -> str | None:
392
+ try:
393
+ with open(ACTIVE_PTR) as f:
394
+ ptr = f.read().strip()
395
+ except OSError:
396
+ return None
397
+ return ptr or None
398
+
399
+
400
+ def set_active_ptr(name: str):
401
+ profiles_dir()
402
+ tmp = f"{ACTIVE_PTR}.tmp-{os.getpid()}"
403
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
404
+ with os.fdopen(fd, "w") as f:
405
+ f.write(name + "\n")
406
+ os.replace(tmp, ACTIVE_PTR)
407
+
408
+
409
+ def active_name(check_api: bool = False) -> str | None:
410
+ """Which saved profile the live login belongs to.
411
+
412
+ Claude Code rotates the access token every few hours, so a value match only
413
+ works right after a switch. The pointer file says which account we put there.
414
+ With check_api=True we confirm the pointer still describes the same account
415
+ by asking the API for the live token's uuid - used before we overwrite a
416
+ saved profile with the live block. If the API is unreachable we trust the
417
+ pointer.
418
+ """
419
+ live = live_oauth()
420
+ if not live:
421
+ return None
422
+ tok = live.get("accessToken")
423
+ for n in list_profiles():
424
+ if load_profile(n).get(OAUTH_KEY, {}).get("accessToken") == tok:
425
+ return n
426
+ ptr = read_ptr()
427
+ if not ptr or ptr not in list_profiles():
428
+ return None
429
+ if check_api:
430
+ saved_uuid = load_profile(ptr).get("accountUuid")
431
+ live_uuid = identity(live).get("accountUuid")
432
+ if saved_uuid and live_uuid and saved_uuid != live_uuid:
433
+ return None # someone logged in as a different account behind our back
434
+ return ptr
435
+
436
+
437
+ # ------------------------------------------------------------------------ API
438
+
439
+
440
+ _IDENT_CACHE: dict[str, dict] = {}
441
+
442
+
443
+ def api_profile(token: str, timeout: float = API_TIMEOUT) -> tuple[int, dict]:
444
+ import urllib.error
445
+ import urllib.request
446
+
447
+ req = urllib.request.Request(
448
+ PROFILE_URL,
449
+ headers={
450
+ "Authorization": f"Bearer {token}",
451
+ "anthropic-beta": "oauth-2025-04-20",
452
+ "Content-Type": "application/json",
453
+ "User-Agent": f"{PROG}/{VERSION}",
454
+ },
455
+ )
456
+ try:
457
+ with urllib.request.urlopen(req, timeout=timeout) as r:
458
+ return r.status, json.loads(r.read().decode())
459
+ except urllib.error.HTTPError as e:
460
+ try:
461
+ return e.code, json.loads(e.read().decode())
462
+ except Exception:
463
+ return e.code, {}
464
+ except Exception as e:
465
+ return 0, {"error": str(e)}
466
+
467
+
468
+ def identity(block: dict) -> dict:
469
+ """email / uuid / plan for a credential block, via the API. {} if unreachable."""
470
+ tok = block.get("accessToken")
471
+ if not tok:
472
+ return {}
473
+ if OFFLINE:
474
+ return {"tokenState": "not checked"}
475
+ if tok not in _IDENT_CACHE:
476
+ _IDENT_CACHE[tok] = _identity_uncached(tok)
477
+ return _IDENT_CACHE[tok]
478
+
479
+
480
+ def _identity_uncached(tok: str) -> dict:
481
+ status, body = api_profile(tok)
482
+ if status == 200:
483
+ a = body.get("account", {})
484
+ org = body.get("organization", {})
485
+ # Team/enterprise seats have neither the max nor the pro flag set; the
486
+ # plan only shows up as the organization type.
487
+ if a.get("has_claude_max"):
488
+ plan = "max"
489
+ elif a.get("has_claude_pro"):
490
+ plan = "pro"
491
+ else:
492
+ plan = (org.get("organization_type") or "-").replace("claude_", "")
493
+ return {
494
+ "email": a.get("email"),
495
+ "accountUuid": a.get("uuid"),
496
+ "plan": plan,
497
+ "org": org.get("name"),
498
+ "tokenState": "ok",
499
+ }
500
+ if status == 403:
501
+ return {"tokenState": "limited-scope"}
502
+ if status in (401, 400):
503
+ return {"tokenState": "invalid"}
504
+ return {"tokenState": f"http {status}" if status else "offline"}
505
+
506
+
507
+ def identify_many(blocks: dict[str, dict]) -> dict[str, dict]:
508
+ """identity() for several accounts at once - one API round trip of latency."""
509
+ todo = {n: b for n, b in blocks.items() if b.get("accessToken")}
510
+ if len(todo) < 2 or OFFLINE:
511
+ return {n: identity(b) for n, b in blocks.items()}
512
+ from concurrent.futures import ThreadPoolExecutor
513
+
514
+ with ThreadPoolExecutor(max_workers=min(8, len(todo))) as pool:
515
+ list(pool.map(lambda b: identity(b), todo.values()))
516
+ return {n: identity(b) for n, b in blocks.items()}
517
+
518
+
519
+ def humanise(seconds: float) -> str:
520
+ if seconds < 3600:
521
+ return f"{int(seconds // 60)}m"
522
+ if seconds < 86400:
523
+ return f"{int(seconds // 3600)}h"
524
+ return f"{int(seconds // 86400)}d"
525
+
526
+
527
+ def expiry_note(block: dict) -> str:
528
+ exp = block.get("expiresAt")
529
+ if not exp:
530
+ return "-"
531
+ left = exp / 1000 - time.time()
532
+ if left <= 0:
533
+ return f"{DIM}expired (auto-renews){OFF}"
534
+ return f"{humanise(left)} left"
535
+
536
+
537
+ def refresh_left(block: dict) -> float | None:
538
+ """Seconds until the refresh token dies, i.e. until a real re-login."""
539
+ exp = block.get("refreshTokenExpiresAt")
540
+ if not exp:
541
+ return None
542
+ return exp / 1000 - time.time()
543
+
544
+
545
+ def token_state(block: dict, ident: dict) -> str:
546
+ """What --verify should print for one account.
547
+
548
+ An access token that has simply aged out reads as `invalid` to the API, but
549
+ Claude Code renews it from the refresh token on the next start - that is not
550
+ a broken account, so say so differently.
551
+ """
552
+ state = ident.get("tokenState", "?")
553
+ if state != "invalid":
554
+ return state
555
+ exp = block.get("expiresAt")
556
+ rleft = refresh_left(block)
557
+ if exp and exp / 1000 <= time.time() and (rleft is None or rleft > 0):
558
+ return "stale (renews)"
559
+ return "invalid"
560
+
561
+
562
+
563
+ # A refresh token is good for about 30 days from the login that issued it, and
564
+ # using the account does NOT extend it - measured 2026-08-06 across four accounts,
565
+ # one of which had its access token reissued that morning and still expired 30 days
566
+ # after its first login. So every account needs a real /login roughly monthly, and
567
+ # with several accounts the dates only drift further apart. Two weeks of notice is
568
+ # enough to plan one sitting; one week is not.
569
+ RELOGIN_NOTICE = 14 * 86400
570
+
571
+
572
+ def refresh_warning(block: dict) -> str | None:
573
+ left = refresh_left(block)
574
+ if left is None:
575
+ return None
576
+ if left <= 0:
577
+ return f"refresh token expired - this account needs `{PROG} add` again"
578
+ if left < RELOGIN_NOTICE:
579
+ return f"refresh token expires in {humanise(left)}"
580
+ return None
581
+
582
+
583
+ def relogin_plan(profiles: dict[str, dict]) -> dict | None:
584
+ """When each account needs a real /login again, and the cheapest day to do it.
585
+
586
+ Logging in early resets the whole 30-day window, so doing every account on the
587
+ day the earliest one comes due collapses them onto a single date - one sitting
588
+ a month instead of one surprise per account.
589
+ """
590
+ due = {
591
+ n: p.get(OAUTH_KEY, {}).get("refreshTokenExpiresAt", 0) / 1000
592
+ for n, p in profiles.items()
593
+ if p.get(OAUTH_KEY, {}).get("refreshTokenExpiresAt")
594
+ }
595
+ if not due:
596
+ return None
597
+ now = time.time()
598
+ live = {n: t for n, t in due.items() if t > now}
599
+ plan = {
600
+ "accounts": len(due),
601
+ "expired": sorted(n for n, t in due.items() if t <= now),
602
+ "first": None,
603
+ "firstAccounts": [],
604
+ "last": None,
605
+ "spreadDays": 0.0,
606
+ }
607
+ if live:
608
+ first, last = min(live.values()), max(live.values())
609
+ plan["first"] = first
610
+ # Anything inside a day of the earliest is part of the same sitting.
611
+ plan["firstAccounts"] = sorted(n for n, t in live.items() if t - first < 86400)
612
+ plan["last"] = last
613
+ plan["spreadDays"] = (last - first) / 86400
614
+ return plan
615
+
616
+
617
+ def relogin_lines(plan: dict) -> list[str]:
618
+ """The `re-login` block for doctor. Empty when there is nothing to say."""
619
+ day = lambda ts: time.strftime("%Y-%m-%d", time.localtime(ts)) # noqa: E731
620
+ lines: list[str] = []
621
+ if plan["expired"]:
622
+ lines.append(
623
+ f"expired: {', '.join(plan['expired'])} - `{PROG} add <name>` to sign in again"
624
+ )
625
+ if not plan["first"]:
626
+ return lines
627
+ head = f"by {day(plan['first'])} ({', '.join(plan['firstAccounts'])})"
628
+ others = plan["accounts"] - len(plan["firstAccounts"]) - len(plan["expired"])
629
+ if others <= 0 or plan["spreadDays"] < 1:
630
+ lines.append(head)
631
+ return lines
632
+ rest = "the other one" if others == 1 else f"the other {others}"
633
+ lines.append(f"{head}; {rest} by {day(plan['last'])}")
634
+ lines += textwrap.wrap(
635
+ f"windows are ~30d from login and do not slide, so re-login all "
636
+ f"{plan['accounts']} on {day(plan['first'])} and they collapse to one date",
637
+ width=68,
638
+ )
639
+ return lines
640
+
641
+
642
+ # --------------------------------------------------------------------- checks
643
+
644
+
645
+ def running_claude_pids() -> list[int]:
646
+ """PIDs of other `claude` processes. Best effort, never raises."""
647
+ me = os.getpid()
648
+ pids: list[int] = []
649
+ if os.path.isdir("/proc"):
650
+ try:
651
+ entries = os.listdir("/proc")
652
+ except OSError:
653
+ entries = []
654
+ for entry in entries:
655
+ if not entry.isdigit() or int(entry) == me:
656
+ continue
657
+ try:
658
+ with open(f"/proc/{entry}/cmdline", "rb") as f:
659
+ argv0 = f.read().split(b"\0")[0].decode(errors="replace")
660
+ except OSError:
661
+ continue
662
+ if os.path.basename(argv0) in ("claude", "claude.exe"):
663
+ pids.append(int(entry))
664
+ return sorted(pids)
665
+ # macOS and anything else without /proc.
666
+ if not shutil.which("pgrep"):
667
+ return []
668
+ try:
669
+ out = subprocess.run(
670
+ ["pgrep", "-x", "claude"], capture_output=True, text=True, timeout=5
671
+ ).stdout
672
+ except (OSError, subprocess.SubprocessError):
673
+ return []
674
+ for line in out.split():
675
+ if line.isdigit() and int(line) != me:
676
+ pids.append(int(line))
677
+ return sorted(pids)
678
+
679
+
680
+ def warn_running():
681
+ pids = running_claude_pids()
682
+ if pids:
683
+ info(
684
+ f"{YELLOW}note:{OFF} {len(pids)} claude session(s) still running "
685
+ f"(pid {', '.join(map(str, pids))}). They keep the old account until you restart "
686
+ f"them, and one of them may rewrite the credential store when its token refreshes. "
687
+ f"Quit them for a clean switch."
688
+ )
689
+
690
+
691
+ class _TerminalGone(Exception):
692
+ """SIGHUP/SIGTERM arrived - unwind so the cleanup blocks get to run."""
693
+
694
+
695
+ def _guard_child_signals() -> dict:
696
+ """Make a long interactive child safe to wait on. Returns what to restore.
697
+
698
+ Two different problems, both of which lost a finished login:
699
+
700
+ * SIGINT. Claude Code uses Ctrl-C to cancel the current turn, not to quit,
701
+ so the first one must not unwind this process - it would save and exit
702
+ while `claude` is still running and still writing to the credential store.
703
+ A shell ignores SIGINT while a foreground child runs; do the same. This
704
+ has to be a no-op Python handler rather than SIG_IGN, because SIG_IGN
705
+ survives exec and would leave `claude` itself unable to see Ctrl-C.
706
+ * SIGHUP/SIGTERM. The default action kills us outright, which skips every
707
+ cleanup path and drops a credential that has already been issued. Turn
708
+ them into an exception instead.
709
+ """
710
+
711
+ def ignore(signum, frame):
712
+ pass
713
+
714
+ def bail(signum, frame):
715
+ raise _TerminalGone(signum)
716
+
717
+ old: dict = {}
718
+ for sig, handler in (
719
+ (getattr(signal, "SIGINT", None), ignore),
720
+ (getattr(signal, "SIGHUP", None), bail),
721
+ (getattr(signal, "SIGTERM", None), bail),
722
+ ):
723
+ if sig is None:
724
+ continue
725
+ try:
726
+ old[sig] = signal.signal(sig, handler)
727
+ except (OSError, ValueError): # not the main thread, or unsupported here
728
+ pass
729
+ return old
730
+
731
+
732
+ def _restore_signals(old: dict):
733
+ for sig, handler in old.items():
734
+ try:
735
+ signal.signal(sig, handler)
736
+ except (OSError, ValueError):
737
+ pass
738
+
739
+
740
+ def confirm(question: str, assume_yes: bool) -> bool:
741
+ if assume_yes or not sys.stdin.isatty():
742
+ return True
743
+ try:
744
+ return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
745
+ except EOFError:
746
+ return False
747
+
748
+
749
+ def slug(email: str) -> str:
750
+ s = re.sub(r"[^a-z0-9._-]", "-", email.split("@")[0].lower()).strip("-.") or "account"
751
+ if not VALID_NAME.match(s):
752
+ s = "account-" + s.lstrip("-.")
753
+ # An account called `list` would shadow the command of the same name.
754
+ return f"{s}-acct" if s in COMMANDS else s
755
+
756
+
757
+ def stash_live(live: dict) -> str:
758
+ """Save an unrecognised live login under its own name. Returns that name."""
759
+ ident = identity(live)
760
+ name = slug(ident["email"]) if ident.get("email") else f"unsaved-{int(time.time())}"
761
+ save_profile(name, live, {k: v for k, v in ident.items() if k != "tokenState"})
762
+ return name
763
+
764
+
765
+ def sync_live_before_switch(live: dict) -> str | None:
766
+ """Never drop a login we cannot get back. Returns the profile it went to."""
767
+ cur = active_name(check_api=True)
768
+ if cur:
769
+ save_profile(cur, live, {})
770
+ return cur
771
+ name = stash_live(live)
772
+ info(f"{DIM}stashed the current login as '{name}' first{OFF}")
773
+ return name
774
+
775
+
776
+ # ------------------------------------------------------------------- commands
777
+
778
+
779
+ def print_table(hdr: tuple, rows: list, highlight=()):
780
+ """Columns wide enough for their widest cell; highlight those row indexes."""
781
+ w = [max(visible_len(str(r[i])) for r in [hdr, *rows]) for i in range(len(hdr))]
782
+ print(DIM + " ".join(pad(str(h), w[i]) for i, h in enumerate(hdr)).rstrip() + OFF)
783
+ for i, r in enumerate(rows):
784
+ line = " ".join(pad(str(c), w[j]) for j, c in enumerate(r)).rstrip()
785
+ print((GREEN + line + OFF) if i in highlight else line)
786
+
787
+
788
+ def cmd_list(verify=False, as_json=False, long_=False, **_):
789
+ names = list_profiles()
790
+ live = live_oauth()
791
+ cur = active_name(check_api=verify)
792
+ if not names and not as_json:
793
+ info("No accounts saved yet.")
794
+ if live:
795
+ info(f"You are logged in - save this one with: {PROG} save <name>")
796
+ else:
797
+ info(f"Log in with: {PROG} add <name>")
798
+ return
799
+
800
+ profiles = {n: load_profile(n) for n in names}
801
+ idents: dict[str, dict] = {}
802
+ if verify:
803
+ idents = identify_many({n: p.get(OAUTH_KEY, {}) for n, p in profiles.items()})
804
+ for n, ident in idents.items():
805
+ if not ident.get("email"):
806
+ continue
807
+ p = profiles[n]
808
+ fresh = {k: ident[k] for k in ("email", "plan", "accountUuid") if ident.get(k)}
809
+ if any(p.get(k) != v for k, v in fresh.items()):
810
+ p.update(fresh)
811
+ write_json_secure(profile_path(n), p)
812
+
813
+ if as_json:
814
+ print(
815
+ json.dumps(
816
+ {
817
+ "backend": STORE.name,
818
+ "active": cur,
819
+ "accounts": [
820
+ {
821
+ "name": n,
822
+ "email": profiles[n].get("email"),
823
+ "plan": profiles[n].get("plan"),
824
+ "accountUuid": profiles[n].get("accountUuid"),
825
+ "savedAt": profiles[n].get("savedAt"),
826
+ "active": n == cur,
827
+ "expiresAt": profiles[n].get(OAUTH_KEY, {}).get("expiresAt"),
828
+ "refreshTokenExpiresAt": profiles[n]
829
+ .get(OAUTH_KEY, {})
830
+ .get("refreshTokenExpiresAt"),
831
+ "tokenState": token_state(
832
+ profiles[n].get(OAUTH_KEY, {}), idents[n]
833
+ )
834
+ if verify
835
+ else None,
836
+ }
837
+ for n in names
838
+ ],
839
+ },
840
+ indent=2,
841
+ )
842
+ )
843
+ return
844
+
845
+ # Default columns answer the only question that matters - which account is
846
+ # which. Token expiry and save dates needed a paragraph of explanation to be
847
+ # readable at all, so they moved behind --long. STATE only means something
848
+ # once we have actually asked the API.
849
+ hdr = ("", "NAME", "EMAIL", "PLAN")
850
+ if verify:
851
+ hdr += ("STATE",)
852
+ if long_:
853
+ hdr += ("TOKEN", "SAVED")
854
+ rows = []
855
+ for n in names:
856
+ blk = profiles[n].get(OAUTH_KEY, {})
857
+ row = (
858
+ "*" if n == cur else " ",
859
+ n,
860
+ profiles[n].get("email") or "?",
861
+ profiles[n].get("plan") or "?",
862
+ )
863
+ if verify:
864
+ row += (token_state(blk, idents.get(n, {})),)
865
+ if long_:
866
+ row += (expiry_note(blk), (profiles[n].get("savedAt") or "")[:10])
867
+ rows.append(row)
868
+ print_table(hdr, rows, {i for i, n in enumerate(names) if n == cur})
869
+
870
+ for n in names:
871
+ warn = refresh_warning(profiles[n].get(OAUTH_KEY, {}))
872
+ if warn:
873
+ info(f"{YELLOW}note:{OFF} {n}: {warn}")
874
+ if cur is None and live:
875
+ info(
876
+ f"\n{YELLOW}note:{OFF} the current login does not match any saved account. "
877
+ f"Save it with: {PROG} save <name>"
878
+ )
879
+
880
+
881
+ def cmd_pick(yes=False, no_sync=False, verify=False, as_json=False, long_=False, **_):
882
+ """`hop` with nothing after it: show the accounts and offer to switch.
883
+
884
+ The tool exists to answer one question - which account? - so ask it, instead
885
+ of printing a table and making you retype a name out of it. Falls back to a
886
+ plain listing when there is nothing to choose between, or when stdout is a
887
+ pipe and there is nobody there to answer.
888
+ """
889
+ names = list_profiles()
890
+ if len(names) < 2 or not (sys.stdin.isatty() and sys.stdout.isatty()):
891
+ cmd_list(verify=verify, as_json=as_json, long_=long_)
892
+ return
893
+
894
+ profiles = {n: load_profile(n) for n in names}
895
+ cur = active_name()
896
+ rows = [
897
+ (f"{i}. *" if n == cur else f"{i}.", n,
898
+ profiles[n].get("email") or "?", profiles[n].get("plan") or "?")
899
+ for i, n in enumerate(names, 1)
900
+ ]
901
+ print_table(("", "NAME", "EMAIL", "PLAN"), rows,
902
+ {i for i, n in enumerate(names) if n == cur})
903
+ try:
904
+ answer = input(f"\nhop to which? [1-{len(names)}, Enter to stay] ").strip()
905
+ except (EOFError, KeyboardInterrupt):
906
+ print()
907
+ return
908
+ if not answer:
909
+ return
910
+
911
+ if answer.isdigit() and 1 <= int(answer) <= len(names):
912
+ chosen = names[int(answer) - 1]
913
+ else: # a name, or enough of one to be unambiguous
914
+ hits = [n for n in names if n == answer] or [n for n in names if n.startswith(answer)]
915
+ if len(hits) != 1:
916
+ die(f"'{answer}' is not one of them. Pick a number, or a name.", USAGE_EXIT)
917
+ chosen = hits[0]
918
+
919
+ if chosen == cur:
920
+ print(f"already on {GREEN}{chosen}{OFF}")
921
+ return
922
+ cmd_use(name=chosen, yes=yes, no_sync=no_sync)
923
+
924
+
925
+ def cmd_active(as_json=False, **_):
926
+ cur = active_name()
927
+ if as_json:
928
+ print(json.dumps({"active": cur, "backend": STORE.name}))
929
+ elif cur:
930
+ print(cur)
931
+ else:
932
+ sys.exit(1)
933
+
934
+
935
+ def cmd_whoami(as_json=False, **_):
936
+ live = live_oauth()
937
+ if not live:
938
+ if as_json:
939
+ print(json.dumps({"loggedIn": False, "backend": STORE.name}))
940
+ else:
941
+ print("not logged in")
942
+ sys.exit(1)
943
+ ident = identity(live)
944
+ name = active_name(check_api=True)
945
+ overrides = [
946
+ v for v in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN") if os.environ.get(v)
947
+ ]
948
+ if as_json:
949
+ print(
950
+ json.dumps(
951
+ {
952
+ "loggedIn": True,
953
+ "backend": STORE.name,
954
+ "account": name,
955
+ "email": ident.get("email"),
956
+ "plan": ident.get("plan") or live.get("subscriptionType"),
957
+ "org": ident.get("org"),
958
+ "accountUuid": ident.get("accountUuid"),
959
+ "tokenState": ident.get("tokenState"),
960
+ "expiresAt": live.get("expiresAt"),
961
+ "envOverrides": overrides,
962
+ },
963
+ indent=2,
964
+ )
965
+ )
966
+ return
967
+ email = ident.get("email") or "(could not reach the API)"
968
+ plan = ident.get("plan") or live.get("subscriptionType") or "?"
969
+ print(
970
+ f"{BOLD}{email}{OFF} [{plan}] saved as: {name or '(unsaved)'} "
971
+ f"token: {expiry_note(live)}"
972
+ )
973
+ for var in overrides:
974
+ info(f"{YELLOW}warning:{OFF} ${var} is set in this shell and overrides the saved login.")
975
+
976
+
977
+ def cmd_save(name=None, yes=False, **_):
978
+ live = live_oauth()
979
+ if not live:
980
+ die("not logged in - nothing to save. Run `claude` and use /login first.")
981
+ ident = identity(live)
982
+ if not name:
983
+ if not ident.get("email"):
984
+ die("could not reach the API to name this account automatically - pass a name.")
985
+ name = slug(ident["email"])
986
+ check_name(name)
987
+ if os.path.exists(profile_path(name)) and not confirm(f"overwrite saved account '{name}'?", yes):
988
+ die("aborted")
989
+ save_profile(name, live, {k: v for k, v in ident.items() if k != "tokenState"})
990
+ set_active_ptr(name)
991
+ print(f"saved {GREEN}{name}{OFF} ({ident.get('email') or 'email unknown'}) -> {profile_path(name)}")
992
+
993
+
994
+ def cmd_sync(**_):
995
+ live = live_oauth()
996
+ if not live:
997
+ die("not logged in - nothing to sync.")
998
+ n = active_name(check_api=True)
999
+ if not n:
1000
+ die(f"current login is not one of the saved accounts. Use `{PROG} save <name>`.")
1001
+ save_profile(n, live, {})
1002
+ print(f"synced live login into {n}")
1003
+
1004
+
1005
+ def cmd_use(name=None, yes=False, no_sync=False, **_):
1006
+ if not name:
1007
+ die(f"usage: {PROG} use <name>", USAGE_EXIT)
1008
+ target = load_profile(name)
1009
+ blk = target.get(OAUTH_KEY)
1010
+ if not blk:
1011
+ die(f"'{name}' has no saved credentials. Re-save it with `{PROG} add {name}`.")
1012
+
1013
+ live = live_oauth()
1014
+ if live and live.get("accessToken") == blk.get("accessToken"):
1015
+ print(f"already on {GREEN}{name}{OFF} ({target.get('email') or '?'})")
1016
+ set_active_ptr(name)
1017
+ return
1018
+
1019
+ if live and not no_sync:
1020
+ sync_live_before_switch(live)
1021
+
1022
+ set_live_oauth(blk)
1023
+ set_active_ptr(name)
1024
+ print(
1025
+ f"switched to {GREEN}{name}{OFF} "
1026
+ f"({target.get('email') or 'email unknown'}, {target.get('plan') or '?'})"
1027
+ )
1028
+ warn = refresh_warning(blk)
1029
+ if warn:
1030
+ info(f"{YELLOW}note:{OFF} {warn}")
1031
+ warn_running()
1032
+ print(f"{DIM}open a new terminal and run `claude` - it will start as this account.{OFF}")
1033
+
1034
+
1035
+ def cmd_add(name=None, yes=False, **_):
1036
+ if not name:
1037
+ die(f"usage: {PROG} add <name>", USAGE_EXIT)
1038
+ check_name(name)
1039
+ target = profile_path(name)
1040
+ # Kept in memory so a replace that turns out to be the wrong account can be
1041
+ # undone without leaving a .bak behind for `doctor` to complain about.
1042
+ prior = read_json(target) if os.path.exists(target) else None
1043
+ if prior is not None and not confirm(f"'{name}' already exists - replace it?", yes):
1044
+ die("aborted")
1045
+ if not shutil.which("claude"):
1046
+ die("`claude` is not on your PATH, so I can't drive the login. Install Claude Code first.")
1047
+ if not sys.stdin.isatty():
1048
+ die(f"adding an account needs a terminal - run `{PROG} add {name}` from one.")
1049
+
1050
+ # A running session writes its own refreshed token into the credential store
1051
+ # without warning, which lands the wrong account under `name`. That is much
1052
+ # easier to prevent than to explain afterwards.
1053
+ pids = running_claude_pids()
1054
+ if pids and not yes:
1055
+ die(
1056
+ f"quit your other claude session(s) first - pid {', '.join(map(str, pids))}.\n"
1057
+ f" While you are logging in, any one of them can write its own token back "
1058
+ f"into the credential store, and '{name}' would end up holding that account "
1059
+ f"instead.\n Run with --yes to go ahead anyway."
1060
+ )
1061
+
1062
+ live = live_oauth()
1063
+ prev_name = None
1064
+ if live:
1065
+ prev_name = sync_live_before_switch(live)
1066
+ info(f"{DIM}saved the current login ({prev_name}) before logging out{OFF}")
1067
+
1068
+ clear_live_oauth()
1069
+ info(f"\nStarting `claude` with no login. Sign in as the {BOLD}{name}{OFF} account")
1070
+ info("(use /login if it does not prompt), then exit with /exit or Ctrl-D.")
1071
+ info(
1072
+ f"{DIM}Your browser is already signed in as one of your other accounts. Paste the\n"
1073
+ f"login URL into a private window, or you will just re-authorise that one.{OFF}\n"
1074
+ )
1075
+
1076
+ new: dict | None = None
1077
+ persisted = False
1078
+
1079
+ def persist() -> bool:
1080
+ """Write whatever `claude` produced to disk. Idempotent.
1081
+
1082
+ Deliberately does no network call first. Looking the account's email up
1083
+ takes up to CLAUDE_HOP_TIMEOUT seconds, and an interrupt anywhere in that
1084
+ window used to lose a credential that had already been issued - you were
1085
+ left logged in as an account with nothing saved for it. The identity
1086
+ lookup happens after the token is safely on disk; `list --verify` fills
1087
+ in an email that never made it.
1088
+ """
1089
+ nonlocal new, persisted
1090
+ if persisted:
1091
+ return True
1092
+ found = live_oauth()
1093
+ if not found or (live and found.get("accessToken") == live.get("accessToken")):
1094
+ # Nothing new was signed in - put back what was there. Never leave
1095
+ # the user logged out of everything.
1096
+ if live:
1097
+ set_live_oauth(live)
1098
+ return False
1099
+ new = found
1100
+ # A replaced profile must not keep the old account's email and plan
1101
+ # against the new account's token, even if we get killed right here.
1102
+ stale = dict.fromkeys(("email", "plan", "accountUuid", "org")) if prior else {}
1103
+ save_profile(name, found, stale)
1104
+ set_active_ptr(name)
1105
+ persisted = True
1106
+ return True
1107
+
1108
+ old_signals = _guard_child_signals()
1109
+ hung_up = False
1110
+ try:
1111
+ try:
1112
+ subprocess.call(["claude"])
1113
+ except _TerminalGone:
1114
+ hung_up = True
1115
+ finally:
1116
+ try:
1117
+ saved = persist()
1118
+ finally:
1119
+ _restore_signals(old_signals)
1120
+
1121
+ if not saved:
1122
+ info(f"{YELLOW}no new login found after that.{OFF} Nothing saved for '{name}'.")
1123
+ if prior is not None:
1124
+ info(f"The existing '{name}' profile was left as it was.")
1125
+ if live:
1126
+ prev = active_name()
1127
+ info("Your previous login was restored" + (f" ({prev})." if prev else "."))
1128
+ else:
1129
+ info("You were not logged in before either, so you still are not.")
1130
+ sys.exit(1)
1131
+
1132
+ ident = identity(new)
1133
+
1134
+ # A `claude` session that was already running refreshes its own token
1135
+ # straight into the credential store, and that block is not the login we
1136
+ # asked for. Saving it under `name` would file one account's live credential
1137
+ # under another account's name.
1138
+ prev_uuid = (
1139
+ read_json(profile_path(prev_name)).get("accountUuid")
1140
+ if prev_name and prev_name != name
1141
+ else None
1142
+ )
1143
+ if prev_uuid and ident.get("accountUuid") == prev_uuid:
1144
+ if prior is not None:
1145
+ write_json_secure(target, prior)
1146
+ else:
1147
+ os.remove(target)
1148
+ save_profile(prev_name, new, {}) # it is a newer token for that account
1149
+ set_active_ptr(prev_name)
1150
+ die(
1151
+ f"the credential store holds {ident.get('email') or prev_name} again, not a new "
1152
+ f"login - another running claude session refreshed it while we waited. Nothing "
1153
+ f"saved for '{name}'. Quit every claude session, then try again."
1154
+ )
1155
+
1156
+ meta = {k: v for k, v in ident.items() if k != "tokenState"}
1157
+ if meta:
1158
+ save_profile(name, new, meta)
1159
+ print(f"saved and now active: {GREEN}{name}{OFF} ({ident.get('email') or 'email unknown'})")
1160
+ if not ident.get("accountUuid"):
1161
+ info(
1162
+ f"{YELLOW}note:{OFF} could not confirm which account that is, so '{name}' has no "
1163
+ f"email or plan recorded. Fill them in with: {PROG} list --verify"
1164
+ )
1165
+ if hung_up:
1166
+ info(f"{DIM}(the terminal went away, but the login was saved){OFF}")
1167
+
1168
+
1169
+ def cmd_rm(name=None, yes=False, **_):
1170
+ if not name:
1171
+ die(f"usage: {PROG} rm <name>", USAGE_EXIT)
1172
+ load_profile(name) # existence check
1173
+ if name == active_name():
1174
+ info(
1175
+ f"{YELLOW}note:{OFF} '{name}' is the account you are logged in as right now; "
1176
+ f"deleting the saved copy does not log you out."
1177
+ )
1178
+ if not confirm(f"delete saved account '{name}'?", yes):
1179
+ die("aborted")
1180
+ for f in (profile_path(name), profile_path(name) + ".bak"):
1181
+ if os.path.exists(f):
1182
+ os.remove(f)
1183
+ if read_ptr() == name:
1184
+ os.remove(ACTIVE_PTR)
1185
+ print(f"deleted {name}")
1186
+
1187
+
1188
+ def cmd_rename(name=None, new=None, **_):
1189
+ if not name or not new:
1190
+ die(f"usage: {PROG} rename <old> <new>", USAGE_EXIT)
1191
+ check_name(new)
1192
+ data = load_profile(name)
1193
+ if os.path.exists(profile_path(new)):
1194
+ die(f"'{new}' already exists")
1195
+ was_active = read_ptr() == name or active_name() == name
1196
+ data["name"] = new
1197
+ write_json_secure(profile_path(new), data)
1198
+ for f in (profile_path(name), profile_path(name) + ".bak"):
1199
+ if os.path.exists(f):
1200
+ os.remove(f)
1201
+ if was_active:
1202
+ set_active_ptr(new)
1203
+ print(f"renamed {name} -> {new}")
1204
+
1205
+
1206
+ def cmd_doctor(fix=False, verify=False, as_json=False, **_):
1207
+ problems: list[dict] = []
1208
+
1209
+ def note(level: str, msg: str, fixed: str | None = None):
1210
+ problems.append({"level": level, "message": msg, "fixed": fixed})
1211
+
1212
+ live = live_oauth()
1213
+ names = list_profiles()
1214
+ profiles = {n: read_json(profile_path(n)) for n in names}
1215
+ plan = relogin_plan(profiles)
1216
+
1217
+ for var in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"):
1218
+ if os.environ.get(var):
1219
+ note("warn", f"${var} is set and overrides the saved login entirely")
1220
+
1221
+ if os.path.isdir(ACCOUNTS):
1222
+ mode = os.stat(ACCOUNTS).st_mode & 0o777
1223
+ if mode & 0o077:
1224
+ if fix:
1225
+ os.chmod(ACCOUNTS, 0o700)
1226
+ note("warn", f"{ACCOUNTS} was {mode:o}", "chmod 700")
1227
+ else:
1228
+ note("error", f"{ACCOUNTS} is mode {mode:o}; it should be 700")
1229
+
1230
+ for n in names:
1231
+ p = profile_path(n)
1232
+ mode = os.stat(p).st_mode & 0o777
1233
+ if mode & 0o077:
1234
+ if fix:
1235
+ os.chmod(p, 0o600)
1236
+ note("warn", f"{p} was {mode:o}", "chmod 600")
1237
+ else:
1238
+ note("error", f"{p} is mode {mode:o}; it should be 600")
1239
+ if not profiles[n].get(OAUTH_KEY, {}).get("accessToken"):
1240
+ note("error", f"'{n}' has no saved credentials; re-add it")
1241
+ warn = refresh_warning(profiles[n].get(OAUTH_KEY, {}))
1242
+ if warn:
1243
+ note("warn", f"{n}: {warn}")
1244
+
1245
+ stale = []
1246
+ if os.path.isdir(ACCOUNTS):
1247
+ stale = [
1248
+ os.path.join(ACCOUNTS, f)
1249
+ for f in os.listdir(ACCOUNTS)
1250
+ if any(m in f for m in TMP_MARKERS) or f.endswith(".json.bak")
1251
+ ]
1252
+ for f in stale:
1253
+ if fix:
1254
+ os.remove(f)
1255
+ note("warn", f"stale file {f}", "removed")
1256
+ else:
1257
+ note("warn", f"stale file {f} (contains an old credential); --fix removes it")
1258
+
1259
+ ptr = read_ptr()
1260
+ if ptr and ptr not in names:
1261
+ note("error", f"active pointer names '{ptr}', which is not a saved account")
1262
+ if live and active_name(check_api=verify) is None:
1263
+ note("warn", f"the live login does not match any saved account; `{PROG} save <name>`")
1264
+ if not live:
1265
+ note("warn", "not logged in right now")
1266
+
1267
+ pids = running_claude_pids()
1268
+ if pids:
1269
+ note("warn", f"{len(pids)} claude session(s) running; they may rewrite the credentials")
1270
+
1271
+ if verify and names:
1272
+ blocks = {n: load_profile(n).get(OAUTH_KEY, {}) for n in names}
1273
+ for n, ident in identify_many(blocks).items():
1274
+ state = token_state(blocks[n], ident)
1275
+ if state in ("ok", "not checked", "stale (renews)", None):
1276
+ continue
1277
+ level = "warn" if state.startswith(("offline", "http")) else "error"
1278
+ note(level, f"'{n}' token state: {state}")
1279
+
1280
+ if as_json:
1281
+ print(
1282
+ json.dumps(
1283
+ {
1284
+ "backend": STORE.name,
1285
+ "store": STORE.where,
1286
+ "configDir": CLAUDE_DIR,
1287
+ "accountsDir": ACCOUNTS,
1288
+ "accounts": names,
1289
+ "active": active_name(),
1290
+ "reloginPlan": plan,
1291
+ "problems": problems,
1292
+ },
1293
+ indent=2,
1294
+ )
1295
+ )
1296
+ else:
1297
+ print(f"{BOLD}{PROG} {VERSION}{OFF}")
1298
+ print(f" backend {STORE.name} ({STORE.where})")
1299
+ print(f" config dir {CLAUDE_DIR}")
1300
+ print(f" accounts {ACCOUNTS} ({len(names)} saved)")
1301
+ print(f" active {active_name() or '(none)'}")
1302
+ for i, line in enumerate(relogin_lines(plan) if plan else []):
1303
+ print(f" {'re-login' if i == 0 else ' '} {line}")
1304
+ print()
1305
+ if not problems:
1306
+ print(f"{GREEN}no problems found{OFF}")
1307
+ for p in problems:
1308
+ colour = RED if p["level"] == "error" else YELLOW
1309
+ suffix = f" {GREEN}[{p['fixed']}]{OFF}" if p["fixed"] else ""
1310
+ print(f" {colour}{p['level']}{OFF} {p['message']}{suffix}")
1311
+ if any(p["level"] == "error" and not p["fixed"] for p in problems):
1312
+ sys.exit(1)
1313
+
1314
+
1315
+ def cmd_help(**_):
1316
+ print(__doc__.strip())
1317
+
1318
+
1319
+ def cmd_version(**_):
1320
+ print(f"{PROG} {VERSION}")
1321
+
1322
+
1323
+ # Second names for a command, kept working but not worth offering on TAB.
1324
+ ALIASES = {"ls", "current", "switch", "new", "login", "remove", "delete", "mv", "check"}
1325
+
1326
+
1327
+ # Verbs and flags worth completing. Built from the tables below at call time, so
1328
+ # a new command can't be added without tab-completion following it.
1329
+ def _completion_words() -> tuple[str, str]:
1330
+ verbs = " ".join(k for k in COMMANDS if not k.startswith("-") and k not in ALIASES)
1331
+ flags = " ".join(sorted(k for k in FLAGS if k.startswith("--")))
1332
+ return verbs, flags
1333
+
1334
+
1335
+ def cmd_shell_init(**_):
1336
+ """Print shell glue for a pip/pipx install: `eval "$(claudehop shell-init)"`.
1337
+
1338
+ The repo installer writes shell/claudehop.sh instead, which also puts
1339
+ ~/.claude/bin on your PATH. A pip install already has the commands on the
1340
+ PATH, so all this adds is the `hop` alias and tab-completion.
1341
+ """
1342
+ verbs, flags = _completion_words()
1343
+ print(
1344
+ textwrap.dedent(
1345
+ f"""\
1346
+ # {PROG} {VERSION} — add to your shell rc: eval "$({PROG} shell-init)"
1347
+ if [ -n "${{ZSH_VERSION:-}}" ]; then
1348
+ autoload -Uz +X bashcompinit 2>/dev/null && bashcompinit 2>/dev/null
1349
+ fi
1350
+ alias hop='{PROG}'
1351
+ _claudehop_names() {{
1352
+ local d="${{CLAUDE_ACCOUNTS_DIR:-${{CLAUDE_CONFIG_DIR:-$HOME/.claude}}/accounts}}" f n
1353
+ for f in "$d"/*.json; do
1354
+ [ -e "$f" ] || continue
1355
+ n="${{f##*/}}"
1356
+ printf '%s ' "${{n%.json}}"
1357
+ done
1358
+ }}
1359
+ _claudehop_complete() {{
1360
+ local cur prev names
1361
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
1362
+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
1363
+ names="$(_claudehop_names)"
1364
+ if [ "$COMP_CWORD" -eq 1 ]; then
1365
+ COMPREPLY=($(compgen -W "{verbs} $names" -- "$cur"))
1366
+ else
1367
+ case "$prev" in
1368
+ use|switch|rm|remove|delete|rename|mv|save|add|new|login)
1369
+ COMPREPLY=($(compgen -W "$names" -- "$cur")) ;;
1370
+ *)
1371
+ COMPREPLY=($(compgen -W "{flags}" -- "$cur")) ;;
1372
+ esac
1373
+ fi
1374
+ }}
1375
+ complete -F _claudehop_complete {PROG} hop 2>/dev/null
1376
+ """
1377
+ ).rstrip()
1378
+ )
1379
+
1380
+
1381
+ COMMANDS = {
1382
+ "list": cmd_list, "ls": cmd_list,
1383
+ "use": cmd_use, "switch": cmd_use,
1384
+ "save": cmd_save,
1385
+ "add": cmd_add, "new": cmd_add, "login": cmd_add,
1386
+ "whoami": cmd_whoami, "current": cmd_whoami,
1387
+ "active": cmd_active,
1388
+ "sync": cmd_sync,
1389
+ "rm": cmd_rm, "remove": cmd_rm, "delete": cmd_rm,
1390
+ "rename": cmd_rename, "mv": cmd_rename,
1391
+ "doctor": cmd_doctor, "check": cmd_doctor,
1392
+ "shell-init": cmd_shell_init,
1393
+ "help": cmd_help, "-h": cmd_help, "--help": cmd_help,
1394
+ "version": cmd_version, "-V": cmd_version, "--version": cmd_version,
1395
+ }
1396
+
1397
+ # Commands that only read; they skip the lock and never touch the store.
1398
+ READ_ONLY = {cmd_list, cmd_whoami, cmd_active, cmd_help, cmd_version, cmd_shell_init}
1399
+
1400
+ FLAGS = {
1401
+ "-y": "yes", "--yes": "yes",
1402
+ "--verify": "verify",
1403
+ "--long": "long_", "-l": "long_",
1404
+ "--no-sync": "no_sync",
1405
+ "--json": "as_json",
1406
+ "--fix": "fix",
1407
+ "--no-color": "no_color",
1408
+ }
1409
+
1410
+
1411
+ def main(argv: list[str]):
1412
+ flags = {"yes": False, "verify": False, "long_": False, "no_sync": False,
1413
+ "as_json": False, "fix": False, "no_color": False}
1414
+ args: list[str] = []
1415
+ only_positional = False
1416
+ for a in argv:
1417
+ if only_positional:
1418
+ args.append(a)
1419
+ elif a == "--":
1420
+ only_positional = True
1421
+ elif a in FLAGS:
1422
+ flags[FLAGS[a]] = True
1423
+ elif a.startswith("-") and a not in COMMANDS:
1424
+ die(f"unknown option '{a}'. Try: {PROG} help", USAGE_EXIT)
1425
+ else:
1426
+ args.append(a)
1427
+
1428
+ # Bare `hop` in a terminal asks which account you want; everywhere else -
1429
+ # a pipe, --json, a statusline - it stays a plain listing, and takes no lock.
1430
+ if not args and not flags["as_json"]:
1431
+ if sys.stdin.isatty() and sys.stdout.isatty():
1432
+ with lock():
1433
+ cmd_pick(**flags)
1434
+ else:
1435
+ cmd_list(**flags)
1436
+ return
1437
+
1438
+ cmd = args[0] if args else "list"
1439
+ fn = COMMANDS.get(cmd)
1440
+ if not fn:
1441
+ # `claudehop work` is a shortcut for `claudehop use work`
1442
+ if VALID_NAME.match(cmd) and os.path.exists(profile_path(cmd)):
1443
+ fn, args = cmd_use, ["use", cmd]
1444
+ else:
1445
+ known = ", ".join(list_profiles())
1446
+ hint = f" Saved accounts: {known}." if known else ""
1447
+ die(f"unknown command '{cmd}'. Try: {PROG} help{hint}", USAGE_EXIT)
1448
+ if len(args) > 3:
1449
+ die(f"too many arguments for '{cmd}'", USAGE_EXIT)
1450
+
1451
+ def call():
1452
+ fn(
1453
+ name=args[1] if len(args) > 1 else None,
1454
+ new=args[2] if len(args) > 2 else None,
1455
+ **flags,
1456
+ )
1457
+
1458
+ # `list --verify` writes the refreshed email/plan back, so it needs the lock too.
1459
+ if fn in READ_ONLY and not flags["verify"]:
1460
+ call()
1461
+ else:
1462
+ with lock():
1463
+ call()
1464
+
1465
+
1466
+ def _run() -> int:
1467
+ try:
1468
+ main(sys.argv[1:])
1469
+ except SystemExit as e:
1470
+ if isinstance(e.code, int):
1471
+ return e.code
1472
+ return 0 if e.code is None else 1
1473
+ return 0
1474
+
1475
+
1476
+ def console_main() -> int:
1477
+ """Entry point for the `claudehop` and `hop` commands, and for `python -m`."""
1478
+ try:
1479
+ code = _run()
1480
+ sys.stdout.flush()
1481
+ except KeyboardInterrupt:
1482
+ code = 130
1483
+ except BrokenPipeError:
1484
+ # `claudehop list | head` — say nothing, leave no traceback.
1485
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
1486
+ code = 0
1487
+ return code
1488
+
1489
+
1490
+ if __name__ == "__main__":
1491
+ sys.exit(console_main())