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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/build/cli.js +197 -0
- package/build/core/claude-processes.d.ts +51 -0
- package/build/core/claude-processes.js +288 -0
- package/build/core/claude-profile.d.ts +2 -0
- package/build/core/claude-profile.js +146 -0
- package/build/core/claude-renew.d.ts +12 -0
- package/build/core/claude-renew.js +79 -0
- package/build/core/continuity.d.ts +90 -0
- package/build/core/continuity.js +1244 -0
- package/build/core/diagnostics.d.ts +25 -0
- package/build/core/diagnostics.js +229 -0
- package/build/core/environment.d.ts +12 -0
- package/build/core/environment.js +25 -0
- package/build/core/errors.d.ts +23 -0
- package/build/core/errors.js +48 -0
- package/build/core/files.d.ts +20 -0
- package/build/core/files.js +136 -0
- package/build/core/glance.d.ts +25 -0
- package/build/core/glance.js +107 -0
- package/build/core/handover.d.ts +61 -0
- package/build/core/handover.js +333 -0
- package/build/core/history.d.ts +19 -0
- package/build/core/history.js +134 -0
- package/build/core/launch.d.ts +78 -0
- package/build/core/launch.js +256 -0
- package/build/core/measurements.d.ts +11 -0
- package/build/core/measurements.js +75 -0
- package/build/core/model-families.d.ts +12 -0
- package/build/core/model-families.js +61 -0
- package/build/core/model.d.ts +32 -0
- package/build/core/model.js +86 -0
- package/build/core/models.d.ts +25 -0
- package/build/core/models.js +164 -0
- package/build/core/redact.d.ts +2 -0
- package/build/core/redact.js +64 -0
- package/build/core/selection.d.ts +65 -0
- package/build/core/selection.js +173 -0
- package/build/core/service.d.ts +35 -0
- package/build/core/service.js +484 -0
- package/build/core/sessions.d.ts +87 -0
- package/build/core/sessions.js +868 -0
- package/build/core/settings.d.ts +16 -0
- package/build/core/settings.js +377 -0
- package/build/core/tray-icon.d.ts +5 -0
- package/build/core/tray-icon.js +91 -0
- package/build/core/updater.d.ts +29 -0
- package/build/core/updater.js +88 -0
- package/build/core/updates.d.ts +5 -0
- package/build/core/updates.js +29 -0
- package/build/launchers/discover-antigravity.ps1 +68 -0
- package/build/launchers/terminal.ps1 +78 -0
- package/build/main/main.js +587 -0
- package/build/providers/antigravity.d.ts +48 -0
- package/build/providers/antigravity.js +463 -0
- package/build/providers/claude-keychain.d.ts +13 -0
- package/build/providers/claude-keychain.js +42 -0
- package/build/providers/claude.d.ts +33 -0
- package/build/providers/claude.js +218 -0
- package/build/providers/codex.d.ts +21 -0
- package/build/providers/codex.js +221 -0
- package/build/providers/common.d.ts +63 -0
- package/build/providers/common.js +294 -0
- package/build/providers/cursor.d.ts +14 -0
- package/build/providers/cursor.js +141 -0
- package/build/providers/profiles.d.ts +10 -0
- package/build/providers/profiles.js +69 -0
- package/build/providers/tls-trust.d.ts +7 -0
- package/build/providers/tls-trust.js +29 -0
- package/build/providers/types.d.ts +19 -0
- package/build/providers/types.js +1 -0
- package/build/shared/account-state.d.ts +19 -0
- package/build/shared/account-state.js +75 -0
- package/build/shared/catalog.d.ts +85 -0
- package/build/shared/catalog.js +125 -0
- package/build/shared/format.d.ts +40 -0
- package/build/shared/format.js +134 -0
- package/build/shared/i18n/index.d.ts +12 -0
- package/build/shared/i18n/index.js +40 -0
- package/build/shared/i18n/locales/de.d.ts +2 -0
- package/build/shared/i18n/locales/de.js +328 -0
- package/build/shared/i18n/locales/en.d.ts +328 -0
- package/build/shared/i18n/locales/en.js +330 -0
- package/build/shared/i18n/locales/es.d.ts +2 -0
- package/build/shared/i18n/locales/es.js +328 -0
- package/build/shared/i18n/locales/fr.d.ts +2 -0
- package/build/shared/i18n/locales/fr.js +328 -0
- package/build/shared/i18n/locales/it.d.ts +2 -0
- package/build/shared/i18n/locales/it.js +327 -0
- package/build/shared/i18n/locales/ja.d.ts +2 -0
- package/build/shared/i18n/locales/ja.js +322 -0
- package/build/shared/i18n/locales/ko.d.ts +2 -0
- package/build/shared/i18n/locales/ko.js +322 -0
- package/build/shared/i18n/locales/nl.d.ts +2 -0
- package/build/shared/i18n/locales/nl.js +327 -0
- package/build/shared/i18n/locales/pl.d.ts +2 -0
- package/build/shared/i18n/locales/pl.js +332 -0
- package/build/shared/i18n/locales/pt.d.ts +2 -0
- package/build/shared/i18n/locales/pt.js +328 -0
- package/build/shared/i18n/locales/ru.d.ts +2 -0
- package/build/shared/i18n/locales/ru.js +331 -0
- package/build/shared/i18n/locales/tr.d.ts +2 -0
- package/build/shared/i18n/locales/tr.js +322 -0
- package/build/shared/i18n/locales/zh.d.ts +2 -0
- package/build/shared/i18n/locales/zh.js +322 -0
- package/build/shared/i18n/messages.d.ts +10 -0
- package/build/shared/i18n/messages.js +1 -0
- package/build/shared/ipc.d.ts +138 -0
- package/build/shared/ipc.js +2 -0
- package/build/shared/tsconfig.tsbuildinfo +1 -0
- package/build/shared/types.d.ts +205 -0
- package/build/shared/types.js +1 -0
- package/package.json +144 -4
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Models each tool accepts, read from the tool's own data. Lists are read from `homeDir` (the
|
|
2
|
+
// tools' data) and `cacheDir` (Vibe Usage's data directory, where the Cursor list is cached;
|
|
3
|
+
// never inside ~/.cursor).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { CLAUDE_FILES, SYNTHETIC_MODEL } from "../providers/claude.js";
|
|
8
|
+
import { accountProfileDirs } from "../providers/profiles.js";
|
|
9
|
+
import { CLAUDE_ALIASES, modelTier, nameMatchesFamily, tierFamilies } from "./model-families.js";
|
|
10
|
+
import { runProcess } from "../providers/common.js";
|
|
11
|
+
import { findCursorAgent } from "../providers/cursor.js";
|
|
12
|
+
import { DURATION_MS } from "../shared/catalog.js";
|
|
13
|
+
import { asArray, attemptSync, field, isObject, JSON_LINES_EXTENSION, jsonLines, newestFiles, readHeadSync, readJsonSync } from "./files.js";
|
|
14
|
+
const CURSOR_CACHE_FILE = 'cursor-models.json';
|
|
15
|
+
const CURSOR_CACHE_TTL_MS = DURATION_MS.DAY;
|
|
16
|
+
const CURSOR_LIST_TIMEOUT_MS = 20000;
|
|
17
|
+
const isStringArray = (value) => Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
18
|
+
function readCursorCache(cacheDir) {
|
|
19
|
+
if (!cacheDir)
|
|
20
|
+
return null;
|
|
21
|
+
const cache = readJsonSync(path.join(cacheDir, CURSOR_CACHE_FILE));
|
|
22
|
+
if (!isObject(cache) || !isStringArray(cache.models) || !cache.models.length)
|
|
23
|
+
return null;
|
|
24
|
+
const { updatedAt } = cache;
|
|
25
|
+
return typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? { models: cache.models, updatedAt } : null;
|
|
26
|
+
}
|
|
27
|
+
// Output format of `cursor-agent --list-models` (verified on CLI 2026.10.01):
|
|
28
|
+
// Available models
|
|
29
|
+
//
|
|
30
|
+
// auto - Auto (default)
|
|
31
|
+
// gpt-5.3-codex - Codex 5.3
|
|
32
|
+
const CURSOR_MODEL_LINE = /^([A-Za-z0-9][A-Za-z0-9._:/-]*) - \S/;
|
|
33
|
+
export function parseCursorModelList(output) {
|
|
34
|
+
const models = output.split(/\r?\n/).flatMap(line => CURSOR_MODEL_LINE.exec(line)?.[1] ?? []);
|
|
35
|
+
return [...new Set(models)];
|
|
36
|
+
}
|
|
37
|
+
// Runs the CLI asynchronously (it takes ~1s) and refreshes the cache at most once a day.
|
|
38
|
+
// Returns the live list, or null when the CLI is unavailable or its output is unusable.
|
|
39
|
+
export async function refreshCursorModels({ cacheDir, force = false, binary, now = Date.now() } = {}) {
|
|
40
|
+
const cached = readCursorCache(cacheDir);
|
|
41
|
+
if (!force && cached && now - cached.updatedAt < CURSOR_CACHE_TTL_MS)
|
|
42
|
+
return cached.models;
|
|
43
|
+
const agent = binary ?? (await findCursorAgent());
|
|
44
|
+
if (!agent)
|
|
45
|
+
return null;
|
|
46
|
+
const output = await runProcess(agent, ['--list-models'], { timeoutMs: CURSOR_LIST_TIMEOUT_MS }).catch(() => '');
|
|
47
|
+
const models = parseCursorModelList(output);
|
|
48
|
+
if (!models.length)
|
|
49
|
+
return null;
|
|
50
|
+
// The cache only saves the next CLI call; failing to write it changes nothing else.
|
|
51
|
+
if (cacheDir) {
|
|
52
|
+
attemptSync(() => {
|
|
53
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(cacheDir, CURSOR_CACHE_FILE), JSON.stringify({ models, updatedAt: now }), 'utf8');
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return models;
|
|
58
|
+
}
|
|
59
|
+
const CLAUDE_RECENT_SESSIONS_PER_PROJECT = 3;
|
|
60
|
+
// The model appears in the first assistant record; the head of the transcript suffices.
|
|
61
|
+
const CLAUDE_TRANSCRIPT_HEAD_BYTES = 64 * 1024;
|
|
62
|
+
// The file a Codex profile caches its model list in, and the visibility of the ones its picker shows.
|
|
63
|
+
const CODEX_MODELS_CACHE = 'models_cache.json';
|
|
64
|
+
const CODEX_LISTED_VISIBILITY = 'list';
|
|
65
|
+
// Cursor's default model, which the CLI accepts without a catalogue.
|
|
66
|
+
const CURSOR_DEFAULT_MODEL = 'auto';
|
|
67
|
+
const CURSOR_UNSET_MODEL = 'default';
|
|
68
|
+
// Full model names used in recent Claude Code sessions of a profile, so a pinned version stays selectable.
|
|
69
|
+
function recentClaudeModels(profileDir) {
|
|
70
|
+
const projects = path.join(profileDir, CLAUDE_FILES.projects);
|
|
71
|
+
const projectDirs = (attemptSync(() => fs.readdirSync(projects, { withFileTypes: true })) ?? []).filter(d => d.isDirectory());
|
|
72
|
+
const found = new Set();
|
|
73
|
+
for (const dir of projectDirs) {
|
|
74
|
+
for (const file of newestFiles(path.join(projects, dir.name), JSON_LINES_EXTENSION).slice(0, CLAUDE_RECENT_SESSIONS_PER_PROJECT)) {
|
|
75
|
+
for (const record of jsonLines(readHeadSync(file, CLAUDE_TRANSCRIPT_HEAD_BYTES))) {
|
|
76
|
+
const model = field(record.message, 'model');
|
|
77
|
+
if (typeof model === 'string' && model !== SYNTHETIC_MODEL)
|
|
78
|
+
found.add(model);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...found];
|
|
83
|
+
}
|
|
84
|
+
// The aliases, then each profile's configured model and recently used ones.
|
|
85
|
+
function claudeModels(profileDirs) {
|
|
86
|
+
const fromProfile = (dir) => {
|
|
87
|
+
const configured = field(readJsonSync(path.join(dir, CLAUDE_FILES.settings)), 'model');
|
|
88
|
+
return [...(typeof configured === 'string' ? [configured] : []), ...recentClaudeModels(dir)];
|
|
89
|
+
};
|
|
90
|
+
return [...new Set([...CLAUDE_ALIASES, ...profileDirs.flatMap(fromProfile)])];
|
|
91
|
+
}
|
|
92
|
+
// The models each profile's Codex lists in its picker.
|
|
93
|
+
function codexModels(profileDirs) {
|
|
94
|
+
const fromProfile = (dir) => asArray(field(readJsonSync(path.join(dir, CODEX_MODELS_CACHE)), 'models')).flatMap(model => {
|
|
95
|
+
const slug = field(model, 'slug');
|
|
96
|
+
return field(model, 'visibility') === CODEX_LISTED_VISIBILITY && typeof slug === 'string' ? [slug] : [];
|
|
97
|
+
});
|
|
98
|
+
return [...new Set(profileDirs.flatMap(fromProfile))];
|
|
99
|
+
}
|
|
100
|
+
// The CLI's live list (a stale list still beats an invented one); without it, the default
|
|
101
|
+
// plus models the user picked in the CLI before.
|
|
102
|
+
function cursorModels(homeDir, cacheDir) {
|
|
103
|
+
const cache = readCursorCache(cacheDir);
|
|
104
|
+
if (cache)
|
|
105
|
+
return cache.models;
|
|
106
|
+
const config = readJsonSync(path.join(homeDir, '.cursor', 'cli-config.json'));
|
|
107
|
+
const history = field(config, 'modelSelectionHistory');
|
|
108
|
+
const used = [field(field(config, 'selectedModel'), 'modelId'), ...asArray(history)].filter((model) => typeof model === 'string' && model !== '' && model !== CURSOR_UNSET_MODEL);
|
|
109
|
+
return [...new Set([CURSOR_DEFAULT_MODEL, ...used])];
|
|
110
|
+
}
|
|
111
|
+
// Models each handover target accepts. Providers whose launch takes no model argument
|
|
112
|
+
// (catalog `acceptsModel: false`) get none.
|
|
113
|
+
export function discoverModels({ homeDir = os.homedir(), cacheDir, accounts = [] } = {}) {
|
|
114
|
+
return {
|
|
115
|
+
claude: claudeModels(accountProfileDirs('claude', homeDir, accounts)),
|
|
116
|
+
codex: codexModels(accountProfileDirs('codex', homeDir, accounts)),
|
|
117
|
+
cursor: cursorModels(homeDir, cacheDir),
|
|
118
|
+
antigravity: []
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const DEFAULT_TIER = 'balanced';
|
|
122
|
+
const TIER_FALLBACK = {
|
|
123
|
+
flagship: ['flagship', 'balanced', 'fast'],
|
|
124
|
+
balanced: ['balanced', 'flagship', 'fast'],
|
|
125
|
+
fast: ['fast', 'balanced', 'flagship']
|
|
126
|
+
};
|
|
127
|
+
// Cursor lists every model per reasoning effort; `-high` (or no suffix) is the standard one.
|
|
128
|
+
const VARIANT_SUFFIX = /-(thinking|fast|max|xhigh|low|medium|minimal|none)(-|$)/;
|
|
129
|
+
const STANDARD_VARIANT = '-high';
|
|
130
|
+
export const resolveModelTier = (modelName) => modelTier(modelName ?? '') ?? DEFAULT_TIER;
|
|
131
|
+
function pickByTier(models, tier) {
|
|
132
|
+
for (const candidateTier of TIER_FALLBACK[tier]) {
|
|
133
|
+
for (const family of tierFamilies(candidateTier)) {
|
|
134
|
+
// A model naming the family as its base but of another tier (a flash-lite for flash) is not it.
|
|
135
|
+
const matches = models.filter(m => nameMatchesFamily(m, family) && modelTier(m) === candidateTier);
|
|
136
|
+
const choice = matches.find(m => !VARIANT_SUFFIX.test(m)) ?? matches[0];
|
|
137
|
+
if (choice)
|
|
138
|
+
return choice;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
// 'Gemini 3.8 Flash (High)' → 'gemini-3.8-flash-high', the form CLI model ids use.
|
|
144
|
+
function modelIdFromDisplayName(name) {
|
|
145
|
+
return name
|
|
146
|
+
.trim()
|
|
147
|
+
.toLowerCase()
|
|
148
|
+
.replace(/\s*\((\w+)\)$/, '-$1')
|
|
149
|
+
.replace(/\s+/g, '-');
|
|
150
|
+
}
|
|
151
|
+
// Target model for a handover: the user's preference, else the same model if the target
|
|
152
|
+
// offers it, else the target's closest tier. A null model lets the tool use its default.
|
|
153
|
+
export function getMappedModel(sourceModel, targetProvider, preferences = {}, location = {}) {
|
|
154
|
+
const tier = resolveModelTier(sourceModel);
|
|
155
|
+
const preferred = preferences[targetProvider];
|
|
156
|
+
if (preferred)
|
|
157
|
+
return { tier, targetModel: preferred, source: 'preference' };
|
|
158
|
+
const models = discoverModels(location)[targetProvider];
|
|
159
|
+
const id = sourceModel ? modelIdFromDisplayName(sourceModel) : '';
|
|
160
|
+
const same = id ? (models.find(m => m === id) ?? models.find(m => m === `${id}${STANDARD_VARIANT}`)) : undefined;
|
|
161
|
+
if (same)
|
|
162
|
+
return { tier, targetModel: same, source: 'same' };
|
|
163
|
+
return { tier, targetModel: pickByTier(models, tier), source: 'tier' };
|
|
164
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Removes recognizable credentials from text the app passes on to another tool (a handover
|
|
2
|
+
// brief). Patterns match the documented shapes of common keys, so ordinary text such as paths,
|
|
3
|
+
// commit hashes and prose stays intact; a secret of an unknown shape (a password given as a
|
|
4
|
+
// command-line flag, a wallet's word list) is not detected.
|
|
5
|
+
export const REDACTED = '[REDACTED_SECRET]';
|
|
6
|
+
// Names that always announce a credential in `name=value` or `"name": "value"` form, optionally
|
|
7
|
+
// prefixed (DB_PASSWORD, STRIPE_SECRET_KEY, config.secret_key_base). The name must end there:
|
|
8
|
+
// `max_tokens` is not one.
|
|
9
|
+
const SECRET_NAME = String.raw `\b([\w.-]*?(?:password|passwd|pwd|secret|token|credentials?|api[_-]?key|access[_-]?key|private[_-]?key|secret[_-]?key|key[_-]?base))\b`;
|
|
10
|
+
// Any other name ending in `key` (ENCRYPTION_KEY, signingKey) counts only with a value that
|
|
11
|
+
// looks generated, since `sortKey: "createdAt"` is not a secret.
|
|
12
|
+
const KEY_NAME = String.raw `\b([\w.-]*?key)\b`;
|
|
13
|
+
const GENERATED = (quote) => String.raw `(?=[^${quote}\s]*\d)(?=[^${quote}\s]*[A-Za-z])[^${quote}\s]{16,}`;
|
|
14
|
+
// A value in double, single or back quotes, which may contain escaped quotes.
|
|
15
|
+
const quoted = (body) => ['"', "'", '`'].map(quote => String.raw `${quote}${body(quote)}${quote}`).join('|');
|
|
16
|
+
const ANY_VALUE = (quote) => String.raw `(?:\\.|[^${quote}\\\r\n]){4,}?`;
|
|
17
|
+
// The end of an unquoted value.
|
|
18
|
+
const UNQUOTED_END = String.raw `(?=$|[\s"'\`,;&)}\]])`;
|
|
19
|
+
// Keeps the name, the separator and the quotes; only the value goes.
|
|
20
|
+
const keepQuotes = (_match, name, separator, value) => `${name}${separator}${value.charAt(0)}${REDACTED}${value.charAt(0)}`;
|
|
21
|
+
// Ordered: whole key blocks first, then token shapes, then values announced by their name.
|
|
22
|
+
const PATTERNS = [
|
|
23
|
+
// PEM and PGP private keys. An unterminated block (a clipped message) is redacted to the end.
|
|
24
|
+
[/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g, REDACTED],
|
|
25
|
+
// OpenAI (sk-, sk-proj-) and Anthropic (sk-ant-) API keys.
|
|
26
|
+
[/\bsk-[A-Za-z0-9_-]{20,}/g, REDACTED],
|
|
27
|
+
// Stripe secret and restricted keys.
|
|
28
|
+
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, REDACTED],
|
|
29
|
+
// GitHub personal, OAuth, app and refresh tokens, and fine-grained tokens.
|
|
30
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, REDACTED],
|
|
31
|
+
// GitLab personal access tokens, npm and Hugging Face tokens.
|
|
32
|
+
[/\b(?:glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z0-9]{30,})/g, REDACTED],
|
|
33
|
+
// AWS access key ids (long-term and temporary).
|
|
34
|
+
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
|
|
35
|
+
// Slack bot, user, app and refresh tokens.
|
|
36
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, REDACTED],
|
|
37
|
+
// Google API keys and OAuth access tokens.
|
|
38
|
+
[/\b(?:AIza[0-9A-Za-z_-]{35}|ya29\.[A-Za-z0-9_-]{20,})/g, REDACTED],
|
|
39
|
+
// JSON Web Tokens: two base64url JSON segments (`{"` encodes as `eyJ`) and a signature.
|
|
40
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g, REDACTED],
|
|
41
|
+
// Any credential in an Authorization header (also as a JSON field), whatever its length.
|
|
42
|
+
[/(\bAuthorization["']?\s*:\s*["']?(?:Bearer|Basic|token)\s+)[A-Za-z0-9._~+/=-]{8,}/gi, `$1${REDACTED}`],
|
|
43
|
+
// A bearer token elsewhere is recognized by its length: "Bearer" alone is an ordinary word.
|
|
44
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, `Bearer ${REDACTED}`],
|
|
45
|
+
// The password in a URL's user info (postgres://user:password@host), up to the last `@`
|
|
46
|
+
// before the host, since a password may contain one.
|
|
47
|
+
[/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s/]*@/gi, `$1${REDACTED}@`],
|
|
48
|
+
// A quoted value after `:` or `=`: JSON, YAML, code.
|
|
49
|
+
[new RegExp(String.raw `${SECRET_NAME}(["'\`]?\s*[:=]\s*)(${quoted(ANY_VALUE)})`, 'gi'), keepQuotes],
|
|
50
|
+
[new RegExp(String.raw `${KEY_NAME}(["'\`]?\s*[:=]\s*)(${quoted(GENERATED)})`, 'gi'), keepQuotes],
|
|
51
|
+
// An unquoted value after `=` (shell, .env, URL queries, connection strings). A call such
|
|
52
|
+
// as `token = getToken()` is code, not a value.
|
|
53
|
+
[new RegExp(String.raw `${SECRET_NAME}(\s*=\s*)[^\s"'\`,;&(){}<>[\]]{8,}${UNQUOTED_END}`, 'gi'), `$1$2${REDACTED}`],
|
|
54
|
+
// An unquoted value after `:` (YAML, headers) counts only when it mixes letters and digits,
|
|
55
|
+
// so prose like `token: required` stays.
|
|
56
|
+
[
|
|
57
|
+
new RegExp(String.raw `${SECRET_NAME}(\s*:\s*)(?=[^\s"'\`]*\d)(?=[^\s"'\`]*[A-Za-z])[^\s"'\`,;(){}<>[\]]{8,}${UNQUOTED_END}`, 'gi'),
|
|
58
|
+
`$1$2${REDACTED}`
|
|
59
|
+
],
|
|
60
|
+
[new RegExp(String.raw `${KEY_NAME}(\s*[:=]\s*)${GENERATED(String.raw `"'\`,;(){}<>[\]`)}${UNQUOTED_END}`, 'gi'), `$1$2${REDACTED}`]
|
|
61
|
+
];
|
|
62
|
+
export function redactSecrets(text) {
|
|
63
|
+
return PATTERNS.reduce((result, [pattern, replacement]) => typeof replacement === 'string' ? result.replace(pattern, replacement) : result.replace(pattern, replacement), text);
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type ProviderId } from '../shared/catalog.ts';
|
|
2
|
+
import type { MessageKey } from '../shared/i18n/index.ts';
|
|
3
|
+
import type { Account, AutoContinue, MeasuredAccount, QuotaWindow, Snapshot } from '../shared/types.ts';
|
|
4
|
+
export declare const QUOTA_FLOOR_PERCENT = 5;
|
|
5
|
+
export type QuotaState = Pick<Snapshot, 'providers'> & {
|
|
6
|
+
settings: Pick<Snapshot['settings'], 'accounts' | 'providerOrder'>;
|
|
7
|
+
};
|
|
8
|
+
interface Options {
|
|
9
|
+
model?: string | undefined;
|
|
10
|
+
now?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function isMeasurementFresh(account: MeasuredAccount | null, now: number): account is MeasuredAccount;
|
|
13
|
+
export type KnownWindow = QuotaWindow & {
|
|
14
|
+
remainingPercent: number;
|
|
15
|
+
};
|
|
16
|
+
export declare function isAccountEligible(account: MeasuredAccount | null, { model, now }?: Options): boolean;
|
|
17
|
+
export declare function tightestWindow(account: MeasuredAccount | null, model?: string): KnownWindow | null;
|
|
18
|
+
export declare const headroomPercent: (account: MeasuredAccount | null, model?: string) => number | null;
|
|
19
|
+
export declare function usableModel(account: MeasuredAccount | null, model: string | undefined, now?: number): {
|
|
20
|
+
model: string | undefined;
|
|
21
|
+
} | null;
|
|
22
|
+
export interface Preference {
|
|
23
|
+
tool?: ProviderId | undefined;
|
|
24
|
+
accountId?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare function selectAccount(state: QuotaState, providerId: ProviderId, { accountId, model, now }?: Options & {
|
|
27
|
+
accountId?: string | undefined;
|
|
28
|
+
}): Account | null;
|
|
29
|
+
export declare function primaryAccount(state: QuotaState, providerId: ProviderId): Account | null;
|
|
30
|
+
export interface ProviderChoice {
|
|
31
|
+
providerId: ProviderId;
|
|
32
|
+
account: Account | null;
|
|
33
|
+
model?: string | undefined;
|
|
34
|
+
verified: boolean;
|
|
35
|
+
isFallback: boolean;
|
|
36
|
+
headroomPercent: number | null;
|
|
37
|
+
}
|
|
38
|
+
export declare function selectProvider(state: QuotaState, { exclude, now, models, prefer }?: {
|
|
39
|
+
exclude?: ProviderId | null;
|
|
40
|
+
now?: number;
|
|
41
|
+
models?: Partial<Record<ProviderId, string>>;
|
|
42
|
+
prefer?: Preference;
|
|
43
|
+
}): ProviderChoice;
|
|
44
|
+
export type ContinueScope = Exclude<AutoContinue, 'off'>;
|
|
45
|
+
export declare const continueScope: (setting: AutoContinue) => ContinueScope;
|
|
46
|
+
export interface Successor {
|
|
47
|
+
providerId: ProviderId;
|
|
48
|
+
account: Account | null;
|
|
49
|
+
model?: string | null;
|
|
50
|
+
}
|
|
51
|
+
export declare const SUCCESSOR_MIN_HEADROOM_PERCENT = 20;
|
|
52
|
+
export declare function chooseSuccessor(state: QuotaState, from: Account, scope: ContinueScope, { now, minHeadroom, models, prefer }?: {
|
|
53
|
+
now?: number;
|
|
54
|
+
minHeadroom?: number;
|
|
55
|
+
models?: Partial<Record<ProviderId, string>>;
|
|
56
|
+
prefer?: Preference;
|
|
57
|
+
}): Successor | null;
|
|
58
|
+
export interface LaunchWarning {
|
|
59
|
+
key: MessageKey;
|
|
60
|
+
params: Readonly<Record<string, string>>;
|
|
61
|
+
}
|
|
62
|
+
export declare function claudeLaunchWarnings(state: QuotaState, account: Account, { model, settingsFiles, now }?: Options & {
|
|
63
|
+
settingsFiles?: Readonly<Record<string, unknown>>;
|
|
64
|
+
}): LaunchWarning[];
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Which account or provider can take new work. One rule for every provider: a fresh,
|
|
2
|
+
// successful measurement whose relevant windows are all known and above the floor. Unknown
|
|
3
|
+
// quota is never treated as available.
|
|
4
|
+
import { DURATION_MS, normalizeProviderOrder } from "../shared/catalog.js";
|
|
5
|
+
import { AppError } from "./errors.js";
|
|
6
|
+
import { isObject } from "./files.js";
|
|
7
|
+
import { nameMatchesFamily, quotaScopeFamily } from "./model-families.js";
|
|
8
|
+
export const QUOTA_FLOOR_PERCENT = 5;
|
|
9
|
+
// How old a reading may be to count as the quota now (eligibility, moves). Kept readings are a
|
|
10
|
+
// separate matter (measurements.ts).
|
|
11
|
+
const FRESH_READING_MAX_AGE_MS = 5 * DURATION_MS.MINUTE;
|
|
12
|
+
// A partial limit that does not cover the model (Cursor's API pool for Auto).
|
|
13
|
+
const exempts = (window, model) => Boolean(window.partial && model && window.except?.includes(model.toLowerCase()));
|
|
14
|
+
// The limits that decide whether the account can take work with `model`. Secondary windows
|
|
15
|
+
// (spend, combined totals) never do; a partial limit does unless it does not cover the model,
|
|
16
|
+
// so without a known model it counts.
|
|
17
|
+
function relevantWindows(account, model) {
|
|
18
|
+
const windows = (account?.windows ?? []).filter(w => w.section !== 'details' && !exempts(w, model));
|
|
19
|
+
const family = quotaScopeFamily(model);
|
|
20
|
+
const covers = (scope) => !family || nameMatchesFamily(scope, family.name);
|
|
21
|
+
return {
|
|
22
|
+
family,
|
|
23
|
+
// Without a model every model scope counts: the work may be for any of them.
|
|
24
|
+
windows: [...windows.filter(w => !w.scope), ...windows.filter(w => w.scope && covers(w.scope))],
|
|
25
|
+
scoped: family !== undefined && windows.some(w => w.scope && covers(w.scope))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function isMeasurementFresh(account, now) {
|
|
29
|
+
const fetchedAt = Date.parse(account?.fetchedAt ?? '');
|
|
30
|
+
return account?.status === 'ok' && Number.isFinite(fetchedAt) && now - fetchedAt <= FRESH_READING_MAX_AGE_MS;
|
|
31
|
+
}
|
|
32
|
+
// A window's `active` is the source's choice of headline row, not whether the limit applies:
|
|
33
|
+
// Claude's weekly rows are reported inactive while they still bind. It says nothing here.
|
|
34
|
+
const isWindowKnown = (window) => !window.stale && window.remainingPercent !== null;
|
|
35
|
+
export function isAccountEligible(account, { model, now = Date.now() } = {}) {
|
|
36
|
+
if (!isMeasurementFresh(account, now))
|
|
37
|
+
return false;
|
|
38
|
+
const { family, windows, scoped } = relevantWindows(account, model);
|
|
39
|
+
if (family?.scopeRequired && !scoped)
|
|
40
|
+
return false;
|
|
41
|
+
return windows.length > 0 && windows.every(w => isWindowKnown(w) && w.remainingPercent > QUOTA_FLOOR_PERCENT);
|
|
42
|
+
}
|
|
43
|
+
// The relevant window with the least left, i.e. the limit that decides what the user can spend.
|
|
44
|
+
export function tightestWindow(account, model) {
|
|
45
|
+
return relevantWindows(account, model)
|
|
46
|
+
.windows.filter(isWindowKnown)
|
|
47
|
+
.reduce((tightest, w) => (tightest && tightest.remainingPercent <= w.remainingPercent ? tightest : w), null);
|
|
48
|
+
}
|
|
49
|
+
export const headroomPercent = (account, model) => tightestWindow(account, model)?.remainingPercent ?? null;
|
|
50
|
+
// The model the account can take the work with: the one planned when it has quota for it, else
|
|
51
|
+
// one its spent partial limits do not cover (Cursor's Auto once the API pool is spent); null
|
|
52
|
+
// when none. `{ model: undefined }` is the account's own default.
|
|
53
|
+
export function usableModel(account, model, now = Date.now()) {
|
|
54
|
+
if (isAccountEligible(account, { model, now }))
|
|
55
|
+
return { model };
|
|
56
|
+
const alternatives = new Set((account?.windows ?? []).filter(w => w.partial).flatMap(w => w.except ?? []));
|
|
57
|
+
for (const alternative of alternatives)
|
|
58
|
+
if (alternative !== model?.toLowerCase() && isAccountEligible(account, { model: alternative, now }))
|
|
59
|
+
return { model: alternative };
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const primaryFirst = (a, b) => Number(b.primary) - Number(a.primary);
|
|
63
|
+
function configuredAccounts(state, providerId) {
|
|
64
|
+
return state.settings.accounts.filter(a => a.providerId === providerId && a.enabled);
|
|
65
|
+
}
|
|
66
|
+
function candidates(state, providerId, prefer = {}) {
|
|
67
|
+
const accounts = configuredAccounts(state, providerId);
|
|
68
|
+
const preferred = accounts.filter(a => a.id === prefer.accountId);
|
|
69
|
+
return preferred.length ? preferred : accounts;
|
|
70
|
+
}
|
|
71
|
+
function measuredAccount(state, account) {
|
|
72
|
+
return state.providers.find(p => p.id === account.providerId)?.accounts.find(a => a.id === account.id) ?? null;
|
|
73
|
+
}
|
|
74
|
+
// Explicit choice wins; otherwise the primary-first eligible account, or null.
|
|
75
|
+
export function selectAccount(state, providerId, { accountId, model, now = Date.now() } = {}) {
|
|
76
|
+
const accounts = configuredAccounts(state, providerId);
|
|
77
|
+
if (accountId) {
|
|
78
|
+
const chosen = accounts.find(a => a.id === accountId);
|
|
79
|
+
if (!chosen)
|
|
80
|
+
throw new AppError('account_not_found');
|
|
81
|
+
return chosen;
|
|
82
|
+
}
|
|
83
|
+
return accounts.sort(primaryFirst).find(a => isAccountEligible(measuredAccount(state, a), { model, now })) ?? null;
|
|
84
|
+
}
|
|
85
|
+
// The account a tool uses when nothing is verified: the user's primary, else the first.
|
|
86
|
+
export function primaryAccount(state, providerId) {
|
|
87
|
+
return configuredAccounts(state, providerId).sort(primaryFirst)[0] ?? null;
|
|
88
|
+
}
|
|
89
|
+
// First provider in the user's order with an eligible account. When none qualifies, the
|
|
90
|
+
// first provider is returned unverified so the caller can say so instead of guessing. A
|
|
91
|
+
// preferred tool is the only candidate.
|
|
92
|
+
export function selectProvider(state, { exclude, now = Date.now(), models = {}, prefer = {} } = {}) {
|
|
93
|
+
const order = normalizeProviderOrder(state.settings.providerOrder).filter(id => id !== exclude && (!prefer.tool || id === prefer.tool || prefer.tool === exclude));
|
|
94
|
+
for (const [index, providerId] of order.entries()) {
|
|
95
|
+
for (const account of candidates(state, providerId, prefer).sort(primaryFirst)) {
|
|
96
|
+
const usable = usableModel(measuredAccount(state, account), models[providerId], now);
|
|
97
|
+
if (usable)
|
|
98
|
+
return {
|
|
99
|
+
providerId,
|
|
100
|
+
account,
|
|
101
|
+
...(usable.model ? { model: usable.model } : {}),
|
|
102
|
+
verified: true,
|
|
103
|
+
isFallback: index > 0,
|
|
104
|
+
headroomPercent: headroomPercent(measuredAccount(state, account), usable.model)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// `exclude` removes at most one provider, so the order is never empty.
|
|
109
|
+
const [first] = order;
|
|
110
|
+
if (!first)
|
|
111
|
+
throw new Error('provider order is empty');
|
|
112
|
+
return { providerId: first, account: primaryAccount(state, first), verified: false, isFallback: false, headroomPercent: null };
|
|
113
|
+
}
|
|
114
|
+
// The scope for a setting: with auto-continue off, work is offered rather than moved, and the
|
|
115
|
+
// offer looks within every tool.
|
|
116
|
+
export const continueScope = (setting) => (setting === 'off' ? 'tools' : setting);
|
|
117
|
+
// Work only moves to an account with real room left: one just above the floor would run out
|
|
118
|
+
// again within minutes and bounce the work on.
|
|
119
|
+
export const SUCCESSOR_MIN_HEADROOM_PERCENT = 20;
|
|
120
|
+
// The tool's eligible account with the most headroom, at least `minHeadroom`, and the model it
|
|
121
|
+
// takes the work with.
|
|
122
|
+
function roomiest(state, accounts, minHeadroom, now, model) {
|
|
123
|
+
return (accounts
|
|
124
|
+
.map(account => {
|
|
125
|
+
const measured = measuredAccount(state, account);
|
|
126
|
+
const usable = usableModel(measured, model, now);
|
|
127
|
+
return { account, model: usable?.model, headroom: usable ? (headroomPercent(measured, usable.model) ?? 0) : -1 };
|
|
128
|
+
})
|
|
129
|
+
.filter(candidate => candidate.headroom >= minHeadroom)
|
|
130
|
+
.sort((a, b) => b.headroom - a.headroom)[0] ?? null);
|
|
131
|
+
}
|
|
132
|
+
// Who takes over from `from`: the same tool's other account with the most verified headroom,
|
|
133
|
+
// else, within `scope`, the first other tool in the fallback order with such an account. A
|
|
134
|
+
// preferred tool comes first among the other tools.
|
|
135
|
+
export function chooseSuccessor(state, from, scope, { now = Date.now(), minHeadroom = SUCCESSOR_MIN_HEADROOM_PERCENT, models = {}, prefer = {} } = {}) {
|
|
136
|
+
// The preferred account running out leaves its tool's other accounts to take over.
|
|
137
|
+
const narrowed = prefer.accountId === from.id ? { tool: prefer.tool } : prefer;
|
|
138
|
+
const others = (providerId) => candidates(state, providerId, narrowed).filter(a => a.id !== from.id);
|
|
139
|
+
const sibling = roomiest(state, others(from.providerId), minHeadroom, now, models[from.providerId]);
|
|
140
|
+
if (sibling)
|
|
141
|
+
return { providerId: from.providerId, account: sibling.account, ...(sibling.model ? { model: sibling.model } : {}) };
|
|
142
|
+
if (scope === 'accounts')
|
|
143
|
+
return null;
|
|
144
|
+
const order = normalizeProviderOrder(state.settings.providerOrder).filter(id => id !== from.providerId);
|
|
145
|
+
const preferred = order.filter(id => id === prefer.tool);
|
|
146
|
+
for (const providerId of [...preferred, ...order.filter(id => id !== prefer.tool)]) {
|
|
147
|
+
const found = roomiest(state, others(providerId), minHeadroom, now, models[providerId]);
|
|
148
|
+
if (found)
|
|
149
|
+
return { providerId, account: found.account, ...(found.model ? { model: found.model } : {}) };
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const CREDENTIAL_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_', 'AWS_'];
|
|
154
|
+
// Risks of starting Claude Code with a chosen profile. `settingsFiles` maps a display name
|
|
155
|
+
// to a parsed Claude settings.json (or null).
|
|
156
|
+
export function claudeLaunchWarnings(state, account, { model, settingsFiles = {}, now = Date.now() } = {}) {
|
|
157
|
+
const warnings = [];
|
|
158
|
+
for (const [file, settings] of Object.entries(settingsFiles)) {
|
|
159
|
+
if (!isObject(settings))
|
|
160
|
+
continue;
|
|
161
|
+
const overrides = isObject(settings.env) ? Object.keys(settings.env) : [];
|
|
162
|
+
if (overrides.some(name => CREDENTIAL_ENV_PREFIXES.some(prefix => name.toUpperCase().startsWith(prefix))))
|
|
163
|
+
warnings.push({ key: 'warnSettingsEnvOverride', params: { file } });
|
|
164
|
+
if (settings.apiKeyHelper)
|
|
165
|
+
warnings.push({ key: 'warnApiKeyHelper', params: { file } });
|
|
166
|
+
}
|
|
167
|
+
const measured = measuredAccount(state, account);
|
|
168
|
+
if (!isAccountEligible(measured, { model, now }))
|
|
169
|
+
warnings.push({ key: 'warnQuotaUnverified', params: {} });
|
|
170
|
+
if (relevantWindows(measured, model).windows.some(w => w.remainingPercent === 0))
|
|
171
|
+
warnings.push({ key: 'warnQuotaExhausted', params: {} });
|
|
172
|
+
return warnings;
|
|
173
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Clock } from '../providers/common.ts';
|
|
2
|
+
import type { DiscoveredAccount, ProviderAdapter } from '../providers/types.ts';
|
|
3
|
+
import { type ProviderId } from '../shared/catalog.ts';
|
|
4
|
+
import type { Account, Settings, Snapshot } from '../shared/types.ts';
|
|
5
|
+
import type { SettingsPatch } from '../shared/ipc.ts';
|
|
6
|
+
export declare const SIGN_IN_WINDOW_MS: number;
|
|
7
|
+
export interface ServiceOptions {
|
|
8
|
+
settingsPath: string;
|
|
9
|
+
measurementsPath?: string;
|
|
10
|
+
renewSignIn?: Partial<Record<ProviderId, (account: Account) => Promise<boolean>>>;
|
|
11
|
+
onChange?: () => void;
|
|
12
|
+
providers?: ProviderAdapter[];
|
|
13
|
+
clock?: Clock;
|
|
14
|
+
home?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RefreshOptions {
|
|
17
|
+
fresh?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface Service {
|
|
20
|
+
snapshot(): Snapshot;
|
|
21
|
+
refresh(providerId?: ProviderId, options?: RefreshOptions): Promise<Snapshot>;
|
|
22
|
+
updateSettings(patch: SettingsPatch): Promise<Snapshot>;
|
|
23
|
+
updateSettingsFrom(input: unknown): Promise<Snapshot>;
|
|
24
|
+
dismissAccount(accountId: unknown): Promise<Snapshot>;
|
|
25
|
+
addPendingAccount(account: Omit<DiscoveredAccount, 'primary'>): Promise<Snapshot>;
|
|
26
|
+
signInOpened(accountId: string): Promise<Snapshot>;
|
|
27
|
+
withdrawPendingAccount(accountId: string): Promise<Snapshot>;
|
|
28
|
+
resetSettings(): Promise<Snapshot>;
|
|
29
|
+
importSettings(document: unknown): Promise<Snapshot>;
|
|
30
|
+
exportSettings(): Omit<Settings, 'window'>;
|
|
31
|
+
dismissSettingsNotice(): Snapshot;
|
|
32
|
+
restoreDismissedAccounts(): Promise<Snapshot>;
|
|
33
|
+
reconcileDiscovery(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function createService({ settingsPath, measurementsPath, renewSignIn, onChange, providers: injected, clock, home }: ServiceOptions): Promise<Service>;
|