@modelprofile.com/authswitch 2.3.0 → 3.1.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.d.ts +28 -6
- package/dist_ts/accounts.js +40 -7
- package/dist_ts/classes.accountlist.js +4 -3
- package/dist_ts/classes.claudecodeharness.d.ts +4 -4
- package/dist_ts/classes.claudecodeharness.js +5 -5
- package/dist_ts/classes.claudestatus.js +94 -18
- package/dist_ts/classes.cli.d.ts +12 -0
- package/dist_ts/classes.cli.js +98 -9
- package/dist_ts/classes.credentialstore.d.ts +20 -7
- package/dist_ts/classes.credentialstore.js +36 -23
- package/dist_ts/classes.fileharness.d.ts +23 -3
- package/dist_ts/classes.fileharness.js +102 -16
- package/dist_ts/classes.harnessprocesses.d.ts +62 -0
- package/dist_ts/classes.harnessprocesses.js +198 -0
- package/dist_ts/classes.limits.js +45 -19
- package/dist_ts/classes.opencodeharness.d.ts +4 -5
- package/dist_ts/classes.opencodeharness.js +5 -5
- package/dist_ts/classes.operations.d.ts +9 -1
- package/dist_ts/classes.operations.js +4 -2
- package/dist_ts/classes.tui.js +51 -5
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.harness.d.ts +79 -8
- package/dist_ts/interfaces.list.d.ts +20 -1
- package/package.json +2 -2
- package/readme.md +137 -31
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +44 -10
- package/ts/classes.accountlist.ts +3 -2
- package/ts/classes.claudecodeharness.ts +7 -6
- package/ts/classes.claudestatus.ts +102 -14
- package/ts/classes.cli.ts +77 -9
- package/ts/classes.credentialstore.ts +41 -18
- package/ts/classes.fileharness.ts +80 -16
- package/ts/classes.harnessprocesses.ts +225 -0
- package/ts/classes.limits.ts +49 -20
- package/ts/classes.opencodeharness.ts +7 -7
- package/ts/classes.operations.ts +10 -1
- package/ts/classes.tui.ts +39 -7
- package/ts/index.ts +1 -0
- package/ts/interfaces.harness.ts +82 -8
- package/ts/interfaces.list.ts +21 -1
package/readme.md
CHANGED
|
@@ -58,6 +58,8 @@ authswitch active # which account each provider is using right n
|
|
|
58
58
|
authswitch codex stash # save the active credential under its account email
|
|
59
59
|
authswitch codex list # Codex accounts, including an unsaved active login, with live status
|
|
60
60
|
authswitch codex use [email] # activate one; prompts when no email is given
|
|
61
|
+
authswitch claude use [email] --stop # stop running Claude Code instances first
|
|
62
|
+
authswitch claude use [email] --keep-running # switch without stopping anything
|
|
61
63
|
authswitch codex preuse <email> # send the default prompt without activating the account
|
|
62
64
|
authswitch codex current # print the account currently in use
|
|
63
65
|
authswitch codex drop <email> # forget a stash
|
|
@@ -79,16 +81,53 @@ the management TUI and human/JSON lists. With an AGL installation that supports
|
|
|
79
81
|
authswitch coordination, the CLI delegates credential changes to AGL. AGL owns
|
|
80
82
|
stopping and restarting its OpenCode runtime. Idle changes need no additional
|
|
81
83
|
restart confirmation; active work requires consent to wait for it to finish.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
Every operation, including `use` and `stash`, now works while OpenCode or Claude
|
|
85
|
+
Code is running: the credential file is written atomically, the outgoing login is
|
|
86
|
+
re-saved from disk first, and a write that races a native rewrite is detected and
|
|
87
|
+
refused rather than half-applied. Status lookups remain read-only. `preuse`
|
|
88
|
+
remains a Codex capability.
|
|
89
|
+
|
|
90
|
+
#### Running instances during a switch
|
|
91
|
+
|
|
92
|
+
A harness that keeps running through a switch still costs something: it holds the
|
|
93
|
+
previous login in memory and can write it back at its next OAuth token refresh.
|
|
94
|
+
So before `use` and before `stash` without `--keep`, authswitch lists that
|
|
95
|
+
harness's own running instances -- pid, start time and command line -- and offers
|
|
96
|
+
to stop them:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
2 Claude Code process(es) are running:
|
|
100
|
+
pid 4821 started 2026-09-16 14:02 UTC claude --session-id 9f2c
|
|
101
|
+
pid 5533 started 2026-09-16 14:02 UTC claude agents
|
|
102
|
+
? Stop these 2 Claude Code process(es) first? (y/N)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Answering yes sends `SIGTERM` to exactly those pids and waits up to ten seconds;
|
|
106
|
+
an instance that survives is reported by pid and the switch continues. Three flags
|
|
107
|
+
decide the same thing without a prompt:
|
|
108
|
+
|
|
109
|
+
| Flag | Effect |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `--stop` | stop the listed instances gracefully, then switch |
|
|
112
|
+
| `--force-stop` | the same, then `SIGKILL` the ones that ignored `SIGTERM` |
|
|
113
|
+
| `--keep-running` | switch without stopping anything |
|
|
114
|
+
|
|
115
|
+
Without a terminal and without a flag, nothing is ever signalled: the instances are
|
|
116
|
+
listed, the switch proceeds, and the command says which flag would have changed that.
|
|
117
|
+
`SIGKILL` is only ever sent for `--force-stop`. Only pids authswitch enumerated for
|
|
118
|
+
the current user in that operation are signalled -- never a name or pattern match,
|
|
119
|
+
never another user's process -- and a pid is re-verified against a fresh listing
|
|
120
|
+
before a forced kill, so a recycled pid cannot inherit it. The session that is
|
|
121
|
+
running the command is listed but never stopped: stopping it would kill the switch.
|
|
122
|
+
On Windows the instances are listed but not stopped, because its process list does
|
|
123
|
+
not identify their owner. `stash --keep` needs none of this: it writes no native
|
|
124
|
+
file and is guarded by proving the native sources unchanged around the read.
|
|
125
|
+
|
|
126
|
+
After a switch that left instances running, the command says so explicitly, because
|
|
127
|
+
one of them refreshing its token can put the previous account back. A backend
|
|
128
|
+
activation through `AuthSwitchService` gets that same caveat in its outcome, but no
|
|
129
|
+
listing and no offer: a host has no terminal to consent with, and a supervisor that
|
|
130
|
+
completed the mutation itself owns its own runtime's lifecycle.
|
|
92
131
|
|
|
93
132
|
`authswitch codex login` and `authswitch opencode login openai` use the shared
|
|
94
133
|
OpenAI device login flow. They display a verification link and code, then save
|
|
@@ -149,7 +188,21 @@ the local file login, so launch-time flags, managed policies and credentials sup
|
|
|
149
188
|
by embedding applications remain under Claude Code's control.
|
|
150
189
|
|
|
151
190
|
Claude's direct OAuth profile and usage lookups report the plan, weekly and five-hour
|
|
152
|
-
usage, model-
|
|
191
|
+
usage, per-model and per-surface windows, and available extra-usage budget information.
|
|
192
|
+
The usage response's `limits[]` rows are the authoritative window list: which
|
|
193
|
+
meters apply, their scope, the service's `severity` (`normal`, `warning`,
|
|
194
|
+
`critical`) and its headline pick are Claude's, so a new per-model weekly limit
|
|
195
|
+
such as **Fable weekly** appears without an authswitch release. `list --json` keeps
|
|
196
|
+
the rows in the service's order; the tables and `limits --json` order them as
|
|
197
|
+
described under **Condensed views**. Rows are classified on their
|
|
198
|
+
kind and reset group exactly as sent. Labels combine the service's model or surface
|
|
199
|
+
name with the window period, and such a scoped row is treated as a feature limit.
|
|
200
|
+
Rows in a reset group authswitch cannot represent (anything but the five-hour
|
|
201
|
+
session and the weekly window) are not shown; one **Unsupported limits**
|
|
202
|
+
availability fact names them. A malformed row, or a `limits` value that is not a
|
|
203
|
+
list, makes the whole usage lookup unavailable rather than partially guessed.
|
|
204
|
+
Responses without `limits[]` rows fall back to the older keyed weekly, five-hour
|
|
205
|
+
and model windows.
|
|
153
206
|
These endpoints require the `user:profile` scope and may reject expired or restricted
|
|
154
207
|
logins. A null reset timestamp is shown as **Not scheduled**. Billing renewal and
|
|
155
208
|
cancellation dates and earned reset credits are unavailable from these endpoints;
|
|
@@ -201,7 +254,8 @@ an uncertain request. `--json` remains a list-only option.
|
|
|
201
254
|
### Management dashboard
|
|
202
255
|
|
|
203
256
|
`authswitch --tui` opens a resizable account table with subscription, usage and
|
|
204
|
-
reset availability, usage bars
|
|
257
|
+
reset availability, usage bars for the lead window and the next general window,
|
|
258
|
+
scrollable account details and an activity log.
|
|
205
259
|
With multiple registered adapters, a harness selector switches the view; account
|
|
206
260
|
selection and mutations always belong to the displayed harness. Status loads
|
|
207
261
|
incrementally through each account's read-only API, without activating it.
|
|
@@ -283,17 +337,25 @@ output to Codex. Each section includes saved accounts and an identifiable active
|
|
|
283
337
|
login even if it has never been saved. `*` marks the active account, and the saved
|
|
284
338
|
marker checks the credential itself rather than just the existence of metadata.
|
|
285
339
|
|
|
286
|
-
The overview compares login state, plan provenance,
|
|
287
|
-
|
|
340
|
+
The overview compares login state, plan provenance, lead-window usage (the
|
|
341
|
+
provider's headline window, otherwise weekly), earned resets and that window's
|
|
342
|
+
reset countdown. Countdowns use days, hours and minutes, such as
|
|
288
343
|
`7d 5h 6min`;
|
|
289
344
|
`<1min` means less than a minute remains and `due` means the reported deadline has
|
|
290
345
|
passed, without claiming the service has refreshed the quota. All countdowns share
|
|
291
346
|
the same snapshot time.
|
|
292
|
-
Usage and countdown refer to the same
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
347
|
+
Usage and countdown refer to the same window. When the provider picks a headline
|
|
348
|
+
window (Claude does, for example its Fable weekly limit), that window leads.
|
|
349
|
+
Otherwise the
|
|
350
|
+
general account window leads, preferring weekly; if no weekly window is reported,
|
|
351
|
+
the longest general window is shown with its actual duration. Other exhausted
|
|
352
|
+
general windows retain their own reset warning. A general window is named by its
|
|
353
|
+
period (`Weekly`, `5h`) unless another shown general window has the same period; a
|
|
354
|
+
feature limit, or a general window sharing its period, is named by its label
|
|
355
|
+
(`Fable weekly: 100% used`, `Claude weekly (weekly_oauth) exhausted`). Feature
|
|
356
|
+
limits such as Spark and code review have a separate table and determine the
|
|
357
|
+
summary only when the provider picks one as its headline. The TUI uses the same
|
|
358
|
+
selection and names.
|
|
297
359
|
Window durations come from the provider, not from the plan name or
|
|
298
360
|
primary/secondary position; a Pro account with only a general weekly window gets
|
|
299
361
|
no invented 5-hour limit.
|
|
@@ -319,9 +381,9 @@ Account limits
|
|
|
319
381
|
┌──────────────────────┬─────────────────────────┬───────────────────────┬────────┬───────────────┐
|
|
320
382
|
│ Provider │ Account │ Limit type │ Used % │ Resets in │
|
|
321
383
|
├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
|
|
322
|
-
│ Claude Code │ phil@example.com │ Claude weekly │
|
|
323
|
-
│ Claude Code │ phil@example.com │ Claude five-hour │
|
|
324
|
-
│ Claude Code │ phil@example.com │
|
|
384
|
+
│ Claude Code │ phil@example.com │ Claude weekly │ 75% │ 4d 3h │
|
|
385
|
+
│ Claude Code │ phil@example.com │ Claude five-hour │ 12% │ 1h 2m │
|
|
386
|
+
│ Claude Code │ phil@example.com │ Fable weekly │ 88% │ 2d 5h │
|
|
325
387
|
│ Codex │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │
|
|
326
388
|
│ Codex │ alice@example.com │ Codex primary │ 37% │ 2h 13m │
|
|
327
389
|
│ Codex │ alice@example.com │ Code review secondary │ 100% │ 3h 59m │
|
|
@@ -343,9 +405,18 @@ account never blanks another's numbers. `not scheduled` means the provider repor
|
|
|
343
405
|
usage without a reset deadline; it is not the same as `n/a`.
|
|
344
406
|
|
|
345
407
|
Limit types are the provider's own window names, ordered general-before-feature and
|
|
346
|
-
weekly-first within an account.
|
|
408
|
+
weekly-first within an account. Claude's per-model and per-surface limits carry the
|
|
409
|
+
service's name (`Fable weekly`); an unscoped Claude meter other than the five-hour
|
|
410
|
+
session and the weekly total keeps its kind in parentheses
|
|
411
|
+
(`Claude weekly (weekly_oauth)`). The provider column names the harness, qualified by
|
|
347
412
|
the credential slot when a harness owns several (`OpenCode / openai`).
|
|
348
413
|
|
|
414
|
+
**Used %** is coloured by the provider's own reading when it reports one: orange for
|
|
415
|
+
`warning`, red for `critical`, uncoloured for `normal`. Without a reading, `n/a` is
|
|
416
|
+
orange and an exhausted window (100% or more) is red. Colour is omitted when
|
|
417
|
+
`NO_COLOR` is set, output is not a terminal, or the terminal is narrower than 40
|
|
418
|
+
columns.
|
|
419
|
+
|
|
349
420
|
`authswitch active` answers "what am I logged in as" for every harness at once:
|
|
350
421
|
|
|
351
422
|
```
|
|
@@ -369,6 +440,22 @@ exist, `none` means neither. **Saved** is how long ago `authswitch` last saved t
|
|
|
369
440
|
login, or `not saved` — which is also the warning that a switch could not bring it
|
|
370
441
|
back. A harness with no active login keeps its row and explains itself in a note.
|
|
371
442
|
|
|
443
|
+
**Source** also answers whether the file still holds what the last switch wrote.
|
|
444
|
+
When authswitch switches a login it records that account and the hash of the
|
|
445
|
+
credential it wrote, in its own store, never in the harness file. If a later read
|
|
446
|
+
finds a different account in that slot, the row's source reads
|
|
447
|
+
`credential file (changed)` and a note explains it:
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
note — Claude Code · bob@example.test: The credential file changed since the last
|
|
451
|
+
switch (likely a running instance refreshed the previous login). It now holds
|
|
452
|
+
bob@example.test, which is saved. Run authswitch claude use alice@example.test again.
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`authswitch <harness> current` prints the same sentence. The same account with a
|
|
456
|
+
rotated token is that account refreshing itself and is not reported; re-running the
|
|
457
|
+
switch re-arms the check against the file's current contents.
|
|
458
|
+
|
|
372
459
|
Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
|
|
373
460
|
Both are read-only: they never activate, save or clear a login, they need no harness
|
|
374
461
|
stopped, and every provider lookup is bounded by the same 10-second timeout and
|
|
@@ -385,19 +472,27 @@ arguments to these commands exit with 2.
|
|
|
385
472
|
`limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
|
|
386
473
|
the same rows as the tables, plus the machine-readable fields the tables condense —
|
|
387
474
|
`accountId`, `slotId`, `scope`, `windowSeconds`, the ISO `resetAt` beside the human
|
|
388
|
-
`resetsIn`, the
|
|
389
|
-
`
|
|
475
|
+
`resetsIn`, the provider's `severity` (`normal`, `warning`, `critical`, or null when
|
|
476
|
+
it gave none) and `headline` (true for the window the provider picks for a
|
|
477
|
+
single-value summary), the ISO `savedAt` beside the human `savedAgo`, the per-row
|
|
478
|
+
`unavailableReason` that the footnotes summarise, and `drift`, which carries the
|
|
479
|
+
expected and current account ids, their labels, whether the current one is saved,
|
|
480
|
+
the ISO `switchedAt` of the switch that was undone, and the same sentence as `reason`. Both carry `schemaVersion: 1`, the
|
|
390
481
|
shared `generatedAt` snapshot every countdown is relative to, and `complete`, which
|
|
391
482
|
is false when any account or harness could not be read.
|
|
392
483
|
|
|
393
484
|
The exported `IAccountList` contract contains:
|
|
394
485
|
|
|
395
486
|
- `schemaVersion: 2`, `generatedAt` (ISO UTC), and `complete`.
|
|
396
|
-
- `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`,
|
|
397
|
-
|
|
487
|
+
- `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`,
|
|
488
|
+
`credentialDrift` (slots whose file no longer holds the account the last switch
|
|
489
|
+
wrote), and harness-level `problems` (distinguishing failed discovery from an
|
|
490
|
+
empty list).
|
|
398
491
|
- Each account's opaque `id`, `label`, `isActive`, verified `isStashed`, `savedAt`,
|
|
399
492
|
`details`, and `status` containing all labelled `facts`, `problems`, and optional
|
|
400
|
-
typed `summary` fields. Missing fields remain omitted, not replaced by zero
|
|
493
|
+
typed `summary` fields. Missing fields remain omitted, not replaced by zero;
|
|
494
|
+
`summary.usageWindows[]` carries `severity` and `headline` only when the provider
|
|
495
|
+
reported them.
|
|
401
496
|
|
|
402
497
|
JSON retains full fact strings and absolute ISO reset/expiry timestamps. It exports
|
|
403
498
|
the credential-free adapter contract, never credentials, raw HTTP responses or
|
|
@@ -502,11 +597,18 @@ explicit zeroes remain zero. A successful reset-detail lookup supplies the summa
|
|
|
502
597
|
count; if it fails, the limits response can still supply availability.
|
|
503
598
|
|
|
504
599
|
Each usage window may set `scope: 'account' | 'feature'` (default `account`).
|
|
505
|
-
Adapters mark model-specific or feature-specific quotas as `feature
|
|
506
|
-
|
|
507
|
-
|
|
600
|
+
Adapters mark model-specific or feature-specific quotas as `feature`; those quotas
|
|
601
|
+
appear in the feature tables, JSON and details and lead the account summary only
|
|
602
|
+
when the provider marks one as its headline (see below). Weekly windows are identified by `durationSeconds: 604800`;
|
|
508
603
|
neither window labels nor plan names are parsed to infer limits.
|
|
509
604
|
|
|
605
|
+
A window may also carry the provider's own reading: `severity?: TUsageSeverity`
|
|
606
|
+
(`'normal' | 'warning' | 'critical'`) and `headline?: true` for the window the
|
|
607
|
+
provider picks for a single-value summary. Adapters set them only when the provider
|
|
608
|
+
reports them, never from local thresholds. A headline window leads the account
|
|
609
|
+
summary whatever its scope; the first one in the adapter's order wins if several are
|
|
610
|
+
marked.
|
|
611
|
+
|
|
510
612
|
Optional `summary.billing` contains provider-reported `hasActiveSubscription`,
|
|
511
613
|
`autoRenew`, `renewsAt`, `cancelsAt`, and `expiresAt`, independent of the plan name.
|
|
512
614
|
Dates are ISO UTC and omitted
|
|
@@ -529,6 +631,8 @@ scripts must qualify them. Account references never resolve across harnesses.
|
|
|
529
631
|
| What | Where |
|
|
530
632
|
| --- | --- |
|
|
531
633
|
| Stashed credentials | `~/.authswitch/codex/<email>/auth.json` (mode 0600, in a 0700 directory) |
|
|
634
|
+
| Saved OpenCode/Claude logins | `~/.authswitch/<harness>/<id>.json` (mode 0600, in a 0700 directory) |
|
|
635
|
+
| Last switch per credential slot | `~/.authswitch/<harness>/switches.json` (account ids, credential hashes and timestamps; no credentials) |
|
|
532
636
|
| Stash metadata and enrollments | `~/.authswitch/codex/<email>/stash.json` |
|
|
533
637
|
| Codex' active credential | `$CODEX_HOME/auth.json`, default `~/.codex/auth.json` |
|
|
534
638
|
| Codex' remote-control enrollments | the `remote_control_enrollments` table in `$CODEX_HOME/state_<n>.sqlite` |
|
|
@@ -543,6 +647,8 @@ Account identity comes from the `id_token` inside the credential — the email c
|
|
|
543
647
|
|
|
544
648
|
**Other Codex clients keep running.** Stopping the managed app-server does not stop an editor extension that spawned its own. Close it, or expect it to keep using the credential it already loaded.
|
|
545
649
|
|
|
650
|
+
**A running OpenCode or Claude Code can undo a switch.** It holds the previous login in memory and rewrites the credential file at its next token refresh. Authswitch lists those instances, offers to stop them, warns when any keep running, and detects the overwrite afterwards — but it cannot prevent it while they run. Restart them after a switch.
|
|
651
|
+
|
|
546
652
|
**The Codex stash is keyed by email.** Two Codex workspaces with the same email
|
|
547
653
|
cannot both be saved under that key. Their account IDs are distinguished during
|
|
548
654
|
listing and identity checks, and a collision refuses to overwrite either login.
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/accounts.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessState,
|
|
1
|
+
import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessUsageWindow } from './interfaces.harness.js';
|
|
2
2
|
import { plainText } from './formatting.js';
|
|
3
3
|
|
|
4
4
|
export interface IAccountRow {
|
|
@@ -41,31 +41,65 @@ export const compactUntil = (timestampArg: string | null, nowArg: number, unavai
|
|
|
41
41
|
if (remaining <= 0) return 'due';
|
|
42
42
|
return remaining < 60000 ? '<1m' : twoUnits(remaining);
|
|
43
43
|
};
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The same two-unit form for a past timestamp, for "saved 3d 4h ago".
|
|
46
|
+
*
|
|
47
|
+
* The result is always a bare duration or the caller's unavailable marker, never a phrase: callers
|
|
48
|
+
* suffix it ("... ago"), and a phrase would read as "just now ago". A span under a minute is `<1m`,
|
|
49
|
+
* exactly as `compactUntil` reports one.
|
|
50
|
+
*/
|
|
45
51
|
export const compactSince = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
|
|
46
52
|
if (timestampArg === null) return unavailableArg;
|
|
47
53
|
const elapsed = nowArg - Date.parse(timestampArg);
|
|
48
54
|
if (!Number.isFinite(elapsed)) return unavailableArg;
|
|
49
|
-
return elapsed < 60000 ? '
|
|
55
|
+
return elapsed < 60000 ? '<1m' : twoUnits(elapsed);
|
|
50
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* One sentence for a credential file that no longer holds the account the last switch wrote.
|
|
59
|
+
*
|
|
60
|
+
* It names what the file holds now and how to put the intended account back, because the usual
|
|
61
|
+
* cause -- a harness instance that kept running through the switch and then refreshed its
|
|
62
|
+
* in-memory login -- leaves no other trace.
|
|
63
|
+
*/
|
|
64
|
+
export const credentialDriftNote = (harnessIdArg: string, driftArg: IHarnessCredentialDrift): string =>
|
|
65
|
+
'The credential file changed since the last switch (likely a running instance refreshed the previous login). '
|
|
66
|
+
+ (driftArg.currentLabel === null ? 'No login is active in that slot now. '
|
|
67
|
+
: `It now holds ${plainText(driftArg.currentLabel)}${driftArg.currentIsSaved ? ', which is saved' : ', which is not saved'}. `)
|
|
68
|
+
+ (driftArg.expectedLabel === null ? 'Switch to the intended account again.'
|
|
69
|
+
: `Run authswitch ${plainText(harnessIdArg)} use ${plainText(driftArg.expectedLabel)} again.`);
|
|
70
|
+
|
|
51
71
|
export const accountPlan = (rowArg: IAccountRow): string => {
|
|
52
72
|
const subscription = rowArg.status?.summary?.subscription;
|
|
53
73
|
return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
|
|
54
74
|
};
|
|
55
|
-
type TUsageWindow = NonNullable<IHarnessStatusSummary['usageWindows']>[number];
|
|
56
75
|
type TAccountStatusRow = Pick<IAccountRow, 'status'>;
|
|
57
|
-
|
|
76
|
+
/**
|
|
77
|
+
* How a single-value summary names a window among the windows it shows. An account window is named by
|
|
78
|
+
* its period while it is the only shown account window of that period; a feature window, or an account
|
|
79
|
+
* window sharing its period with another, is named by its own label, so no meter reads as another's.
|
|
80
|
+
*/
|
|
81
|
+
export const usageWindowName = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[]): string =>
|
|
82
|
+
windowArg.scope === 'feature' || shownArg.some(other => other !== windowArg && other.scope !== 'feature' && other.durationSeconds === windowArg.durationSeconds)
|
|
83
|
+
? plainText(windowArg.label)
|
|
84
|
+
: windowArg.durationSeconds === 604800 ? 'Weekly' : duration(windowArg.durationSeconds);
|
|
58
85
|
/** Order by scope and actual duration: a provider's primary slot need not be five hours. */
|
|
59
|
-
export const orderedUsageWindows = (windowsArg: readonly
|
|
86
|
+
export const orderedUsageWindows = (windowsArg: readonly IHarnessUsageWindow[] = []): IHarnessUsageWindow[] => [...windowsArg].sort((left, right) =>
|
|
60
87
|
Number(left.scope === 'feature') - Number(right.scope === 'feature') ||
|
|
61
88
|
Number(left.durationSeconds !== 604800) - Number(right.durationSeconds !== 604800) || right.durationSeconds - left.durationSeconds);
|
|
62
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The windows a single-value summary shows, lead window first: the provider's headline pick, whatever
|
|
91
|
+
* its scope, then the account windows in display order. Other feature quotas never lead a summary.
|
|
92
|
+
*/
|
|
93
|
+
export const accountUsageWindows = (rowArg: TAccountStatusRow): IHarnessUsageWindow[] | undefined => {
|
|
63
94
|
const windows = rowArg.status?.summary?.usageWindows;
|
|
64
|
-
|
|
95
|
+
if (windows === undefined) return undefined;
|
|
96
|
+
const headline = windows.find(window => window.headline === true);
|
|
97
|
+
const accountWindows = orderedUsageWindows(windows.filter(window => window !== headline && window.scope !== 'feature'));
|
|
98
|
+
return headline === undefined ? accountWindows : [headline, ...accountWindows];
|
|
65
99
|
};
|
|
66
100
|
export const accountUsage = (rowArg: TAccountStatusRow): string => {
|
|
67
101
|
const windows = accountUsageWindows(rowArg);
|
|
68
|
-
return windows?.length ? `${
|
|
102
|
+
return windows?.length ? `${usageWindowName(windows[0], windows)}: ${windows[0].usedPercent}% used` : windows ? 'None reported' : rowArg.status ? 'Unavailable' : 'Loading';
|
|
69
103
|
};
|
|
70
104
|
export const accountResets = (rowArg: IAccountRow): string => rowArg.status?.summary?.resets === undefined ? rowArg.status ? 'Unavailable' : 'Loading' : String(rowArg.status.summary.resets.available);
|
|
71
105
|
export const accountNextReset = (rowArg: TAccountStatusRow, nowArg = Date.now()): string => {
|
|
@@ -76,7 +110,7 @@ export const accountQuotaSummary = (rowArg: TAccountStatusRow, nowArg: number):
|
|
|
76
110
|
const windows = accountUsageWindows(rowArg);
|
|
77
111
|
return [accountUsage(rowArg), ...(windows?.length ? [
|
|
78
112
|
`Reset in: ${accountNextReset(rowArg, nowArg)}`,
|
|
79
|
-
...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${
|
|
113
|
+
...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usageWindowName(window, windows)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
|
|
80
114
|
] : [])].join('\n');
|
|
81
115
|
};
|
|
82
116
|
export const accountState = (rowArg: IAccountRow): string => `${rowArg.account.isActive ? '* active, ' : ''}${rowArg.account.isStashed ? 'saved' : 'not saved'}`;
|
|
@@ -16,7 +16,7 @@ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus =
|
|
|
16
16
|
...(summary === undefined ? {} : { summary: {
|
|
17
17
|
...(summary.subscription === undefined ? {} : { subscription: { plan: summary.subscription.plan, source: summary.subscription.source } }),
|
|
18
18
|
...(summary.billing === undefined ? {} : { billing: { hasActiveSubscription: summary.billing.hasActiveSubscription, autoRenew: summary.billing.autoRenew, renewsAt: summary.billing.renewsAt, cancelsAt: summary.billing.cancelsAt, expiresAt: summary.billing.expiresAt } }),
|
|
19
|
-
...(summary.usageWindows === undefined ? {} : { usageWindows: summary.usageWindows.map(({ label, scope, durationSeconds, usedPercent, resetAt }) => ({ label, scope, durationSeconds, usedPercent, resetAt })) }),
|
|
19
|
+
...(summary.usageWindows === undefined ? {} : { usageWindows: summary.usageWindows.map(({ label, scope, durationSeconds, usedPercent, resetAt, severity, headline }) => ({ label, scope, durationSeconds, usedPercent, resetAt, severity, headline })) }),
|
|
20
20
|
...(summary.resets === undefined ? {} : { resets: {
|
|
21
21
|
available: summary.resets.available,
|
|
22
22
|
...(summary.resets.details === undefined ? {} : { details: summary.resets.details.map(({ status, kind, expiresAt }) => ({ status, kind, expiresAt })) }),
|
|
@@ -28,10 +28,11 @@ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus =
|
|
|
28
28
|
export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAccountList> => {
|
|
29
29
|
const harnesses: IHarnessAccountList[] = [];
|
|
30
30
|
for (const harness of harnessesArg) {
|
|
31
|
-
const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], problems: [] };
|
|
31
|
+
const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], credentialDrift: [], problems: [] };
|
|
32
32
|
try {
|
|
33
33
|
const { state, rows } = await readAccountRows(harness);
|
|
34
34
|
result.saveUnavailableReason = state.saveUnavailableReason;
|
|
35
|
+
result.credentialDrift = state.credentialDrift ?? [];
|
|
35
36
|
result.accounts = rows.map(({ account, status }) => ({
|
|
36
37
|
id: account.id, label: account.label, isActive: account.isActive, isStashed: account.isStashed,
|
|
37
38
|
savedAt: account.savedAt, details: [...account.details], ...(account.slotId === undefined ? {} : { slotId: account.slotId }),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
|
|
3
|
-
import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw
|
|
3
|
+
import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
|
|
4
|
+
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
4
5
|
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
5
6
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
6
|
-
import type { IHarnessAccountStatus } from './interfaces.harness.js';
|
|
7
|
+
import type { IHarnessAccountStatus, IHarnessProcessControl } from './interfaces.harness.js';
|
|
7
8
|
|
|
8
9
|
export interface IClaudeCodeHarnessOptions {
|
|
9
10
|
configDir?: string;
|
|
@@ -12,7 +13,8 @@ export interface IClaudeCodeHarnessOptions {
|
|
|
12
13
|
env?: NodeJS.ProcessEnv;
|
|
13
14
|
platform?: NodeJS.Platform;
|
|
14
15
|
fetch?: typeof fetch;
|
|
15
|
-
|
|
16
|
+
/** Running-instance inspection, injectable for isolated installations and tests. */
|
|
17
|
+
processes?: IHarnessProcessControl;
|
|
16
18
|
/** Local settings sources to inspect for credential overrides, in increasing precedence. */
|
|
17
19
|
settingsFiles?: string[];
|
|
18
20
|
}
|
|
@@ -30,7 +32,7 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
30
32
|
private readonly env: NodeJS.ProcessEnv;
|
|
31
33
|
private readonly platform: NodeJS.Platform;
|
|
32
34
|
private readonly status: ClaudeAccountStatus;
|
|
33
|
-
|
|
35
|
+
public readonly processes: IHarnessProcessControl;
|
|
34
36
|
private readonly settingsFiles: string[];
|
|
35
37
|
|
|
36
38
|
constructor(options: IClaudeCodeHarnessOptions = {}) {
|
|
@@ -43,11 +45,10 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
43
45
|
this.store = new CredentialStore(this.id, options.stashRoot);
|
|
44
46
|
this.platform = options.platform ?? process.platform;
|
|
45
47
|
this.status = new ClaudeAccountStatus(options.fetch);
|
|
46
|
-
this.
|
|
48
|
+
this.processes = options.processes ?? new HarnessProcesses('claude', { platform: this.platform });
|
|
47
49
|
this.settingsFiles = options.settingsFiles ?? [plugins.path.join(dir, 'settings.json'),
|
|
48
50
|
plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
|
|
49
51
|
}
|
|
50
|
-
protected beforeMutation(): void { this.checkStopped(); }
|
|
51
52
|
/** One record holds the credential and its matching metadata, so both files must be proven stable. */
|
|
52
53
|
protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file), readCredentialRaw(this.configFile)]; }
|
|
53
54
|
protected inspect(credential: Record<string, unknown>, slotId: string): IFileAccount {
|
|
@@ -1,8 +1,107 @@
|
|
|
1
1
|
import { credentialRecord, credentialText } from './classes.credentialstore.js';
|
|
2
|
-
import
|
|
2
|
+
import { plainText } from './formatting.js';
|
|
3
|
+
import type { IHarnessAccountStatus, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
|
|
3
4
|
|
|
4
5
|
class ClaudeStatusError extends Error {}
|
|
5
6
|
|
|
7
|
+
interface IClaudeUsageWindows {
|
|
8
|
+
windows: IHarnessUsageWindow[];
|
|
9
|
+
/** Server rows whose reset group the window model cannot represent, named by kind and group. */
|
|
10
|
+
unsupported: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Reset times are normalised to ISO 8601 UTC; an unparsable one rejects the whole usage response. */
|
|
14
|
+
const resetTimestamp = (value: unknown): string | null => value == null ? null : new Date(String(value)).toISOString();
|
|
15
|
+
|
|
16
|
+
const SERVER_NAME_LENGTH = 64;
|
|
17
|
+
|
|
18
|
+
/** A server-supplied name becomes label text: no control characters, bounded by code points, and never blank. */
|
|
19
|
+
const serverName = (value: unknown): string => {
|
|
20
|
+
const name = typeof value === 'string' ? Array.from(plainText(value).trim()).slice(0, SERVER_NAME_LENGTH).join('').trimEnd() : '';
|
|
21
|
+
if (!name) throw new Error('Unsupported Claude usage name.');
|
|
22
|
+
return name;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Keyed windows, read only from a usage body that carries no limits[] rows. */
|
|
26
|
+
const KEYED_WINDOWS = [
|
|
27
|
+
['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
|
|
28
|
+
['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const keyedWindows = (usage: Record<string, unknown>): IHarnessUsageWindow[] => {
|
|
32
|
+
const windows: IHarnessUsageWindow[] = [];
|
|
33
|
+
for (const [key, label, durationSeconds, scope] of KEYED_WINDOWS) {
|
|
34
|
+
if (usage[key] == null) continue;
|
|
35
|
+
const window = credentialRecord(usage[key]);
|
|
36
|
+
if (window.utilization == null) continue;
|
|
37
|
+
if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error('Unsupported Claude usage utilization.');
|
|
38
|
+
windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
|
|
39
|
+
}
|
|
40
|
+
return windows;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The limits[] row groups the window model represents: each group's duration, the period word its
|
|
45
|
+
* labels use, and the kind of the group's unscoped account meter, which keeps the plain label.
|
|
46
|
+
*/
|
|
47
|
+
const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; period: string; accountKind: string }> = new Map([
|
|
48
|
+
['session', { durationSeconds: 18000, period: 'five-hour', accountKind: 'session' }],
|
|
49
|
+
['weekly', { durationSeconds: 604800, period: 'weekly', accountKind: 'weekly_all' }],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const isUsageSeverity = (value: unknown): value is TUsageSeverity => value === 'normal' || value === 'warning' || value === 'critical';
|
|
53
|
+
|
|
54
|
+
/** A scope part is absent (null or omitted) or names the model or surface a row is for. */
|
|
55
|
+
const scopeSubject = (value: unknown): string | undefined => value == null ? undefined : serverName(credentialRecord(value).display_name);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The server's usage rows, in the server's order. An unscoped row is an account meter; a model- or
|
|
59
|
+
* surface-scoped row is a feature quota named by the server's own label. Rows are classified on the
|
|
60
|
+
* kind and group exactly as sent; only the text shown to users is sanitised. Every row is validated
|
|
61
|
+
* before a row in an unrepresentable group is set aside, so a malformed row can never hide behind one.
|
|
62
|
+
*/
|
|
63
|
+
const limitWindows = (rows: readonly unknown[]): IClaudeUsageWindows => {
|
|
64
|
+
const windows: IHarnessUsageWindow[] = [];
|
|
65
|
+
const unsupported = new Set<string>();
|
|
66
|
+
for (const value of rows) {
|
|
67
|
+
const row = credentialRecord(value);
|
|
68
|
+
const { kind, group } = row;
|
|
69
|
+
if (typeof kind !== 'string' || typeof group !== 'string') throw new Error('Unsupported Claude usage meter.');
|
|
70
|
+
const kindName = serverName(kind);
|
|
71
|
+
const groupName = serverName(group);
|
|
72
|
+
const usedPercent = row.percent;
|
|
73
|
+
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent) || usedPercent < 0) throw new Error('Unsupported Claude usage percentage.');
|
|
74
|
+
const resetAt = resetTimestamp(row.resets_at);
|
|
75
|
+
const scope: Record<string, unknown> = row.scope == null ? {} : credentialRecord(row.scope);
|
|
76
|
+
const model = scopeSubject(scope.model);
|
|
77
|
+
const surface = scopeSubject(scope.surface);
|
|
78
|
+
const subject = model ?? surface;
|
|
79
|
+
const meter = LIMIT_GROUPS.get(group);
|
|
80
|
+
if (!meter) {
|
|
81
|
+
unsupported.add(`${kindName} in group ${groupName}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const severity = row.severity;
|
|
85
|
+
windows.push({
|
|
86
|
+
label: subject === undefined ? `Claude ${meter.period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${meter.period}`,
|
|
87
|
+
durationSeconds: meter.durationSeconds, scope: subject === undefined ? 'account' : 'feature', usedPercent, resetAt,
|
|
88
|
+
...(isUsageSeverity(severity) ? { severity } : {}),
|
|
89
|
+
...(row.is_active === true ? { headline: true as const } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return { windows, unsupported: [...unsupported] };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A usage body's windows. Its limits[] rows are authoritative whenever the server sends any; the keyed
|
|
97
|
+
* fields describe the same meters and are read only from a body without rows.
|
|
98
|
+
*/
|
|
99
|
+
const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows => {
|
|
100
|
+
const limits = usage.limits;
|
|
101
|
+
if (limits != null && !Array.isArray(limits)) throw new Error('Unsupported Claude usage limits.');
|
|
102
|
+
return Array.isArray(limits) && limits.length ? limitWindows(limits) : { windows: keyedWindows(usage), unsupported: [] };
|
|
103
|
+
};
|
|
104
|
+
|
|
6
105
|
/** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
|
|
7
106
|
export class ClaudeAccountStatus {
|
|
8
107
|
constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
|
|
@@ -64,18 +163,7 @@ export class ClaudeAccountStatus {
|
|
|
64
163
|
} else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
|
|
65
164
|
if (usage.status === 'fulfilled') {
|
|
66
165
|
try {
|
|
67
|
-
const windows
|
|
68
|
-
for (const [key, label, durationSeconds, scope] of [
|
|
69
|
-
['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
|
|
70
|
-
['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
|
|
71
|
-
] as const) {
|
|
72
|
-
if (usage.value[key] == null) continue;
|
|
73
|
-
const window = credentialRecord(usage.value[key]);
|
|
74
|
-
if (window.utilization == null) continue;
|
|
75
|
-
if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error();
|
|
76
|
-
const resetAt = window.resets_at == null ? null : new Date(String(window.resets_at)).toISOString();
|
|
77
|
-
windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt });
|
|
78
|
-
}
|
|
166
|
+
const { windows, unsupported } = claudeUsageWindows(usage.value);
|
|
79
167
|
result.summary = { ...result.summary, usageWindows: windows };
|
|
80
168
|
for (const window of windows) result.facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: window.label, value: `${window.usedPercent}% used; ${window.resetAt ? 'resets ' + window.resetAt : 'reset not scheduled'}` });
|
|
81
169
|
if (usage.value.extra_usage != null) {
|
|
@@ -90,7 +178,7 @@ export class ClaudeAccountStatus {
|
|
|
90
178
|
}
|
|
91
179
|
if (limit !== undefined && limit > 0 && used !== undefined) result.facts.push({ section: 'Extra usage', label: 'Monthly budget used', value: `${Math.round(used / limit * 10000) / 100}%` });
|
|
92
180
|
}
|
|
93
|
-
if (
|
|
181
|
+
if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
|
|
94
182
|
} catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
|
|
95
183
|
} else result.problems.push(`Usage: ${usage.reason instanceof ClaudeStatusError ? usage.reason.message : 'Lookup failed.'}`);
|
|
96
184
|
result.facts.push({ section: 'Availability', label: 'Billing dates and earned resets', value: 'Renewal dates, cancellation dates and earned reset credits are not exposed by these Claude Code endpoints.' });
|