@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,138 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IAuthHarness, IHarnessOutcome, IHarnessState } from './interfaces.harness.js';
3
+ import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usagePeriod, until, readAccountRows, type IAccountRow } from './accounts.js';
4
+ import { plainText } from './formatting.js';
5
+ import { AuthSwitchOperations } from './classes.operations.js';
6
+
7
+ /** Owns account-management actions; terminal rendering and interaction belong to smartconsole. */
8
+ export class AuthSwitchTui {
9
+ constructor(private readonly harnesses: readonly IAuthHarness[], private readonly out: plugins.smartconsole.SmartConsole, private readonly operations = new AuthSwitchOperations()) {}
10
+
11
+ public async run(preferredArg?: IAuthHarness): Promise<number> {
12
+ let harness = preferredArg ?? this.harnesses[0];
13
+ if (!harness) { await this.out.warn('No harnesses registered.'); return 2; }
14
+ const ui = this.out.tui;
15
+ let state: IHarnessState | undefined;
16
+ let rows: IAccountRow[] = [];
17
+ const footer = ui.text('Loading account status…', { height: 2 });
18
+ const details = ui.viewer('Select an account to inspect its status.');
19
+ const table = ui.table<IAccountRow>({
20
+ rows: [], rowKey: row => JSON.stringify([row.harness.id, row.account.id]),
21
+ columns: [
22
+ { key: 'account', title: 'Account', value: row => plainText(row.account.label) },
23
+ { key: 'state', title: 'State', value: accountState },
24
+ { key: 'plan', title: 'Plan', value: accountPlan },
25
+ { key: 'usage', title: 'Usage / reset', value: row => accountQuotaSummary(row, Date.now()).replaceAll('\n', ' / ') },
26
+ { key: 'resets', title: 'Resets', value: row => row.status?.summary?.resets?.available, render: accountResets, align: 'right' },
27
+ ],
28
+ onSelect: () => updateDetails(),
29
+ onActivate: async (_row, screen) => { await perform(screen, () => switchSelected(screen)); },
30
+ });
31
+ const updateDetails = (): void => { details.setText(table.selected ? accountDetails(table.selected) : state?.saveUnavailableReason ?? harness.loginHint); };
32
+ const showOutcome = (outcomeArg: IHarnessOutcome): boolean => {
33
+ for (const line of outcomeArg.lines) ui.logs.append(plainText(line));
34
+ for (const problem of outcomeArg.problems) ui.logs.append('Incomplete: ' + plainText(problem));
35
+ footer.setText(outcomeArg.problems.length ? 'Incomplete: ' + outcomeArg.problems.map(plainText).join('; ') : outcomeArg.lines.map(plainText).join(' · ') || 'Done.');
36
+ return outcomeArg.problems.length === 0;
37
+ };
38
+ const refresh = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
39
+ footer.setText(`Loading ${plainText(harness.label)} accounts and live status…`);
40
+ await screen.invalidate();
41
+ try {
42
+ const result = await readAccountRows(harness, { signal: screen.signal, onUpdate: async (updatedArg, stateArg) => {
43
+ if (screen.signal.aborted) return;
44
+ rows = updatedArg; state = stateArg; table.setRows(rows); updateDetails(); await screen.invalidate();
45
+ } });
46
+ if (screen.signal.aborted) return;
47
+ state = result.state; rows = result.rows;
48
+ const failures = rows.filter(row => row.status?.problems.length).length;
49
+ footer.setText(`${rows.length} account(s) · ${failures ? `${failures} with unavailable status sections` : 'Status refreshed'}${state.saveUnavailableReason ? ' · ' + plainText(state.saveUnavailableReason) : ''}`);
50
+ } catch {
51
+ state = undefined; rows = []; table.setRows([]);
52
+ footer.setText(`Could not read ${plainText(harness.label)} accounts. Run authswitch ${harness.id} doctor.`);
53
+ }
54
+ updateDetails();
55
+ };
56
+ const perform = async (screen: plugins.smartconsole.ITuiContext, action: () => Promise<void>): Promise<void> => {
57
+ if (screen.signal.aborted) return;
58
+ try { await action(); }
59
+ catch { footer.setText(`Operation failed. Run authswitch ${harness.id} doctor for diagnostics.`); }
60
+ updateDetails();
61
+ };
62
+ const save = async (screen: plugins.smartconsole.ITuiContext, keepActiveArg: boolean): Promise<void> => {
63
+ const latest = await harness.readState();
64
+ const activeAccounts = latest.accounts.filter(account => account.isActive);
65
+ const active = activeAccounts.find(account => account.id === table.selected?.account.id) ?? (activeAccounts.length === 1 ? activeAccounts[0] : undefined);
66
+ if (!active && activeAccounts.length > 1) { footer.setText('Select the active account you want to save.'); return; }
67
+ if (!active || latest.saveUnavailableReason) { footer.setText(latest.saveUnavailableReason ?? 'No active login to save.'); return; }
68
+ if (!await screen.confirm(`Save ${plainText(active.label)}${keepActiveArg ? ' and keep it active' : ' and clear the active login'}?`)) return;
69
+ if (screen.signal.aborted) return;
70
+ const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'save', keepActive: keepActiveArg, accountId: active.id }, message => screen.confirm(message));
71
+ await refresh(screen); showOutcome(outcome);
72
+ };
73
+ const switchSelected = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
74
+ const selected = table.selected;
75
+ if (!selected) return;
76
+ const latest = await harness.readState();
77
+ const target = latest.accounts.find(account => account.id === selected.account.id && account.isStashed);
78
+ if (!target) { footer.setText('Select an account with a verified saved login.'); return; }
79
+ const active = latest.accounts.find(account => account.isActive && account.slotId === target.slotId);
80
+ if (active && !active.isStashed) {
81
+ if (latest.saveUnavailableReason) { footer.setText(latest.saveUnavailableReason); return; }
82
+ if (!await screen.confirm(`${plainText(active.label)} is not saved. Save this login before switching?`, { confirmLabel: 'Save first' })) return;
83
+ if (screen.signal.aborted) return;
84
+ if (!showOutcome(await this.operations.run(harness, { harnessId: harness.id, action: 'save', keepActive: true, accountId: active.id }, message => screen.confirm(message)))) return;
85
+ const verified = await harness.readState();
86
+ if (!verified.accounts.some(account => account.isActive && account.isStashed && account.id === active.id)) { footer.setText('The current login could not be verified as saved. Switching was cancelled.'); return; }
87
+ }
88
+ if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}?`, { confirmLabel: 'Switch' })) return;
89
+ if (screen.signal.aborted) return;
90
+ const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id }, message => screen.confirm(message));
91
+ await refresh(screen); showOutcome(outcome);
92
+ };
93
+ const remove = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
94
+ const selected = table.selected;
95
+ if (!selected || (!selected.account.isStashed && selected.account.savedAt === null)) { footer.setText('Select an account with a saved copy.'); return; }
96
+ if (!await screen.confirm(`Remove the saved login for ${plainText(selected.account.label)}? The active login stays as it is.`, { confirmLabel: 'Remove saved copy' })) return;
97
+ if (screen.signal.aborted) return;
98
+ const outcome = await harness.removeAccount(selected.account.id);
99
+ await refresh(screen); showOutcome(outcome);
100
+ };
101
+ const harnessTable = ui.table({
102
+ rows: this.harnesses, rowKey: item => item.id, selectedKey: harness.id,
103
+ columns: [{ key: 'harness', title: 'Harness', value: item => item.label }],
104
+ onActivate: async (selected, screen) => {
105
+ harness = selected; state = undefined; table.setRows([]);
106
+ await refresh(screen); screen.focus(table);
107
+ },
108
+ });
109
+ const tabs = ui.tabs([{ title: 'Account details', content: details }, { title: 'Activity log', content: ui.logs }]);
110
+ await ui.run({
111
+ view: () => {
112
+ const usage = accountUsageWindows({ status: table.selected?.status })?.slice(0, 2).map(window => ui.progress(
113
+ `${usagePeriod(window)} (${window.usedPercent}% used; reset in ${until(window.resetAt, Date.now())})`, Math.min(100, window.usedPercent), 100,
114
+ )) ?? [];
115
+ const accountView = ui.column([
116
+ ui.panel(`${plainText(harness.label)} accounts`, table),
117
+ ui.panel('Status and diagnostics', ui.column([...usage, tabs])),
118
+ ]);
119
+ return ui.column([
120
+ ui.text('authswitch · account management', { height: 1 }),
121
+ this.harnesses.length > 1 ? ui.row([ui.panel('Harnesses', harnessTable, { width: 23 }), accountView]) : accountView,
122
+ ui.text('Enter switch · a save · c save+clear · d remove · r refresh · g diagnose · Tab focus · q quit', { height: 2 }),
123
+ footer,
124
+ ]);
125
+ },
126
+ onReady: async screen => { screen.focus(table); await refresh(screen); },
127
+ keys: {
128
+ q: screen => screen.close(),
129
+ r: screen => refresh(screen),
130
+ a: screen => perform(screen, () => save(screen, true)),
131
+ c: screen => perform(screen, () => save(screen, false)),
132
+ d: screen => perform(screen, () => remove(screen)),
133
+ g: screen => perform(screen, async () => { showOutcome(await harness.diagnose()); }),
134
+ },
135
+ });
136
+ return 0;
137
+ }
138
+ }
@@ -0,0 +1,14 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ const styled = (): boolean => process.stdout.isTTY === true && !Object.hasOwn(process.env, 'NO_COLOR');
4
+ const render = (textArg: string, styleArg: plugins.smartconsole.ITextStyle): string =>
5
+ styled() ? plugins.smartconsole.color.toAnsi(plugins.smartconsole.color.text(textArg, styleArg)) : plainText(textArg);
6
+ export const dim = (textArg: string): string => render(textArg, { dim: true });
7
+ export const bold = (textArg: string): string => render(textArg, { bold: true });
8
+ const paint = (textArg: string, colorArg: plugins.smartconsole.TColorName): string => render(textArg, { foreground: colorArg });
9
+ export const green = (textArg: string): string => paint(textArg, 'green');
10
+ export const red = (textArg: string): string => paint(textArg, 'red');
11
+ export const orange = (textArg: string): string => paint(textArg, 'orange');
12
+ export const cyan = (textArg: string): string => paint(textArg, 'cyan');
13
+ /** Remote labels cannot inject terminal control sequences or extra output rows. */
14
+ export const plainText = (textArg: string): string => textArg.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 500);
package/ts/helpers.ts ADDED
@@ -0,0 +1,109 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IRemoteControlEnrollment, TEnrollmentState } from './interfaces.js';
3
+
4
+ /**
5
+ * Writes a secret to `destinationPathArg` atomically and owner-only.
6
+ *
7
+ * Three things matter here, and none of them are free with a plain
8
+ * `writeFileSync`:
9
+ *
10
+ * - The temp file is created with the `wx` flag, so a leftover file from an
11
+ * interrupted run cannot silently receive a credential at whatever mode that
12
+ * run left behind.
13
+ * - Its mode is set explicitly after creation, because `writeFileSync`'s `mode`
14
+ * option only applies when the file is created.
15
+ * - It is removed on every failure path, so a complete credential is never left
16
+ * at a predictable path that nothing lists, cleans, or reports.
17
+ *
18
+ * The rename is atomic within a filesystem, so a reader sees either the old
19
+ * content or the new one, never a partial write.
20
+ */
21
+ export const writeSecretFileAtomically = (
22
+ destinationPathArg: string,
23
+ contentArg: string,
24
+ ): void => {
25
+ const tempPath = `${destinationPathArg}.authswitch-tmp`;
26
+ // A stale temp file means an earlier run died mid-write. Its content is
27
+ // superseded by what we are about to write, so clearing it is safe -- and
28
+ // necessary, since `wx` would otherwise fail forever.
29
+ if (plugins.fs.existsSync(tempPath)) {
30
+ plugins.fs.rmSync(tempPath, { force: true });
31
+ }
32
+
33
+ let handle: number | null = null;
34
+ try {
35
+ handle = plugins.fs.openSync(tempPath, 'wx', 0o600);
36
+ plugins.fs.fchmodSync(handle, 0o600);
37
+ plugins.fs.writeFileSync(handle, contentArg, { encoding: 'utf8' });
38
+ plugins.fs.fsyncSync(handle);
39
+ plugins.fs.closeSync(handle);
40
+ handle = null;
41
+ plugins.fs.renameSync(tempPath, destinationPathArg);
42
+ } catch (errorArg) {
43
+ if (handle !== null) {
44
+ try {
45
+ plugins.fs.closeSync(handle);
46
+ } catch {
47
+ // The handle is being discarded either way.
48
+ }
49
+ }
50
+ plugins.fs.rmSync(tempPath, { force: true });
51
+ throw errorArg;
52
+ }
53
+ plugins.fs.chmodSync(destinationPathArg, 0o600);
54
+ };
55
+
56
+ /**
57
+ * Trims third-party process output down to something safe to put in an error
58
+ * message: one line, length-capped, and never empty.
59
+ */
60
+ export const summariseProcessOutput = (
61
+ stdoutArg: string,
62
+ stderrArg: string,
63
+ spawnErrorArg: string | null,
64
+ ): string => {
65
+ const candidate = (spawnErrorArg ?? stderrArg.trim() ?? '') || stdoutArg.trim();
66
+ if (!candidate) {
67
+ return 'no output';
68
+ }
69
+ const firstLine = candidate.split('\n').find((lineArg) => lineArg.trim().length > 0) ?? '';
70
+ const collapsed = firstLine.trim();
71
+ return collapsed.length > 200 ? `${collapsed.slice(0, 200)}...` : collapsed;
72
+ };
73
+
74
+ /**
75
+ * Classifies an enrollment row. See TEnrollmentState for why NULL and 0 are not
76
+ * the same answer.
77
+ */
78
+ export const enrollmentState = (enrollmentArg: IRemoteControlEnrollment): TEnrollmentState => {
79
+ if (enrollmentArg.remoteControlEnabled === null) {
80
+ return 'unpaired';
81
+ }
82
+ return enrollmentArg.remoteControlEnabled === 0 ? 'disabled' : 'paired';
83
+ };
84
+
85
+ /** How many of these enrollments carry a working pairing. */
86
+ export const countPaired = (enrollmentsArg: IRemoteControlEnrollment[]): number =>
87
+ enrollmentsArg.filter((enrollmentArg) => enrollmentState(enrollmentArg) === 'paired').length;
88
+
89
+ /**
90
+ * Turns a failed Codex invocation into a diagnosis rather than an errno.
91
+ *
92
+ * A timeout and a missing binary are different problems with different fixes,
93
+ * and neither is "the stop command failed" -- surfacing the raw spawn error
94
+ * leaves the user to guess which one they hit.
95
+ */
96
+ export const describeCodexFailure = (
97
+ binaryArg: string,
98
+ stdoutArg: string,
99
+ stderrArg: string,
100
+ spawnErrorArg: string | null,
101
+ ): string => {
102
+ if (spawnErrorArg && /ETIMEDOUT/i.test(spawnErrorArg)) {
103
+ return `the Codex app-server did not respond in time - it is running but unresponsive. Try \`codex app-server daemon restart\`, then run this again`;
104
+ }
105
+ if (spawnErrorArg && /ENOENT/i.test(spawnErrorArg)) {
106
+ return `the Codex CLI "${binaryArg}" was not found - put it on PATH or set AUTHSWITCH_CODEX_BIN to its full path`;
107
+ }
108
+ return summariseProcessOutput(stdoutArg, stderrArg, spawnErrorArg);
109
+ };
package/ts/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export * from './interfaces.js';
2
+ export * from './interfaces.harness.js';
3
+ export * from './interfaces.list.js';
4
+ export * from './classes.codexharness.js';
5
+ export * from './classes.opencodeharness.js';
6
+ export * from './classes.claudecodeharness.js';
7
+ export * from './classes.claudestatus.js';
8
+ export * from './classes.codexstatus.js';
9
+ export * from './classes.codexpreuse.js';
10
+ export * from './preuse.js';
11
+ export * from './helpers.js';
12
+ export * from './classes.codexhome.js';
13
+ export * from './classes.codexauth.js';
14
+ export * from './classes.remotecontrol.js';
15
+ export * from './classes.stashstore.js';
16
+ export * from './classes.codexdaemon.js';
17
+ export * from './classes.codexswitcher.js';
18
+ export * from './classes.cli.js';
19
+ export * from './classes.login.js';
20
+ export * from './classes.operations.js';
21
+ export * from './classes.service.js';
22
+
23
+ import { AuthSwitchCli } from './classes.cli.js';
24
+
25
+ export const runCli = async (argvArg: string[] = process.argv.slice(2)): Promise<void> => {
26
+ const exitCode = await new AuthSwitchCli().run(argvArg);
27
+ process.exitCode = exitCode;
28
+ };
@@ -0,0 +1,112 @@
1
+ import type { TAuthLoginPrompt, TAuthLoginFlow } from './classes.login.js';
2
+
3
+ export interface IHarnessLoginProvider { providerId: string; label: string; flows: TAuthLoginFlow[]; }
4
+ export interface IHarnessLoginOptions { providerId: string; flow: TAuthLoginFlow; }
5
+ export interface IHarnessLoginResult { outcome: IHarnessOutcome; account?: Pick<IHarnessAccount, 'id' | 'label' | 'slotId'>; }
6
+
7
+ export interface IHarnessLoginHandle {
8
+ readonly prompt: TAuthLoginPrompt;
9
+ /** Completion saves the new account without activating it. No credentials cross this interface. */
10
+ readonly completion: Promise<IHarnessLoginResult>;
11
+ cancel(): Promise<void>;
12
+ close(): Promise<void>;
13
+ }
14
+
15
+ /** Account IDs are opaque and scoped to one harness. Credentials never cross this interface. */
16
+ export interface IHarnessAccount {
17
+ id: string;
18
+ label: string;
19
+ /** Accounts in different slots can be active together (for example OpenCode providers). */
20
+ slotId?: string;
21
+ isActive: boolean;
22
+ isStashed: boolean;
23
+ savedAt: string | null;
24
+ details: string[];
25
+ }
26
+
27
+ export interface IHarnessState {
28
+ accounts: IHarnessAccount[];
29
+ /** Explains why an active login cannot be saved, when applicable. */
30
+ saveUnavailableReason: string | null;
31
+ }
32
+
33
+ export interface IHarnessAccountStatus {
34
+ /** Provider-reported facts, already labelled with their units and provenance. */
35
+ facts: IHarnessStatusFact[];
36
+ /** Missing/failed information is explicit and never represented as zero usage. */
37
+ problems: string[];
38
+ /** Optional structured data for shared presentation; never parse the display facts. */
39
+ summary?: IHarnessStatusSummary;
40
+ }
41
+
42
+ export interface IHarnessStatusFact {
43
+ label: string;
44
+ value: string;
45
+ /** Optional display group; ungrouped adapters use Additional information. */
46
+ section?: string;
47
+ /** The fact duplicates this structured field; omit it only when that field is displayed. */
48
+ summaryKey?: 'subscription' | 'billing' | 'usageWindows' | 'resets' | 'resetDetails';
49
+ }
50
+
51
+ export interface IHarnessStatusSummary {
52
+ /** A plan name does not imply an active billing status. */
53
+ subscription?: { plan: string; source: 'live' | 'stored' };
54
+ /** Live billing information only. Never infer these fields from a plan or entitlement expiry. */
55
+ billing?: { hasActiveSubscription?: boolean; autoRenew?: boolean; renewsAt?: string; cancelsAt?: string; expiresAt?: string };
56
+ /** Missing windows are unknown, not unused. Times are ISO 8601 UTC. */
57
+ usageWindows?: {
58
+ label: string;
59
+ /** Feature-specific quotas do not determine the general account summary. Omission means account. */
60
+ scope?: 'account' | 'feature';
61
+ durationSeconds: number;
62
+ usedPercent: number;
63
+ resetAt: string | null;
64
+ }[];
65
+ resets?: {
66
+ available: number;
67
+ /** May be a partial list. Omission means the detail lookup was unavailable. */
68
+ details?: { status: string; kind: string; expiresAt: string | null }[];
69
+ };
70
+ }
71
+
72
+ export interface IHarnessOutcome {
73
+ lines: string[];
74
+ problems: string[];
75
+ }
76
+
77
+ /** A single text-only inference request. Credentials remain inside the adapter. */
78
+ export interface IHarnessPreuseOptions {
79
+ prompt: string;
80
+ model?: string;
81
+ signal?: AbortSignal;
82
+ }
83
+
84
+ export interface IHarnessPreuseResult {
85
+ model: string;
86
+ inputTokens?: number;
87
+ outputTokens?: number;
88
+ totalTokens?: number;
89
+ }
90
+
91
+ export type THarnessResult<T> = T | Promise<T>;
92
+
93
+ /** Each adapter owns its credential storage, account matching, status API and process lifecycle. */
94
+ export interface IAuthHarness {
95
+ readonly id: string;
96
+ readonly label: string;
97
+ readonly loginHint: string;
98
+ readonly diagnosticsLabel: string;
99
+ readState(): THarnessResult<IHarnessState>;
100
+ readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus>;
101
+ readonly loginProviders?: IHarnessLoginProvider[];
102
+ beginLogin?(options: IHarnessLoginOptions): Promise<IHarnessLoginHandle>;
103
+ /** Optional quota-consuming capability; never changes the active login. */
104
+ preuseAccount?(accountIdArg: string, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult>;
105
+ resolveAccount(referenceArg: string): THarnessResult<{ id: string } | { candidates: string[] }>;
106
+ saveCurrent(optionsArg: { keepActive: boolean; accountId?: string }): THarnessResult<IHarnessOutcome>;
107
+ /** Preserve and verify the outgoing credential even when invoked without the interactive guide. */
108
+ switchAccount(accountIdArg: string): THarnessResult<IHarnessOutcome>;
109
+ /** Remove the saved copy only, preserving the active login. */
110
+ removeAccount(accountIdArg: string): THarnessResult<IHarnessOutcome>;
111
+ diagnose(): THarnessResult<IHarnessOutcome>;
112
+ }
@@ -0,0 +1,19 @@
1
+ import type { IHarnessAccount, IHarnessAccountStatus } from './interfaces.harness.js';
2
+
3
+ /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
4
+ export interface IAccountList {
5
+ schemaVersion: 2;
6
+ generatedAt: string;
7
+ complete: boolean;
8
+ harnesses: IHarnessAccountList[];
9
+ }
10
+
11
+ export interface IHarnessAccountList {
12
+ id: string;
13
+ label: string;
14
+ loginHint: string;
15
+ saveUnavailableReason: string | null;
16
+ accounts: (IHarnessAccount & { status: IHarnessAccountStatus })[];
17
+ /** A state lookup failure is distinct from an empty account list. */
18
+ problems: string[];
19
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Lowercase harness namespace. Adapters are registered independently of the CLI.
3
+ */
4
+ export type THarnessName = string;
5
+
6
+ /**
7
+ * Where the currently active Codex credential lives.
8
+ * - 'file' -> $CODEX_HOME/auth.json
9
+ * - 'keyring' -> an OS keyring entry (authswitch cannot move these)
10
+ * - 'none' -> no credential is active
11
+ */
12
+ export type TAuthSource = 'file' | 'keyring' | 'none';
13
+
14
+ /**
15
+ * How a stashed credential identifies its account.
16
+ * - 'chatgpt' -> ChatGPT OAuth login, identified by the id_token email claim
17
+ * - 'apikey' -> plain API key login, which carries no email
18
+ */
19
+ export type TAccountKind = 'chatgpt' | 'apikey';
20
+
21
+ /**
22
+ * Identity extracted from a Codex credential without ever exposing the token.
23
+ */
24
+ export interface ICodexIdentity {
25
+ kind: TAccountKind;
26
+ /** The account email, or a synthetic `apikey:<fingerprint>` label for API-key logins. */
27
+ email: string;
28
+ accountId: string | null;
29
+ planType: string | null;
30
+ userId: string | null;
31
+ }
32
+
33
+ /**
34
+ * The active credential as found on disk, plus its decoded identity.
35
+ * `raw` is the verbatim file content and is never logged.
36
+ */
37
+ export interface IActiveAuth {
38
+ source: TAuthSource;
39
+ authFilePath: string;
40
+ identity: ICodexIdentity | null;
41
+ raw: string | null;
42
+ }
43
+
44
+ /**
45
+ * One row of the `remote_control_enrollments` table in Codex' state database.
46
+ * This is the record that binds a Codex app-server to a paired ChatGPT client.
47
+ */
48
+ export interface IRemoteControlEnrollment {
49
+ websocketUrl: string;
50
+ accountId: string;
51
+ appServerClientName: string;
52
+ serverId: string;
53
+ environmentId: string;
54
+ serverName: string;
55
+ updatedAt: number;
56
+ remoteControlEnabled: number | null;
57
+ }
58
+
59
+ /**
60
+ * Metadata stored alongside a stashed credential.
61
+ */
62
+ export interface IStashEntry {
63
+ email: string;
64
+ kind: TAccountKind;
65
+ accountId: string | null;
66
+ planType: string | null;
67
+ userId: string | null;
68
+ stashedAt: string;
69
+ codexHome: string;
70
+ enrollments: IRemoteControlEnrollment[];
71
+ }
72
+
73
+ /**
74
+ * How far along an enrollment row is.
75
+ *
76
+ * Codex writes the row when an account registers a server, but its upsert
77
+ * deliberately leaves `remote_control_enabled` alone, so a freshly logged-in
78
+ * account starts at NULL. A row is therefore not the same thing as a working
79
+ * pairing, and the three states have to stay distinguishable:
80
+ * - 'paired' -> remote_control_enabled = 1, reachable from the ChatGPT app
81
+ * - 'disabled' -> remote_control_enabled = 0, deliberately turned off
82
+ * - 'unpaired' -> remote_control_enabled IS NULL, registered but never paired
83
+ */
84
+ export type TEnrollmentState = 'paired' | 'disabled' | 'unpaired';
85
+
86
+ /**
87
+ * A stash entry plus the derived facts the CLI needs for display.
88
+ */
89
+ export interface IStashListing extends IStashEntry {
90
+ slug: string;
91
+ isActive: boolean;
92
+ /** Rows stashed for this account, paired or not. */
93
+ enrollmentsTotal: number;
94
+ /** Rows that actually carry a working pairing. */
95
+ enrollmentsPaired: number;
96
+ /**
97
+ * True only when at least one enrollment is paired. A registered-but-unpaired
98
+ * row is not remote control, and reporting it as such would tell the user the
99
+ * account is reachable when it is not.
100
+ */
101
+ hasRemoteControl: boolean;
102
+ }
103
+
104
+ /**
105
+ * The outcome of running a Codex subcommand.
106
+ */
107
+ export interface ICommandResult {
108
+ ok: boolean;
109
+ stdout: string;
110
+ stderr: string;
111
+ code: number | null;
112
+ /** Set when the process could not be spawned at all, or timed out. */
113
+ spawnError: string | null;
114
+ }
115
+
116
+ /**
117
+ * What `codex app-server daemon version` reports about the managed daemon.
118
+ */
119
+ export interface IDaemonStatus {
120
+ running: boolean;
121
+ backend: string | null;
122
+ appServerVersion: string | null;
123
+ cliVersion: string | null;
124
+ socketPath: string | null;
125
+ }
126
+
127
+ export interface ICodexSwitcherOptions {
128
+ codexHome?: string;
129
+ stashRoot?: string;
130
+ codexBinary?: string;
131
+ }
132
+
133
+ /**
134
+ * Result of stashing the active credential.
135
+ *
136
+ * `complete` is false when the credential was saved but something the user
137
+ * cares about did not finish -- the app-server failing to come back, say.
138
+ */
139
+ export interface IStashResult {
140
+ entry: IStashEntry;
141
+ daemonRestarted: boolean;
142
+ complete: boolean;
143
+ notes: string[];
144
+ problems: string[];
145
+ }
146
+
147
+ /**
148
+ * Result of a switch, used for reporting and for the CLI exit code.
149
+ */
150
+ export interface ISwitchResult {
151
+ from: string | null;
152
+ to: string;
153
+ enrollmentsRestored: number;
154
+ daemonRestarted: boolean;
155
+ complete: boolean;
156
+ notes: string[];
157
+ problems: string[];
158
+ }
package/ts/plugins.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Node native modules
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import * as childProcess from 'node:child_process';
6
+ import * as crypto from 'node:crypto';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+
9
+ export { childProcess, crypto, fs, os, path, DatabaseSync };
10
+
11
+ // @push.rocks modules
12
+ import * as smartconsole from '@push.rocks/smartconsole';
13
+ export { smartconsole };
14
+
15
+ // @modelprofile.com modules
16
+ import * as flexModels from '@modelprofile.com/flexharness-models';
17
+ import * as flexOpenAi from '@modelprofile.com/flexharness-providers/openai';
18
+ import * as flexAuth from '@modelprofile.com/flexharness-providers/auth';
19
+ import * as flexAccounts from '@modelprofile.com/flexharness-providers/accounts';
20
+ export { flexModels, flexOpenAi, flexAuth, flexAccounts };
package/ts/preuse.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { IHarnessPreuseOptions } from './interfaces.harness.js';
2
+
3
+ export const defaultPreusePrompt = 'Write 2000 words about strawberries.';
4
+
5
+ /** Only fixed local messages may cross the inference error boundary. */
6
+ export class PreuseError extends Error {}
7
+
8
+ export const validatePreuseOptions = (optionsArg: IHarnessPreuseOptions): void => {
9
+ if (typeof optionsArg.prompt !== 'string' || !optionsArg.prompt.trim() || Buffer.byteLength(optionsArg.prompt, 'utf8') > 16_384) {
10
+ throw new PreuseError('The preuse prompt must contain between 1 and 16384 UTF-8 bytes.');
11
+ }
12
+ if (optionsArg.model !== undefined && (typeof optionsArg.model !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$/.test(optionsArg.model))) {
13
+ throw new PreuseError('The preuse model must be a valid model identifier of at most 128 characters.');
14
+ }
15
+ };