@modelprofile.com/authswitch 2.1.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.d.ts +3 -0
- package/dist_ts/accounts.js +37 -1
- package/dist_ts/classes.accountlist.d.ts +4 -0
- package/dist_ts/classes.accountlist.js +18 -12
- package/dist_ts/classes.claudecodeharness.d.ts +3 -1
- package/dist_ts/classes.claudecodeharness.js +5 -3
- package/dist_ts/classes.cli.d.ts +5 -0
- package/dist_ts/classes.cli.js +31 -8
- package/dist_ts/classes.credentialstore.d.ts +8 -1
- package/dist_ts/classes.credentialstore.js +24 -8
- package/dist_ts/classes.fileharness.d.ts +14 -0
- package/dist_ts/classes.fileharness.js +36 -3
- package/dist_ts/classes.limits.d.ts +24 -0
- package/dist_ts/classes.limits.js +178 -0
- package/dist_ts/classes.opencodeharness.d.ts +2 -0
- package/dist_ts/classes.opencodeharness.js +4 -2
- package/dist_ts/helpers.d.ts +9 -0
- package/dist_ts/helpers.js +12 -1
- package/dist_ts/interfaces.list.d.ts +51 -0
- package/package.json +3 -4
- package/readme.md +90 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +29 -0
- package/ts/classes.accountlist.ts +22 -10
- package/ts/classes.claudecodeharness.ts +4 -2
- package/ts/classes.cli.ts +28 -7
- package/ts/classes.credentialstore.ts +19 -7
- package/ts/classes.fileharness.ts +32 -2
- package/ts/classes.limits.ts +187 -0
- package/ts/classes.opencodeharness.ts +3 -1
- package/ts/helpers.ts +12 -0
- package/ts/interfaces.list.ts +55 -0
- package/.smartconfig.json +0 -34
|
@@ -49,21 +49,33 @@ export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAc
|
|
|
49
49
|
};
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
export const consoleWidth = (): number => process.stdout.columns ?? 100;
|
|
53
|
+
export const consoleHeading = (textArg: string): void => { process.stdout.write(`\n${bold(textArg)}\n`); };
|
|
54
|
+
|
|
55
|
+
/** One table implementation for every command, including the label-per-line fallback on narrow terminals. */
|
|
56
|
+
export const consoleTable = async <TRow>(
|
|
57
|
+
outArg: plugins.smartconsole.SmartConsole,
|
|
58
|
+
rowsArg: TRow[],
|
|
59
|
+
columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
|
|
60
|
+
): Promise<void> => {
|
|
61
|
+
if (!rowsArg.length) return;
|
|
62
|
+
if (consoleWidth() < 40) {
|
|
63
|
+
for (const row of rowsArg) {
|
|
64
|
+
process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await outArg.table(rowsArg, { columns: columnsArg, overflow: 'wrap', theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } } });
|
|
69
|
+
};
|
|
70
|
+
|
|
52
71
|
/** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
|
|
53
72
|
export class AccountListRenderer {
|
|
54
73
|
constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
|
|
55
|
-
private get width(): number { return
|
|
56
|
-
private heading(textArg: string): void {
|
|
74
|
+
private get width(): number { return consoleWidth(); }
|
|
75
|
+
private heading(textArg: string): void { consoleHeading(textArg); }
|
|
57
76
|
|
|
58
77
|
private async table<TRow>(rowsArg: TRow[], columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[]): Promise<void> {
|
|
59
|
-
|
|
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 } } });
|
|
78
|
+
await consoleTable(this.out, rowsArg, columnsArg);
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
public async render(listArg: IAccountList): Promise<void> {
|
|
@@ -1,6 +1,6 @@
|
|
|
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, requireHarnessStopped } from './classes.credentialstore.js';
|
|
3
|
+
import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, requireHarnessStopped } from './classes.credentialstore.js';
|
|
4
4
|
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
5
5
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
6
6
|
import type { IHarnessAccountStatus } from './interfaces.harness.js';
|
|
@@ -23,7 +23,7 @@ const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAns
|
|
|
23
23
|
export class ClaudeCodeHarness extends FileHarness {
|
|
24
24
|
public readonly id = 'claude';
|
|
25
25
|
public readonly label = 'Claude Code';
|
|
26
|
-
public readonly loginHint = 'Log in using claude auth login,
|
|
26
|
+
public readonly loginHint = 'Log in using claude auth login, then authswitch claude stash --keep; Claude Code may stay running.';
|
|
27
27
|
protected readonly store: CredentialStore;
|
|
28
28
|
private readonly file: string;
|
|
29
29
|
private readonly configFile: string;
|
|
@@ -48,6 +48,8 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
48
48
|
plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
|
|
49
49
|
}
|
|
50
50
|
protected beforeMutation(): void { this.checkStopped(); }
|
|
51
|
+
/** One record holds the credential and its matching metadata, so both files must be proven stable. */
|
|
52
|
+
protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file), readCredentialRaw(this.configFile)]; }
|
|
51
53
|
protected inspect(credential: Record<string, unknown>, slotId: string): IFileAccount {
|
|
52
54
|
if (slotId !== 'anthropic') throw new Error('Unsupported Claude Code credential slot.');
|
|
53
55
|
const oauth = credentialRecord(credential.claudeAiOauth);
|
package/ts/classes.cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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
8
|
import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
|
|
9
|
+
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
9
10
|
import { duration, orderedUsageWindows, until } from './accounts.js';
|
|
10
11
|
import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
|
|
11
12
|
import type { CodexSwitcher } from './classes.codexswitcher.js';
|
|
@@ -14,7 +15,9 @@ import { bold, dim, green, orange, plainText, red } from './formatting.js';
|
|
|
14
15
|
|
|
15
16
|
const canPrompt = (): boolean =>
|
|
16
17
|
process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
|
|
17
|
-
const COMMANDS = ['login', 'stash', 'list', 'ls', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
|
|
18
|
+
const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
|
|
19
|
+
/** Read-only overviews that work across every registered harness and support --json. */
|
|
20
|
+
const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
|
|
18
21
|
|
|
19
22
|
/** Harness-independent command routing and interactive account management. */
|
|
20
23
|
export class AuthSwitchCli {
|
|
@@ -48,6 +51,8 @@ ${bold('Usage')}
|
|
|
48
51
|
authswitch list list all accounts with live subscription, usage and resets
|
|
49
52
|
authswitch <harness> list list accounts for one harness
|
|
50
53
|
authswitch list --json print all account information as JSON
|
|
54
|
+
authswitch limits one row per account and limit type: used % and reset countdown
|
|
55
|
+
authswitch active one row per provider: which account is in use, and since when
|
|
51
56
|
authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
|
|
52
57
|
send one prompt through that account without switching
|
|
53
58
|
authswitch <harness> stash [account/provider] [--keep]
|
|
@@ -68,7 +73,7 @@ ${bold('Options')}
|
|
|
68
73
|
-v, --version show the version
|
|
69
74
|
-i, --interactive open the guided account manager
|
|
70
75
|
--tui open the terminal management dashboard
|
|
71
|
-
--json JSON output for list / ls only
|
|
76
|
+
--json JSON output for list / ls / limits / active only
|
|
72
77
|
|
|
73
78
|
Preuse consumes the selected account's quota. Its default prompt is:
|
|
74
79
|
"${defaultPreusePrompt}"
|
|
@@ -97,8 +102,8 @@ ${bold('Environment')}
|
|
|
97
102
|
args.splice(args.indexOf('--json'), 1);
|
|
98
103
|
const route = [...args];
|
|
99
104
|
if (this.harnesses.has(route[0])) route.shift();
|
|
100
|
-
if (route.length !== 1 || !
|
|
101
|
-
process.stderr.write(
|
|
105
|
+
if (route.length !== 1 || !OVERVIEW_COMMANDS.includes(route[0])) {
|
|
106
|
+
process.stderr.write(`--json requires ${OVERVIEW_COMMANDS.join(', ')}, optionally qualified by a harness.\n`);
|
|
102
107
|
return 2;
|
|
103
108
|
}
|
|
104
109
|
}
|
|
@@ -130,9 +135,12 @@ ${bold('Environment')}
|
|
|
130
135
|
process.stderr.write(`${red('unknown harness or command')} "${plainText(first)}"\n\n${this.usage()}\n`);
|
|
131
136
|
return 2;
|
|
132
137
|
}
|
|
133
|
-
if (
|
|
134
|
-
if (args.length) { process.stderr.write(
|
|
135
|
-
|
|
138
|
+
if (OVERVIEW_COMMANDS.includes(command)) {
|
|
139
|
+
if (args.length) { process.stderr.write(`${command} accepts only --json; optionally put a harness before ${command}.\n`); return 2; }
|
|
140
|
+
const harnesses = harness ? [harness] : [...this.harnesses.values()];
|
|
141
|
+
return command === 'limits' || command === 'active'
|
|
142
|
+
? await this.commandCondensed(command, harnesses, json)
|
|
143
|
+
: await this.commandList(harnesses, json);
|
|
136
144
|
}
|
|
137
145
|
if (!harness) {
|
|
138
146
|
harness = await this.selectHarness();
|
|
@@ -426,6 +434,19 @@ ${bold('Environment')}
|
|
|
426
434
|
return outcomeArg.problems.length === 0 ? 0 : 1;
|
|
427
435
|
}
|
|
428
436
|
|
|
437
|
+
/**
|
|
438
|
+
* The condensed views are projections of the same read-only account list, so a provider is queried
|
|
439
|
+
* once, per account, with the same bounded timeouts and per-account failure isolation as `list`.
|
|
440
|
+
*/
|
|
441
|
+
private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
|
|
442
|
+
const list = await readAccountList(harnessesArg);
|
|
443
|
+
const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
|
|
444
|
+
if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
445
|
+
else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
|
|
446
|
+
else await new CondensedRenderer(this.out).renderActive(document);
|
|
447
|
+
return document.complete ? 0 : 1;
|
|
448
|
+
}
|
|
449
|
+
|
|
429
450
|
private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
|
|
430
451
|
const list = await readAccountList(harnessesArg);
|
|
431
452
|
if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
|
|
@@ -8,8 +8,8 @@ export const credentialRecord = (value: unknown): Record<string, unknown> => {
|
|
|
8
8
|
export const credentialText = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
9
9
|
export const credentialHash = (value: string): string => plugins.crypto.createHash('sha256').update(value).digest('hex');
|
|
10
10
|
|
|
11
|
-
/** A bounded
|
|
12
|
-
export const
|
|
11
|
+
/** A bounded raw read. Callers that only compare bytes must not have to parse a file another process owns. */
|
|
12
|
+
export const readCredentialRaw = (file: string): string | null => {
|
|
13
13
|
let fd: number | undefined;
|
|
14
14
|
try {
|
|
15
15
|
fd = plugins.fs.openSync(file, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NONBLOCK);
|
|
@@ -23,14 +23,21 @@ export const readCredentialDocument = (file: string): { raw: string | null; data
|
|
|
23
23
|
size += count;
|
|
24
24
|
}
|
|
25
25
|
if (size === buffer.length) throw new Error();
|
|
26
|
-
|
|
27
|
-
return { raw, data: credentialRecord(JSON.parse(raw)) };
|
|
26
|
+
return buffer.subarray(0, size).toString('utf8');
|
|
28
27
|
} catch (error) {
|
|
29
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
30
29
|
throw new Error('Credential file is unreadable, malformed, or too large; no changes were made.');
|
|
31
30
|
} finally { if (fd !== undefined) plugins.fs.closeSync(fd); }
|
|
32
31
|
};
|
|
33
32
|
|
|
33
|
+
/** A bounded file read; malformed secrets never become parser diagnostics in the CLI. */
|
|
34
|
+
export const readCredentialDocument = (file: string): { raw: string | null; data: Record<string, unknown> } => {
|
|
35
|
+
const raw = readCredentialRaw(file);
|
|
36
|
+
if (raw === null) return { raw: null, data: {} };
|
|
37
|
+
try { return { raw, data: credentialRecord(JSON.parse(raw)) }; }
|
|
38
|
+
catch { throw new Error('Credential file is unreadable, malformed, or too large; no changes were made.'); }
|
|
39
|
+
};
|
|
40
|
+
|
|
34
41
|
export interface IStoredCredential {
|
|
35
42
|
schemaVersion: 1;
|
|
36
43
|
id: string;
|
|
@@ -87,7 +94,12 @@ export class CredentialStore {
|
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Native harnesses hold their login in memory and rewrite it on their own refresh schedule, so a
|
|
99
|
+
* credential written underneath a running one is destroyed by the next refresh. Operations that
|
|
100
|
+
* replace a native login therefore require it stopped. Reading for a save that keeps the login in
|
|
101
|
+
* place writes nothing native and is guarded by content stability instead (see FileHarness).
|
|
102
|
+
*/
|
|
91
103
|
export const requireHarnessStopped = (binary: 'opencode' | 'claude'): void => {
|
|
92
104
|
let names: string[];
|
|
93
105
|
try {
|
|
@@ -97,5 +109,5 @@ export const requireHarnessStopped = (binary: 'opencode' | 'claude'): void => {
|
|
|
97
109
|
names = plugins.childProcess.execFileSync('ps', ['-u', String(process.getuid!()), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => plugins.path.basename(line.trim()));
|
|
98
110
|
}
|
|
99
111
|
} catch { throw new Error(`Could not verify that ${binary} is stopped. No login was changed.`); }
|
|
100
|
-
if (names.some(name => name === binary || name === `${binary}.exe`)) throw new Error(`Exit running ${binary} processes before
|
|
112
|
+
if (names.some(name => name === binary || name === `${binary}.exe`)) throw new Error(`Exit running ${binary} processes before replacing or clearing its login, then start ${binary} again. Saving with --keep does not need this.`);
|
|
101
113
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IAuthHarness, IHarnessAccountStatus, IHarnessOutcome, IHarnessState } from './interfaces.harness.js';
|
|
2
2
|
import { CredentialStore, type IStoredCredential } from './classes.credentialstore.js';
|
|
3
|
+
import { pauseSynchronously } from './helpers.js';
|
|
3
4
|
import { plainText } from './formatting.js';
|
|
4
5
|
|
|
5
6
|
export interface IFileAccount {
|
|
@@ -22,10 +23,38 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
22
23
|
protected abstract readonly store: CredentialStore;
|
|
23
24
|
protected abstract snapshot(): IFileHarnessSnapshot;
|
|
24
25
|
protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
|
|
26
|
+
/** Guards the operations that write a native credential file. Reads must not depend on it. */
|
|
25
27
|
protected abstract beforeMutation(): void;
|
|
28
|
+
/** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
|
|
29
|
+
protected abstract nativeSources(): (string | null)[];
|
|
26
30
|
protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
|
|
27
31
|
public abstract readAccountStatus(id: string): Promise<IHarnessAccountStatus>;
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A snapshot proven not to have been taken across a native write.
|
|
35
|
+
*
|
|
36
|
+
* Saving a login without replacing it writes no native file, so it must not require the harness
|
|
37
|
+
* to be stopped. A running harness does rotate its tokens on its own schedule, and its fallback
|
|
38
|
+
* writer truncates the file in place, so reading the same sources before and after the snapshot
|
|
39
|
+
* and requiring identical bytes is what keeps a torn or half-rotated credential out of the stash.
|
|
40
|
+
* Both the retry count and the pause between attempts are bounded; an unstable file is reported,
|
|
41
|
+
* never guessed at.
|
|
42
|
+
*/
|
|
43
|
+
private stableSnapshot(): IFileHarnessSnapshot {
|
|
44
|
+
let failure: unknown;
|
|
45
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
46
|
+
if (attempt) pauseSynchronously(20);
|
|
47
|
+
try {
|
|
48
|
+
const before = JSON.stringify(this.nativeSources());
|
|
49
|
+
const snapshot = this.snapshot();
|
|
50
|
+
if (JSON.stringify(this.nativeSources()) === before) return snapshot;
|
|
51
|
+
failure = undefined;
|
|
52
|
+
} catch (error) { failure = error; }
|
|
53
|
+
}
|
|
54
|
+
if (failure) throw failure;
|
|
55
|
+
throw new Error(`${this.label} kept rewriting its credential files while they were being read; nothing was saved. Try again in a moment.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
29
58
|
private saved(entry: IStoredCredential): IFileAccount {
|
|
30
59
|
const account = this.inspect(entry.credential, entry.slotId);
|
|
31
60
|
if (account.identity !== entry.identity) throw new Error('Saved credential does not match its account identity.');
|
|
@@ -83,8 +112,9 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
83
112
|
}
|
|
84
113
|
public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
|
|
85
114
|
return this.outcome(() => {
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
// Keeping the login in place only reads the native files, so a running harness is no reason to refuse.
|
|
116
|
+
if (!options.keepActive) this.beforeMutation();
|
|
117
|
+
const snapshot = this.stableSnapshot();
|
|
88
118
|
if (snapshot.unavailable) throw new Error(snapshot.unavailable);
|
|
89
119
|
const matches = snapshot.accounts.filter(account => !options.accountId || this.store.id(account.slotId, account.identity) === options.accountId || account.slotId === options.accountId || account.label === options.accountId);
|
|
90
120
|
if (matches.length !== 1) throw new Error(matches.length ? 'Several logins are active. Select the account or provider to save.' : 'No matching active login to save.');
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { compactSince, compactUntil, orderedUsageWindows } from './accounts.js';
|
|
3
|
+
import { consoleHeading, consoleTable } from './classes.accountlist.js';
|
|
4
|
+
import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
|
|
5
|
+
import { dim, plainText } from './formatting.js';
|
|
6
|
+
|
|
7
|
+
type TListedAccount = IHarnessAccountList['accounts'][number];
|
|
8
|
+
|
|
9
|
+
const UNAVAILABLE = 'n/a';
|
|
10
|
+
const NONE = 'none';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Provider naming stays data-driven.
|
|
14
|
+
*
|
|
15
|
+
* A harness that owns exactly one credential slot is its own provider; one that owns several (an
|
|
16
|
+
* OpenCode installation with an OpenAI and an Anthropic login) has to say which slot a row is about.
|
|
17
|
+
*/
|
|
18
|
+
const providerName = (harnessArg: IHarnessAccountList, accountArg?: TListedAccount): string => {
|
|
19
|
+
const slots = new Set(harnessArg.accounts.map(account => account.slotId).filter((slot): slot is string => slot !== undefined));
|
|
20
|
+
return accountArg?.slotId !== undefined && slots.size > 1
|
|
21
|
+
? `${plainText(harnessArg.label)} / ${plainText(accountArg.slotId)}`
|
|
22
|
+
: plainText(harnessArg.label);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Why an account contributes no numbers.
|
|
27
|
+
*
|
|
28
|
+
* The provider's own explanation is preferred over ours, and a failed lookup is never presented as a
|
|
29
|
+
* missing capability: the two have different fixes.
|
|
30
|
+
*/
|
|
31
|
+
const missingLimitReason = (accountArg: TListedAccount): string => {
|
|
32
|
+
if (accountArg.status.problems.length) return accountArg.status.problems.map(plainText).join(' ');
|
|
33
|
+
const availability = accountArg.status.facts.filter(fact => fact.section === 'Availability').map(fact => plainText(fact.value));
|
|
34
|
+
if (accountArg.status.summary?.usageWindows === undefined) {
|
|
35
|
+
return availability[0] ?? 'This login does not expose a usage or quota API.';
|
|
36
|
+
}
|
|
37
|
+
return 'The provider reported no usage window for this account.';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount) => ({
|
|
41
|
+
harnessId: harnessArg.id,
|
|
42
|
+
provider: providerName(harnessArg, accountArg),
|
|
43
|
+
account: plainText(accountArg.label),
|
|
44
|
+
accountId: accountArg.id,
|
|
45
|
+
...(accountArg.slotId === undefined ? {} : { slotId: plainText(accountArg.slotId) }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
|
|
49
|
+
harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
|
|
50
|
+
|
|
51
|
+
/** Provider then account, keeping each account's windows in the order the adapter ranked them. */
|
|
52
|
+
const byProviderThenAccount = <TRow extends { provider: string; account: string | null }>(rowsArg: TRow[]): TRow[] =>
|
|
53
|
+
[...rowsArg].sort((left, right) => left.provider.localeCompare(right.provider)
|
|
54
|
+
|| (left.account ?? '').localeCompare(right.account ?? ''));
|
|
55
|
+
|
|
56
|
+
const noteFor = (rowArg: { provider: string; account: string | null; unavailableReason: string | null }): string | null =>
|
|
57
|
+
rowArg.unavailableReason === null ? null
|
|
58
|
+
: `${rowArg.provider}${rowArg.account === null ? '' : ` · ${rowArg.account}`}: ${rowArg.unavailableReason}`;
|
|
59
|
+
|
|
60
|
+
const collectNotes = (rowsArg: { provider: string; account: string | null; unavailableReason: string | null }[]): string[] =>
|
|
61
|
+
[...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One row per account and limit type, projected from the account list.
|
|
65
|
+
*
|
|
66
|
+
* Nothing here talks to a provider: the list is already the read-only, per-account-isolated lookup,
|
|
67
|
+
* so a single failing account costs its own rows a number and nothing else.
|
|
68
|
+
*/
|
|
69
|
+
export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
70
|
+
const now = Date.parse(listArg.generatedAt);
|
|
71
|
+
const rows: IAccountLimitRow[] = [];
|
|
72
|
+
for (const harness of listArg.harnesses) {
|
|
73
|
+
const problem = harnessProblem(harness);
|
|
74
|
+
const empty = {
|
|
75
|
+
limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE,
|
|
76
|
+
} as const;
|
|
77
|
+
if (problem !== null) {
|
|
78
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!harness.accounts.length) {
|
|
82
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty,
|
|
83
|
+
unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const account of harness.accounts) {
|
|
87
|
+
const windows = orderedUsageWindows(account.status.summary?.usageWindows);
|
|
88
|
+
if (!windows.length) {
|
|
89
|
+
rows.push({ ...accountKeys(harness, account), isActive: account.isActive, ...empty, unavailableReason: missingLimitReason(account) });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const window of windows) {
|
|
93
|
+
rows.push({
|
|
94
|
+
...accountKeys(harness, account), isActive: account.isActive,
|
|
95
|
+
limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
|
|
96
|
+
usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
|
|
97
|
+
unavailableReason: null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const limits = byProviderThenAccount(rows);
|
|
103
|
+
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: collectNotes(limits) };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One row per active credential slot.
|
|
108
|
+
*
|
|
109
|
+
* A harness with no active login still gets a row: "nothing is active here" is an answer, and
|
|
110
|
+
* silently dropping the provider would read as "this provider does not exist".
|
|
111
|
+
*/
|
|
112
|
+
export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
113
|
+
const now = Date.parse(listArg.generatedAt);
|
|
114
|
+
const rows: IActiveAccountRow[] = [];
|
|
115
|
+
for (const harness of listArg.harnesses) {
|
|
116
|
+
const problem = harnessProblem(harness);
|
|
117
|
+
if (problem !== null) {
|
|
118
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const active = harness.accounts.filter(account => account.isActive);
|
|
122
|
+
if (!active.length) {
|
|
123
|
+
const stashed = harness.accounts.filter(account => account.isStashed);
|
|
124
|
+
rows.push({
|
|
125
|
+
harnessId: harness.id, provider: providerName(harness), account: null, accountId: null,
|
|
126
|
+
savedAt: null, savedAgo: UNAVAILABLE, source: stashed.length ? 'stash only' : 'none',
|
|
127
|
+
unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
|
|
128
|
+
: stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
|
|
129
|
+
: `No login is active and none is saved. ${plainText(harness.loginHint)}`,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const account of active) {
|
|
134
|
+
rows.push({
|
|
135
|
+
...accountKeys(harness, account),
|
|
136
|
+
savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
|
|
137
|
+
source: 'credential file',
|
|
138
|
+
unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const active = byProviderThenAccount(rows);
|
|
143
|
+
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: collectNotes(active) };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
|
|
147
|
+
|
|
148
|
+
/** Condensed presentation for `authswitch limits` and `authswitch active`. */
|
|
149
|
+
export class CondensedRenderer {
|
|
150
|
+
constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
|
|
151
|
+
|
|
152
|
+
private notes(notesArg: string[], prefixArg: string): void {
|
|
153
|
+
if (!notesArg.length) return;
|
|
154
|
+
process.stdout.write('\n');
|
|
155
|
+
for (const note of notesArg) process.stdout.write(`${dim(`${prefixArg}${note}`)}\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
|
|
159
|
+
consoleHeading('Account limits');
|
|
160
|
+
if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
|
|
161
|
+
await consoleTable(this.out, limitsArg.limits, [
|
|
162
|
+
{ key: 'provider', title: 'Provider', value: row => row.provider },
|
|
163
|
+
{ key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, style: row => ({ bold: row.isActive }) },
|
|
164
|
+
{ key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
|
|
165
|
+
{ key: 'used', title: 'Used %', value: percent, align: 'right',
|
|
166
|
+
style: row => ({ foreground: row.usedPercent === null ? 'orange' : row.usedPercent >= 100 ? 'red' : undefined }) },
|
|
167
|
+
{ key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
|
|
168
|
+
]);
|
|
169
|
+
this.notes(limitsArg.notes, 'n/a — ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public async renderActive(activeArg: IActiveAccounts): Promise<void> {
|
|
173
|
+
consoleHeading('Active accounts');
|
|
174
|
+
if (!activeArg.active.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
|
|
175
|
+
await consoleTable(this.out, activeArg.active, [
|
|
176
|
+
{ key: 'provider', title: 'Provider', value: row => row.provider },
|
|
177
|
+
{ key: 'account', title: 'Account', value: row => row.account ?? NONE,
|
|
178
|
+
style: row => ({ bold: row.account !== null, foreground: row.account === null ? 'orange' : undefined }) },
|
|
179
|
+
// The absolute timestamp stays in --json; one screen is worth more here than one more column.
|
|
180
|
+
{ key: 'saved', title: 'Saved', value: row => row.savedAt === null ? (row.account === null ? UNAVAILABLE : 'not saved') : `${row.savedAgo} ago`, align: 'right',
|
|
181
|
+
style: row => ({ foreground: row.savedAt === null && row.account !== null ? 'orange' : undefined }) },
|
|
182
|
+
{ key: 'source', title: 'Source', value: row => row.source,
|
|
183
|
+
style: row => ({ foreground: row.source === 'credential file' ? 'green' : 'orange' }) },
|
|
184
|
+
]);
|
|
185
|
+
this.notes(activeArg.notes, 'note — ');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
|
|
3
|
-
import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, requireHarnessStopped } from './classes.credentialstore.js';
|
|
3
|
+
import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, requireHarnessStopped } from './classes.credentialstore.js';
|
|
4
4
|
import { CodexAccountStatus } from './classes.codexstatus.js';
|
|
5
5
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
6
6
|
import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
|
|
@@ -39,6 +39,8 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
39
39
|
this.checkStopped = options.checkStopped ?? (() => requireHarnessStopped('opencode'));
|
|
40
40
|
}
|
|
41
41
|
protected beforeMutation(): void { this.checkStopped(); }
|
|
42
|
+
/** Every provider entry lives in one file, so its bytes are the whole stability proof. */
|
|
43
|
+
protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file)]; }
|
|
42
44
|
/** Public identity only, for reconciling a host's OpenAI association after native refresh/restart. */
|
|
43
45
|
public activeOpenAiAccountId(): string | undefined {
|
|
44
46
|
const account = this.snapshot().accounts.find(item => item.slotId === 'openai');
|
package/ts/helpers.ts
CHANGED
|
@@ -53,6 +53,18 @@ export const writeSecretFileAtomically = (
|
|
|
53
53
|
plugins.fs.chmodSync(destinationPathArg, 0o600);
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Blocks the calling thread for `millisecondsArg`.
|
|
58
|
+
*
|
|
59
|
+
* Credential reads are synchronous all the way down, so a retry that re-reads
|
|
60
|
+
* immediately can land inside the very same in-flight write it is retrying past.
|
|
61
|
+
* `Atomics.wait` on a private buffer is the only synchronous pause Node offers,
|
|
62
|
+
* and the callers bound both the count and the duration.
|
|
63
|
+
*/
|
|
64
|
+
export const pauseSynchronously = (millisecondsArg: number): void => {
|
|
65
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, millisecondsArg);
|
|
66
|
+
};
|
|
67
|
+
|
|
56
68
|
/**
|
|
57
69
|
* Trims third-party process output down to something safe to put in an error
|
|
58
70
|
* message: one line, length-capped, and never empty.
|
package/ts/interfaces.list.ts
CHANGED
|
@@ -17,3 +17,58 @@ export interface IHarnessAccountList {
|
|
|
17
17
|
/** A state lookup failure is distinct from an empty account list. */
|
|
18
18
|
problems: string[];
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/** Credential-free, versioned output of limits --json. One entry per account and limit type. */
|
|
22
|
+
export interface IAccountLimits {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
generatedAt: string;
|
|
25
|
+
complete: boolean;
|
|
26
|
+
limits: IAccountLimitRow[];
|
|
27
|
+
/** Why a row has no numbers. Never a substitute for a value. */
|
|
28
|
+
notes: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IAccountLimitRow {
|
|
32
|
+
harnessId: string;
|
|
33
|
+
/** Harness label, qualified by the credential slot when the harness owns several. */
|
|
34
|
+
provider: string;
|
|
35
|
+
/** The account's public label, which is its email wherever the provider reports one. */
|
|
36
|
+
account: string | null;
|
|
37
|
+
accountId: string | null;
|
|
38
|
+
slotId?: string;
|
|
39
|
+
isActive: boolean;
|
|
40
|
+
/** The provider's own window name, or null when no limit data is available. */
|
|
41
|
+
limitType: string | null;
|
|
42
|
+
scope: 'account' | 'feature' | null;
|
|
43
|
+
windowSeconds: number | null;
|
|
44
|
+
/** Never inferred: null means the provider reported no percentage, not zero usage. */
|
|
45
|
+
usedPercent: number | null;
|
|
46
|
+
resetAt: string | null;
|
|
47
|
+
/** Human countdown for the same `resetAt`, relative to `generatedAt`. */
|
|
48
|
+
resetsIn: string;
|
|
49
|
+
unavailableReason: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Credential-free, versioned output of active --json. One entry per active credential slot. */
|
|
53
|
+
export interface IActiveAccounts {
|
|
54
|
+
schemaVersion: 1;
|
|
55
|
+
generatedAt: string;
|
|
56
|
+
complete: boolean;
|
|
57
|
+
active: IActiveAccountRow[];
|
|
58
|
+
notes: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IActiveAccountRow {
|
|
62
|
+
harnessId: string;
|
|
63
|
+
provider: string;
|
|
64
|
+
account: string | null;
|
|
65
|
+
accountId: string | null;
|
|
66
|
+
slotId?: string;
|
|
67
|
+
/** When authswitch last saved this login; null when it was never stashed. */
|
|
68
|
+
savedAt: string | null;
|
|
69
|
+
/** Human elapsed time for the same `savedAt`, relative to `generatedAt`. */
|
|
70
|
+
savedAgo: string;
|
|
71
|
+
/** Where the shown account comes from. Whether it is also saved is the `savedAt` field's answer. */
|
|
72
|
+
source: 'credential file' | 'stash only' | 'none';
|
|
73
|
+
unavailableReason: string | null;
|
|
74
|
+
}
|
package/.smartconfig.json
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"@git.zone/cli": {
|
|
3
|
-
"schemaVersion": 2,
|
|
4
|
-
"projectType": "npm",
|
|
5
|
-
"module": {
|
|
6
|
-
"githost": "code.foss.global",
|
|
7
|
-
"gitscope": "modelprofile.com",
|
|
8
|
-
"gitrepo": "authswitch",
|
|
9
|
-
"description": "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status",
|
|
10
|
-
"npmPackagename": "@modelprofile.com/authswitch",
|
|
11
|
-
"license": "MIT",
|
|
12
|
-
"projectDomain": "modelprofile.com"
|
|
13
|
-
},
|
|
14
|
-
"release": {
|
|
15
|
-
"targets": {
|
|
16
|
-
"git": {
|
|
17
|
-
"enabled": true,
|
|
18
|
-
"remote": "origin",
|
|
19
|
-
"pushBranch": true,
|
|
20
|
-
"pushTags": true
|
|
21
|
-
},
|
|
22
|
-
"npm": {
|
|
23
|
-
"enabled": true,
|
|
24
|
-
"registries": [
|
|
25
|
-
"https://verdaccio.lossless.digital",
|
|
26
|
-
"https://registry.npmjs.org"
|
|
27
|
-
],
|
|
28
|
-
"accessLevel": "public",
|
|
29
|
-
"alreadyPublished": "success"
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|