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 +1491 -0
- claudehop_cli-1.4.0.dist-info/METADATA +293 -0
- claudehop_cli-1.4.0.dist-info/RECORD +7 -0
- claudehop_cli-1.4.0.dist-info/WHEEL +5 -0
- claudehop_cli-1.4.0.dist-info/entry_points.txt +3 -0
- claudehop_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
- claudehop_cli-1.4.0.dist-info/top_level.txt +1 -0
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())
|