ai-acct-autopilot 1.0.0

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.
@@ -0,0 +1,847 @@
1
+ #!/usr/bin/env bash
2
+ # claude-acct — seamless Claude CLI account rotation via keychain token-swap.
3
+ # Login to each account once (OAuth), `save` it under a name, then `use <name>`
4
+ # to switch with zero re-authentication. Tokens auto-refresh; switching back is instant.
5
+ #
6
+ # Per-session pinning (`token` / `pin` / `unpin` / `sessions`): pins a worktree to
7
+ # one account by writing env.CLAUDE_CODE_OAUTH_TOKEN into its
8
+ # .claude/settings.local.json. Env beats keychain in Claude's auth precedence, so
9
+ # pinned sessions keep their account no matter what `use` does globally. Pins use
10
+ # 1-year tokens from `claude setup-token` (stored as <name>.oat), NOT the keychain
11
+ # blobs — blob access tokens expire in hours and an expired pin silently falls
12
+ # back to the keychain account.
13
+ set -euo pipefail
14
+
15
+ SVC="Claude Code-credentials"
16
+ ACCT="${USER}" # macOS keychain account attr Claude uses
17
+ DIR="${HOME}/.claude/accounts"
18
+ ACTIVE="${DIR}/.active"
19
+ CLAUDE_JSON="${HOME}/.claude.json"
20
+ mkdir -p "$DIR"; chmod 700 "$DIR"
21
+
22
+ read_keychain() { security find-generic-password -s "$SVC" -a "$ACCT" -w 2>/dev/null; }
23
+ write_keychain() { security add-generic-password -U -s "$SVC" -a "$ACCT" -w "$1"; }
24
+ active_name() { cat "$ACTIVE" 2>/dev/null || true; }
25
+ blob_hash() { shasum -a 256 | awk '{print $1}'; }
26
+ file_hash() { [ -f "$1" ] && blob_hash < "$1"; }
27
+
28
+ auth_email() {
29
+ claude auth status --json 2>/dev/null \
30
+ | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{const j=JSON.parse(s); if (j.email) process.stdout.write(j.email)}catch{}})'
31
+ }
32
+
33
+ cached_oauth_email() {
34
+ node -e 'const fs=require("fs"); const p=process.env.HOME+"/.claude.json"; try { const j=JSON.parse(fs.readFileSync(p,"utf8")); if (j.oauthAccount?.emailAddress) process.stdout.write(j.oauthAccount.emailAddress); } catch {}'
35
+ }
36
+
37
+ save_meta() {
38
+ local name="$1" email="${2:-}"
39
+ [ -n "$email" ] || return 0
40
+ printf 'email=%s\n' "$email" > "${DIR}/${name}.meta"; chmod 600 "${DIR}/${name}.meta"
41
+ }
42
+
43
+ save_oauth_account_meta() {
44
+ local name="$1"
45
+ [ -f "$CLAUDE_JSON" ] || return 0
46
+ node -e '
47
+ const fs = require("fs");
48
+ const src = process.argv[1], dest = process.argv[2];
49
+ try {
50
+ const j = JSON.parse(fs.readFileSync(src, "utf8"));
51
+ if (j.oauthAccount) fs.writeFileSync(dest, JSON.stringify(j.oauthAccount, null, 2) + "\n", { mode: 0o600 });
52
+ } catch {}
53
+ ' "$CLAUDE_JSON" "${DIR}/${name}.oauthAccount.json"
54
+ [ -f "${DIR}/${name}.oauthAccount.json" ] && chmod 600 "${DIR}/${name}.oauthAccount.json"
55
+ return 0
56
+ }
57
+
58
+ apply_oauth_account_meta() {
59
+ local name="$1" meta="${DIR}/${name}.oauthAccount.json"
60
+ [ -f "$CLAUDE_JSON" ] || return 0
61
+ if [ -f "$meta" ]; then
62
+ node -e '
63
+ const fs = require("fs");
64
+ const cfg = process.argv[1], meta = process.argv[2];
65
+ const j = JSON.parse(fs.readFileSync(cfg, "utf8"));
66
+ j.oauthAccount = JSON.parse(fs.readFileSync(meta, "utf8"));
67
+ fs.writeFileSync(cfg, JSON.stringify(j, null, 2) + "\n", { mode: 0o600 });
68
+ ' "$CLAUDE_JSON" "$meta"
69
+ else
70
+ node -e '
71
+ const fs = require("fs");
72
+ const cfg = process.argv[1];
73
+ const j = JSON.parse(fs.readFileSync(cfg, "utf8"));
74
+ delete j.oauthAccount;
75
+ fs.writeFileSync(cfg, JSON.stringify(j, null, 2) + "\n", { mode: 0o600 });
76
+ ' "$CLAUDE_JSON"
77
+ echo "Warning: '${name}' has no saved oauthAccount display cache; Claude /status will show email=null until you re-save or re-add that account." >&2
78
+ fi
79
+ }
80
+
81
+ meta_email() {
82
+ local name="$1" file="${DIR}/${name}.meta"
83
+ [ -f "$file" ] || return 1
84
+ sed -n 's/^email=//p' "$file" | head -n 1
85
+ }
86
+
87
+ name_from_live_email() {
88
+ local email="$1" f n meta local_part
89
+ [ -n "$email" ] || return 1
90
+ for f in "${DIR}"/*.meta; do
91
+ [ -e "$f" ] || continue
92
+ n="$(basename "$f" .meta)"
93
+ meta="$(sed -n 's/^email=//p' "$f" | head -n 1)"
94
+ [ "$meta" = "$email" ] && { printf '%s' "$n"; return 0; }
95
+ done
96
+ local_part="${email%@*}"
97
+ [ -f "${DIR}/${local_part}.json" ] && { printf '%s' "$local_part"; return 0; }
98
+ return 1
99
+ }
100
+
101
+ name_from_exact_blob() {
102
+ local blob="$1" h f n
103
+ [ -n "$blob" ] || return 1
104
+ h="$(printf '%s' "$blob" | blob_hash)"
105
+ for f in "${DIR}"/*.json; do
106
+ [ -e "$f" ] || continue
107
+ [ "$(file_hash "$f")" = "$h" ] || continue
108
+ n="$(basename "$f" .json)"
109
+ case "$n" in *.oauthAccount) continue;; esac
110
+ [ "$n" = "usage-history" ] && continue
111
+ printf '%s' "$n"
112
+ return 0
113
+ done
114
+ return 1
115
+ }
116
+
117
+ write_account_file() {
118
+ local name="$1" blob="$2"
119
+ printf '%s' "$blob" > "${DIR}/${name}.json"; chmod 600 "${DIR}/${name}.json"
120
+ }
121
+
122
+ # Snapshot whatever account is currently live back into its named file, so any
123
+ # refreshed/rotated tokens are preserved before we overwrite the keychain.
124
+ snapshot_active() {
125
+ local name blob email resolved email_resolved exact_resolved stamp
126
+ blob="$(read_keychain)" || return 0
127
+ [ -z "$blob" ] && return 0
128
+ email="$(auth_email || true)"
129
+ [ -n "$email" ] || email="$(cached_oauth_email || true)"
130
+ exact_resolved="$(name_from_exact_blob "$blob" 2>/dev/null || true)"
131
+ email_resolved="$(name_from_live_email "$email" 2>/dev/null || true)"
132
+ resolved="$exact_resolved"
133
+ [ -n "$resolved" ] || resolved="$email_resolved"
134
+ name="$(active_name)"
135
+ if [ -n "$resolved" ]; then
136
+ write_account_file "$resolved" "$blob"
137
+ if [ -n "$email" ] && [ "$resolved" = "$email_resolved" ]; then
138
+ save_meta "$resolved" "$email"
139
+ save_oauth_account_meta "$resolved"
140
+ fi
141
+ printf '%s' "$resolved" > "$ACTIVE"
142
+ return 0
143
+ fi
144
+ if [ -n "$name" ]; then
145
+ stamp="$(date +%Y%m%d-%H%M%S)"
146
+ write_account_file "unsaved-live-${stamp}" "$blob"
147
+ save_meta "unsaved-live-${stamp}" "$email"
148
+ save_oauth_account_meta "unsaved-live-${stamp}"
149
+ echo "Warning: live Claude credential did not match saved account '${name}'; saved it as 'unsaved-live-${stamp}' instead of overwriting '${name}'." >&2
150
+ fi
151
+ }
152
+
153
+ print_auth_status() {
154
+ claude auth status --json 2>/dev/null \
155
+ | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{const j=JSON.parse(s); const parts=[]; if (j.email) parts.push(j.email); if (j.subscriptionType) parts.push(j.subscriptionType); if (parts.length) console.log("Claude auth: "+parts.join(" · "))}catch{}})' \
156
+ || true
157
+ }
158
+
159
+ usage_report() {
160
+ node - "$@" <<'NODE'
161
+ const fs = require("fs");
162
+ const https = require("https");
163
+ const path = require("path");
164
+
165
+ const args = process.argv.slice(2);
166
+ const asJson = args.includes("--json");
167
+ const noHistory = args.includes("--no-history");
168
+ const dir = path.join(process.env.HOME, ".claude", "accounts");
169
+ const historyPath = path.join(dir, "usage-history.json");
170
+ const activePath = path.join(dir, ".active");
171
+ const selected = fs.existsSync(activePath) ? fs.readFileSync(activePath, "utf8").trim() : "";
172
+
173
+ function readJson(file) {
174
+ try {
175
+ return JSON.parse(fs.readFileSync(file, "utf8"));
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ function metaEmail(name) {
182
+ const meta = path.join(dir, `${name}.meta`);
183
+ if (fs.existsSync(meta)) {
184
+ const match = fs.readFileSync(meta, "utf8").match(/^email=(.+)$/m);
185
+ if (match) return match[1];
186
+ }
187
+ const oauth = readJson(path.join(dir, `${name}.oauthAccount.json`));
188
+ return oauth?.emailAddress || null;
189
+ }
190
+
191
+ function cachedAuthEmail() {
192
+ const cfg = readJson(path.join(process.env.HOME, ".claude.json"));
193
+ return cfg?.oauthAccount?.emailAddress || null;
194
+ }
195
+
196
+ function request(token, endpoint) {
197
+ return new Promise((resolve) => {
198
+ if (!token) {
199
+ resolve({ ok: false, status: 0, error: "missing access token" });
200
+ return;
201
+ }
202
+ const req = https.request(
203
+ {
204
+ hostname: "claude.ai",
205
+ path: endpoint,
206
+ method: "GET",
207
+ timeout: 15000,
208
+ headers: {
209
+ authorization: `Bearer ${token}`,
210
+ accept: "application/json",
211
+ "anthropic-client-platform": "cli",
212
+ "user-agent": "Claude-Code/2.1.170 claude-acct-usage",
213
+ },
214
+ },
215
+ (res) => {
216
+ let body = "";
217
+ res.on("data", (chunk) => {
218
+ body += chunk;
219
+ });
220
+ res.on("end", () => {
221
+ let data = null;
222
+ try {
223
+ data = JSON.parse(body);
224
+ } catch {
225
+ data = body;
226
+ }
227
+ resolve({
228
+ ok: res.statusCode >= 200 && res.statusCode < 300,
229
+ status: res.statusCode,
230
+ data,
231
+ });
232
+ });
233
+ },
234
+ );
235
+ req.on("error", (error) => resolve({ ok: false, status: 0, error: error.message }));
236
+ req.on("timeout", () => {
237
+ req.destroy();
238
+ resolve({ ok: false, status: 0, error: "timeout" });
239
+ });
240
+ req.end();
241
+ });
242
+ }
243
+
244
+ function pct(value) {
245
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : null;
246
+ }
247
+
248
+ function resetText(value) {
249
+ if (!value) return "reset unknown";
250
+ const date = new Date(value);
251
+ if (Number.isNaN(date.getTime())) return "reset unknown";
252
+ const now = Date.now();
253
+ const deltaMs = date.getTime() - now;
254
+ const abs = Math.abs(deltaMs);
255
+ const hours = Math.floor(abs / 3_600_000);
256
+ const minutes = Math.round((abs % 3_600_000) / 60_000);
257
+ const rel = deltaMs >= 0 ? `in ${hours}h ${minutes}m` : `${hours}h ${minutes}m ago`;
258
+ return `${date.toLocaleString(undefined, {
259
+ year: "numeric",
260
+ month: "short",
261
+ day: "2-digit",
262
+ hour: "2-digit",
263
+ minute: "2-digit",
264
+ timeZoneName: "short",
265
+ })} (${rel})`;
266
+ }
267
+
268
+ function resetMinute(value) {
269
+ if (!value) return null;
270
+ const date = new Date(value);
271
+ if (Number.isNaN(date.getTime())) return null;
272
+ date.setUTCSeconds(0, 0);
273
+ return date.toISOString();
274
+ }
275
+
276
+ function bar(value, width = 24) {
277
+ const used = pct(value);
278
+ if (used === null) return `[${"?".repeat(width)}]`;
279
+ const filled = Math.round((used / 100) * width);
280
+ return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
281
+ }
282
+
283
+ function trend(entries, currentReset) {
284
+ if (!entries || entries.length < 2) return "";
285
+ const resetKey = resetMinute(currentReset);
286
+ const scoped = resetKey
287
+ ? entries.filter((entry) => entry.resetMinute === resetKey)
288
+ : entries.filter((entry) => !entry.resetMinute && !entry.resetsAt);
289
+ if (scoped.length < 2) return "";
290
+ const chars = " .:-=+*#%@";
291
+ return scoped
292
+ .slice(-18)
293
+ .map((entry) => chars[Math.max(0, Math.min(chars.length - 1, Math.round((pct(entry.usedPercent) ?? 0) / 100 * (chars.length - 1))))])
294
+ .join("");
295
+ }
296
+
297
+ function statusFor(window) {
298
+ const used = pct(window?.utilization);
299
+ if (used === null) return "unknown";
300
+ if (used >= 100) return "limited";
301
+ if (used >= 90) return "hot";
302
+ return "ok";
303
+ }
304
+
305
+ function shouldShowWindow(data, { optional = false } = {}) {
306
+ if (!data) return false;
307
+ if (!optional) return true;
308
+ const used = pct(data.utilization);
309
+ return Boolean(data.resets_at) || (used !== null && used > 0);
310
+ }
311
+
312
+ function accountFiles() {
313
+ return fs
314
+ .readdirSync(dir)
315
+ .filter((file) => file.endsWith(".json"))
316
+ .filter((file) => !file.endsWith(".oauthAccount.json"))
317
+ .filter((file) => file !== "usage-history.json")
318
+ .sort();
319
+ }
320
+
321
+ function appendHistory(results) {
322
+ if (noHistory) return null;
323
+ const now = new Date().toISOString();
324
+ const history = readJson(historyPath) || { version: 1, accounts: {} };
325
+ history.version = 1;
326
+ history.accounts ||= {};
327
+ for (const result of results) {
328
+ if (!result.usage?.ok) continue;
329
+ const key = result.email || result.account;
330
+ history.accounts[key] ||= {};
331
+ for (const [name, source] of [
332
+ ["five_hour", result.usage.data.five_hour],
333
+ ["seven_day", result.usage.data.seven_day],
334
+ ["seven_day_opus", result.usage.data.seven_day_opus],
335
+ ["seven_day_sonnet", result.usage.data.seven_day_sonnet],
336
+ ]) {
337
+ if (!source) continue;
338
+ history.accounts[key][name] ||= [];
339
+ const entries = history.accounts[key][name];
340
+ for (const existing of entries) {
341
+ if (!existing.resetMinute && existing.resetsAt) existing.resetMinute = resetMinute(existing.resetsAt);
342
+ }
343
+ const last = entries[entries.length - 1];
344
+ const entry = {
345
+ capturedAt: now,
346
+ usedPercent: pct(source.utilization),
347
+ resetsAt: source.resets_at || null,
348
+ resetMinute: resetMinute(source.resets_at),
349
+ };
350
+ if (!last || last.usedPercent !== entry.usedPercent || last.resetMinute !== entry.resetMinute) {
351
+ entries.push(entry);
352
+ }
353
+ while (entries.length > 240) entries.shift();
354
+ }
355
+ }
356
+ fs.writeFileSync(historyPath, `${JSON.stringify(history, null, 2)}\n`, { mode: 0o600 });
357
+ return history;
358
+ }
359
+
360
+ async function main() {
361
+ const results = [];
362
+ const activeEmail = cachedAuthEmail();
363
+ for (const file of accountFiles()) {
364
+ const account = file.replace(/\.json$/, "");
365
+ const credential = readJson(path.join(dir, file));
366
+ const token = credential?.claudeAiOauth?.accessToken;
367
+ const [profile, usage] = await Promise.all([
368
+ request(token, "/api/oauth/profile"),
369
+ request(token, "/api/oauth/usage"),
370
+ ]);
371
+ const profileEmail = profile.ok ? profile.data?.account?.email : null;
372
+ const email = profileEmail || metaEmail(account);
373
+ results.push({
374
+ account,
375
+ selected: account === selected,
376
+ active: activeEmail ? email === activeEmail : account === selected,
377
+ email,
378
+ subscriptionType: profile.ok ? credential?.claudeAiOauth?.subscriptionType || null : null,
379
+ profile,
380
+ usage,
381
+ });
382
+ }
383
+ const history = appendHistory(results) || readJson(historyPath) || { accounts: {} };
384
+ if (asJson) {
385
+ const activeResult = results.find((result) => result.active);
386
+ console.log(JSON.stringify({ generatedAt: new Date().toISOString(), active: activeResult?.account || selected, selected, activeEmail, results }, null, 2));
387
+ return;
388
+ }
389
+
390
+ console.log(`Claude account usage (${new Date().toLocaleString(undefined, { timeZoneName: "short" })})`);
391
+ console.log("");
392
+ for (const result of results) {
393
+ const recovered = result.account.startsWith("unsaved-live-") ? " (recovered live credential)" : "";
394
+ const staleSelected = result.selected && !result.active ? " (selected marker stale)" : "";
395
+ const marker = result.active ? "*" : (result.selected ? "!" : " ");
396
+ const label = `${marker} ${result.account}${result.email ? ` <${result.email}>` : ""}${recovered}${staleSelected}`;
397
+ console.log(label);
398
+ if (!result.usage.ok) {
399
+ const message = result.usage.data?.error?.message || result.usage.error || `HTTP ${result.usage.status}`;
400
+ const activeHint = result.active ? " ACTIVE ACCOUNT IS NOT USABLE" : "";
401
+ console.log(` usage: unavailable (${message})${activeHint}`);
402
+ console.log(" fix: re-save this account after a successful login/request if the token is stale");
403
+ console.log("");
404
+ continue;
405
+ }
406
+ const windows = [
407
+ ["5h", "five_hour", result.usage.data.five_hour, false],
408
+ ["weekly", "seven_day", result.usage.data.seven_day, false],
409
+ ["weekly opus", "seven_day_opus", result.usage.data.seven_day_opus, true],
410
+ ["weekly sonnet", "seven_day_sonnet", result.usage.data.seven_day_sonnet, true],
411
+ ].filter(([, , data, optional]) => shouldShowWindow(data, { optional }));
412
+ for (const [labelName, key, data] of windows) {
413
+ const used = pct(data.utilization);
414
+ const keyHistory = history.accounts?.[result.email || result.account]?.[key] || [];
415
+ const trendText = trend(keyHistory, data.resets_at || null);
416
+ const pctText = used === null ? "???" : `${String(Math.round(used)).padStart(3)}%`;
417
+ const suffix = trendText.trim() ? ` trend ${trendText}` : "";
418
+ console.log(` ${labelName.padEnd(13)} ${bar(used)} ${pctText} ${statusFor(data)}${suffix}`);
419
+ const reset = data.resets_at ? resetText(data.resets_at) : (used === 0 ? "no active window" : "reset unknown");
420
+ console.log(` ${"".padEnd(13)} resets ${reset}`);
421
+ }
422
+ const extra = result.usage.data.extra_usage;
423
+ if (extra && (Number(extra.used_credits || 0) > 0 || extra.utilization != null)) {
424
+ const used = pct(extra.utilization);
425
+ const credits = extra.used_credits != null ? ` ${extra.used_credits}${extra.currency ? ` ${extra.currency}` : ""}` : "";
426
+ console.log(` extra usage ${bar(used)} ${used === null ? "n/a" : `${Math.round(used)}%`}${credits}`);
427
+ }
428
+ console.log("");
429
+ }
430
+ }
431
+
432
+ main().catch((error) => {
433
+ console.error(error instanceof Error ? error.message : String(error));
434
+ process.exit(1);
435
+ });
436
+ NODE
437
+ }
438
+
439
+ # ---------- per-session pinning ----------
440
+
441
+ SETTINGS_REL=".claude/settings.local.json"
442
+
443
+ oat_file() { printf '%s\n' "${DIR}/$1.oat"; }
444
+
445
+ oat_names() {
446
+ shopt -s nullglob
447
+ local f
448
+ for f in "${DIR}"/*.oat; do basename "$f" .oat; done
449
+ }
450
+
451
+ pin_token_of() { # <dir> -> pinned token or nothing
452
+ node -e '
453
+ const fs = require("fs"), path = require("path");
454
+ try {
455
+ const p = path.join(process.argv[1], ".claude", "settings.local.json");
456
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
457
+ const t = j?.env?.CLAUDE_CODE_OAUTH_TOKEN;
458
+ if (t) process.stdout.write(t);
459
+ } catch {}
460
+ ' "$1"
461
+ }
462
+
463
+ pin_name_of() { # <dir> -> account name, "(unmanaged)", or nothing
464
+ local tok f
465
+ tok="$(pin_token_of "$1")"
466
+ [ -n "$tok" ] || return 0
467
+ shopt -s nullglob
468
+ for f in "${DIR}"/*.oat; do
469
+ [ "$(cat "$f")" = "$tok" ] && { basename "$f" .oat; return 0; }
470
+ done
471
+ echo "(unmanaged)"
472
+ }
473
+
474
+ write_pin() { # <dir> <token-or-empty-to-remove>
475
+ node -e '
476
+ const fs = require("fs"), path = require("path");
477
+ const dir = process.argv[1], tok = process.argv[2];
478
+ const p = path.join(dir, ".claude", "settings.local.json");
479
+ let j = {};
480
+ if (fs.existsSync(p)) {
481
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
482
+ catch { console.error(`refusing to touch invalid JSON: ${p}`); process.exit(1); }
483
+ } else if (!tok) {
484
+ process.exit(0);
485
+ }
486
+ j.env ||= {};
487
+ if (tok) j.env.CLAUDE_CODE_OAUTH_TOKEN = tok;
488
+ else { delete j.env.CLAUDE_CODE_OAUTH_TOKEN; if (!Object.keys(j.env).length) delete j.env; }
489
+ fs.mkdirSync(path.dirname(p), { recursive: true });
490
+ fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n", { mode: 0o600 });
491
+ fs.chmodSync(p, 0o600);
492
+ ' "$1" "$2"
493
+ }
494
+
495
+ exclude_pin_from_git() { # <dir> — keep the token file out of git status forever
496
+ git -C "$1" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
497
+ local ex
498
+ ex="$(git -C "$1" rev-parse --path-format=absolute --git-path info/exclude 2>/dev/null)" || return 0
499
+ [ -n "$ex" ] || return 0
500
+ mkdir -p "$(dirname "$ex")" 2>/dev/null || true
501
+ grep -qxF "$SETTINGS_REL" "$ex" 2>/dev/null || echo "$SETTINGS_REL" >> "$ex"
502
+ }
503
+
504
+ running_claude_sessions() { # lines: pid<TAB>cwd
505
+ # ps for discovery, one targeted lsof -p for cwds: `lsof -c claude` misses
506
+ # most claude processes when run sandboxed, the -p form does not.
507
+ local pids
508
+ pids="$(ps -axo pid=,comm= | awk '$2 ~ /\/claude$|^claude$/ { printf "%s%s", s, $1; s="," }')"
509
+ [ -n "$pids" ] || return 0
510
+ lsof -a -p "$pids" -d cwd -Fpn 2>/dev/null | awk '
511
+ /^p/ { pid = substr($0, 2) }
512
+ /^n/ { print pid "\t" substr($0, 2) }
513
+ '
514
+ }
515
+
516
+ candidate_dirs() { # running session cwds first, then recent agent worktrees
517
+ {
518
+ running_claude_sessions | cut -f2
519
+ ls -td "$HOME"/.superset/worktrees/*/* 2>/dev/null | head -10
520
+ ls -td "$HOME"/.paseo/worktrees/*/* 2>/dev/null | head -10
521
+ } | awk '!seen[$0]++'
522
+ }
523
+
524
+ short_dir() { printf '%s\n' "${1/#$HOME/~}"; }
525
+
526
+ oat_profile_email() { # <token> — best-effort ownership lookup; prints email or nothing
527
+ node -e '
528
+ const https = require("https");
529
+ const tok = process.argv[1];
530
+ const req = https.request(
531
+ {
532
+ hostname: "claude.ai",
533
+ path: "/api/oauth/profile",
534
+ method: "GET",
535
+ timeout: 10000,
536
+ headers: {
537
+ authorization: `Bearer ${tok}`,
538
+ accept: "application/json",
539
+ "anthropic-client-platform": "cli",
540
+ },
541
+ },
542
+ (res) => {
543
+ let b = "";
544
+ res.on("data", (d) => { b += d; });
545
+ res.on("end", () => {
546
+ try {
547
+ const j = JSON.parse(b);
548
+ const e = j?.account?.email;
549
+ if (res.statusCode === 200 && e) process.stdout.write(e);
550
+ } catch {}
551
+ });
552
+ },
553
+ );
554
+ req.on("error", () => {});
555
+ req.on("timeout", () => req.destroy());
556
+ req.end();
557
+ ' "$1"
558
+ }
559
+
560
+ do_pin() { # <dir> <account-name>
561
+ local d="$1" name="$2" tok pids
562
+ d="$(cd "$d" 2>/dev/null && pwd)" || { echo "Not a directory: $1" >&2; exit 1; }
563
+ tok="$(cat "$(oat_file "$name")" 2>/dev/null)" || true
564
+ if [ -z "${tok:-}" ]; then
565
+ echo "Account '${name}' has no pin token yet. Mint one first:" >&2
566
+ echo " claude-acct token ${name}" >&2
567
+ exit 1
568
+ fi
569
+ write_pin "$d" "$tok"
570
+ exclude_pin_from_git "$d"
571
+ echo "Pinned $(short_dir "$d") -> ${name}"
572
+ pids="$(running_claude_sessions | awk -F'\t' -v d="$d" '$2 == d { printf "%s%s", s, $1; s="," }')"
573
+ if [ -n "$pids" ]; then
574
+ echo "Applies on next session start. Restart the running session there (pid ${pids}) —"
575
+ echo "in Superset/Paseo, restarting the thread resumes the conversation on the new account."
576
+ else
577
+ echo "No session currently running there; the next one picks it up automatically."
578
+ fi
579
+ }
580
+
581
+ case "${1:-}" in
582
+ add)
583
+ # Safe capture of a NEW account: snapshot the current one, then sign into the
584
+ # next via overwrite (claude auth login) — NEVER /logout, which revokes the
585
+ # previous account's session/Remote-Control capability server-side.
586
+ name="${2:?usage: claude-acct add <name>}"
587
+ before="$(read_keychain 2>/dev/null | blob_hash || true)"
588
+ snapshot_active
589
+ echo "Signing in to the new account (browser). Do NOT log out of the current one."
590
+ claude auth login
591
+ blob="$(read_keychain)" || { echo "Login did not produce a credential." >&2; exit 1; }
592
+ [ -z "$blob" ] && { echo "Keychain credential is empty after login." >&2; exit 1; }
593
+ after="$(printf '%s' "$blob" | blob_hash)"
594
+ if [ -n "$before" ] && [ "$before" = "$after" ]; then
595
+ echo "Login did not change the Claude keychain credential; refusing to save '${name}' over the existing account." >&2
596
+ echo "Open a fresh browser/login flow for the new account, then re-run: claude-acct add ${name}" >&2
597
+ exit 1
598
+ fi
599
+ write_account_file "$name" "$blob"
600
+ printf '%s' "$name" > "$ACTIVE"
601
+ save_meta "$name" "$(auth_email || true)"
602
+ save_oauth_account_meta "$name"
603
+ echo "Added and switched to '${name}'."
604
+ print_auth_status
605
+ ;;
606
+ save)
607
+ name="${2:?usage: claude-acct save <name>}"
608
+ blob="$(read_keychain)" || { echo "No active Claude credential in keychain. Run 'claude' and log in first." >&2; exit 1; }
609
+ [ -z "$blob" ] && { echo "Keychain credential is empty — log in first." >&2; exit 1; }
610
+ write_account_file "$name" "$blob"
611
+ printf '%s' "$name" > "$ACTIVE"
612
+ save_meta "$name" "$(auth_email || true)"
613
+ save_oauth_account_meta "$name"
614
+ echo "Saved current account as '${name}'."
615
+ print_auth_status
616
+ ;;
617
+ use)
618
+ name="${2:?usage: claude-acct use <name>}"
619
+ file="${DIR}/${name}.json"
620
+ [ -f "$file" ] || { echo "No saved account '${name}'. Run: claude-acct list" >&2; exit 1; }
621
+ snapshot_active # preserve outgoing account's latest tokens
622
+ write_keychain "$(cat "$file")"
623
+ live="$(read_keychain)" || { echo "Failed to read keychain after switching." >&2; exit 1; }
624
+ if [ "$(printf '%s' "$live" | blob_hash)" != "$(file_hash "$file")" ]; then
625
+ echo "Switch verification failed: keychain does not match '${name}' after write." >&2
626
+ exit 1
627
+ fi
628
+ apply_oauth_account_meta "$name"
629
+ printf '%s' "$name" > "$ACTIVE"
630
+ save_meta "$name" "$(auth_email || true)"
631
+ save_oauth_account_meta "$name"
632
+ echo "Switched to '${name}'. New Claude processes use it immediately; already-running Claude processes may keep their startup account until restarted."
633
+ print_auth_status
634
+ ;;
635
+ list)
636
+ cur="$(active_name)"
637
+ live_blob="$(read_keychain 2>/dev/null || true)"
638
+ live_exact="$(name_from_exact_blob "$live_blob" 2>/dev/null || true)"
639
+ live_email="$(auth_email || true)"
640
+ live_email_name="$(name_from_live_email "$live_email" 2>/dev/null || true)"
641
+ live_active="${live_email_name:-${live_exact:-$cur}}"
642
+ shopt -s nullglob
643
+ found=0
644
+ for f in "${DIR}"/*.json; do
645
+ found=1; n="$(basename "$f" .json)"
646
+ case "$n" in *.oauthAccount) continue;; esac
647
+ [ "$n" = "usage-history" ] && continue
648
+ marker=" "
649
+ [ "$n" = "$live_active" ] && marker="*"
650
+ [ "$n" = "$cur" ] && [ "$n" != "$live_active" ] && marker="!"
651
+ suffix=""
652
+ [ "$n" = "$live_exact" ] && suffix="${suffix} live-exact"
653
+ [ -z "$live_exact" ] && [ "$n" = "$live_email_name" ] && suffix="${suffix} live-email"
654
+ [ "$n" = "$cur" ] && [ "$n" != "$live_active" ] && suffix="${suffix} selected-marker-stale"
655
+ [ ! -f "${DIR}/${n}.oauthAccount.json" ] && suffix="${suffix} no-status-cache"
656
+ [ -n "$suffix" ] && suffix=" (${suffix# })"
657
+ [ "$n" = "$live_active" ] && suffix="${suffix:- (active)}"
658
+ echo "${marker} ${n}${suffix}"
659
+ done
660
+ [ "$found" = 0 ] && echo "No saved accounts yet. Log in, then: claude-acct save <name>"
661
+ [ -n "$live_email" ] && echo "Claude auth: ${live_email}" || true
662
+ ;;
663
+ current)
664
+ live_email="$(auth_email || true)"
665
+ live_name="$(name_from_live_email "$live_email" 2>/dev/null || true)"
666
+ [ -n "$live_name" ] || live_name="$(name_from_exact_blob "$(read_keychain 2>/dev/null || true)" 2>/dev/null || true)"
667
+ if [ -n "$live_name" ]; then
668
+ printf '%s\n' "$live_name"
669
+ else
670
+ active_name && echo "" || echo "(none recorded)"
671
+ fi
672
+ [ -n "$live_email" ] && echo "Claude auth: ${live_email}" >&2 || true
673
+ ;;
674
+ doctor)
675
+ echo "marker: $(active_name || true)"
676
+ live_email="$(auth_email || true)"
677
+ echo "claude_auth_email: ${live_email:-unknown}"
678
+ live_blob="$(read_keychain 2>/dev/null || true)"
679
+ if [ -n "$live_blob" ]; then
680
+ live_hash="$(printf '%s' "$live_blob" | blob_hash)"
681
+ echo "keychain_hash: ${live_hash}"
682
+ exact="$(name_from_exact_blob "$live_blob" 2>/dev/null || true)"
683
+ email_match="$(name_from_live_email "$live_email" 2>/dev/null || true)"
684
+ echo "exact_saved_match: ${exact:-none}"
685
+ echo "email_saved_match: ${email_match:-none}"
686
+ else
687
+ echo "keychain_hash: none"
688
+ fi
689
+ echo "status_cache_files:"
690
+ shopt -s nullglob
691
+ for f in "${DIR}"/*.json; do
692
+ n="$(basename "$f" .json)"
693
+ case "$n" in *.oauthAccount) continue;; esac
694
+ [ "$n" = "usage-history" ] && continue
695
+ if [ -f "${DIR}/${n}.oauthAccount.json" ]; then
696
+ email="$(node -e 'const fs=require("fs"); try { const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (j.emailAddress) process.stdout.write(j.emailAddress); } catch {}' "${DIR}/${n}.oauthAccount.json")"
697
+ echo " ${n}: ${email:-present}"
698
+ else
699
+ echo " ${n}: missing"
700
+ fi
701
+ done
702
+ ;;
703
+ usage)
704
+ shift
705
+ usage_report "$@"
706
+ ;;
707
+ token)
708
+ name="${2:?usage: claude-acct token <name>}"
709
+ [ -f "${DIR}/${name}.json" ] || [ -f "$(oat_file "$name")" ] || {
710
+ echo "Unknown account '${name}'. Save it first (claude-acct add/save), or pick from: $(ls "$DIR" | sed -n 's/\.json$//p' | grep -v -e oauthAccount -e usage-history | tr '\n' ' ')" >&2
711
+ exit 1
712
+ }
713
+ expected_email="$(meta_email "$name" 2>/dev/null || true)"
714
+ echo "Minting a long-lived (~1 year) pin token for '${name}'${expected_email:+ <$expected_email>}."
715
+ echo "A browser OAuth flow will start. The account signed into THAT browser window owns the token."
716
+ [ -n "$expected_email" ] && echo ">>> Make sure the browser is signed into ${expected_email} (use the right profile or a private window)."
717
+ tmp="$(mktemp)"
718
+ claude setup-token 2>&1 | tee "$tmp" || true
719
+ tok="$(grep -oE 'sk-ant-oat01-[A-Za-z0-9_-]+' "$tmp" | tail -1)"
720
+ rm -f "$tmp"
721
+ if [ -z "$tok" ]; then
722
+ printf 'Could not auto-capture the token from setup-token output. Paste it (input hidden): '
723
+ read -rs tok; echo
724
+ fi
725
+ [ -n "$tok" ] || { echo "No token captured." >&2; exit 1; }
726
+ got="$(oat_profile_email "$tok" || true)"
727
+ if [ -n "$got" ] && [ -n "$expected_email" ] && [ "$got" != "$expected_email" ]; then
728
+ echo "REFUSING to save: this token belongs to ${got}, but '${name}' is ${expected_email}." >&2
729
+ echo "Re-run with the browser signed into ${expected_email}, or save it under the matching account name." >&2
730
+ exit 1
731
+ fi
732
+ printf '%s' "$tok" > "$(oat_file "$name")"
733
+ chmod 600 "$(oat_file "$name")"
734
+ if [ -n "$got" ]; then
735
+ echo "Saved pin token for '${name}' (verified owner: ${got})."
736
+ else
737
+ echo "Saved pin token for '${name}' (ownership not verifiable — watch 'claude-acct usage' after first pinned run)."
738
+ fi
739
+ echo "Pin sessions to it with: claude-acct pin"
740
+ ;;
741
+ pin)
742
+ if [ -n "${2:-}" ] && [ -n "${3:-}" ]; then
743
+ do_pin "$2" "$3"
744
+ exit 0
745
+ fi
746
+ # interactive picker
747
+ dirs=(); i=0
748
+ while IFS= read -r d; do [ -d "$d" ] && { dirs[i]="$d"; i=$((i+1)); }; done < <(candidate_dirs)
749
+ [ "$i" -gt 0 ] || { echo "No running Claude sessions or known worktrees found." >&2; exit 1; }
750
+ running="$(running_claude_sessions)"
751
+ echo "Sessions:"
752
+ j=0
753
+ while [ "$j" -lt "$i" ]; do
754
+ d="${dirs[j]}"
755
+ n="$(printf '%s\n' "$running" | awk -F'\t' -v d="$d" '$2 == d' | wc -l | tr -d ' ')"
756
+ state="idle"; [ "$n" -gt 0 ] && state="RUNNING x$n"
757
+ acct="$(pin_name_of "$d")"
758
+ [ -n "$acct" ] || acct="default->$(active_name 2>/dev/null || echo '?')"
759
+ printf '%3d) %-11s %-24s %s\n' "$((j+1))" "$state" "$acct" "$(short_dir "$d")"
760
+ j=$((j+1))
761
+ done
762
+ printf 'Pin which session? [1-%d] ' "$i"
763
+ read -r sel
764
+ case "$sel" in (*[!0-9]*|'') echo "Not a number." >&2; exit 1;; esac
765
+ [ "$sel" -ge 1 ] && [ "$sel" -le "$i" ] || { echo "Out of range." >&2; exit 1; }
766
+ target="${dirs[sel-1]}"
767
+ accts=(); k=0
768
+ while IFS= read -r a; do [ -n "$a" ] && { accts[k]="$a"; k=$((k+1)); }; done < <(oat_names)
769
+ if [ "$k" -eq 0 ]; then
770
+ echo "No accounts have pin tokens yet. Mint one per account first:" >&2
771
+ echo " claude-acct token <name> # for each of: $(ls "$DIR" | sed -n 's/\.json$//p' | grep -v -e oauthAccount -e usage-history | tr '\n' ' ')" >&2
772
+ exit 1
773
+ fi
774
+ echo "Accounts:"
775
+ m=0
776
+ while [ "$m" -lt "$k" ]; do
777
+ printf '%3d) %-14s %s\n' "$((m+1))" "${accts[m]}" "$(meta_email "${accts[m]}" 2>/dev/null || true)"
778
+ m=$((m+1))
779
+ done
780
+ echo " 0) remove pin (use the global keychain account)"
781
+ printf 'Pin to which account? [0-%d] ' "$k"
782
+ read -r asel
783
+ case "$asel" in (*[!0-9]*|'') echo "Not a number." >&2; exit 1;; esac
784
+ if [ "$asel" -eq 0 ]; then
785
+ write_pin "$target" ""
786
+ echo "Unpinned $(short_dir "$target"). Restart the session there to apply."
787
+ exit 0
788
+ fi
789
+ [ "$asel" -ge 1 ] && [ "$asel" -le "$k" ] || { echo "Out of range." >&2; exit 1; }
790
+ do_pin "$target" "${accts[asel-1]}"
791
+ ;;
792
+ unpin)
793
+ d="${2:-$PWD}"
794
+ d="$(cd "$d" 2>/dev/null && pwd)" || { echo "Not a directory: ${2:-$PWD}" >&2; exit 1; }
795
+ was="$(pin_name_of "$d")"
796
+ [ -n "$was" ] || { echo "No pin on $(short_dir "$d")."; exit 0; }
797
+ write_pin "$d" ""
798
+ echo "Unpinned $(short_dir "$d") (was: ${was}). Restart the session there to apply."
799
+ ;;
800
+ sessions)
801
+ cur="$(active_name 2>/dev/null || echo '?')"
802
+ running="$(running_claude_sessions)"
803
+ printf '%-11s %-24s %s\n' "STATE" "ACCOUNT" "SESSION"
804
+ candidate_dirs | while IFS= read -r d; do
805
+ [ -d "$d" ] || continue
806
+ n="$(printf '%s\n' "$running" | awk -F'\t' -v d="$d" '$2 == d' | wc -l | tr -d ' ')"
807
+ state="idle"; [ "$n" -gt 0 ] && state="RUNNING x$n"
808
+ acct="$(pin_name_of "$d")"
809
+ [ -n "$acct" ] || acct="default->${cur}"
810
+ printf '%-11s %-24s %s\n' "$state" "$acct" "$(short_dir "$d")"
811
+ done
812
+ echo ""
813
+ echo "default->${cur} sessions follow the keychain account (claude-acct use). Pinned ones don't."
814
+ echo "Pins apply at session start — restart a running session after changing its pin."
815
+ ;;
816
+ *)
817
+ cat >&2 <<'EOF'
818
+ claude-acct — rotate Claude CLI accounts without re-logging-in
819
+
820
+ claude-acct add <name> Sign into a NEW account (overwrite, no logout) and save it
821
+ claude-acct save <name> Snapshot the currently logged-in account under <name>
822
+ claude-acct use <name> Switch the keychain to a previously saved account
823
+ claude-acct usage [--json] Show 5h + weekly usage/reset status for saved accounts
824
+ claude-acct list List saved accounts (* = active)
825
+ claude-acct current Print the active account name
826
+ claude-acct doctor Compare marker, keychain, and Claude auth status
827
+
828
+ Per-session pinning — run different accounts in PARALLEL sessions:
829
+ claude-acct token <name> Mint+save a ~1-year pin token for an account (one browser flow)
830
+ claude-acct pin Pick a running session/worktree, pin it to an account
831
+ claude-acct pin <dir> <name> Pin a directory non-interactively
832
+ claude-acct unpin [dir] Remove a pin (defaults to cwd)
833
+ claude-acct sessions Show running Claude sessions and which account each uses
834
+
835
+ Pins write env.CLAUDE_CODE_OAUTH_TOKEN into <dir>/.claude/settings.local.json.
836
+ Env beats keychain, so pinned sessions ignore `claude-acct use`. A pin applies on
837
+ the NEXT start of a session there (Superset/Paseo restart resumes the thread).
838
+ CAVEAT: if a pin token expires or is revoked, Claude silently falls back to the
839
+ keychain account — re-mint with `claude-acct token <name>` (~yearly).
840
+
841
+ Add accounts with `claude-acct add <name>` — it uses overwrite-login, never /logout.
842
+ NEVER run `/logout` (or `claude auth logout`) to switch: logout REVOKES that
843
+ account's Remote Control / session capability server-side and breaks its saved blob.
844
+ After accounts are added, `claude-acct use <name>` switches instantly — no re-entry.
845
+ EOF
846
+ exit 1;;
847
+ esac