vibe-usage-cli 0.0.0-stage → 0.1.1

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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/build/cli.js +197 -0
  4. package/build/core/claude-processes.d.ts +51 -0
  5. package/build/core/claude-processes.js +288 -0
  6. package/build/core/claude-profile.d.ts +2 -0
  7. package/build/core/claude-profile.js +146 -0
  8. package/build/core/claude-renew.d.ts +12 -0
  9. package/build/core/claude-renew.js +79 -0
  10. package/build/core/continuity.d.ts +90 -0
  11. package/build/core/continuity.js +1244 -0
  12. package/build/core/diagnostics.d.ts +25 -0
  13. package/build/core/diagnostics.js +229 -0
  14. package/build/core/environment.d.ts +12 -0
  15. package/build/core/environment.js +25 -0
  16. package/build/core/errors.d.ts +23 -0
  17. package/build/core/errors.js +48 -0
  18. package/build/core/files.d.ts +20 -0
  19. package/build/core/files.js +136 -0
  20. package/build/core/glance.d.ts +25 -0
  21. package/build/core/glance.js +107 -0
  22. package/build/core/handover.d.ts +61 -0
  23. package/build/core/handover.js +333 -0
  24. package/build/core/history.d.ts +19 -0
  25. package/build/core/history.js +134 -0
  26. package/build/core/launch.d.ts +78 -0
  27. package/build/core/launch.js +256 -0
  28. package/build/core/measurements.d.ts +11 -0
  29. package/build/core/measurements.js +75 -0
  30. package/build/core/model-families.d.ts +12 -0
  31. package/build/core/model-families.js +61 -0
  32. package/build/core/model.d.ts +32 -0
  33. package/build/core/model.js +86 -0
  34. package/build/core/models.d.ts +25 -0
  35. package/build/core/models.js +164 -0
  36. package/build/core/redact.d.ts +2 -0
  37. package/build/core/redact.js +64 -0
  38. package/build/core/selection.d.ts +65 -0
  39. package/build/core/selection.js +173 -0
  40. package/build/core/service.d.ts +35 -0
  41. package/build/core/service.js +484 -0
  42. package/build/core/sessions.d.ts +87 -0
  43. package/build/core/sessions.js +868 -0
  44. package/build/core/settings.d.ts +16 -0
  45. package/build/core/settings.js +377 -0
  46. package/build/core/tray-icon.d.ts +5 -0
  47. package/build/core/tray-icon.js +91 -0
  48. package/build/core/updater.d.ts +29 -0
  49. package/build/core/updater.js +88 -0
  50. package/build/core/updates.d.ts +5 -0
  51. package/build/core/updates.js +29 -0
  52. package/build/launchers/discover-antigravity.ps1 +68 -0
  53. package/build/launchers/terminal.ps1 +78 -0
  54. package/build/main/main.js +587 -0
  55. package/build/providers/antigravity.d.ts +48 -0
  56. package/build/providers/antigravity.js +463 -0
  57. package/build/providers/claude-keychain.d.ts +13 -0
  58. package/build/providers/claude-keychain.js +42 -0
  59. package/build/providers/claude.d.ts +33 -0
  60. package/build/providers/claude.js +218 -0
  61. package/build/providers/codex.d.ts +21 -0
  62. package/build/providers/codex.js +221 -0
  63. package/build/providers/common.d.ts +63 -0
  64. package/build/providers/common.js +294 -0
  65. package/build/providers/cursor.d.ts +14 -0
  66. package/build/providers/cursor.js +141 -0
  67. package/build/providers/profiles.d.ts +10 -0
  68. package/build/providers/profiles.js +69 -0
  69. package/build/providers/tls-trust.d.ts +7 -0
  70. package/build/providers/tls-trust.js +29 -0
  71. package/build/providers/types.d.ts +19 -0
  72. package/build/providers/types.js +1 -0
  73. package/build/shared/account-state.d.ts +19 -0
  74. package/build/shared/account-state.js +75 -0
  75. package/build/shared/catalog.d.ts +85 -0
  76. package/build/shared/catalog.js +125 -0
  77. package/build/shared/format.d.ts +40 -0
  78. package/build/shared/format.js +134 -0
  79. package/build/shared/i18n/index.d.ts +12 -0
  80. package/build/shared/i18n/index.js +40 -0
  81. package/build/shared/i18n/locales/de.d.ts +2 -0
  82. package/build/shared/i18n/locales/de.js +328 -0
  83. package/build/shared/i18n/locales/en.d.ts +328 -0
  84. package/build/shared/i18n/locales/en.js +330 -0
  85. package/build/shared/i18n/locales/es.d.ts +2 -0
  86. package/build/shared/i18n/locales/es.js +328 -0
  87. package/build/shared/i18n/locales/fr.d.ts +2 -0
  88. package/build/shared/i18n/locales/fr.js +328 -0
  89. package/build/shared/i18n/locales/it.d.ts +2 -0
  90. package/build/shared/i18n/locales/it.js +327 -0
  91. package/build/shared/i18n/locales/ja.d.ts +2 -0
  92. package/build/shared/i18n/locales/ja.js +322 -0
  93. package/build/shared/i18n/locales/ko.d.ts +2 -0
  94. package/build/shared/i18n/locales/ko.js +322 -0
  95. package/build/shared/i18n/locales/nl.d.ts +2 -0
  96. package/build/shared/i18n/locales/nl.js +327 -0
  97. package/build/shared/i18n/locales/pl.d.ts +2 -0
  98. package/build/shared/i18n/locales/pl.js +332 -0
  99. package/build/shared/i18n/locales/pt.d.ts +2 -0
  100. package/build/shared/i18n/locales/pt.js +328 -0
  101. package/build/shared/i18n/locales/ru.d.ts +2 -0
  102. package/build/shared/i18n/locales/ru.js +331 -0
  103. package/build/shared/i18n/locales/tr.d.ts +2 -0
  104. package/build/shared/i18n/locales/tr.js +322 -0
  105. package/build/shared/i18n/locales/zh.d.ts +2 -0
  106. package/build/shared/i18n/locales/zh.js +322 -0
  107. package/build/shared/i18n/messages.d.ts +10 -0
  108. package/build/shared/i18n/messages.js +1 -0
  109. package/build/shared/ipc.d.ts +138 -0
  110. package/build/shared/ipc.js +2 -0
  111. package/build/shared/tsconfig.tsbuildinfo +1 -0
  112. package/build/shared/types.d.ts +205 -0
  113. package/build/shared/types.js +1 -0
  114. package/package.json +144 -4
@@ -0,0 +1,256 @@
1
+ // Starts provider tools for the user: a CLI in a new terminal window, or a desktop app.
2
+ // Arguments are always passed as arrays; nothing is concatenated into a shell command line.
3
+ import { spawn } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import fsp from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { findBinary, TOOL_INSTALLS, unpackedPath, windowsAppPath, windowsPowerShell } from "../providers/common.js";
9
+ import { CLAUDE_FILES, DEFAULT_ACCOUNT_ID as CLAUDE_DEFAULT_ACCOUNT_ID } from "../providers/claude.js";
10
+ import { findCursorAgent } from "../providers/cursor.js";
11
+ import { CLAUDE_EFFORT_LEVELS, CLAUDE_PERMISSION_MODES, isOneOf, MODEL_ID_PATTERN, PROFILE_DIRS, PROFILE_ENV } from "../shared/catalog.js";
12
+ import { prepareClaudeProfile } from "./claude-profile.js";
13
+ import { applyEnvironment, environmentChanges } from "./environment.js";
14
+ import { AppError } from "./errors.js";
15
+ import { claudeLaunchWarnings } from "./selection.js";
16
+ import { isDirectory, isFile, readJson, writeFileAtomic } from "./files.js";
17
+ import { hasControlChars } from "./model.js";
18
+ import { validateConfigDir } from "./settings.js";
19
+ const LAUNCHER_DIR = 'launchers';
20
+ const MAC_OPEN = '/usr/bin/open';
21
+ const TERMINAL_SCRIPT = unpackedPath(path.resolve(import.meta.dirname, '../launchers/terminal.ps1'));
22
+ const DETACHED = { shell: false, detached: true, stdio: 'ignore' };
23
+ export function shellQuote(value) {
24
+ if (hasControlChars(value))
25
+ throw new AppError('invalid_argument');
26
+ return `'${value.replaceAll("'", `'\\''`)}'`;
27
+ }
28
+ function waitForSpawn(child) {
29
+ return new Promise((resolve, reject) => {
30
+ child.once('spawn', () => {
31
+ child.unref();
32
+ resolve();
33
+ });
34
+ child.once('error', error => {
35
+ reject(new AppError('launch_failed', {}, { cause: error }));
36
+ });
37
+ });
38
+ }
39
+ // A one-off script Terminal runs; it removes itself once started, so none accumulate.
40
+ async function writeMacLauncher({ cwd, command, changes, title, dataDir }) {
41
+ const dir = path.join(dataDir, LAUNCHER_DIR);
42
+ const script = path.join(dir, `${path.basename(command[0])}-${randomBytes(6).toString('hex')}.command`);
43
+ const lines = [
44
+ '#!/bin/zsh',
45
+ `rm -f -- ${shellQuote(script)}`,
46
+ `cd -- ${shellQuote(cwd)} || exit 1`,
47
+ changes.unset.length ? `unset ${changes.unset.join(' ')}` : '',
48
+ ...Object.entries(changes.set).map(([name, value]) => `export ${name}=${shellQuote(value)}`),
49
+ title ? `printf '%s\\n' ${shellQuote(title)}` : '',
50
+ `exec ${command.map(shellQuote).join(' ')}`
51
+ ].filter(Boolean);
52
+ await fsp.mkdir(dir, { recursive: true });
53
+ await fsp.writeFile(script, `${lines.join('\n')}\n`, { mode: 0o700 });
54
+ return script;
55
+ }
56
+ // NUL cannot occur inside an argument, so it separates them unambiguously.
57
+ const encodeArguments = (args) => Buffer.from(args.join('\0'), 'utf8').toString('base64');
58
+ // Opens `binary args…` in a new terminal window inside `cwd`.
59
+ export async function openInTerminal({ cwd, binary, args, env, title, handoff, dataDir, platform = process.platform, spawnProcess = spawn }) {
60
+ if (args.some(arg => arg.includes('\0')))
61
+ throw new AppError('invalid_argument');
62
+ const changes = environmentChanges(env);
63
+ if (platform === 'darwin') {
64
+ const script = await writeMacLauncher({ cwd, command: [binary, ...args], changes, title, dataDir });
65
+ return waitForSpawn(spawnProcess(MAC_OPEN, ['-a', 'Terminal', script], DETACHED));
66
+ }
67
+ if (platform === 'win32') {
68
+ // A detached process gets no console, and PowerShell exits without one; the hidden
69
+ // script opens the tool's window itself (see terminal.ps1 -NewWindow).
70
+ const psArgs = [
71
+ '-NoLogo',
72
+ '-NoProfile',
73
+ '-NonInteractive',
74
+ '-ExecutionPolicy',
75
+ 'Bypass',
76
+ '-File',
77
+ TERMINAL_SCRIPT,
78
+ '-WorkingDirectory',
79
+ cwd,
80
+ '-Executable',
81
+ binary,
82
+ ...(title ? ['-Title', title] : []),
83
+ ...(args.length ? ['-ArgumentsBase64', encodeArguments(args)] : []),
84
+ ...(handoff ? ['-Handoff', handoff] : []),
85
+ '-NewWindow'
86
+ ];
87
+ return waitForSpawn(spawnProcess(windowsPowerShell(), psArgs, {
88
+ shell: false,
89
+ stdio: 'ignore',
90
+ windowsHide: true,
91
+ env: applyEnvironment(changes, process.env, platform)
92
+ }));
93
+ }
94
+ throw new AppError('unsupported_platform');
95
+ }
96
+ // Opens the tool's desktop app, on the project folder when there is one.
97
+ export async function openDesktopApp({ providerId, cwd, platform = process.platform, env = process.env, home = os.homedir(), spawnProcess = spawn }) {
98
+ const { app } = TOOL_INSTALLS[providerId];
99
+ if (!app)
100
+ throw new AppError('unsupported_platform');
101
+ const project = cwd ? [cwd] : [];
102
+ if (platform === 'darwin')
103
+ return waitForSpawn(spawnProcess(MAC_OPEN, ['-a', app.mac, ...project], DETACHED));
104
+ if (platform === 'win32') {
105
+ const executable = windowsAppPath(app, env, home);
106
+ if (!(await isFile(executable)))
107
+ throw new AppError('app_not_found', { app: app.mac });
108
+ return waitForSpawn(spawnProcess(executable, project, DETACHED));
109
+ }
110
+ throw new AppError('unsupported_platform');
111
+ }
112
+ // The environment that points a tool at the account's profile folder, for launches and
113
+ // sign-ins alike. The default Claude profile must run with CLAUDE_CONFIG_DIR unset: setting
114
+ // it, even to the default directory, selects a different Keychain entry on macOS.
115
+ // Resuming a long session idle for over an hour, Claude Code asks whether to compact it or go
116
+ // on. A session the app resumes may do so unattended, so for it that threshold is moved out
117
+ // of reach (ten years). The user's own resumes keep the question.
118
+ export const CLAUDE_RESUME_THRESHOLD_ENV = 'CLAUDE_CODE_RESUME_THRESHOLD_MINUTES';
119
+ export const RESUME_WITHOUT_QUESTION = String(10 * 365 * 24 * 60);
120
+ export function profileEnvironment(account) {
121
+ if (account.providerId === 'claude') {
122
+ const isDefault = account.id === CLAUDE_DEFAULT_ACCOUNT_ID || account.configDirExplicit === false;
123
+ return isDefault ? { unset: [PROFILE_ENV.claude] } : { set: { [PROFILE_ENV.claude]: validateConfigDir(account.configDir) } };
124
+ }
125
+ if (account.providerId === 'codex' && account.configDir)
126
+ return { set: { [PROFILE_ENV.codex]: validateConfigDir(account.configDir) } };
127
+ return {};
128
+ }
129
+ // Session ids are UUIDs; anything else (a flag, say) never reaches the command line.
130
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/;
131
+ const optional = (flag, value) => (value ? [flag, value] : []);
132
+ const trailing = (value) => (value ? [value] : []);
133
+ // How each provider's CLI starts with an optional model and initial prompt; null when the
134
+ // provider only exists as a desktop app.
135
+ const LAUNCHERS = {
136
+ async claude({ account, model, prompt, resume, permissionMode, effort }) {
137
+ if (!account?.configDir)
138
+ throw new AppError('account_not_found');
139
+ const env = profileEnvironment(account);
140
+ return {
141
+ binary: await findBinary('claude'),
142
+ args: [
143
+ ...optional('--resume', resume),
144
+ ...optional('--model', model),
145
+ ...optional('--permission-mode', permissionMode),
146
+ ...optional('--effort', effort),
147
+ ...trailing(prompt)
148
+ ],
149
+ env: resume ? { ...env, set: { ...env.set, [CLAUDE_RESUME_THRESHOLD_ENV]: RESUME_WITHOUT_QUESTION } } : env
150
+ };
151
+ },
152
+ async codex({ account, model, prompt }) {
153
+ return {
154
+ binary: await findBinary('codex'),
155
+ args: [...optional('-m', model), ...trailing(prompt)],
156
+ env: account ? profileEnvironment(account) : {}
157
+ };
158
+ },
159
+ async cursor({ model, prompt }) {
160
+ // Only the Cursor Agent CLI runs a task in a terminal; without it the app is opened.
161
+ const binary = await findCursorAgent();
162
+ return binary ? { binary, args: ['--trust', ...optional('--model', model), ...trailing(prompt)], env: {} } : null;
163
+ },
164
+ antigravity: () => Promise.resolve(null)
165
+ };
166
+ // Model ids come from settings, the UI or the command line, so they are checked here,
167
+ // synchronously, before anything is looked up.
168
+ export function launchPlan(providerId, options) {
169
+ if (options.model && !MODEL_ID_PATTERN.test(options.model))
170
+ throw new AppError('invalid_argument');
171
+ if (options.resume !== undefined && (providerId !== 'claude' || !SESSION_ID_PATTERN.test(options.resume)))
172
+ throw new AppError('invalid_argument');
173
+ if (options.permissionMode !== undefined && (providerId !== 'claude' || !isOneOf(CLAUDE_PERMISSION_MODES, options.permissionMode)))
174
+ throw new AppError('invalid_argument');
175
+ if (options.effort !== undefined && (providerId !== 'claude' || !isOneOf(CLAUDE_EFFORT_LEVELS, options.effort)))
176
+ throw new AppError('invalid_argument');
177
+ return LAUNCHERS[providerId](options);
178
+ }
179
+ // A project folder from the user: a valid local path to an existing directory.
180
+ export async function projectDirectory(dir) {
181
+ const directory = validateConfigDir(dir);
182
+ if (!(await isDirectory(directory)))
183
+ throw new AppError('folder_not_found');
184
+ return directory;
185
+ }
186
+ async function preparedLaunch({ providerId, account, cwd, model, prompt, resume, permissionMode, effort, accounts, homeDir }) {
187
+ const directory = await projectDirectory(cwd);
188
+ const plan = await launchPlan(providerId, { account, model, prompt, resume, permissionMode, effort });
189
+ // Unprepared, the session would wait at Claude Code's first-run questions; they can still
190
+ // be answered there, so a failure to prepare it does not stop the launch.
191
+ if (plan && providerId === 'claude' && account && accounts)
192
+ await prepareClaudeProfile(account, accounts, directory, homeDir).catch(() => undefined);
193
+ return { directory, plan };
194
+ }
195
+ export async function launchTool(launch) {
196
+ const { providerId, dataDir, title, platform, spawnProcess, handoff } = launch;
197
+ const { directory, plan } = await preparedLaunch(launch);
198
+ if (!plan) {
199
+ await openDesktopApp({ providerId, cwd: directory, platform, spawnProcess });
200
+ return { mode: 'app' };
201
+ }
202
+ await openInTerminal({ cwd: directory, ...plan, title, handoff, dataDir, platform, spawnProcess });
203
+ return { mode: 'terminal' };
204
+ }
205
+ // Writes the command a tab opened with `handoff` runs next, once its tool exits (see
206
+ // terminal.ps1). False when the tool has no command line to run there.
207
+ export async function writeHandoff(file, launch) {
208
+ const { directory, plan } = await preparedLaunch(launch);
209
+ if (!plan)
210
+ return false;
211
+ if (plan.args.some(arg => arg.includes('\0')))
212
+ throw new AppError('invalid_argument');
213
+ const { set, unset } = environmentChanges(plan.env);
214
+ const next = {
215
+ workingDirectory: directory,
216
+ executable: plan.binary,
217
+ title: launch.title,
218
+ argumentsBase64: encodeArguments(plan.args),
219
+ set,
220
+ unset
221
+ };
222
+ await writeFileAtomic(file, JSON.stringify(next));
223
+ return true;
224
+ }
225
+ // Sign-in flows: each CLI handles its own login; desktop apps sign in inside the app.
226
+ // Plain `claude` only asks to sign in when no credentials exist; expired ones open a session.
227
+ const LOGIN_ARGS = { claude: ['auth', 'login'], codex: ['login'] };
228
+ export async function openSignIn({ providerId, account, dataDir, cwd, platform, spawnProcess }) {
229
+ const args = LOGIN_ARGS[providerId];
230
+ if (!args)
231
+ return openDesktopApp({ providerId, platform, spawnProcess });
232
+ const env = account ? profileEnvironment(account) : {};
233
+ return openInTerminal({ cwd, binary: await findBinary(providerId), args, env, dataDir, platform, spawnProcess });
234
+ }
235
+ // Claude settings files that can override the selected profile's identity, keyed by name.
236
+ export async function loadClaudeSettingsFiles(projectDir, configDir) {
237
+ const files = {};
238
+ const read = async (dir, file) => {
239
+ files[`${path.basename(dir)}/${file}`] = await readJson(path.join(dir, file));
240
+ };
241
+ if (projectDir) {
242
+ const projectSettings = path.join(projectDir, PROFILE_DIRS.claude);
243
+ await read(projectSettings, CLAUDE_FILES.settings);
244
+ await read(projectSettings, CLAUDE_FILES.localSettings);
245
+ }
246
+ if (configDir)
247
+ await read(configDir, CLAUDE_FILES.settings);
248
+ return files;
249
+ }
250
+ // What to warn about before starting the tool signed in as `account` in `projectDir`. Only
251
+ // Claude Code has settings that can override the chosen profile.
252
+ export async function launchWarnings(snapshot, account, { projectDir, model }) {
253
+ if (account.providerId !== 'claude')
254
+ return [];
255
+ return claudeLaunchWarnings(snapshot, account, { model, settingsFiles: await loadClaudeSettingsFiles(projectDir, account.configDir) });
256
+ }
@@ -0,0 +1,11 @@
1
+ import type { MeasuredAccount, QuotaWindow } from '../shared/types.ts';
2
+ export declare const MEASUREMENTS_FILE = "measurements.json";
3
+ export interface SavedMeasurement {
4
+ configDir: string | null;
5
+ fetchedAt: string;
6
+ plan?: string;
7
+ windows: QuotaWindow[];
8
+ }
9
+ export declare function loadMeasurements(file: string, now?: number): Promise<Map<string, SavedMeasurement>>;
10
+ export declare function measurementOf(account: MeasuredAccount): SavedMeasurement | null;
11
+ export declare function saveMeasurements(file: string, saved: ReadonlyMap<string, SavedMeasurement>): Promise<void>;
@@ -0,0 +1,75 @@
1
+ // The last good reading of each account, kept on disk. After a restart the app shows it,
2
+ // labelled stale, until the first fresh read: a source that is rate limited or offline at that
3
+ // moment then leaves the last known quota in view instead of an empty card. Only what the
4
+ // readings say about quota is kept, never tokens, emails or names.
5
+ import { hasKey } from "../shared/i18n/index.js";
6
+ import { isObject, readJson, writeFileAtomic } from "./files.js";
7
+ import { normalizeWindow } from "./model.js";
8
+ import { DURATION_MS } from "../shared/catalog.js";
9
+ export const MEASUREMENTS_FILE = 'measurements.json';
10
+ // A reading older than this says too little about the quota now to be worth showing.
11
+ const KEPT_READING_MAX_AGE_MS = 7 * DURATION_MS.DAY;
12
+ const MAX_WINDOWS = 50;
13
+ // A message key from the file counts only when it is one the app knows.
14
+ const isKnownKey = (value) => typeof value === 'string' && hasKey(value);
15
+ function savedWindow(value) {
16
+ if (!isObject(value) || typeof value.id !== 'string')
17
+ return null;
18
+ const n = isObject(value.labelParams) ? value.labelParams.n : undefined;
19
+ return normalizeWindow({
20
+ id: value.id,
21
+ usedPercent: value.usedPercent,
22
+ remainingPercent: value.remainingPercent,
23
+ resetsAt: value.resetsAt,
24
+ ...(typeof value.label === 'string' ? { label: value.label } : {}),
25
+ ...(isKnownKey(value.labelKey) ? { labelKey: value.labelKey } : {}),
26
+ ...(typeof n === 'number' && Number.isFinite(n) ? { labelParams: { n } } : {}),
27
+ ...(isKnownKey(value.detailKey) ? { detailKey: value.detailKey } : {}),
28
+ ...(isKnownKey(value.valueKey) ? { valueKey: value.valueKey } : {}),
29
+ ...(typeof value.durationMinutes === 'number' ? { durationMinutes: value.durationMinutes } : {}),
30
+ ...(typeof value.scope === 'string' ? { scope: value.scope } : {}),
31
+ active: value.active,
32
+ ...(typeof value.group === 'string' ? { group: value.group } : {}),
33
+ ...(value.section === 'details' ? { section: 'details' } : {}),
34
+ ...(value.partial === true ? { partial: true } : {}),
35
+ except: value.except,
36
+ amounts: value.amounts
37
+ });
38
+ }
39
+ // The saved readings by account id; a damaged or foreign file gives none.
40
+ export async function loadMeasurements(file, now = Date.now()) {
41
+ const document = await readJson(file);
42
+ const saved = new Map();
43
+ if (!isObject(document) || !isObject(document.accounts))
44
+ return saved;
45
+ for (const [id, entry] of Object.entries(document.accounts)) {
46
+ if (!isObject(entry) || typeof entry.fetchedAt !== 'string' || !Array.isArray(entry.windows))
47
+ continue;
48
+ const at = Date.parse(entry.fetchedAt);
49
+ if (!Number.isFinite(at) || at > now || now - at > KEPT_READING_MAX_AGE_MS)
50
+ continue;
51
+ const windows = entry.windows
52
+ .slice(0, MAX_WINDOWS)
53
+ .map(savedWindow)
54
+ .filter(w => w !== null);
55
+ if (!windows.length)
56
+ continue;
57
+ saved.set(id, {
58
+ configDir: typeof entry.configDir === 'string' ? entry.configDir : null,
59
+ fetchedAt: new Date(at).toISOString(),
60
+ ...(typeof entry.plan === 'string' ? { plan: entry.plan } : {}),
61
+ windows
62
+ });
63
+ }
64
+ return saved;
65
+ }
66
+ // The reading worth keeping from an account: a good one, without what was carried over.
67
+ export function measurementOf(account) {
68
+ const windows = account.windows.filter(w => !w.stale).map(({ exhaustsAt: _forecast, ...window }) => window);
69
+ if (account.status !== 'ok' || !account.fetchedAt || !windows.length)
70
+ return null;
71
+ return { configDir: account.configDir ?? null, fetchedAt: account.fetchedAt, ...(account.plan ? { plan: account.plan } : {}), windows };
72
+ }
73
+ export function saveMeasurements(file, saved) {
74
+ return writeFileAtomic(file, JSON.stringify({ version: 1, accounts: Object.fromEntries(saved) }));
75
+ }
@@ -0,0 +1,12 @@
1
+ declare const MODEL_TIERS: readonly ["flagship", "balanced", "fast"];
2
+ export type ModelTier = (typeof MODEL_TIERS)[number];
3
+ export declare const nameMatchesFamily: (name: string, family: string) => boolean;
4
+ export declare const tierFamilies: (tier: ModelTier) => string[];
5
+ export declare function modelTier(model: string): ModelTier | undefined;
6
+ export declare function quotaScopeFamily(model: string | undefined): {
7
+ name: string;
8
+ scopeRequired: boolean;
9
+ } | undefined;
10
+ export declare const CLAUDE_ALIASES: readonly string[];
11
+ export declare function claudeAlias(tier: ModelTier): string;
12
+ export {};
@@ -0,0 +1,61 @@
1
+ // What the app knows about model families. No provider publishes a model's tier or which
2
+ // quota covers it, so this is matched by name; this table is the only place that knows it.
3
+ // From the most capable to the fastest.
4
+ const MODEL_TIERS = ['flagship', 'balanced', 'fast'];
5
+ // Within a tier, the most preferred family first.
6
+ const MODEL_FAMILIES = {
7
+ opus: { tier: 'flagship', quotaScope: true, claudeAlias: true },
8
+ fable: { tier: 'flagship', quotaScope: true, scopeRequired: true, claudeAlias: true },
9
+ terra: { tier: 'flagship' },
10
+ sol: { tier: 'flagship' },
11
+ pro: { tier: 'flagship' },
12
+ 'grok-4': { tier: 'flagship' },
13
+ sonnet: { tier: 'balanced', quotaScope: true, claudeAlias: true },
14
+ luna: { tier: 'balanced' },
15
+ flash: { tier: 'balanced' },
16
+ composer: { tier: 'balanced' },
17
+ haiku: { tier: 'fast', quotaScope: true, claudeAlias: true },
18
+ mini: { tier: 'fast' },
19
+ nano: { tier: 'fast' },
20
+ lite: { tier: 'fast' },
21
+ spark: { tier: 'fast' },
22
+ gemini: { quotaScope: true },
23
+ gpt: { quotaScope: true }
24
+ };
25
+ const FAMILIES = Object.entries(MODEL_FAMILIES).map(([name, family]) => ({ name, ...family }));
26
+ // Whether a model or quota scope name belongs to the family: as a word of its own, so that
27
+ // "gemini" is not taken for "mini".
28
+ export const nameMatchesFamily = (name, family) => familyPosition(name, family) >= 0;
29
+ // Where the family's name stands in a name as a word of its own; -1 when it does not.
30
+ function familyPosition(name, family) {
31
+ const match = new RegExp(`(^|[^a-z])${RegExp.escape(family)}([^a-z]|$)`).exec(name.toLowerCase());
32
+ return match ? match.index + (match[1] ?? '').length : -1;
33
+ }
34
+ // The tier's families, most preferred first.
35
+ export const tierFamilies = (tier) => FAMILIES.filter(f => f.tier === tier).map(f => f.name);
36
+ // A model's tier: a name naming several families takes the last one, as a variant is named after
37
+ // its base (`gemini-2.5-flash-lite` is the lite one, `gpt-5.4-mini` the mini one).
38
+ export function modelTier(model) {
39
+ let tier;
40
+ let last = -1;
41
+ for (const family of FAMILIES) {
42
+ const at = family.tier ? familyPosition(model, family.name) : -1;
43
+ if (at > last)
44
+ [tier, last] = [family.tier, at];
45
+ }
46
+ return tier;
47
+ }
48
+ // The family a provider would report the model's own quota under, if any.
49
+ export function quotaScopeFamily(model) {
50
+ const family = model ? FAMILIES.find(f => f.quotaScope && nameMatchesFamily(model, f.name)) : undefined;
51
+ return family ? { name: family.name, scopeRequired: family.scopeRequired === true } : undefined;
52
+ }
53
+ // The names Claude Code takes for its newest model of a family, most capable first.
54
+ export const CLAUDE_ALIASES = MODEL_TIERS.flatMap(tier => FAMILIES.filter(f => f.tier === tier && f.claudeAlias).map(f => f.name));
55
+ // Claude Code's alias for the newest model of the tier.
56
+ export function claudeAlias(tier) {
57
+ const alias = FAMILIES.find(f => f.tier === tier && f.claudeAlias);
58
+ if (!alias)
59
+ throw new Error(`no Claude Code alias for the ${tier} tier`);
60
+ return alias.name;
61
+ }
@@ -0,0 +1,32 @@
1
+ import type { MessageKey } from '../shared/i18n/index.ts';
2
+ import type { QuotaWindow } from '../shared/types.ts';
3
+ export declare const percent: (value: unknown) => number | null;
4
+ export declare function isoDate(value: unknown): string | null;
5
+ export declare function isoFromUnixSeconds(value: unknown): string | null;
6
+ export declare const hasControlChars: (value: string) => boolean;
7
+ export declare function safeText(value: unknown, fallback?: string, max?: number): string;
8
+ export declare const normalizeEmail: (value: string) => string;
9
+ export declare function safeEmail(value: unknown): string | undefined;
10
+ export declare function safePlan(value: unknown): string | undefined;
11
+ export interface WindowInput {
12
+ id: string;
13
+ usedPercent?: unknown;
14
+ remainingPercent?: unknown;
15
+ resetsAt?: unknown;
16
+ label?: string;
17
+ labelKey?: MessageKey;
18
+ labelParams?: {
19
+ n: number;
20
+ };
21
+ detailKey?: MessageKey;
22
+ valueKey?: MessageKey | undefined;
23
+ durationMinutes?: number;
24
+ scope?: string;
25
+ active?: unknown;
26
+ group?: string;
27
+ section?: 'details';
28
+ partial?: boolean;
29
+ except?: unknown;
30
+ amounts?: unknown;
31
+ }
32
+ export declare function normalizeWindow(input: WindowInput): QuotaWindow;
@@ -0,0 +1,86 @@
1
+ import { MODEL_ID_PATTERN } from "../shared/catalog.js";
2
+ import { asArray, isObject } from "./files.js";
3
+ export const percent = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : null;
4
+ const toIso = (date) => (Number.isFinite(date.getTime()) ? date.toISOString() : null);
5
+ // An ISO timestamp from a date string or a Date; null for anything else.
6
+ export function isoDate(value) {
7
+ return (typeof value === 'string' && value !== '') || value instanceof Date ? toIso(new Date(value)) : null;
8
+ }
9
+ export function isoFromUnixSeconds(value) {
10
+ return typeof value === 'number' && Number.isFinite(value) ? toIso(new Date(value * 1000)) : null;
11
+ }
12
+ // Control characters never belong in a name, path, id or token the app shows or passes on.
13
+ // eslint-disable-next-line no-control-regex -- matching control characters is the purpose
14
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
15
+ // `search` and `replace` ignore lastIndex, so the shared global pattern carries no state.
16
+ export const hasControlChars = (value) => value.search(CONTROL_CHARS) !== -1;
17
+ const stripControlChars = (value) => value.replace(CONTROL_CHARS, '');
18
+ const TEXT_MAX_LENGTH = 160;
19
+ export function safeText(value, fallback = '', max = TEXT_MAX_LENGTH) {
20
+ return typeof value === 'string' ? stripControlChars(value).slice(0, max) : fallback;
21
+ }
22
+ // An e-mail address a tool recorded for its signed-in account, shown as the account's name.
23
+ const EMAIL = /^[^\s@]{1,128}@[^\s@]{1,127}$/;
24
+ // The one form e-mail addresses are compared and kept in.
25
+ export const normalizeEmail = (value) => value.trim().toLowerCase();
26
+ export function safeEmail(value) {
27
+ const email = typeof value === 'string' ? normalizeEmail(value) : '';
28
+ return EMAIL.test(email) && !hasControlChars(email) ? email : undefined;
29
+ }
30
+ // Plan names come from the provider and are shown as a badge; anything unusual is dropped.
31
+ const PLAN_NAME = /^[a-zA-Z][a-zA-Z0-9_ -]{0,40}$/;
32
+ export function safePlan(value) {
33
+ return typeof value === 'string' && PLAN_NAME.test(value) ? value : undefined;
34
+ }
35
+ const AMOUNT_FIELDS = ['used', 'limit', 'remaining', 'bonus'];
36
+ function safeAmounts(input) {
37
+ if (!isObject(input))
38
+ return undefined;
39
+ const amounts = {};
40
+ for (const field of AMOUNT_FIELDS) {
41
+ const value = input[field];
42
+ if (typeof value === 'number' && Number.isFinite(value))
43
+ amounts[field] = value;
44
+ }
45
+ return Object.keys(amounts).length ? amounts : undefined;
46
+ }
47
+ // A window with only contract fields. Unknown numbers and resets stay unknown; a used or a
48
+ // remaining percentage implies the other.
49
+ export function normalizeWindow(input) {
50
+ let usedPercent = percent(input.usedPercent);
51
+ let remainingPercent = percent(input.remainingPercent);
52
+ if (usedPercent === null && remainingPercent !== null)
53
+ usedPercent = 100 - remainingPercent;
54
+ if (remainingPercent === null && usedPercent !== null)
55
+ remainingPercent = 100 - usedPercent;
56
+ const window = { id: safeText(input.id, 'unknown'), usedPercent, remainingPercent, resetsAt: isoDate(input.resetsAt) };
57
+ if (input.label)
58
+ window.label = safeText(input.label);
59
+ if (input.labelKey)
60
+ window.labelKey = input.labelKey;
61
+ if (input.labelParams)
62
+ window.labelParams = input.labelParams;
63
+ if (input.detailKey)
64
+ window.detailKey = input.detailKey;
65
+ if (input.valueKey)
66
+ window.valueKey = input.valueKey;
67
+ if (input.durationMinutes !== undefined && Number.isFinite(input.durationMinutes))
68
+ window.durationMinutes = input.durationMinutes;
69
+ if (input.scope)
70
+ window.scope = safeText(input.scope);
71
+ if (typeof input.active === 'boolean')
72
+ window.active = input.active;
73
+ if (input.group === 'team')
74
+ window.group = 'team';
75
+ if (input.section === 'details')
76
+ window.section = 'details';
77
+ if (input.partial === true)
78
+ window.partial = true;
79
+ const except = asArray(input.except).filter((m) => typeof m === 'string' && MODEL_ID_PATTERN.test(m));
80
+ if (window.partial && except.length)
81
+ window.except = except.map(m => m.toLowerCase());
82
+ const amounts = safeAmounts(input.amounts);
83
+ if (amounts)
84
+ window.amounts = amounts;
85
+ return window;
86
+ }
@@ -0,0 +1,25 @@
1
+ import { type ModelTier } from './model-families.ts';
2
+ import type { ProviderId } from '../shared/catalog.ts';
3
+ import type { ModelCatalog } from '../shared/ipc.ts';
4
+ import type { Account } from '../shared/types.ts';
5
+ export interface ModelLocation {
6
+ homeDir?: string | undefined;
7
+ cacheDir?: string | undefined;
8
+ accounts?: readonly Account[] | undefined;
9
+ }
10
+ export declare function parseCursorModelList(output: string): string[];
11
+ export interface CursorRefresh {
12
+ cacheDir?: string;
13
+ force?: boolean;
14
+ binary?: string;
15
+ now?: number;
16
+ }
17
+ export declare function refreshCursorModels({ cacheDir, force, binary, now }?: CursorRefresh): Promise<string[] | null>;
18
+ export declare function discoverModels({ homeDir, cacheDir, accounts }?: ModelLocation): ModelCatalog;
19
+ export declare const resolveModelTier: (modelName: string | null | undefined) => ModelTier;
20
+ export interface ModelMapping {
21
+ tier: ModelTier;
22
+ targetModel: string | null;
23
+ source: 'preference' | 'same' | 'tier';
24
+ }
25
+ export declare function getMappedModel(sourceModel: string | null | undefined, targetProvider: ProviderId, preferences?: Partial<Record<ProviderId, string>>, location?: ModelLocation): ModelMapping;