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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/assets/screenshot.png +0 -0
- package/bin/ai-acct-autopilot.js +1205 -0
- package/bin/claude-acct +847 -0
- package/bin/usage-stats.js +336 -0
- package/docs/how-it-works.md +169 -0
- package/install.sh +19 -0
- package/package.json +46 -0
package/bin/claude-acct
ADDED
|
@@ -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
|