claude-switcher-account 2.2.0 → 2.3.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/README.md +17 -5
- package/dist/bin.js +289 -98
- package/package.json +1 -9
package/README.md
CHANGED
|
@@ -30,13 +30,11 @@ npm i -g claude-switcher-account
|
|
|
30
30
|
|
|
31
31
|
(The npm package is `claude-switcher-account`; the command it installs is `claudep`.)
|
|
32
32
|
|
|
33
|
-
From source
|
|
33
|
+
From source (the `prepare` script builds automatically):
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
git clone https://github.com/khainguyenhm-itr/claude-switcher-account.git
|
|
39
|
-
cd claude-switcher-account && npm i -g .
|
|
36
|
+
# from a local clone of the sources:
|
|
37
|
+
npm i -g .
|
|
40
38
|
```
|
|
41
39
|
|
|
42
40
|
Verify with `claudep doctor`. On macOS the first run may raise a Keychain
|
|
@@ -88,6 +86,18 @@ or `NO_COLOR` is set.
|
|
|
88
86
|
|
|
89
87
|
Global flags: `--json` (on `list`), `--version`, `--help`.
|
|
90
88
|
|
|
89
|
+
`claudep list` and the menu show each account's plan usage, the same numbers as
|
|
90
|
+
Claude Code's `/usage`:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
5h: 40.0% Reset: 55m
|
|
94
|
+
Weekly: 12.0% Weekly Reset: 5d 9hr 45m
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
They are fetched live from Anthropic with each account's saved token. An account
|
|
98
|
+
whose token has expired, or any fetch that fails, shows `--`; tokens are never
|
|
99
|
+
refreshed by claudep.
|
|
100
|
+
|
|
91
101
|
### Interactive menu
|
|
92
102
|
|
|
93
103
|
Running `claudep` with no arguments (in a terminal) opens a menu of your saved
|
|
@@ -139,6 +149,8 @@ Configured in `~/.claude-profiles/config.json`:
|
|
|
139
149
|
- **Linux/Windows**: canonical login file `~/.claude/.credentials.json`; saved
|
|
140
150
|
accounts in `~/.claude-profiles/creds.json` (mode `0600`).
|
|
141
151
|
- Account metadata: `~/.claude-profiles/accounts.json`.
|
|
152
|
+
- `CLAUDE_CONFIG_DIR` is not supported: only the default `~/.claude` login is
|
|
153
|
+
managed, and `claudep doctor` flags the variable when it is set.
|
|
142
154
|
|
|
143
155
|
On macOS the first read of Claude's Keychain item may raise an "allow access"
|
|
144
156
|
dialog — click Allow. Run `claudep doctor` if anything looks off.
|
package/dist/bin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { Command } from "commander";
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
5
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
6
6
|
import { homedir as homedir5 } from "os";
|
|
7
7
|
import { join as join6 } from "path";
|
|
@@ -19,10 +19,14 @@ import { promisify } from "util";
|
|
|
19
19
|
var execFileP = promisify(execFile);
|
|
20
20
|
var CLAUDE_SERVICE = "Claude Code-credentials";
|
|
21
21
|
var STORE_SERVICE = "claude-profiles-accounts";
|
|
22
|
-
var defaultExec = async (args) => {
|
|
23
|
-
const
|
|
22
|
+
var defaultExec = async (args, input) => {
|
|
23
|
+
const p = execFileP("security", args);
|
|
24
|
+
p.child.stdin?.end(input ?? "");
|
|
25
|
+
const { stdout } = await p;
|
|
24
26
|
return stdout;
|
|
25
27
|
};
|
|
28
|
+
var INTERACTIVE_MAX_LINE = 4e3;
|
|
29
|
+
var quote = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
26
30
|
var MacKeychainStore = class {
|
|
27
31
|
exec;
|
|
28
32
|
osUsername;
|
|
@@ -43,8 +47,18 @@ var MacKeychainStore = class {
|
|
|
43
47
|
return "";
|
|
44
48
|
}
|
|
45
49
|
}
|
|
50
|
+
/** Upsert a secret, keeping it off the argv (visible to `ps`) whenever it fits on one `security -i` line. */
|
|
51
|
+
async addPassword(service, account, blob) {
|
|
52
|
+
const line = `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(blob)}
|
|
53
|
+
`;
|
|
54
|
+
if (!/[\r\n]/.test(blob) && Buffer.byteLength(line) <= INTERACTIVE_MAX_LINE) {
|
|
55
|
+
await this.exec(["-i"], line);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await this.exec(["add-generic-password", "-U", "-s", service, "-a", account, "-w", blob]);
|
|
59
|
+
}
|
|
46
60
|
async writeCanonical(blob) {
|
|
47
|
-
await this.
|
|
61
|
+
await this.addPassword(CLAUDE_SERVICE, this.osUsername, blob);
|
|
48
62
|
}
|
|
49
63
|
async readSaved(name) {
|
|
50
64
|
try {
|
|
@@ -55,7 +69,7 @@ var MacKeychainStore = class {
|
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
async writeSaved(name, blob) {
|
|
58
|
-
await this.
|
|
72
|
+
await this.addPassword(STORE_SERVICE, name, blob);
|
|
59
73
|
}
|
|
60
74
|
async deleteSaved(name) {
|
|
61
75
|
try {
|
|
@@ -66,8 +80,46 @@ var MacKeychainStore = class {
|
|
|
66
80
|
};
|
|
67
81
|
|
|
68
82
|
// src/stores/fileStore.ts
|
|
69
|
-
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "fs";
|
|
83
|
+
import { mkdirSync as mkdirSync2, readFileSync, writeFileSync, renameSync, existsSync } from "fs";
|
|
84
|
+
import { dirname as dirname2 } from "path";
|
|
85
|
+
|
|
86
|
+
// src/lock.ts
|
|
87
|
+
import { mkdirSync, statSync, rmSync } from "fs";
|
|
70
88
|
import { dirname } from "path";
|
|
89
|
+
var LOCK_STALE_MS = 1e4;
|
|
90
|
+
var LOCK_RETRY_MS = 25;
|
|
91
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
92
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
93
|
+
async function acquireLock(lockPath) {
|
|
94
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
95
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
96
|
+
for (; ; ) {
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(lockPath);
|
|
99
|
+
return;
|
|
100
|
+
} catch {
|
|
101
|
+
try {
|
|
102
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
103
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
}
|
|
108
|
+
if (Date.now() > deadline) throw new Error(`Timed out acquiring lock ${lockPath}`);
|
|
109
|
+
await sleep(LOCK_RETRY_MS);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function withLock(lockPath, fn) {
|
|
114
|
+
await acquireLock(lockPath);
|
|
115
|
+
try {
|
|
116
|
+
return await fn();
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/stores/fileStore.ts
|
|
71
123
|
var FileStore = class {
|
|
72
124
|
canonicalPath;
|
|
73
125
|
credsPath;
|
|
@@ -81,19 +133,28 @@ var FileStore = class {
|
|
|
81
133
|
return this.platform === "linux" || this.platform === "win32";
|
|
82
134
|
}
|
|
83
135
|
atomicWrite(path, data, mode) {
|
|
84
|
-
|
|
136
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
85
137
|
const tmp = `${path}.tmp-${process.pid}`;
|
|
86
138
|
writeFileSync(tmp, data, mode !== void 0 ? { mode } : void 0);
|
|
87
139
|
renameSync(tmp, path);
|
|
88
140
|
}
|
|
141
|
+
/** Throws if the file exists but can't be parsed, so a write never replaces it with an empty map. */
|
|
89
142
|
readMap() {
|
|
90
143
|
if (!existsSync(this.credsPath)) return {};
|
|
144
|
+
let parsed;
|
|
91
145
|
try {
|
|
92
|
-
|
|
93
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
146
|
+
parsed = JSON.parse(readFileSync(this.credsPath, "utf8"));
|
|
94
147
|
} catch {
|
|
95
|
-
|
|
148
|
+
parsed = null;
|
|
96
149
|
}
|
|
150
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`Saved creds file ${this.credsPath} is unreadable.`);
|
|
151
|
+
return parsed;
|
|
152
|
+
}
|
|
153
|
+
updateMap(mutate) {
|
|
154
|
+
return withLock(`${this.credsPath}.lock`, () => {
|
|
155
|
+
const map = this.readMap();
|
|
156
|
+
if (mutate(map)) this.atomicWrite(this.credsPath, JSON.stringify(map, null, 2), 384);
|
|
157
|
+
});
|
|
97
158
|
}
|
|
98
159
|
async readCanonical() {
|
|
99
160
|
try {
|
|
@@ -106,18 +167,24 @@ var FileStore = class {
|
|
|
106
167
|
this.atomicWrite(this.canonicalPath, blob, 384);
|
|
107
168
|
}
|
|
108
169
|
async readSaved(name) {
|
|
109
|
-
|
|
170
|
+
try {
|
|
171
|
+
return this.readMap()[name] ?? "";
|
|
172
|
+
} catch {
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
110
175
|
}
|
|
111
176
|
async writeSaved(name, blob) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
177
|
+
await this.updateMap((map) => {
|
|
178
|
+
map[name] = blob;
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
115
181
|
}
|
|
116
182
|
async deleteSaved(name) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
183
|
+
await this.updateMap((map) => {
|
|
184
|
+
if (!(name in map)) return false;
|
|
185
|
+
delete map[name];
|
|
186
|
+
return true;
|
|
187
|
+
});
|
|
121
188
|
}
|
|
122
189
|
};
|
|
123
190
|
|
|
@@ -134,12 +201,8 @@ function createCredentialStore(opts) {
|
|
|
134
201
|
}
|
|
135
202
|
|
|
136
203
|
// src/metadataStore.ts
|
|
137
|
-
import { mkdirSync as
|
|
138
|
-
import { dirname as
|
|
139
|
-
var LOCK_STALE_MS = 1e4;
|
|
140
|
-
var LOCK_RETRY_MS = 25;
|
|
141
|
-
var LOCK_TIMEOUT_MS = 5e3;
|
|
142
|
-
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
204
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, existsSync as existsSync2 } from "fs";
|
|
205
|
+
import { dirname as dirname3 } from "path";
|
|
143
206
|
var MetadataStore = class {
|
|
144
207
|
path;
|
|
145
208
|
lockPath;
|
|
@@ -151,53 +214,34 @@ var MetadataStore = class {
|
|
|
151
214
|
if (!existsSync2(this.path)) return { accounts: [] };
|
|
152
215
|
try {
|
|
153
216
|
const parsed = JSON.parse(readFileSync2(this.path, "utf8"));
|
|
154
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
lastActive: parsed?.lastActive,
|
|
219
|
+
pendingRefresh: parsed?.pendingRefresh,
|
|
220
|
+
accounts: Array.isArray(parsed?.accounts) ? parsed.accounts : []
|
|
221
|
+
};
|
|
155
222
|
} catch {
|
|
156
223
|
return { accounts: [] };
|
|
157
224
|
}
|
|
158
225
|
}
|
|
159
|
-
async acquireLock() {
|
|
160
|
-
mkdirSync2(dirname2(this.lockPath), { recursive: true });
|
|
161
|
-
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
162
|
-
for (; ; ) {
|
|
163
|
-
try {
|
|
164
|
-
mkdirSync2(this.lockPath);
|
|
165
|
-
return;
|
|
166
|
-
} catch {
|
|
167
|
-
try {
|
|
168
|
-
if (Date.now() - statSync(this.lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
169
|
-
rmSync(this.lockPath, { recursive: true, force: true });
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
} catch {
|
|
173
|
-
}
|
|
174
|
-
if (Date.now() > deadline) throw new Error(`Timed out acquiring lock ${this.lockPath}`);
|
|
175
|
-
await sleep(LOCK_RETRY_MS);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
releaseLock() {
|
|
180
|
-
rmSync(this.lockPath, { recursive: true, force: true });
|
|
181
|
-
}
|
|
182
226
|
async update(mutator) {
|
|
183
|
-
|
|
184
|
-
try {
|
|
227
|
+
return withLock(this.lockPath, () => {
|
|
185
228
|
const data = this.read();
|
|
186
229
|
mutator(data);
|
|
187
|
-
|
|
230
|
+
mkdirSync3(dirname3(this.path), { recursive: true });
|
|
188
231
|
const tmp = `${this.path}.tmp-${process.pid}`;
|
|
189
|
-
writeFileSync2(
|
|
232
|
+
writeFileSync2(
|
|
233
|
+
tmp,
|
|
234
|
+
JSON.stringify({ lastActive: data.lastActive, pendingRefresh: data.pendingRefresh, accounts: data.accounts }, null, 2)
|
|
235
|
+
);
|
|
190
236
|
renameSync2(tmp, this.path);
|
|
191
237
|
return data;
|
|
192
|
-
}
|
|
193
|
-
this.releaseLock();
|
|
194
|
-
}
|
|
238
|
+
});
|
|
195
239
|
}
|
|
196
240
|
};
|
|
197
241
|
|
|
198
242
|
// src/profileFile.ts
|
|
199
|
-
import { mkdirSync as
|
|
200
|
-
import { dirname as
|
|
243
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3, renameSync as renameSync3 } from "fs";
|
|
244
|
+
import { dirname as dirname4 } from "path";
|
|
201
245
|
var ProfileFile = class {
|
|
202
246
|
path;
|
|
203
247
|
constructor(opts) {
|
|
@@ -241,7 +285,7 @@ var ProfileFile = class {
|
|
|
241
285
|
if (snapshot) d.oauthAccount = snapshot;
|
|
242
286
|
else delete d.oauthAccount;
|
|
243
287
|
try {
|
|
244
|
-
|
|
288
|
+
mkdirSync4(dirname4(this.path), { recursive: true });
|
|
245
289
|
const tmp = `${this.path}.tmp-${process.pid}`;
|
|
246
290
|
writeFileSync3(tmp, JSON.stringify(d, null, 2));
|
|
247
291
|
renameSync3(tmp, this.path);
|
|
@@ -285,6 +329,7 @@ function orderAccounts(views) {
|
|
|
285
329
|
}
|
|
286
330
|
|
|
287
331
|
// src/accountManager.ts
|
|
332
|
+
var REFRESH_CONFIRM_MS = 1e4;
|
|
288
333
|
function toView(a, active) {
|
|
289
334
|
return {
|
|
290
335
|
name: a.name,
|
|
@@ -301,12 +346,14 @@ var AccountManager = class {
|
|
|
301
346
|
profile;
|
|
302
347
|
config;
|
|
303
348
|
now;
|
|
349
|
+
clock;
|
|
304
350
|
constructor(deps) {
|
|
305
351
|
this.store = deps.store;
|
|
306
352
|
this.meta = deps.meta;
|
|
307
353
|
this.profile = deps.profile;
|
|
308
354
|
this.config = deps.config;
|
|
309
355
|
this.now = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
356
|
+
this.clock = deps.clock ?? Date.now;
|
|
310
357
|
}
|
|
311
358
|
matchByIdentity(accounts, id) {
|
|
312
359
|
if (id.uuid) {
|
|
@@ -365,7 +412,25 @@ var AccountManager = class {
|
|
|
365
412
|
const ordered = orderAccounts(await this.listAccounts());
|
|
366
413
|
return ordered[n - 1]?.name ?? null;
|
|
367
414
|
}
|
|
415
|
+
/** Before the login is replaced, copy a token the active account rotated since its last save — reconcile
|
|
416
|
+
* may still be waiting out REFRESH_CONFIRM_MS on it. Skipped when the credential is another saved
|
|
417
|
+
* account's, i.e. a login-over whose ~/.claude.json write hasn't landed. */
|
|
418
|
+
async keepActiveCredential() {
|
|
419
|
+
let blob = "";
|
|
420
|
+
try {
|
|
421
|
+
blob = await this.store.readCanonical();
|
|
422
|
+
} catch {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!blob) return;
|
|
426
|
+
const fp = fingerprint(blob);
|
|
427
|
+
const accounts = this.meta.read().accounts;
|
|
428
|
+
if (accounts.some((a) => a.fingerprint === fp)) return;
|
|
429
|
+
const active = this.matchByIdentity(accounts, this.profile.currentIdentity());
|
|
430
|
+
if (active) await this.refreshStoredCredential(active.name, blob, fp);
|
|
431
|
+
}
|
|
368
432
|
async switchTo(name) {
|
|
433
|
+
await this.keepActiveCredential();
|
|
369
434
|
const blob = (await this.store.readSaved(name)).trim();
|
|
370
435
|
if (!blob) throw new Error(`Saved account '${name}' not found.`);
|
|
371
436
|
await this.store.writeCanonical(blob);
|
|
@@ -385,7 +450,8 @@ var AccountManager = class {
|
|
|
385
450
|
if (!entry) throw new Error(`Saved account '${oldName}' not found.`);
|
|
386
451
|
if (data.accounts.some((a) => a.name === newName)) throw new Error(`Account '${newName}' already exists.`);
|
|
387
452
|
const blob = await this.store.readSaved(oldName);
|
|
388
|
-
if (blob)
|
|
453
|
+
if (!blob) throw new Error(`Saved account '${oldName}' has no stored credential.`);
|
|
454
|
+
await this.store.writeSaved(newName, blob);
|
|
389
455
|
await this.store.deleteSaved(oldName);
|
|
390
456
|
await this.meta.update((d) => {
|
|
391
457
|
d.accounts = d.accounts.map((a) => a.name === oldName ? { ...a, name: newName } : a);
|
|
@@ -403,6 +469,14 @@ var AccountManager = class {
|
|
|
403
469
|
}
|
|
404
470
|
return accounts.map((a) => toView(a, byId ? a.name === byId.name : activeFp !== null && a.fingerprint === activeFp));
|
|
405
471
|
}
|
|
472
|
+
/** The account's credential: the live login for the active one (its saved copy may be older), else the saved one. */
|
|
473
|
+
async credentialOf(view) {
|
|
474
|
+
try {
|
|
475
|
+
return view.active ? await this.store.readCanonical() : await this.store.readSaved(view.name);
|
|
476
|
+
} catch {
|
|
477
|
+
return "";
|
|
478
|
+
}
|
|
479
|
+
}
|
|
406
480
|
current() {
|
|
407
481
|
const label = this.profile.peekLabel();
|
|
408
482
|
const accounts = this.meta.read().accounts;
|
|
@@ -492,16 +566,30 @@ var AccountManager = class {
|
|
|
492
566
|
const accounts = this.meta.read().accounts;
|
|
493
567
|
const known = this.matchByIdentity(accounts, this.profile.currentIdentity()) ?? accounts.find((a) => a.fingerprint === fpNow);
|
|
494
568
|
if (known) {
|
|
569
|
+
const nowMs = this.clock();
|
|
570
|
+
let confirmed = false;
|
|
495
571
|
await this.meta.update((d) => {
|
|
496
572
|
d.lastActive = known.name;
|
|
573
|
+
if (known.fingerprint === fpNow) {
|
|
574
|
+
d.pendingRefresh = void 0;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const p = d.pendingRefresh;
|
|
578
|
+
if (p?.name !== known.name || p.fingerprint !== fpNow) {
|
|
579
|
+
d.pendingRefresh = { name: known.name, fingerprint: fpNow, since: nowMs };
|
|
580
|
+
} else if (nowMs - p.since >= REFRESH_CONFIRM_MS) {
|
|
581
|
+
d.pendingRefresh = void 0;
|
|
582
|
+
confirmed = true;
|
|
583
|
+
}
|
|
497
584
|
});
|
|
498
|
-
if (
|
|
585
|
+
if (confirmed) await this.refreshStoredCredential(known.name, blob, fpNow);
|
|
499
586
|
return nothing;
|
|
500
587
|
}
|
|
501
588
|
const saved = await this.autoSaveIfNewLogin();
|
|
502
589
|
if (saved) {
|
|
503
590
|
await this.meta.update((d) => {
|
|
504
591
|
d.lastActive = saved.name;
|
|
592
|
+
d.pendingRefresh = void 0;
|
|
505
593
|
});
|
|
506
594
|
}
|
|
507
595
|
return { autoSaved: saved, removed: null, switchedTo: null };
|
|
@@ -522,6 +610,68 @@ function createManager(env) {
|
|
|
522
610
|
return new AccountManager({ store, meta, profile, config });
|
|
523
611
|
}
|
|
524
612
|
|
|
613
|
+
// src/usage.ts
|
|
614
|
+
var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
615
|
+
var TIMEOUT_MS = 3e3;
|
|
616
|
+
function accessTokenOf(blob, nowMs) {
|
|
617
|
+
try {
|
|
618
|
+
const o = JSON.parse(blob)?.claudeAiOauth;
|
|
619
|
+
if (typeof o?.accessToken !== "string") return null;
|
|
620
|
+
if (typeof o.expiresAt === "number" && o.expiresAt <= nowMs) return null;
|
|
621
|
+
return o.accessToken;
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function parseWindow(w) {
|
|
627
|
+
const o = w;
|
|
628
|
+
if (typeof o?.utilization !== "number") return null;
|
|
629
|
+
const t = typeof o.resets_at === "string" ? Date.parse(o.resets_at) : NaN;
|
|
630
|
+
return { pct: o.utilization, resetsAt: Number.isNaN(t) ? null : t };
|
|
631
|
+
}
|
|
632
|
+
function parseUsage(json) {
|
|
633
|
+
if (!json || typeof json !== "object") return null;
|
|
634
|
+
const o = json;
|
|
635
|
+
return { fiveHour: parseWindow(o.five_hour), weekly: parseWindow(o.seven_day) };
|
|
636
|
+
}
|
|
637
|
+
async function fetchUsage(token, fetchFn = fetch) {
|
|
638
|
+
try {
|
|
639
|
+
const res = await fetchFn(USAGE_URL, {
|
|
640
|
+
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
641
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
642
|
+
});
|
|
643
|
+
if (!res.ok) return null;
|
|
644
|
+
return parseUsage(await res.json());
|
|
645
|
+
} catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async function loadUsage(mgr, views, nowMs, fetchFn = fetchUsage) {
|
|
650
|
+
const entries = await Promise.all(
|
|
651
|
+
views.map(async (v) => {
|
|
652
|
+
const token = accessTokenOf(await mgr.credentialOf(v), nowMs);
|
|
653
|
+
return [v.name, token ? await fetchFn(token) : null];
|
|
654
|
+
})
|
|
655
|
+
);
|
|
656
|
+
return Object.fromEntries(entries);
|
|
657
|
+
}
|
|
658
|
+
function formatDuration(ms) {
|
|
659
|
+
const totalMin = Math.floor(Math.max(0, ms) / 6e4);
|
|
660
|
+
const d = Math.floor(totalMin / 1440);
|
|
661
|
+
const h = Math.floor(totalMin % 1440 / 60);
|
|
662
|
+
const m = totalMin % 60;
|
|
663
|
+
const parts = [d && `${d}d`, h && `${h}hr`, m && `${m}m`].filter(Boolean);
|
|
664
|
+
return parts.length ? parts.join(" ") : "<1m";
|
|
665
|
+
}
|
|
666
|
+
function formatUsageLines(u, nowMs) {
|
|
667
|
+
const pct = (w) => w ? `${w.pct.toFixed(1)}%` : "--";
|
|
668
|
+
const reset = (w) => w?.resetsAt != null ? formatDuration(w.resetsAt - nowMs) : "--";
|
|
669
|
+
return [
|
|
670
|
+
`5h: ${pct(u?.fiveHour)} Reset: ${reset(u?.fiveHour)}`,
|
|
671
|
+
`Weekly: ${pct(u?.weekly)} Weekly Reset: ${reset(u?.weekly)}`
|
|
672
|
+
];
|
|
673
|
+
}
|
|
674
|
+
|
|
525
675
|
// src/format.ts
|
|
526
676
|
var SYMBOLS = { active: "\u25CF", ok: "\u2713", err: "\u2717", warn: "!", off: "\u25CB" };
|
|
527
677
|
function formatRelative(iso, nowMs) {
|
|
@@ -562,13 +712,16 @@ function formatList(views, nowMs, colors, opts = {}) {
|
|
|
562
712
|
const idxW = String(ordered.length).length;
|
|
563
713
|
const emailW = Math.max(...ordered.map((v) => v.email.length));
|
|
564
714
|
const orgW = Math.min(ORG_MAX, Math.max(...ordered.map((v) => (v.organizationName ?? "\u2014").length)));
|
|
565
|
-
const
|
|
715
|
+
const usageIndent = " ".repeat(2 + idxW + 4);
|
|
716
|
+
const rows = ordered.flatMap((v, i) => {
|
|
566
717
|
const idx = colors.dim(pad(String(i + 1), idxW));
|
|
567
718
|
const mark = v.active ? colors.green(SYMBOLS.active) : " ";
|
|
568
719
|
const email = v.active ? colors.green(pad(v.email, emailW)) : pad(v.email, emailW);
|
|
569
720
|
const org = colors.dim(pad(truncate(v.organizationName ?? "\u2014", ORG_MAX), orgW));
|
|
570
721
|
const when = v.active ? colors.green("active") : colors.dim(formatRelative(v.savedAt, nowMs));
|
|
571
|
-
|
|
722
|
+
const row = ` ${idx} ${mark} ${email} ${org} ${when}`;
|
|
723
|
+
if (!opts.usage) return [row];
|
|
724
|
+
return [row, ...formatUsageLines(opts.usage[v.name] ?? null, nowMs).map((l) => colors.dim(usageIndent + l))];
|
|
572
725
|
});
|
|
573
726
|
const lines = [header, colors.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"), ...rows, ""];
|
|
574
727
|
if (opts.external) {
|
|
@@ -608,7 +761,15 @@ async function runDoctor(deps, colors = makeColors(false)) {
|
|
|
608
761
|
label: "credential backend",
|
|
609
762
|
detail: deps.store.isAvailable() ? "available" : "unavailable",
|
|
610
763
|
hint: "This OS/backend is not supported yet."
|
|
611
|
-
}
|
|
764
|
+
},
|
|
765
|
+
...deps.claudeConfigDir ? [
|
|
766
|
+
{
|
|
767
|
+
ok: false,
|
|
768
|
+
label: "CLAUDE_CONFIG_DIR",
|
|
769
|
+
detail: `${deps.claudeConfigDir} (not supported)`,
|
|
770
|
+
hint: "claudep only manages the default ~/.claude login; unset CLAUDE_CONFIG_DIR to use it."
|
|
771
|
+
}
|
|
772
|
+
] : []
|
|
612
773
|
]
|
|
613
774
|
});
|
|
614
775
|
const blob = await deps.store.readCanonical().catch(() => "");
|
|
@@ -671,14 +832,15 @@ function buildDoctorDeps(env) {
|
|
|
671
832
|
profile: new ProfileFile({ path: join3(home, ".claude.json") }),
|
|
672
833
|
meta: new MetadataStore({ path: join3(home, ".claude-profiles", "accounts.json") }),
|
|
673
834
|
config: new ConfigStore({ path: join3(home, ".claude-profiles", "config.json") }).load(),
|
|
674
|
-
platform
|
|
835
|
+
platform,
|
|
836
|
+
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || void 0
|
|
675
837
|
};
|
|
676
838
|
}
|
|
677
839
|
|
|
678
840
|
// package.json
|
|
679
841
|
var package_default = {
|
|
680
842
|
name: "claude-switcher-account",
|
|
681
|
-
version: "2.
|
|
843
|
+
version: "2.3.0",
|
|
682
844
|
description: "Switch between multiple Claude Code accounts from your terminal.",
|
|
683
845
|
keywords: [
|
|
684
846
|
"claude",
|
|
@@ -696,14 +858,6 @@ var package_default = {
|
|
|
696
858
|
"cli",
|
|
697
859
|
"developer-tools"
|
|
698
860
|
],
|
|
699
|
-
homepage: "https://github.com/khainguyenhm-itr/claude-switcher-account#readme",
|
|
700
|
-
repository: {
|
|
701
|
-
type: "git",
|
|
702
|
-
url: "git+https://github.com/khainguyenhm-itr/claude-switcher-account.git"
|
|
703
|
-
},
|
|
704
|
-
bugs: {
|
|
705
|
-
url: "https://github.com/khainguyenhm-itr/claude-switcher-account/issues"
|
|
706
|
-
},
|
|
707
861
|
license: "MIT",
|
|
708
862
|
author: "khainguyenhm-itr",
|
|
709
863
|
type: "module",
|
|
@@ -778,7 +932,7 @@ import { createInterface } from "readline/promises";
|
|
|
778
932
|
function pad2(s, width) {
|
|
779
933
|
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
780
934
|
}
|
|
781
|
-
function buildMenuChoices(accounts) {
|
|
935
|
+
function buildMenuChoices(accounts, usage, nowMs = Date.now()) {
|
|
782
936
|
const ordered = orderAccounts(accounts);
|
|
783
937
|
if (!ordered.length) return [];
|
|
784
938
|
const emailW = Math.max(...ordered.map((a) => a.email.length));
|
|
@@ -788,7 +942,8 @@ function buildMenuChoices(accounts) {
|
|
|
788
942
|
const org = a.organizationName ? ` ${a.organizationName}` : "";
|
|
789
943
|
return {
|
|
790
944
|
label: `${i + 1} ${mark} ${pad2(a.email, emailW)}${org} ${when}`,
|
|
791
|
-
action: { type: "switch", name: a.name }
|
|
945
|
+
action: { type: "switch", name: a.name },
|
|
946
|
+
...usage && { detail: formatUsageLines(usage[a.name] ?? null, nowMs) }
|
|
792
947
|
};
|
|
793
948
|
});
|
|
794
949
|
}
|
|
@@ -802,7 +957,8 @@ async function dispatchMenu(manager, action, io) {
|
|
|
802
957
|
case "switch":
|
|
803
958
|
try {
|
|
804
959
|
await manager.switchTo(action.name);
|
|
805
|
-
io.out(`${SYMBOLS.ok} Now using ${action.name}
|
|
960
|
+
io.out(`${SYMBOLS.ok} Now using ${action.name}
|
|
961
|
+
restart running claude sessions to apply`);
|
|
806
962
|
} catch (e) {
|
|
807
963
|
io.out(`${SYMBOLS.err} ${e.message}`);
|
|
808
964
|
}
|
|
@@ -840,7 +996,11 @@ async function runMenu(manager, io) {
|
|
|
840
996
|
} catch {
|
|
841
997
|
}
|
|
842
998
|
const accounts = await manager.listAccounts();
|
|
843
|
-
const
|
|
999
|
+
const usage = io.usage ? await io.usage(accounts) : void 0;
|
|
1000
|
+
const choice = await io.pick(
|
|
1001
|
+
`claudep \xB7 ${accounts.length} account${accounts.length === 1 ? "" : "s"}`,
|
|
1002
|
+
buildMenuChoices(accounts, usage)
|
|
1003
|
+
);
|
|
844
1004
|
if (!choice) return;
|
|
845
1005
|
await dispatchMenu(manager, choice.action, io);
|
|
846
1006
|
}
|
|
@@ -865,12 +1025,15 @@ function interactiveSelect(title, choices) {
|
|
|
865
1025
|
${colors.dim(` ${DIVIDER}`)}
|
|
866
1026
|
`);
|
|
867
1027
|
if (!choices.length) stdout.write(colors.dim(" (no saved accounts yet)\n"));
|
|
1028
|
+
const rowLines = choices.reduce((n, c) => n + 1 + (c.detail?.length ?? 0), 0);
|
|
868
1029
|
const render = (first) => {
|
|
869
|
-
if (!first) stdout.write(`\x1B[${
|
|
1030
|
+
if (!first) stdout.write(`\x1B[${rowLines + chrome}A`);
|
|
870
1031
|
for (let i = 0; i < choices.length; i++) {
|
|
871
1032
|
const active = i === selected;
|
|
872
1033
|
const line = active ? `${colors.cyan("\u276F")} ${choices[i].label}` : ` ${choices[i].label}`;
|
|
873
1034
|
stdout.write(`\x1B[2K\r${line}
|
|
1035
|
+
`);
|
|
1036
|
+
for (const d of choices[i].detail ?? []) stdout.write(`\x1B[2K\r${colors.dim(` ${d}`)}
|
|
874
1037
|
`);
|
|
875
1038
|
}
|
|
876
1039
|
stdout.write(`\x1B[2K\r${colors.dim(` ${DIVIDER}`)}
|
|
@@ -930,13 +1093,8 @@ async function runInteractiveMenu(manager, out) {
|
|
|
930
1093
|
rl.close();
|
|
931
1094
|
return a;
|
|
932
1095
|
},
|
|
933
|
-
confirm: async (q) => {
|
|
934
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
935
|
-
const a = (await rl.question(`${q} [y/N] `)).trim().toLowerCase();
|
|
936
|
-
rl.close();
|
|
937
|
-
return a === "y" || a === "yes";
|
|
938
|
-
},
|
|
939
1096
|
doctor: async () => (await runDoctor(buildDoctorDeps())).report,
|
|
1097
|
+
usage: (accounts) => loadUsage(manager, accounts, Date.now()),
|
|
940
1098
|
out
|
|
941
1099
|
};
|
|
942
1100
|
await runMenu(manager, io);
|
|
@@ -966,9 +1124,23 @@ function startDaemon(manager, opts = {}) {
|
|
|
966
1124
|
const interval = opts.intervalMs ?? 1e3;
|
|
967
1125
|
const log = opts.log ?? ((s) => console.log(s));
|
|
968
1126
|
const subscribe = opts.watch ?? defaultWatch;
|
|
1127
|
+
let running = false;
|
|
1128
|
+
let again = false;
|
|
1129
|
+
const run = async () => {
|
|
1130
|
+
if (running) {
|
|
1131
|
+
again = true;
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
running = true;
|
|
1135
|
+
do {
|
|
1136
|
+
again = false;
|
|
1137
|
+
await reconcileAndLog(manager, log);
|
|
1138
|
+
} while (again);
|
|
1139
|
+
running = false;
|
|
1140
|
+
};
|
|
969
1141
|
log(`[claudep] watching ${path} every ${interval}ms \u2014 instant capture on login/switch/logout`);
|
|
970
|
-
void
|
|
971
|
-
return subscribe(path, interval, () => void
|
|
1142
|
+
void run();
|
|
1143
|
+
return subscribe(path, interval, () => void run());
|
|
972
1144
|
}
|
|
973
1145
|
function defaultWatch(path, intervalMs, onChange) {
|
|
974
1146
|
const listener = () => onChange();
|
|
@@ -979,9 +1151,9 @@ function defaultWatch(path, intervalMs, onChange) {
|
|
|
979
1151
|
// src/autostart.ts
|
|
980
1152
|
import { execFile as execFile2 } from "child_process";
|
|
981
1153
|
import { promisify as promisify2 } from "util";
|
|
982
|
-
import { writeFileSync as writeFileSync4, mkdirSync as
|
|
1154
|
+
import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, rmSync as rmSync2, existsSync as existsSync3 } from "fs";
|
|
983
1155
|
import { homedir as homedir4 } from "os";
|
|
984
|
-
import { dirname as
|
|
1156
|
+
import { dirname as dirname5, join as join5 } from "path";
|
|
985
1157
|
var execFileP2 = promisify2(execFile2);
|
|
986
1158
|
var LABEL = "com.claude-switcher-account.daemon";
|
|
987
1159
|
var WIN_TASK = "claude-switcher-account";
|
|
@@ -992,6 +1164,8 @@ var LEGACY_UNIT = "claude-login-switcher.service";
|
|
|
992
1164
|
var defaultExec2 = async (file, args) => {
|
|
993
1165
|
await execFileP2(file, args);
|
|
994
1166
|
};
|
|
1167
|
+
var xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1168
|
+
var systemdArg = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
|
|
995
1169
|
function launchdPlist(p) {
|
|
996
1170
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
997
1171
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -1000,15 +1174,15 @@ function launchdPlist(p) {
|
|
|
1000
1174
|
<key>Label</key><string>${LABEL}</string>
|
|
1001
1175
|
<key>ProgramArguments</key>
|
|
1002
1176
|
<array>
|
|
1003
|
-
<string>${p.nodePath}</string>
|
|
1004
|
-
<string>${p.binPath}</string>
|
|
1177
|
+
<string>${xml(p.nodePath)}</string>
|
|
1178
|
+
<string>${xml(p.binPath)}</string>
|
|
1005
1179
|
<string>daemon</string>
|
|
1006
1180
|
<string>run</string>
|
|
1007
1181
|
</array>
|
|
1008
1182
|
<key>RunAtLoad</key><true/>
|
|
1009
1183
|
<key>KeepAlive</key><true/>
|
|
1010
|
-
<key>StandardOutPath</key><string>${p.logPath}</string>
|
|
1011
|
-
<key>StandardErrorPath</key><string>${p.logPath}</string>
|
|
1184
|
+
<key>StandardOutPath</key><string>${xml(p.logPath)}</string>
|
|
1185
|
+
<key>StandardErrorPath</key><string>${xml(p.logPath)}</string>
|
|
1012
1186
|
</dict>
|
|
1013
1187
|
</plist>
|
|
1014
1188
|
`;
|
|
@@ -1018,7 +1192,7 @@ function systemdUnit(p) {
|
|
|
1018
1192
|
Description=claude-switcher-account instant login watcher
|
|
1019
1193
|
|
|
1020
1194
|
[Service]
|
|
1021
|
-
ExecStart=${p.nodePath} ${p.binPath} daemon run
|
|
1195
|
+
ExecStart=${systemdArg(p.nodePath)} ${systemdArg(p.binPath)} daemon run
|
|
1022
1196
|
Restart=always
|
|
1023
1197
|
StandardOutput=append:${p.logPath}
|
|
1024
1198
|
StandardError=append:${p.logPath}
|
|
@@ -1048,7 +1222,7 @@ function autostartInstalled(platform, home = homedir4()) {
|
|
|
1048
1222
|
return false;
|
|
1049
1223
|
}
|
|
1050
1224
|
function writeFile(path, contents) {
|
|
1051
|
-
|
|
1225
|
+
mkdirSync5(dirname5(path), { recursive: true });
|
|
1052
1226
|
writeFileSync4(path, contents);
|
|
1053
1227
|
}
|
|
1054
1228
|
async function removeLegacyAutostart(platform, home = homedir4(), exec = defaultExec2) {
|
|
@@ -1085,7 +1259,14 @@ async function installAutostart(platform, paths, home = homedir4(), exec = defau
|
|
|
1085
1259
|
return `systemd user service installed at ${unit}`;
|
|
1086
1260
|
}
|
|
1087
1261
|
if (platform === "win32") {
|
|
1088
|
-
|
|
1262
|
+
try {
|
|
1263
|
+
await exec("schtasks", windowsCreateArgs(paths));
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`${e.message.trim()}
|
|
1267
|
+
Creating a logon task can require an Administrator terminal \u2014 rerun \`claudep daemon install\` from one.`
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1089
1270
|
return `Task Scheduler task '${WIN_TASK}' created (runs at logon)`;
|
|
1090
1271
|
}
|
|
1091
1272
|
throw new Error(`Autostart not supported on ${platform}.`);
|
|
@@ -1118,6 +1299,11 @@ async function defaultConfirm(question) {
|
|
|
1118
1299
|
rl.close();
|
|
1119
1300
|
return answer === "y" || answer === "yes";
|
|
1120
1301
|
}
|
|
1302
|
+
function handleFatal(err, errOut) {
|
|
1303
|
+
if (err instanceof CommanderError) return err.exitCode;
|
|
1304
|
+
errOut(` ${SYMBOLS.err} ${err instanceof Error ? err.message : String(err)}`);
|
|
1305
|
+
return 1;
|
|
1306
|
+
}
|
|
1121
1307
|
function buildProgram(deps = {}) {
|
|
1122
1308
|
const out = deps.out ?? ((s) => console.log(s));
|
|
1123
1309
|
const now = deps.now ?? (() => Date.now());
|
|
@@ -1186,7 +1372,8 @@ function buildProgram(deps = {}) {
|
|
|
1186
1372
|
}
|
|
1187
1373
|
const cur = mgr.current();
|
|
1188
1374
|
const external = !cur.saved && cur.label ? cur.label.email : void 0;
|
|
1189
|
-
|
|
1375
|
+
const usage = await loadUsage(mgr, views, now(), deps.fetchUsage ?? fetchUsage);
|
|
1376
|
+
out(formatList(views, now(), colors, { external, usage }));
|
|
1190
1377
|
});
|
|
1191
1378
|
program2.command("switch <target>").alias("use").description("Make an account the active login (by name or number)").action(async (target) => {
|
|
1192
1379
|
const mgr = manager();
|
|
@@ -1202,6 +1389,11 @@ function buildProgram(deps = {}) {
|
|
|
1202
1389
|
process.exitCode = 1;
|
|
1203
1390
|
return;
|
|
1204
1391
|
}
|
|
1392
|
+
if (!(await mgr.listAccounts()).some((a) => a.name === name)) {
|
|
1393
|
+
out(err(`Saved account '${name}' not found.`) + "\n" + note(listHint));
|
|
1394
|
+
process.exitCode = 1;
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1205
1397
|
if (!opts.yes) {
|
|
1206
1398
|
const yes = await confirm(`Remove ${name}? This deletes its stored credential.`);
|
|
1207
1399
|
if (!yes) {
|
|
@@ -1272,6 +1464,5 @@ var program = buildProgram();
|
|
|
1272
1464
|
try {
|
|
1273
1465
|
await program.parseAsync(process.argv);
|
|
1274
1466
|
} catch (err) {
|
|
1275
|
-
|
|
1276
|
-
process.exit(code);
|
|
1467
|
+
process.exit(handleFatal(err, (s) => console.error(s)));
|
|
1277
1468
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-switcher-account",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Switch between multiple Claude Code accounts from your terminal.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -18,14 +18,6 @@
|
|
|
18
18
|
"cli",
|
|
19
19
|
"developer-tools"
|
|
20
20
|
],
|
|
21
|
-
"homepage": "https://github.com/khainguyenhm-itr/claude-switcher-account#readme",
|
|
22
|
-
"repository": {
|
|
23
|
-
"type": "git",
|
|
24
|
-
"url": "git+https://github.com/khainguyenhm-itr/claude-switcher-account.git"
|
|
25
|
-
},
|
|
26
|
-
"bugs": {
|
|
27
|
-
"url": "https://github.com/khainguyenhm-itr/claude-switcher-account/issues"
|
|
28
|
-
},
|
|
29
21
|
"license": "MIT",
|
|
30
22
|
"author": "khainguyenhm-itr",
|
|
31
23
|
"type": "module",
|