@modelprofile.com/authswitch 2.1.2

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.
Files changed (95) hide show
  1. package/.smartconfig.json +34 -0
  2. package/cli.js +21 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/accounts.d.ts +31 -0
  6. package/dist_ts/accounts.js +74 -0
  7. package/dist_ts/classes.accountlist.d.ts +17 -0
  8. package/dist_ts/classes.accountlist.js +214 -0
  9. package/dist_ts/classes.claudecodeharness.d.ts +34 -0
  10. package/dist_ts/classes.claudecodeharness.js +123 -0
  11. package/dist_ts/classes.claudestatus.d.ts +8 -0
  12. package/dist_ts/classes.claudestatus.js +139 -0
  13. package/dist_ts/classes.cli.d.ts +31 -0
  14. package/dist_ts/classes.cli.js +584 -0
  15. package/dist_ts/classes.codexauth.d.ts +35 -0
  16. package/dist_ts/classes.codexauth.js +184 -0
  17. package/dist_ts/classes.codexdaemon.d.ts +38 -0
  18. package/dist_ts/classes.codexdaemon.js +122 -0
  19. package/dist_ts/classes.codexharness.d.ts +35 -0
  20. package/dist_ts/classes.codexharness.js +199 -0
  21. package/dist_ts/classes.codexhome.d.ts +25 -0
  22. package/dist_ts/classes.codexhome.js +60 -0
  23. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  24. package/dist_ts/classes.codexpreuse.js +90 -0
  25. package/dist_ts/classes.codexstatus.d.ts +20 -0
  26. package/dist_ts/classes.codexstatus.js +304 -0
  27. package/dist_ts/classes.codexswitcher.d.ts +61 -0
  28. package/dist_ts/classes.codexswitcher.js +226 -0
  29. package/dist_ts/classes.credentialstore.d.ts +32 -0
  30. package/dist_ts/classes.credentialstore.js +115 -0
  31. package/dist_ts/classes.fileharness.d.ts +47 -0
  32. package/dist_ts/classes.fileharness.js +119 -0
  33. package/dist_ts/classes.login.d.ts +42 -0
  34. package/dist_ts/classes.login.js +102 -0
  35. package/dist_ts/classes.opencodeharness.d.ts +34 -0
  36. package/dist_ts/classes.opencodeharness.js +125 -0
  37. package/dist_ts/classes.operations.d.ts +44 -0
  38. package/dist_ts/classes.operations.js +97 -0
  39. package/dist_ts/classes.remotecontrol.d.ts +46 -0
  40. package/dist_ts/classes.remotecontrol.js +169 -0
  41. package/dist_ts/classes.service.d.ts +47 -0
  42. package/dist_ts/classes.service.js +142 -0
  43. package/dist_ts/classes.stashstore.d.ts +63 -0
  44. package/dist_ts/classes.stashstore.js +192 -0
  45. package/dist_ts/classes.tui.d.ts +11 -0
  46. package/dist_ts/classes.tui.js +195 -0
  47. package/dist_ts/formatting.d.ts +8 -0
  48. package/dist_ts/formatting.js +13 -0
  49. package/dist_ts/helpers.d.ts +39 -0
  50. package/dist_ts/helpers.js +92 -0
  51. package/dist_ts/index.d.ts +22 -0
  52. package/dist_ts/index.js +27 -0
  53. package/dist_ts/interfaces.d.ts +145 -0
  54. package/dist_ts/interfaces.harness.d.ts +130 -0
  55. package/dist_ts/interfaces.harness.js +2 -0
  56. package/dist_ts/interfaces.js +2 -0
  57. package/dist_ts/interfaces.list.d.ts +19 -0
  58. package/dist_ts/interfaces.list.js +2 -0
  59. package/dist_ts/plugins.d.ts +14 -0
  60. package/dist_ts/plugins.js +18 -0
  61. package/dist_ts/preuse.d.ts +6 -0
  62. package/dist_ts/preuse.js +13 -0
  63. package/license.md +21 -0
  64. package/package.json +53 -0
  65. package/readme.md +502 -0
  66. package/ts/00_commitinfo_data.ts +8 -0
  67. package/ts/accounts.ts +78 -0
  68. package/ts/classes.accountlist.ts +211 -0
  69. package/ts/classes.claudecodeharness.ts +111 -0
  70. package/ts/classes.claudestatus.ts +99 -0
  71. package/ts/classes.cli.ts +479 -0
  72. package/ts/classes.codexauth.ts +199 -0
  73. package/ts/classes.codexdaemon.ts +137 -0
  74. package/ts/classes.codexharness.ts +233 -0
  75. package/ts/classes.codexhome.ts +68 -0
  76. package/ts/classes.codexpreuse.ts +76 -0
  77. package/ts/classes.codexstatus.ts +257 -0
  78. package/ts/classes.codexswitcher.ts +272 -0
  79. package/ts/classes.credentialstore.ts +101 -0
  80. package/ts/classes.fileharness.ts +120 -0
  81. package/ts/classes.login.ts +103 -0
  82. package/ts/classes.opencodeharness.ts +114 -0
  83. package/ts/classes.operations.ts +101 -0
  84. package/ts/classes.remotecontrol.ts +208 -0
  85. package/ts/classes.service.ts +152 -0
  86. package/ts/classes.stashstore.ts +223 -0
  87. package/ts/classes.tui.ts +138 -0
  88. package/ts/formatting.ts +14 -0
  89. package/ts/helpers.ts +109 -0
  90. package/ts/index.ts +28 -0
  91. package/ts/interfaces.harness.ts +112 -0
  92. package/ts/interfaces.list.ts +19 -0
  93. package/ts/interfaces.ts +158 -0
  94. package/ts/plugins.ts +20 -0
  95. package/ts/preuse.ts +15 -0
@@ -0,0 +1,211 @@
1
+ import * as plugins from './plugins.js';
2
+ import { accountQuotaSummary, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
3
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessStatusFact } from './interfaces.harness.js';
4
+ import type { IAccountList, IHarnessAccountList } from './interfaces.list.js';
5
+ import { bold, dim, orange, plainText } from './formatting.js';
6
+
7
+ type TListedAccount = IHarnessAccountList['accounts'][number];
8
+ interface IDisplayFact { account: number; label: string; value: string; }
9
+
10
+ /** Allowlist the public data contract: never serialize a harness or its credential-bearing internals. */
11
+ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus => {
12
+ const summary = statusArg.summary;
13
+ return {
14
+ facts: statusArg.facts.map(({ label, value, section, summaryKey }) => ({ label, value, section, summaryKey })),
15
+ problems: [...statusArg.problems],
16
+ ...(summary === undefined ? {} : { summary: {
17
+ ...(summary.subscription === undefined ? {} : { subscription: { plan: summary.subscription.plan, source: summary.subscription.source } }),
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 })) }),
20
+ ...(summary.resets === undefined ? {} : { resets: {
21
+ available: summary.resets.available,
22
+ ...(summary.resets.details === undefined ? {} : { details: summary.resets.details.map(({ status, kind, expiresAt }) => ({ status, kind, expiresAt })) }),
23
+ } }),
24
+ } }),
25
+ };
26
+ };
27
+
28
+ export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAccountList> => {
29
+ const harnesses: IHarnessAccountList[] = [];
30
+ for (const harness of harnessesArg) {
31
+ const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], problems: [] };
32
+ try {
33
+ const { state, rows } = await readAccountRows(harness);
34
+ result.saveUnavailableReason = state.saveUnavailableReason;
35
+ result.accounts = rows.map(({ account, status }) => ({
36
+ id: account.id, label: account.label, isActive: account.isActive, isStashed: account.isStashed,
37
+ savedAt: account.savedAt, details: [...account.details], ...(account.slotId === undefined ? {} : { slotId: account.slotId }),
38
+ status: publicStatus(status!),
39
+ }));
40
+ } catch {
41
+ result.problems.push(`Could not read accounts. Run authswitch ${harness.id} doctor.`);
42
+ }
43
+ harnesses.push(result);
44
+ }
45
+ return {
46
+ schemaVersion: 2, generatedAt: new Date().toISOString(),
47
+ complete: harnesses.every(harness => !harness.problems.length && harness.accounts.every(account => !account.status.problems.length)),
48
+ harnesses,
49
+ };
50
+ };
51
+
52
+ /** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
53
+ export class AccountListRenderer {
54
+ constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
55
+ private get width(): number { return process.stdout.columns ?? 100; }
56
+ private heading(textArg: string): void { process.stdout.write(`\n${bold(textArg)}\n`); }
57
+
58
+ private async table<TRow>(rowsArg: TRow[], columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[]): Promise<void> {
59
+ if (!rowsArg.length) return;
60
+ if (this.width < 40) {
61
+ for (const row of rowsArg) {
62
+ process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
63
+ }
64
+ return;
65
+ }
66
+ await this.out.table(rowsArg, { columns: columnsArg, overflow: 'wrap', theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } } });
67
+ }
68
+
69
+ public async render(listArg: IAccountList): Promise<void> {
70
+ const now = Date.parse(listArg.generatedAt);
71
+ if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
72
+ for (const harness of listArg.harnesses) {
73
+ this.heading(plainText(harness.label));
74
+ if (harness.problems.length) {
75
+ for (const problem of harness.problems) process.stdout.write(`${orange('Unavailable')}: ${plainText(problem)}\n`);
76
+ continue;
77
+ }
78
+ if (!harness.accounts.length) {
79
+ process.stdout.write(`${dim(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint))}\n`);
80
+ continue;
81
+ }
82
+ await this.overview(harness.accounts, now);
83
+ const schedules = harness.accounts.flatMap(account => orderedUsageWindows(account.status.summary?.usageWindows).map(window => ({ account: plainText(account.label), window })));
84
+ for (const feature of [false, true]) {
85
+ const scopedSchedules = schedules.filter(row => (row.window.scope === 'feature') === feature);
86
+ if (!scopedSchedules.length) continue;
87
+ this.heading(feature ? 'Feature-specific limits' : 'Usage & reset schedule');
88
+ await this.table(scopedSchedules, [
89
+ { key: 'account', title: 'Account', value: row => row.account },
90
+ { key: 'window', title: 'Window', value: row => `${plainText(row.window.label)} (${duration(row.window.durationSeconds)})` },
91
+ { key: 'usage', title: 'Usage', value: row => `${row.window.usedPercent}% used\n${Math.max(0, 100 - row.window.usedPercent)}% left` },
92
+ { key: 'reset', title: 'Reset in', value: row => until(row.window.resetAt, now) },
93
+ ]);
94
+ }
95
+ const resets = harness.accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ account: plainText(account.label), reset })));
96
+ if (resets.length) {
97
+ this.heading('Earned reset details');
98
+ await this.table(resets, [
99
+ { key: 'account', title: 'Account', value: row => row.account },
100
+ { key: 'kind', title: 'Type', value: row => plainText(row.reset.kind) },
101
+ { key: 'status', title: 'Status', value: row => plainText(row.reset.status) },
102
+ { key: 'expiry', title: 'Expires (UTC)', value: row => row.reset.expiresAt === null ? 'Not reported' : plainText(row.reset.expiresAt).replace('T', '\n').replace('.000Z', '').replace(/Z$/, '') },
103
+ ]);
104
+ }
105
+ const groups = new Map<string, IDisplayFact[]>();
106
+ const add = (section: string, fact: IDisplayFact) => { const group = groups.get(section) ?? []; group.push(fact); groups.set(section, group); };
107
+ harness.accounts.forEach((account, index) => {
108
+ for (const fact of account.status.facts) {
109
+ if (!this.covered(fact, account.status)) add(plainText(fact.section ?? 'Additional information'), { account: index, label: plainText(fact.label), value: plainText(fact.value) });
110
+ }
111
+ });
112
+ for (const [section, facts] of groups) await this.facts(section, facts, harness.accounts);
113
+ const saved = harness.accounts.flatMap((account, index) => [
114
+ ...(account.savedAt ? [{ account: index, label: 'Saved (UTC)', value: plainText(account.savedAt) }] : []),
115
+ ...account.details.map((detail, detailIndex) => ({ account: index, label: `Note ${detailIndex + 1}`, value: plainText(detail) })),
116
+ ]);
117
+ await this.facts('Saved logins', saved, harness.accounts);
118
+ const issues = harness.accounts.flatMap(account => account.status.problems.map(problem => ({ account: plainText(account.label), issue: plainText(problem) })));
119
+ if (harness.saveUnavailableReason) issues.push({ account: 'Harness', issue: plainText(harness.saveUnavailableReason) });
120
+ if (issues.length) {
121
+ this.heading('Availability & actions');
122
+ await this.table(issues, [
123
+ { key: 'account', title: 'Account', value: row => row.account },
124
+ { key: 'issue', title: 'Unavailable / action needed', value: row => row.issue },
125
+ ]);
126
+ }
127
+ }
128
+ }
129
+
130
+ private async overview(accountsArg: TListedAccount[], nowArg: number): Promise<void> {
131
+ const rows = accountsArg;
132
+ const state = (account: TListedAccount) => `${account.isActive ? '* active\n' : ''}${account.isStashed ? 'saved' : 'not saved'}${account.status.problems.length ? '\n! partial status' : ''}`;
133
+ const plan = (account: TListedAccount) => account.status.summary?.subscription ? `${plainText(account.status.summary.subscription.plan)} (${account.status.summary.subscription.source})${account.status.summary.subscription.source === 'stored' ? '\nunverified' : ''}` : 'Unavailable';
134
+ const billing = (account: TListedAccount) => {
135
+ const info = account.status.summary?.billing;
136
+ if (!info) return 'Unavailable';
137
+ const dates = [
138
+ ...(info.renewsAt ? [`${info.autoRenew === true ? 'Renews' : 'Renewal boundary'}: ${plainText(info.renewsAt)}`] : []),
139
+ ...(info.cancelsAt ? [`Cancels: ${plainText(info.cancelsAt)}`] : []),
140
+ ...(info.expiresAt ? [`Expires: ${plainText(info.expiresAt)}`] : []),
141
+ ];
142
+ return [...(info.hasActiveSubscription === undefined ? [] : [info.hasActiveSubscription ? 'Subscription active' : 'No active subscription']),
143
+ info.autoRenew === true ? 'Auto-renew on' : info.autoRenew === false ? 'Auto-renew off' : 'Auto-renew unknown', ...dates,
144
+ ...(!dates.length ? ['Date unavailable'] : [])].join('\n');
145
+ };
146
+ const resets = (account: TListedAccount) => account.status.summary?.resets === undefined ? 'Unavailable' : String(account.status.summary.resets.available);
147
+ const usage = (account: TListedAccount) => accountQuotaSummary(account, nowArg);
148
+ const columns: plugins.smartconsole.IBackendTableColumn<typeof rows[number]>[] = [
149
+ { key: 'account', title: 'Account', value: row => plainText(row.label), style: row => ({ bold: row.isActive }) },
150
+ ];
151
+ if (this.width >= 100) columns.push(
152
+ { key: 'state', title: 'Login', value: state, style: row => ({ foreground: row.isStashed ? 'green' : 'orange' }) },
153
+ { key: 'plan', title: 'Plan', value: plan },
154
+ { key: 'usage', title: 'Usage / reset', value: usage },
155
+ { key: 'resets', title: 'Resets', value: resets, align: 'right' },
156
+ );
157
+ if (this.width >= 120) columns.splice(3, 0, { key: 'billing', title: 'Renewal / cancellation', value: billing });
158
+ else if (this.width >= 100) columns[2] = { key: 'plan', title: 'Plan / billing', value: row => `${plan(row)}\n${row.status.summary?.billing ? billing(row) : 'Billing unavailable'}` };
159
+ else if (this.width >= 60) columns.push(
160
+ { key: 'login', title: 'Login / plan', value: row => `${state(row)}\n${plan(row)}\n${row.status.summary?.billing ? billing(row) : 'Billing unavailable'}` },
161
+ { key: 'usage', title: 'Usage / resets', value: row => `${usage(row)}\nEarned resets: ${resets(row)}` },
162
+ );
163
+ else columns.push({ key: 'status', title: 'Status', value: row => `${state(row)}\nPlan: ${plan(row)}\nBilling: ${billing(row)}\nUsage: ${usage(row)}\nEarned resets: ${resets(row)}` });
164
+ await this.table(rows, columns);
165
+ }
166
+
167
+ private covered(factArg: IHarnessStatusFact, statusArg: IHarnessAccountStatus): boolean {
168
+ const summary = statusArg.summary;
169
+ if (!summary) return false;
170
+ switch (factArg.summaryKey) {
171
+ case 'subscription': return summary.subscription !== undefined;
172
+ case 'billing': return summary.billing !== undefined;
173
+ case 'usageWindows': return summary.usageWindows !== undefined;
174
+ case 'resets': return summary.resets !== undefined;
175
+ case 'resetDetails': return summary.resets?.details !== undefined;
176
+ default: return false;
177
+ }
178
+ }
179
+
180
+ /** Compare like metrics across accounts, keeping duplicate labels as separate rows. */
181
+ private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TListedAccount[]): Promise<void> {
182
+ if (!factsArg.length) return;
183
+ this.heading(titleArg);
184
+ const valueBudget = Math.max(10, Math.min(40, Math.max(...factsArg.map(fact => Math.max(fact.value.length, plainText(accountsArg[fact.account].label).length))) + 3));
185
+ const perPage = Math.max(1, Math.min(6, Math.floor((this.width - 30) / valueBudget)));
186
+ const accountIndices = accountsArg.map((_, index) => index).filter(index => factsArg.some(fact => fact.account === index));
187
+ for (let start = 0; start < accountIndices.length; start += perPage) {
188
+ const indices = accountIndices.slice(start, start + perPage);
189
+ const metrics = new Map<string, { label: string; values: Map<number, string> }>();
190
+ const occurrences = new Map<string, number>();
191
+ for (const fact of factsArg.filter(fact => indices.includes(fact.account))) {
192
+ const occurrenceKey = JSON.stringify([fact.account, fact.label]);
193
+ const occurrence = occurrences.get(occurrenceKey) ?? 0;
194
+ occurrences.set(occurrenceKey, occurrence + 1);
195
+ const key = JSON.stringify([fact.label, occurrence]);
196
+ const metric = metrics.get(key) ?? { label: fact.label, values: new Map<number, string>() };
197
+ metric.values.set(fact.account, fact.value);
198
+ metrics.set(key, metric);
199
+ }
200
+ if (this.width < 40) {
201
+ for (const index of indices) {
202
+ process.stdout.write(` Account: ${plainText(accountsArg[index].label)}\n`);
203
+ for (const metric of metrics.values()) if (metric.values.has(index)) process.stdout.write(` - ${metric.label}: ${metric.values.get(index)}\n`);
204
+ }
205
+ } else await this.table([...metrics.values()], [
206
+ { key: 'metric', title: 'Metric', value: row => row.label },
207
+ ...indices.map(index => ({ key: String(index), title: plainText(accountsArg[index].label), value: (row: { values: Map<number, string> }) => row.values.get(index) ?? 'Not reported' })),
208
+ ]);
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,111 @@
1
+ import * as plugins from './plugins.js';
2
+ import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
+ import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, requireHarnessStopped } from './classes.credentialstore.js';
4
+ import { ClaudeAccountStatus } from './classes.claudestatus.js';
5
+ import { writeSecretFileAtomically } from './helpers.js';
6
+ import type { IHarnessAccountStatus } from './interfaces.harness.js';
7
+
8
+ export interface IClaudeCodeHarnessOptions {
9
+ configDir?: string;
10
+ globalConfigFile?: string;
11
+ stashRoot?: string;
12
+ env?: NodeJS.ProcessEnv;
13
+ platform?: NodeJS.Platform;
14
+ fetch?: typeof fetch;
15
+ checkStopped?: () => void;
16
+ /** Local settings sources to inspect for credential overrides, in increasing precedence. */
17
+ settingsFiles?: string[];
18
+ }
19
+
20
+ const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAnsweredAt', 'additionalModelCostsCache', 'modelAccessCache', 'orgModelDefaultCache', 'lastSeenOrgDefaultUpdatedAt', 'clientDataCache', 'clientDataCacheSlots', 'autoCompactWindowsCache', 'cachedUsageUtilization'];
21
+
22
+ /** File-backed Claude Code subscriber login and its matching global oauthAccount metadata. */
23
+ export class ClaudeCodeHarness extends FileHarness {
24
+ public readonly id = 'claude';
25
+ public readonly label = 'Claude Code';
26
+ public readonly loginHint = 'Log in using claude auth login, exit Claude Code, then authswitch claude stash --keep.';
27
+ protected readonly store: CredentialStore;
28
+ private readonly file: string;
29
+ private readonly configFile: string;
30
+ private readonly env: NodeJS.ProcessEnv;
31
+ private readonly platform: NodeJS.Platform;
32
+ private readonly status: ClaudeAccountStatus;
33
+ private readonly checkStopped: () => void;
34
+ private readonly settingsFiles: string[];
35
+
36
+ constructor(options: IClaudeCodeHarnessOptions = {}) {
37
+ super();
38
+ this.env = options.env ?? process.env;
39
+ const custom = options.configDir ?? this.env.CLAUDE_CONFIG_DIR;
40
+ const dir = custom || plugins.path.join(plugins.os.homedir(), '.claude');
41
+ this.file = plugins.path.join(dir, '.credentials.json');
42
+ this.configFile = options.globalConfigFile ?? plugins.path.join(custom || plugins.os.homedir(), '.claude.json');
43
+ this.store = new CredentialStore(this.id, options.stashRoot);
44
+ this.platform = options.platform ?? process.platform;
45
+ this.status = new ClaudeAccountStatus(options.fetch);
46
+ this.checkStopped = options.checkStopped ?? (() => requireHarnessStopped('claude'));
47
+ this.settingsFiles = options.settingsFiles ?? [plugins.path.join(dir, 'settings.json'),
48
+ plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
49
+ }
50
+ protected beforeMutation(): void { this.checkStopped(); }
51
+ protected inspect(credential: Record<string, unknown>, slotId: string): IFileAccount {
52
+ if (slotId !== 'anthropic') throw new Error('Unsupported Claude Code credential slot.');
53
+ const oauth = credentialRecord(credential.claudeAiOauth);
54
+ const account = credentialRecord(credential.oauthAccount);
55
+ if (!credentialText(oauth.accessToken) || !Array.isArray(oauth.scopes) || !oauth.scopes.every(scope => typeof scope === 'string')
56
+ || !credentialText(account.accountUuid) || !credentialText(account.organizationUuid) || !credentialText(account.emailAddress)) throw new Error('Claude Code login or its matching account metadata is incomplete. Log in again with Claude Code before saving.');
57
+ return { slotId, identity: JSON.stringify([account.accountUuid, account.organizationUuid]), label: String(account.emailAddress).toLowerCase(), credential };
58
+ }
59
+ protected snapshot(): IFileHarnessSnapshot {
60
+ if (this.platform === 'darwin') return { accounts: [], unavailable: 'Claude Code uses the macOS Keychain. This adapter supports file credentials on Linux and Windows; Keychain switching is unavailable.' };
61
+ const overrides = ['CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_PROFILE', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_BASE_URL'];
62
+ const settings: Record<string, unknown> = {};
63
+ const environment: Record<string, unknown> = { ...this.env };
64
+ for (const file of this.settingsFiles) {
65
+ const data = readCredentialDocument(file).data;
66
+ Object.assign(settings, data);
67
+ if (data.env != null) Object.assign(environment, credentialRecord(data.env));
68
+ }
69
+ if (overrides.some(key => environment[key]) || credentialText(settings.apiKeyHelper)) return { accounts: [], unavailable: 'Claude Code has a settings or environment credential/provider override. Remove it before managing subscriber file logins.' };
70
+ const data = readCredentialDocument(this.file).data;
71
+ if (data.claudeAiOauth == null) return { accounts: [], unavailable: null };
72
+ const config = readCredentialDocument(this.configFile).data;
73
+ try { return { accounts: [this.inspect({ claudeAiOauth: data.claudeAiOauth, oauthAccount: config.oauthAccount }, 'anthropic')], unavailable: null }; }
74
+ catch { return { accounts: [], unavailable: 'Claude Code login or account metadata is incomplete. Log in again with Claude Code; existing files were preserved.' }; }
75
+ }
76
+ protected apply(_slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void {
77
+ const original = readCredentialDocument(this.file);
78
+ const config = readCredentialDocument(this.configFile);
79
+ const oldOauth = previous?.credential.claudeAiOauth;
80
+ const oldAccount = previous?.credential.oauthAccount;
81
+ if (JSON.stringify(original.data.claudeAiOauth) !== JSON.stringify(oldOauth)
82
+ || (previous && JSON.stringify(config.data.oauthAccount) !== JSON.stringify(oldAccount))) throw new Error('Claude Code login changed during the operation; no credential was replaced.');
83
+ const next = { ...original.data };
84
+ const nextConfig = { ...config.data };
85
+ if (target) { next.claudeAiOauth = target.credential.claudeAiOauth; nextConfig.oauthAccount = target.credential.oauthAccount; }
86
+ else { delete next.claudeAiOauth; delete nextConfig.oauthAccount; }
87
+ for (const key of accountCaches) delete nextConfig[key];
88
+ const raw = JSON.stringify(next, null, 2) + '\n';
89
+ const configRaw = JSON.stringify(nextConfig, null, 2) + '\n';
90
+ if (readCredentialDocument(this.file).raw !== original.raw || readCredentialDocument(this.configFile).raw !== config.raw) throw new Error('Claude Code files changed during the operation; try again after Claude Code exits.');
91
+ plugins.fs.mkdirSync(plugins.path.dirname(this.file), { recursive: true, mode: 0o700 });
92
+ plugins.fs.mkdirSync(plugins.path.dirname(this.configFile), { recursive: true, mode: 0o700 });
93
+ try {
94
+ writeSecretFileAtomically(this.file, raw);
95
+ if (readCredentialDocument(this.file).raw !== raw || readCredentialDocument(this.configFile).raw !== config.raw) throw new Error();
96
+ writeSecretFileAtomically(this.configFile, configRaw);
97
+ if (readCredentialDocument(this.configFile).raw !== configRaw) throw new Error();
98
+ } catch {
99
+ // Roll back only our own writes; a concurrent writer's data must never be overwritten.
100
+ try {
101
+ if (readCredentialDocument(this.file).raw === raw) writeSecretFileAtomically(this.file, original.raw ?? '{}\n');
102
+ if (readCredentialDocument(this.configFile).raw === configRaw) writeSecretFileAtomically(this.configFile, config.raw ?? '{}\n');
103
+ } catch { throw new Error('Claude Code switch and rollback were incomplete. Keep Claude Code stopped and restore a verified saved account.'); }
104
+ throw new Error('Claude Code switch failed while updating account metadata. The outgoing login remains saved; check doctor before restarting.');
105
+ }
106
+ }
107
+ public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
108
+ const account = this.readCredential(id);
109
+ return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount));
110
+ }
111
+ }
@@ -0,0 +1,99 @@
1
+ import { credentialRecord, credentialText } from './classes.credentialstore.js';
2
+ import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
3
+
4
+ class ClaudeStatusError extends Error {}
5
+
6
+ /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
7
+ export class ClaudeAccountStatus {
8
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
9
+ private async get(route: 'usage' | 'profile', accessToken: string): Promise<Record<string, unknown>> {
10
+ const signal = AbortSignal.timeout(10_000);
11
+ try {
12
+ const response = await this.fetcher(`https://api.anthropic.com/api/oauth/${route}`, {
13
+ method: 'GET', redirect: 'error', signal,
14
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json',
15
+ ...(route === 'usage' ? { 'anthropic-beta': 'oauth-2025-04-20' } : { 'Cache-Control': 'no-cache' }) },
16
+ });
17
+ if (!response.ok) {
18
+ await response.body?.cancel();
19
+ throw new ClaudeStatusError(response.status === 401 ? 'Login expired or was rejected. Log in again with Claude Code and save it.' : `Claude account service returned HTTP ${response.status}.`);
20
+ }
21
+ const reader = response.body?.getReader();
22
+ if (!reader) throw new ClaudeStatusError('The Claude account service returned an empty response.');
23
+ const chunks: Uint8Array[] = [];
24
+ let size = 0;
25
+ try {
26
+ while (true) {
27
+ const part = await reader.read();
28
+ if (part.done) break;
29
+ size += part.value.byteLength;
30
+ if (size > 1024 * 1024) { await reader.cancel(); throw new ClaudeStatusError('The Claude account response is too large.'); }
31
+ chunks.push(part.value);
32
+ }
33
+ } finally { reader.releaseLock(); }
34
+ try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
35
+ catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
36
+ } catch (error) {
37
+ if (signal.aborted) throw new ClaudeStatusError('The Claude account request timed out.');
38
+ if (error instanceof ClaudeStatusError) throw error;
39
+ throw new ClaudeStatusError('The Claude account service could not be reached.');
40
+ }
41
+ }
42
+ public async read(oauth: Record<string, unknown>, account: Record<string, unknown>): Promise<IHarnessAccountStatus> {
43
+ const result: IHarnessAccountStatus = { facts: [], problems: [] };
44
+ const storedPlan = credentialText(oauth.subscriptionType);
45
+ if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
46
+ if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes('user:profile')) {
47
+ result.facts.push({ section: 'Availability', label: 'Live account status', value: 'This login lacks the user:profile scope. Log in through Claude Code to obtain subscriber status.' });
48
+ return result;
49
+ }
50
+ const token = credentialText(oauth.accessToken);
51
+ if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
52
+ const [profile, usage] = await Promise.allSettled([this.get('profile', token), this.get('usage', token)]);
53
+ if (profile.status === 'fulfilled') {
54
+ try {
55
+ const user = credentialRecord(profile.value.account);
56
+ const organization = credentialRecord(profile.value.organization);
57
+ if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { facts: [], problems: ['Claude returned a different account or organization; no account data was displayed.'] };
58
+ const plan = credentialText(organization.organization_type)?.replace(/^claude_/, '');
59
+ if (plan) result.summary = { ...result.summary, subscription: { plan, source: 'live' } };
60
+ for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
61
+ if (credentialText(value)) result.facts.push({ section: 'Account', label: String(label), value: String(value) });
62
+ }
63
+ } catch { result.problems.push('Claude profile response is unsupported; no profile values were inferred.'); }
64
+ } else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
65
+ if (usage.status === 'fulfilled') {
66
+ try {
67
+ const windows: NonNullable<IHarnessStatusSummary['usageWindows']> = [];
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
+ }
79
+ result.summary = { ...result.summary, usageWindows: windows };
80
+ 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
+ if (usage.value.extra_usage != null) {
82
+ const extra = credentialRecord(usage.value.extra_usage);
83
+ if (typeof extra.is_enabled === 'boolean') result.facts.push({ section: 'Extra usage', label: 'Enabled', value: extra.is_enabled ? 'yes' : 'no' });
84
+ const currency = credentialText(extra.currency);
85
+ const amount = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
86
+ const limit = amount(extra.monthly_limit);
87
+ const used = amount(extra.used_credits);
88
+ for (const [label, value] of [['Monthly limit', limit], ['Spent', used]] as const) {
89
+ if (value !== undefined) result.facts.push({ section: 'Extra usage', label, value: currency ? `${(value / 100).toFixed(2)} ${currency}` : `${value} currency minor units (currency not reported)` });
90
+ }
91
+ 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
+ }
93
+ if (Array.isArray(usage.value.limits) && usage.value.limits.length) result.facts.push({ section: 'Availability', label: 'Additional limits', value: 'The service also returned limits without a supported reset-window contract.' });
94
+ } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
95
+ } else result.problems.push(`Usage: ${usage.reason instanceof ClaudeStatusError ? usage.reason.message : 'Lookup failed.'}`);
96
+ 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.' });
97
+ return result;
98
+ }
99
+ }