@modelprofile.com/authswitch 3.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/accounts.d.ts +52 -15
- package/dist_ts/accounts.js +110 -27
- package/dist_ts/classes.accountlist.d.ts +0 -25
- package/dist_ts/classes.accountlist.js +2 -216
- package/dist_ts/classes.claudecodeharness.d.ts +81 -2
- package/dist_ts/classes.claudecodeharness.js +208 -14
- package/dist_ts/classes.claudecodelocks.d.ts +38 -0
- package/dist_ts/classes.claudecodelocks.js +118 -0
- package/dist_ts/classes.claudestatus.d.ts +15 -2
- package/dist_ts/classes.claudestatus.js +100 -83
- package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
- package/dist_ts/classes.claudetokenrefresh.js +77 -0
- package/dist_ts/classes.cli.d.ts +14 -7
- package/dist_ts/classes.cli.js +126 -66
- package/dist_ts/classes.codexharness.d.ts +4 -0
- package/dist_ts/classes.codexharness.js +5 -1
- package/dist_ts/classes.codexstatus.d.ts +7 -2
- package/dist_ts/classes.codexstatus.js +49 -21
- package/dist_ts/classes.credentialstore.d.ts +43 -2
- package/dist_ts/classes.credentialstore.js +60 -13
- package/dist_ts/classes.fileharness.d.ts +45 -21
- package/dist_ts/classes.fileharness.js +64 -26
- package/dist_ts/classes.limits.d.ts +46 -7
- package/dist_ts/classes.limits.js +94 -36
- package/dist_ts/classes.listrenderer.d.ts +17 -0
- package/dist_ts/classes.listrenderer.js +313 -0
- package/dist_ts/classes.login.d.ts +1 -1
- package/dist_ts/classes.login.js +1 -1
- package/dist_ts/classes.opencodeharness.d.ts +4 -0
- package/dist_ts/classes.opencodeharness.js +8 -4
- package/dist_ts/classes.operations.js +3 -2
- package/dist_ts/classes.tui.js +4 -3
- package/dist_ts/classes.watch.d.ts +108 -0
- package/dist_ts/classes.watch.js +219 -0
- package/dist_ts/classes.watchlock.d.ts +33 -0
- package/dist_ts/classes.watchlock.js +118 -0
- package/dist_ts/claudehttp.d.ts +39 -0
- package/dist_ts/claudehttp.js +83 -0
- package/dist_ts/cliargs.d.ts +36 -0
- package/dist_ts/cliargs.js +60 -0
- package/dist_ts/consoletable.d.ts +21 -0
- package/dist_ts/consoletable.js +63 -0
- package/dist_ts/helpers.d.ts +7 -0
- package/dist_ts/helpers.js +16 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/interfaces.harness.d.ts +60 -11
- package/dist_ts/interfaces.list.d.ts +3 -1
- package/dist_ts/plugins.d.ts +7 -0
- package/dist_ts/plugins.js +6 -1
- package/dist_ts/ratelimit.d.ts +8 -0
- package/dist_ts/ratelimit.js +13 -0
- package/dist_ts/watchpolicy.d.ts +44 -0
- package/dist_ts/watchpolicy.js +82 -0
- package/package.json +5 -3
- package/readme.md +349 -115
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/accounts.ts +122 -35
- package/ts/classes.accountlist.ts +2 -219
- package/ts/classes.claudecodeharness.ts +199 -13
- package/ts/classes.claudecodelocks.ts +133 -0
- package/ts/classes.claudestatus.ts +100 -65
- package/ts/classes.claudetokenrefresh.ts +85 -0
- package/ts/classes.cli.ts +114 -54
- package/ts/classes.codexharness.ts +4 -0
- package/ts/classes.codexstatus.ts +40 -18
- package/ts/classes.credentialstore.ts +78 -10
- package/ts/classes.fileharness.ts +76 -34
- package/ts/classes.limits.ts +110 -36
- package/ts/classes.listrenderer.ts +328 -0
- package/ts/classes.login.ts +1 -1
- package/ts/classes.opencodeharness.ts +7 -3
- package/ts/classes.operations.ts +2 -1
- package/ts/classes.tui.ts +3 -2
- package/ts/classes.watch.ts +263 -0
- package/ts/classes.watchlock.ts +100 -0
- package/ts/claudehttp.ts +92 -0
- package/ts/cliargs.ts +71 -0
- package/ts/consoletable.ts +62 -0
- package/ts/helpers.ts +14 -0
- package/ts/index.ts +3 -0
- package/ts/interfaces.harness.ts +60 -5
- package/ts/interfaces.list.ts +3 -1
- package/ts/plugins.ts +9 -0
- package/ts/ratelimit.ts +14 -0
- package/ts/watchpolicy.ts +121 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { credentialRecord, credentialText } from './classes.credentialstore.js';
|
|
1
|
+
import { credentialHash, credentialRecord, credentialText } from './classes.credentialstore.js';
|
|
2
|
+
import { claudeRequest, ClaudeLoginRejectedError, ClaudeRateLimitError, ClaudeRequestError } from './claudehttp.js';
|
|
3
|
+
import { latestRetryAt } from './ratelimit.js';
|
|
2
4
|
import { plainText } from './formatting.js';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
class ClaudeStatusError extends Error {}
|
|
5
|
+
import { usageWindowPeriod } from './accounts.js';
|
|
6
|
+
import type { IHarnessAccountStatus, IHarnessStatusFact, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
|
|
6
7
|
|
|
7
8
|
interface IClaudeUsageWindows {
|
|
8
9
|
windows: IHarnessUsageWindow[];
|
|
@@ -22,31 +23,31 @@ const serverName = (value: unknown): string => {
|
|
|
22
23
|
return name;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
/** Keyed windows, read only from a usage body that carries no limits[] rows. */
|
|
26
|
+
/** Keyed windows, read only from a usage body that carries no limits[] rows: what each meter is about, and how long its window is. */
|
|
26
27
|
const KEYED_WINDOWS = [
|
|
27
|
-
['five_hour', 'Claude
|
|
28
|
-
['seven_day_oauth_apps', 'OAuth apps
|
|
28
|
+
['five_hour', 'Claude', 18000, 'account'], ['seven_day', 'Claude', 604800, 'account'],
|
|
29
|
+
['seven_day_oauth_apps', 'OAuth apps', 604800, 'feature'], ['seven_day_opus', 'Opus', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet', 604800, 'feature'],
|
|
29
30
|
] as const;
|
|
30
31
|
|
|
31
32
|
const keyedWindows = (usage: Record<string, unknown>): IHarnessUsageWindow[] => {
|
|
32
33
|
const windows: IHarnessUsageWindow[] = [];
|
|
33
|
-
for (const [key,
|
|
34
|
+
for (const [key, subject, durationSeconds, scope] of KEYED_WINDOWS) {
|
|
34
35
|
if (usage[key] == null) continue;
|
|
35
36
|
const window = credentialRecord(usage[key]);
|
|
36
37
|
if (window.utilization == null) continue;
|
|
37
38
|
if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error('Unsupported Claude usage utilization.');
|
|
38
|
-
windows.push({ label
|
|
39
|
+
windows.push({ label: `${subject} ${usageWindowPeriod(durationSeconds)}`, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
|
|
39
40
|
}
|
|
40
41
|
return windows;
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
|
-
* The limits[] row groups the window model represents: each group's duration,
|
|
45
|
-
*
|
|
45
|
+
* The limits[] row groups the window model represents: each group's duration, which names its windows,
|
|
46
|
+
* and the kind of the group's unscoped account meter, which keeps the plain label.
|
|
46
47
|
*/
|
|
47
|
-
const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number;
|
|
48
|
-
['session', { durationSeconds: 18000,
|
|
49
|
-
['weekly', { durationSeconds: 604800,
|
|
48
|
+
const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; accountKind: string }> = new Map([
|
|
49
|
+
['session', { durationSeconds: 18000, accountKind: 'session' }],
|
|
50
|
+
['weekly', { durationSeconds: 604800, accountKind: 'weekly_all' }],
|
|
50
51
|
]);
|
|
51
52
|
|
|
52
53
|
const isUsageSeverity = (value: unknown): value is TUsageSeverity => value === 'normal' || value === 'warning' || value === 'critical';
|
|
@@ -82,8 +83,9 @@ const limitWindows = (rows: readonly unknown[]): IClaudeUsageWindows => {
|
|
|
82
83
|
continue;
|
|
83
84
|
}
|
|
84
85
|
const severity = row.severity;
|
|
86
|
+
const period = usageWindowPeriod(meter.durationSeconds);
|
|
85
87
|
windows.push({
|
|
86
|
-
label: subject === undefined ? `Claude ${
|
|
88
|
+
label: subject === undefined ? `Claude ${period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${period}`,
|
|
87
89
|
durationSeconds: meter.durationSeconds, scope: subject === undefined ? 'account' : 'feature', usedPercent, resetAt,
|
|
88
90
|
...(isUsageSeverity(severity) ? { severity } : {}),
|
|
89
91
|
...(row.is_active === true ? { headline: true as const } : {}),
|
|
@@ -102,66 +104,96 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
|
|
|
102
104
|
return Array.isArray(limits) && limits.length ? limitWindows(limits) : { windows: keyedWindows(usage), unsupported: [] };
|
|
103
105
|
};
|
|
104
106
|
|
|
105
|
-
/**
|
|
107
|
+
/** A profile lookup's answer. Only a lookup that could not be completed is thrown, and so never reused. */
|
|
108
|
+
type TClaudeProfile =
|
|
109
|
+
| { kind: 'found'; plan: string | undefined; facts: IHarnessStatusFact[] }
|
|
110
|
+
| { kind: 'mismatch' }
|
|
111
|
+
| { kind: 'unavailable'; problem: string };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
|
|
115
|
+
* organization, billing type) changes far more rarely, so it is read at most hourly there.
|
|
116
|
+
*/
|
|
117
|
+
const PROFILE_REUSE_MS = 3_600_000;
|
|
118
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
119
|
+
const MISMATCH = 'Claude returned a different account or organization; no account data was displayed.';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Claude Code's read-only OAuth account endpoints: the profile and usage of one login, with the access token it
|
|
123
|
+
* carries. It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active
|
|
124
|
+
* login's tokens to Claude Code. No inference or browser session is used.
|
|
125
|
+
*/
|
|
106
126
|
export class ClaudeAccountStatus {
|
|
107
|
-
constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
127
|
+
constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
|
|
128
|
+
|
|
129
|
+
private async get(route: 'usage' | 'profile', accessToken: string, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
|
130
|
+
const { status, body } = await claudeRequest(this.fetcher, {
|
|
131
|
+
url: `https://api.anthropic.com/api/oauth/${route}`, method: 'GET', subject: 'Claude account', timeoutMs: REQUEST_TIMEOUT_MS, signal, now: this.now,
|
|
132
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json',
|
|
133
|
+
...(route === 'usage' ? { 'anthropic-beta': 'oauth-2025-04-20' } : { 'Cache-Control': 'no-cache' }) },
|
|
134
|
+
});
|
|
135
|
+
if (status === 401) throw new ClaudeLoginRejectedError();
|
|
136
|
+
if (status < 200 || status > 299) throw new ClaudeRequestError(`Claude account service returned HTTP ${status}.`);
|
|
137
|
+
if (body === null) throw new ClaudeRequestError('The Claude account service returned invalid data.');
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The profile, from the session while it is younger than an hour; the session key is a hash of the token, never the token. */
|
|
142
|
+
private profile(token: string, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
|
|
143
|
+
const lookup = () => this.lookupProfile(token, account, optionsArg.signal);
|
|
144
|
+
return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(token)}`, PROFILE_REUSE_MS, lookup) : lookup();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A rejected login and an unsupported or foreign profile are answers; a rate limit or a failed request is thrown. */
|
|
148
|
+
private async lookupProfile(token: string, account: Record<string, unknown>, signal?: AbortSignal): Promise<TClaudeProfile> {
|
|
149
|
+
let body: Record<string, unknown>;
|
|
150
|
+
try { body = await this.get('profile', token, signal); }
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
111
155
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await response.body?.cancel();
|
|
119
|
-
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}.`);
|
|
156
|
+
const user = credentialRecord(body.account);
|
|
157
|
+
const organization = credentialRecord(body.organization);
|
|
158
|
+
if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { kind: 'mismatch' };
|
|
159
|
+
const facts: IHarnessStatusFact[] = [];
|
|
160
|
+
for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
|
|
161
|
+
if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
|
|
120
162
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
size += part.value.byteLength;
|
|
130
|
-
if (size > 1024 * 1024) { await reader.cancel(); throw new ClaudeStatusError('The Claude account response is too large.'); }
|
|
131
|
-
chunks.push(part.value);
|
|
132
|
-
}
|
|
133
|
-
} finally { reader.releaseLock(); }
|
|
134
|
-
try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
135
|
-
catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
|
|
136
|
-
} catch (error) {
|
|
137
|
-
if (signal.aborted) throw new ClaudeStatusError(timeout.aborted ? 'The Claude account request timed out.' : 'The Claude account request was cancelled.');
|
|
138
|
-
if (error instanceof ClaudeStatusError) throw error;
|
|
139
|
-
throw new ClaudeStatusError('The Claude account service could not be reached.');
|
|
140
|
-
}
|
|
163
|
+
return { kind: 'found', plan: credentialText(organization.organization_type)?.replace(/^claude_/, ''), facts };
|
|
164
|
+
} catch { return { kind: 'unavailable', problem: 'Claude profile response is unsupported; no profile values were inferred.' }; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The plan the login was saved with, the only one known while the service cannot be asked. */
|
|
168
|
+
private storedSummary(oauth: Record<string, unknown>): Pick<IHarnessAccountStatus, 'summary'> {
|
|
169
|
+
const plan = credentialText(oauth.subscriptionType);
|
|
170
|
+
return plan ? { summary: { subscription: { plan, source: 'stored' } } } : {};
|
|
141
171
|
}
|
|
172
|
+
|
|
173
|
+
/** A login that could not be made usable for a lookup: its stored plan and why, with the service's retry time for a rate limit. */
|
|
174
|
+
public unavailable(oauth: Record<string, unknown>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
|
|
175
|
+
return { facts: [], problems: [problem], ...this.storedSummary(oauth), ...(rateLimit ? { rateLimit } : {}) };
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
143
|
-
const result: IHarnessAccountStatus = { facts: [], problems: [] };
|
|
144
|
-
const storedPlan = credentialText(oauth.subscriptionType);
|
|
145
|
-
if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
|
|
179
|
+
const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(oauth) };
|
|
146
180
|
if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes('user:profile')) {
|
|
147
181
|
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.' });
|
|
148
182
|
return result;
|
|
149
183
|
}
|
|
150
184
|
const token = credentialText(oauth.accessToken);
|
|
151
185
|
if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
|
|
152
|
-
const [profile, usage] = await Promise.allSettled([this.
|
|
186
|
+
const [profile, usage] = await Promise.allSettled([this.profile(token, account, optionsArg), this.get('usage', token, optionsArg.signal)]);
|
|
187
|
+
const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
|
|
153
188
|
if (profile.status === 'fulfilled') {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
} catch { result.problems.push('Claude profile response is unsupported; no profile values were inferred.'); }
|
|
164
|
-
} else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
|
|
189
|
+
const found = profile.value;
|
|
190
|
+
if (found.kind === 'mismatch') return { facts: [], problems: [MISMATCH] };
|
|
191
|
+
if (found.kind === 'unavailable') result.problems.push(found.problem);
|
|
192
|
+
else {
|
|
193
|
+
if (found.plan) result.summary = { ...result.summary, subscription: { plan: found.plan, source: 'live' } };
|
|
194
|
+
result.facts.push(...found.facts.map(fact => ({ ...fact })));
|
|
195
|
+
}
|
|
196
|
+
} else result.problems.push(`Profile: ${failure(profile.reason)}`);
|
|
165
197
|
if (usage.status === 'fulfilled') {
|
|
166
198
|
try {
|
|
167
199
|
const { windows, unsupported } = claudeUsageWindows(usage.value);
|
|
@@ -181,8 +213,11 @@ export class ClaudeAccountStatus {
|
|
|
181
213
|
}
|
|
182
214
|
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(', ')}.` });
|
|
183
215
|
} catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
|
|
184
|
-
} else result.problems.push(`Usage: ${usage.reason
|
|
185
|
-
|
|
216
|
+
} else result.problems.push(`Usage: ${failure(usage.reason)}`);
|
|
217
|
+
const limited = [profile, usage].flatMap(item => item.status === 'rejected' && item.reason instanceof ClaudeRateLimitError ? [item.reason] : []);
|
|
218
|
+
if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
|
|
219
|
+
// The missing renewal dates are the harness's `renewalUnavailableReason`, stated once by the views that show them.
|
|
220
|
+
result.facts.push({ section: 'Availability', label: 'Earned resets', value: 'authswitch does not read earned reset credits for Claude Code logins.' });
|
|
186
221
|
return result;
|
|
187
222
|
}
|
|
188
223
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { credentialText } from './classes.credentialstore.js';
|
|
2
|
+
import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError } from './claudehttp.js';
|
|
3
|
+
|
|
4
|
+
/** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @12733057; see hints.md). */
|
|
5
|
+
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
6
|
+
/** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @12733057), used when a login does not name its own. */
|
|
7
|
+
export const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
8
|
+
/** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @15359182). */
|
|
9
|
+
const REFRESH_MARGIN_MS = 300_000;
|
|
10
|
+
/**
|
|
11
|
+
* Claude Code 2.1.273 allows a refresh 30 seconds. A refresh is never cut shorter than that, nor cancelled once sent:
|
|
12
|
+
* the service may already have replaced the refresh token the request carries.
|
|
13
|
+
*/
|
|
14
|
+
const REFRESH_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
/** The token fields a refresh replaces in a stored `claudeAiOauth`; every other field is kept. */
|
|
17
|
+
export interface IClaudeTokens {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
refreshToken: string;
|
|
20
|
+
/** Milliseconds since the epoch, as Claude Code stores them. */
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
refreshTokenExpiresAt?: number;
|
|
23
|
+
scopes?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const seconds = (valueArg: unknown): number | undefined =>
|
|
27
|
+
typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @15355055).
|
|
31
|
+
*
|
|
32
|
+
* It only talks to the token endpoint; which login is refreshed, and how the result is stored, belongs to the harness.
|
|
33
|
+
*/
|
|
34
|
+
export class ClaudeTokenRefresh {
|
|
35
|
+
constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether Claude Code would refresh this login before using it: its access token expires within five minutes. A
|
|
39
|
+
* login without a numeric expiry or without a refresh token is used as it is, exactly as Claude Code does.
|
|
40
|
+
*/
|
|
41
|
+
public due(oauthArg: Record<string, unknown>): boolean {
|
|
42
|
+
const expiresAt = oauthArg.expiresAt;
|
|
43
|
+
return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
|
|
44
|
+
&& this.now() + REFRESH_MARGIN_MS >= expiresAt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
|
|
49
|
+
* stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
|
|
50
|
+
* rejects is `ClaudeLoginRejectedError`, and a rate limit is `ClaudeRateLimitError`.
|
|
51
|
+
*/
|
|
52
|
+
public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
|
|
53
|
+
const refreshToken = credentialText(oauthArg.refreshToken);
|
|
54
|
+
const scopes = oauthArg.scopes;
|
|
55
|
+
if (!refreshToken || !Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string')) {
|
|
56
|
+
throw new ClaudeRequestError('This saved login has no refresh token or scopes to refresh with. Log in again with Claude Code and save it.');
|
|
57
|
+
}
|
|
58
|
+
const { status, body } = await claudeRequest(this.fetcher, {
|
|
59
|
+
url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
grant_type: 'refresh_token', refresh_token: refreshToken,
|
|
62
|
+
client_id: credentialText(oauthArg.clientId) ?? CLAUDE_CODE_CLIENT_ID, scope: scopes.join(' '),
|
|
63
|
+
}),
|
|
64
|
+
timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
|
|
65
|
+
});
|
|
66
|
+
if ((status === 400 || status === 401) && body?.error === 'invalid_grant') throw new ClaudeLoginRejectedError();
|
|
67
|
+
if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
|
|
68
|
+
const receivedAt = this.now();
|
|
69
|
+
const accessToken = credentialText(body?.access_token);
|
|
70
|
+
const tokenType = body?.token_type;
|
|
71
|
+
const expiresIn = seconds(body?.expires_in);
|
|
72
|
+
const refreshExpiresIn = body?.refresh_token_expires_in == null ? null : seconds(body.refresh_token_expires_in);
|
|
73
|
+
const rotated = body?.refresh_token == null ? null : credentialText(body.refresh_token);
|
|
74
|
+
const scope = body?.scope == null ? null : typeof body.scope === 'string' ? body.scope.split(/\s+/).filter(Boolean) : undefined;
|
|
75
|
+
if (!accessToken || (tokenType != null && (typeof tokenType !== 'string' || tokenType.toLowerCase() !== 'bearer'))
|
|
76
|
+
|| expiresIn === undefined || refreshExpiresIn === undefined || rotated === undefined || scope === undefined) {
|
|
77
|
+
throw new ClaudeRequestError('The Claude sign-in service returned an unsupported token response; the saved login was not changed.');
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
accessToken, refreshToken: rotated ?? refreshToken, expiresAt: receivedAt + expiresIn * 1000,
|
|
81
|
+
...(refreshExpiresIn === null ? {} : { refreshTokenExpiresAt: receivedAt + refreshExpiresIn * 1000 }),
|
|
82
|
+
...(scope === null || !scope.length ? {} : { scopes: scope }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
package/ts/classes.cli.ts
CHANGED
|
@@ -5,29 +5,34 @@ import { OpenCodeHarness } from './classes.opencodeharness.js';
|
|
|
5
5
|
import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
|
|
6
6
|
import { AuthSwitchTui } from './classes.tui.js';
|
|
7
7
|
import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
8
|
-
import {
|
|
8
|
+
import { readAccountList } from './classes.accountlist.js';
|
|
9
|
+
import { AccountListRenderer } from './classes.listrenderer.js';
|
|
9
10
|
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
10
|
-
import {
|
|
11
|
+
import { consoleTable } from './consoletable.js';
|
|
12
|
+
import { accountName, credentialDriftNote, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
|
|
11
13
|
import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
|
|
12
14
|
import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
|
|
15
|
+
import { parseCommandArgs, parseDurationOption, parseIntegerOption, UsageError } from './cliargs.js';
|
|
16
|
+
import { AuthSwitchWatch, watchEventText } from './classes.watch.js';
|
|
17
|
+
import { WatchBusyError, WatchLock } from './classes.watchlock.js';
|
|
18
|
+
import { authSwitchHome } from './classes.credentialstore.js';
|
|
13
19
|
import type { CodexSwitcher } from './classes.codexswitcher.js';
|
|
14
20
|
import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome, IHarnessPreuseResult } from './interfaces.harness.js';
|
|
15
21
|
import { bold, dim, green, orange, plainText, red } from './formatting.js';
|
|
16
22
|
|
|
17
23
|
const canPrompt = (): boolean =>
|
|
18
24
|
process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
|
|
19
|
-
const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
|
|
25
|
+
const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'watch', 'current', 'drop', 'rm', 'doctor'];
|
|
20
26
|
/** What to do about the harness's own running instances before a credential file is rewritten. */
|
|
21
27
|
export type TStopMode = 'stop' | 'force-stop' | 'keep-running';
|
|
22
28
|
const STOP_FLAGS = ['--stop', '--force-stop', '--keep-running'];
|
|
23
29
|
/** Commands that replace or clear a native login, and therefore offer to stop its instances. */
|
|
24
30
|
const STOP_COMMANDS = ['use', 'stash'];
|
|
25
|
-
/**
|
|
31
|
+
/** Overviews across every registered harness; none of them changes which account is in use, and all support --json. */
|
|
26
32
|
const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
`${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
|
|
33
|
+
const WATCH_USAGE = 'Usage: authswitch [harness] watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]';
|
|
34
|
+
const WATCH_INTERVAL = { defaultMs: 120_000, minMs: 60_000, maxMs: 86_400_000 };
|
|
35
|
+
const WATCH_THRESHOLD = { default: 95, min: 50, max: 100 };
|
|
31
36
|
|
|
32
37
|
/** The account a command acts on, named as it was offered, with the badge it was offered with. */
|
|
33
38
|
interface IChosenAccount {
|
|
@@ -73,6 +78,9 @@ ${bold('Usage')}
|
|
|
73
78
|
authswitch active one row per provider: which account is in use, and since when
|
|
74
79
|
authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
|
|
75
80
|
send one prompt through that account without switching
|
|
81
|
+
authswitch watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]
|
|
82
|
+
check usage every 2m and switch to a better saved account
|
|
83
|
+
when the active one reaches the threshold (95%)
|
|
76
84
|
authswitch <harness> stash [account/provider] [--keep]
|
|
77
85
|
save one active login; --keep leaves it active
|
|
78
86
|
authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
|
|
@@ -92,14 +100,24 @@ ${bold('Options')}
|
|
|
92
100
|
-v, --version show the version
|
|
93
101
|
-i, --interactive open the guided account manager
|
|
94
102
|
--tui open the terminal management dashboard
|
|
95
|
-
--json JSON output for list / ls / limits / active
|
|
103
|
+
--json JSON output for list / ls / limits / active; one JSON event per line for
|
|
104
|
+
watch, written after the command (authswitch watch --json)
|
|
96
105
|
--stop stop this harness's running instances before the switch
|
|
97
106
|
--force-stop the same, then kill instances that ignore SIGTERM
|
|
98
107
|
--keep-running switch without stopping anything
|
|
99
108
|
|
|
100
|
-
A switch works while OpenCode or Claude Code is running.
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
A switch works while OpenCode or Claude Code is running. Claude Code picks the new login
|
|
110
|
+
up on its next request, so its sessions are stopped only on --stop or --force-stop.
|
|
111
|
+
OpenCode keeps the previous login in memory and can write it back at its next token
|
|
112
|
+
refresh, so an OpenCode switch offers to stop it first; without a terminal it leaves
|
|
113
|
+
it running and says so.
|
|
114
|
+
|
|
115
|
+
Watch covers the harnesses that support automatic switching (${[...this.harnesses.values()].filter(harness => harness.autoSwitch === true).map(harness => harness.id).join(', ') || 'none registered'}).
|
|
116
|
+
--interval takes seconds or a unit (120, 90s, 2m; from 1m to 1d); --threshold takes 50 to 100.
|
|
117
|
+
A switch goes to a saved account at least 10 points below the threshold, or, when every
|
|
118
|
+
account is used up, to the one usable again first. No session of yours is stopped, though
|
|
119
|
+
a Codex switch restarts Codex' own app-server; --dry-run only reports, --once checks once.
|
|
120
|
+
One watch runs per AUTHSWITCH_HOME.
|
|
103
121
|
|
|
104
122
|
Preuse consumes the selected account's quota. Its default prompt is:
|
|
105
123
|
"${defaultPreusePrompt}"
|
|
@@ -121,6 +139,11 @@ ${bold('Environment')}
|
|
|
121
139
|
const qualified = args[0] !== 'preuse';
|
|
122
140
|
return await this.commandPreuse(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
|
|
123
141
|
}
|
|
142
|
+
// Watch owns its options, --json among them.
|
|
143
|
+
if (args[0] === 'watch' || (this.harnesses.has(args[0]) && args[1] === 'watch')) {
|
|
144
|
+
const qualified = args[0] !== 'watch';
|
|
145
|
+
return await this.commandWatch(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
|
|
146
|
+
}
|
|
124
147
|
const jsonFlags = args.filter(value => value === '--json');
|
|
125
148
|
const json = jsonFlags.length === 1;
|
|
126
149
|
if (jsonFlags.length > 1) { process.stderr.write('Use --json only once.\n'); return 2; }
|
|
@@ -234,14 +257,17 @@ ${bold('Environment')}
|
|
|
234
257
|
* Decide what happens to the harness's own running instances before its credential file changes.
|
|
235
258
|
*
|
|
236
259
|
* The switch itself no longer depends on this: it writes atomically and re-saves the outgoing
|
|
237
|
-
* login either way.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
260
|
+
* login either way. A harness that picks up swaps live needs nothing here, so its instances are
|
|
261
|
+
* only stopped on an explicit --stop or --force-stop. Any other running instance still costs
|
|
262
|
+
* durability -- it holds the previous login in memory and can write it back at its next refresh --
|
|
263
|
+
* so the instances are named and stopping them is offered, never assumed. Without a terminal and
|
|
264
|
+
* without a flag nothing is signalled, and only pids enumerated here for this user are ever
|
|
265
|
+
* signalled at all.
|
|
241
266
|
*/
|
|
242
267
|
private async settleRunningInstances(harnessArg: IAuthHarness, modeArg?: TStopMode): Promise<void> {
|
|
243
268
|
const control = harnessArg.processes;
|
|
244
|
-
|
|
269
|
+
const stopRequested = modeArg === 'stop' || modeArg === 'force-stop';
|
|
270
|
+
if (!control || (harnessArg.liveSwap === true && !stopRequested)) return;
|
|
245
271
|
let running: IHarnessProcess[];
|
|
246
272
|
try { running = control.list(); }
|
|
247
273
|
catch { process.stdout.write(`${orange(`Could not check for running ${plainText(harnessArg.label)} processes; continuing with the switch.`)}\n`); return; }
|
|
@@ -250,7 +276,7 @@ ${bold('Environment')}
|
|
|
250
276
|
if (control.stopUnavailableReason !== null) { process.stdout.write(`${dim(control.stopUnavailableReason)}\n`); return; }
|
|
251
277
|
const stoppable = running.filter(item => !item.isAncestor);
|
|
252
278
|
if (!stoppable.length || modeArg === 'keep-running') return;
|
|
253
|
-
let stop =
|
|
279
|
+
let stop = stopRequested;
|
|
254
280
|
if (modeArg === undefined) {
|
|
255
281
|
if (!canPrompt()) { process.stdout.write(`${dim('Leaving them running. Use --stop, --force-stop or --keep-running to decide this without a terminal.')}\n`); return; }
|
|
256
282
|
stop = await this.out.prompts.ask({
|
|
@@ -298,28 +324,18 @@ ${bold('Environment')}
|
|
|
298
324
|
|
|
299
325
|
private async commandPreuse(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
|
|
300
326
|
let reference: string | undefined;
|
|
301
|
-
let prompt
|
|
327
|
+
let prompt: string;
|
|
302
328
|
let model: string | undefined;
|
|
303
|
-
const flags = new Set<string>();
|
|
304
329
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (flags.has(flag)) throw new PreuseError(`Use ${flag} only once.`);
|
|
311
|
-
flags.add(flag);
|
|
312
|
-
const value = separator < 0 ? argsArg[++index] : argument.slice(separator + 1);
|
|
313
|
-
if (value === undefined) throw new PreuseError(`${flag} requires a value.`);
|
|
314
|
-
if (flag === '--prompt') prompt = value; else model = value;
|
|
315
|
-
} else if (argument.startsWith('-') || reference !== undefined) {
|
|
316
|
-
throw new PreuseError('Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]');
|
|
317
|
-
} else reference = argument;
|
|
318
|
-
}
|
|
330
|
+
const parsed = parseCommandArgs(argsArg, { values: ['--prompt', '--model'], flags: [], maxPositionals: 1,
|
|
331
|
+
usage: 'Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]' });
|
|
332
|
+
[reference] = parsed.positionals;
|
|
333
|
+
prompt = parsed.values.get('--prompt') ?? defaultPreusePrompt;
|
|
334
|
+
model = parsed.values.get('--model');
|
|
319
335
|
validatePreuseOptions({ prompt, model });
|
|
320
|
-
if (!reference && !canPrompt()) throw new
|
|
336
|
+
if (!reference && !canPrompt()) throw new UsageError('preuse requires an account outside an interactive terminal.');
|
|
321
337
|
} catch (error) {
|
|
322
|
-
process.stderr.write(`${error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
|
|
338
|
+
process.stderr.write(`${error instanceof UsageError || error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
|
|
323
339
|
return 2;
|
|
324
340
|
}
|
|
325
341
|
const harness = harnessArg ?? await this.selectHarness();
|
|
@@ -366,13 +382,12 @@ ${bold('Environment')}
|
|
|
366
382
|
if (windows?.length) {
|
|
367
383
|
const now = Date.now();
|
|
368
384
|
process.stdout.write('Reset schedule reported after the prompt:\n');
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
{ key: '
|
|
373
|
-
{ key: 'usage', title: 'Used', value: row => `${row.usedPercent}%` },
|
|
385
|
+
await consoleTable(this.out, windows, [
|
|
386
|
+
// The label already names the window by its length, so the column never repeats it.
|
|
387
|
+
{ key: 'window', title: 'Window', value: row => plainText(row.label) },
|
|
388
|
+
{ key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
|
|
374
389
|
{ key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
|
|
375
|
-
]
|
|
390
|
+
]);
|
|
376
391
|
} else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
|
|
377
392
|
} catch {
|
|
378
393
|
process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
|
|
@@ -384,6 +399,57 @@ ${bold('Environment')}
|
|
|
384
399
|
}
|
|
385
400
|
}
|
|
386
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Watch usage and switch automatically; see `AuthSwitchWatch`. SIGINT and SIGTERM end it cleanly with status 0.
|
|
404
|
+
* `--once` runs one check and exits 1 when a switch it attempted did not complete.
|
|
405
|
+
*/
|
|
406
|
+
private async commandWatch(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
|
|
407
|
+
let options: { harnesses: IAuthHarness[]; intervalMs: number; threshold: number; dryRun: boolean; once: boolean; json: boolean };
|
|
408
|
+
try {
|
|
409
|
+
const parsed = parseCommandArgs(argsArg, { values: ['--interval', '--threshold'], flags: ['--dry-run', '--once', '--json', '--help', '-h'],
|
|
410
|
+
maxPositionals: harnessArg ? 0 : 1, usage: WATCH_USAGE });
|
|
411
|
+
if (parsed.flags.has('--help') || parsed.flags.has('-h')) { process.stdout.write(`${this.usage()}\n`); return 0; }
|
|
412
|
+
const named = parsed.positionals[0];
|
|
413
|
+
const harness = harnessArg ?? (named === undefined ? undefined : this.harnesses.get(named));
|
|
414
|
+
if (named !== undefined && !harness) throw new UsageError(`Unknown harness "${plainText(named)}". ${WATCH_USAGE}`);
|
|
415
|
+
if (harness && harness.autoSwitch !== true) throw new UsageError(`${plainText(harness.label)} does not support automatic switching.`);
|
|
416
|
+
const harnesses = (harness ? [harness] : [...this.harnesses.values()].filter(item => item.autoSwitch === true))
|
|
417
|
+
.sort((left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
|
|
418
|
+
if (!harnesses.length) throw new UsageError('No registered harness supports automatic switching.');
|
|
419
|
+
const interval = parsed.values.get('--interval');
|
|
420
|
+
const threshold = parsed.values.get('--threshold');
|
|
421
|
+
options = {
|
|
422
|
+
harnesses, dryRun: parsed.flags.has('--dry-run'), once: parsed.flags.has('--once'), json: parsed.flags.has('--json'),
|
|
423
|
+
intervalMs: interval === undefined ? WATCH_INTERVAL.defaultMs : parseDurationOption('--interval', interval, WATCH_INTERVAL),
|
|
424
|
+
threshold: threshold === undefined ? WATCH_THRESHOLD.default : parseIntegerOption('--threshold', threshold, WATCH_THRESHOLD),
|
|
425
|
+
};
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (!(error instanceof UsageError)) throw error;
|
|
428
|
+
process.stderr.write(`${error.message}\n`);
|
|
429
|
+
return 2;
|
|
430
|
+
}
|
|
431
|
+
const watch = new AuthSwitchWatch({
|
|
432
|
+
harnesses: options.harnesses, operations: this.operations, lock: new WatchLock(authSwitchHome()),
|
|
433
|
+
intervalMs: options.intervalMs, threshold: options.threshold, dryRun: options.dryRun,
|
|
434
|
+
onEvent: event => { process.stdout.write(`${options.json ? JSON.stringify(event) : watchEventText(event)}\n`); },
|
|
435
|
+
});
|
|
436
|
+
const controller = new AbortController();
|
|
437
|
+
const stop = () => controller.abort();
|
|
438
|
+
process.once('SIGINT', stop);
|
|
439
|
+
process.once('SIGTERM', stop);
|
|
440
|
+
try {
|
|
441
|
+
const completed = await watch.run(controller.signal, { once: options.once });
|
|
442
|
+
return options.once && !completed ? 1 : 0;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (!(error instanceof WatchBusyError)) throw error;
|
|
445
|
+
process.stderr.write(`${error.message}\n`);
|
|
446
|
+
return 1;
|
|
447
|
+
} finally {
|
|
448
|
+
process.removeListener('SIGINT', stop);
|
|
449
|
+
process.removeListener('SIGTERM', stop);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
387
453
|
private async selectHarness(): Promise<IAuthHarness | undefined> {
|
|
388
454
|
if (this.harnesses.size === 1) return this.harnesses.values().next().value;
|
|
389
455
|
if (this.harnesses.size === 0 || !canPrompt()) {
|
|
@@ -494,7 +560,7 @@ ${bold('Environment')}
|
|
|
494
560
|
process.stdout.write(`${dim('no account is currently active')}\n`);
|
|
495
561
|
}
|
|
496
562
|
if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
|
|
497
|
-
for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg
|
|
563
|
+
for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg, drift))}\n`);
|
|
498
564
|
}
|
|
499
565
|
|
|
500
566
|
private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
|
|
@@ -515,12 +581,13 @@ ${bold('Environment')}
|
|
|
515
581
|
}
|
|
516
582
|
|
|
517
583
|
/**
|
|
518
|
-
* The condensed views are projections of the same
|
|
519
|
-
* once, per account, with the same bounded timeouts and per-account failure
|
|
584
|
+
* The condensed views are projections of the same account list, which never changes which account is in
|
|
585
|
+
* use, so a provider is queried once, per account, with the same bounded timeouts and per-account failure
|
|
586
|
+
* isolation as `list`.
|
|
520
587
|
*/
|
|
521
588
|
private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
|
|
522
589
|
const list = await readAccountList(harnessesArg);
|
|
523
|
-
const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
|
|
590
|
+
const document = commandArg === 'limits' ? accountLimits(list, harnessesArg) : activeAccounts(list, harnessesArg);
|
|
524
591
|
if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
525
592
|
else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
|
|
526
593
|
else await new CondensedRenderer(this.out).renderActive(document);
|
|
@@ -530,17 +597,10 @@ ${bold('Environment')}
|
|
|
530
597
|
private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
|
|
531
598
|
const list = await readAccountList(harnessesArg);
|
|
532
599
|
if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
|
|
533
|
-
else await new AccountListRenderer(this.out).render(list);
|
|
600
|
+
else await new AccountListRenderer(this.out).render(list, harnessesArg);
|
|
534
601
|
return list.complete ? 0 : 1;
|
|
535
602
|
}
|
|
536
603
|
|
|
537
|
-
private printAccount(accountArg: IHarnessAccount): void {
|
|
538
|
-
const saved = accountArg.isStashed ? 'saved' : 'not saved';
|
|
539
|
-
process.stdout.write(`${accountArg.isActive ? green('*') : ' '} ${bold(plainText(accountArg.label))} ${dim(`(${saved})`)}\n`);
|
|
540
|
-
if (accountArg.savedAt) process.stdout.write(` ${dim('Saved: ' + plainText(accountArg.savedAt))}\n`);
|
|
541
|
-
for (const detail of accountArg.details) process.stdout.write(` ${detail}\n`);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
604
|
private async resolveAccount(harnessArg: IAuthHarness, referenceArg: string): Promise<string | null> {
|
|
545
605
|
const resolved = await harnessArg.resolveAccount(referenceArg);
|
|
546
606
|
if ('id' in resolved) return resolved.id;
|
|
@@ -16,6 +16,10 @@ export class CodexHarness implements IAuthHarness {
|
|
|
16
16
|
public readonly loginHint = 'Use authswitch codex login to sign in and save another account.';
|
|
17
17
|
public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
|
|
18
18
|
public readonly diagnosticsLabel = 'Check credential storage and remote control';
|
|
19
|
+
/** Running Codex sessions keep the login they loaded; the switch itself restarts the managed app-server. */
|
|
20
|
+
public readonly liveSwap = false;
|
|
21
|
+
/** Every saved ChatGPT login reports its usage, and a switch restarts only the managed app-server. */
|
|
22
|
+
public readonly autoSwitch = true;
|
|
19
23
|
|
|
20
24
|
constructor(
|
|
21
25
|
private readonly switcher = new CodexSwitcher(),
|