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,294 @@
|
|
|
1
|
+
// Transport shared by the providers: HTTPS to the allow-listed quota endpoints, local
|
|
2
|
+
// processes, and locating the tools' binaries.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import fsp from 'node:fs/promises';
|
|
7
|
+
import https from 'node:https';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { cleanEnvironment } from "../core/environment.js";
|
|
11
|
+
import { ProviderError, systemErrorCode } from "../core/errors.js";
|
|
12
|
+
import { isFile, isObject } from "../core/files.js";
|
|
13
|
+
import { hasControlChars } from "../core/model.js";
|
|
14
|
+
import { APP_SLUG, DURATION_MS } from "../shared/catalog.js";
|
|
15
|
+
import { getQuotaAgent } from "./tls-trust.js";
|
|
16
|
+
const packageJson = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
17
|
+
export const APP_VERSION = isObject(packageJson) && typeof packageJson.version === 'string' ? packageJson.version : '0.0.0';
|
|
18
|
+
export const USER_AGENT = `${APP_SLUG}/${APP_VERSION}`;
|
|
19
|
+
export const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
20
|
+
const DEFAULT_HTTP_TIMEOUT_MS = 15000;
|
|
21
|
+
const DEFAULT_PROCESS_TIMEOUT_MS = 20000;
|
|
22
|
+
// Treat tokens this close to expiry as expired: a request started now would fail mid-flight.
|
|
23
|
+
const TOKEN_EXPIRY_SKEW_MS = DURATION_MS.MINUTE;
|
|
24
|
+
// The only remote endpoints the app may call. Everything else is rejected before a request.
|
|
25
|
+
export const QUOTA_ENDPOINTS = {
|
|
26
|
+
claude: 'https://api.anthropic.com/api/oauth/usage',
|
|
27
|
+
cursor: 'https://cursor.com/api/usage-summary'
|
|
28
|
+
};
|
|
29
|
+
const ALLOWED_ENDPOINTS = new Set(Object.values(QUOTA_ENDPOINTS));
|
|
30
|
+
const CERTIFICATE_ERRORS = new Set([
|
|
31
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
32
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
33
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
34
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
35
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
36
|
+
'CERT_HAS_EXPIRED',
|
|
37
|
+
'CERT_NOT_YET_VALID',
|
|
38
|
+
'CERT_UNTRUSTED',
|
|
39
|
+
'CERT_REJECTED',
|
|
40
|
+
'CERT_SIGNATURE_FAILURE',
|
|
41
|
+
'ERR_TLS_CERT_ALTNAME_INVALID'
|
|
42
|
+
]);
|
|
43
|
+
// The first `length` hex digits of the value's SHA-256: a stable, short id that reveals nothing.
|
|
44
|
+
export const sha256Prefix = (value, length) => crypto.createHash('sha256').update(value).digest('hex').slice(0, length);
|
|
45
|
+
export function isExpired(epochMs, clock = Date.now) {
|
|
46
|
+
return typeof epochMs === 'number' && Number.isFinite(epochMs) && epochMs <= clock() + TOKEN_EXPIRY_SKEW_MS;
|
|
47
|
+
}
|
|
48
|
+
// Reads JWT claims without verifying the signature: only the expiry and subject are used, to
|
|
49
|
+
// fail fast locally; the provider still authenticates the token.
|
|
50
|
+
export function jwtClaims(token) {
|
|
51
|
+
const payload = typeof token === 'string' ? token.split('.') : [];
|
|
52
|
+
if (payload.length !== 3)
|
|
53
|
+
throw new ProviderError('auth_invalid');
|
|
54
|
+
try {
|
|
55
|
+
const claims = JSON.parse(Buffer.from(payload[1] ?? '', 'base64url').toString('utf8'));
|
|
56
|
+
if (isObject(claims))
|
|
57
|
+
return claims;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// reported below
|
|
61
|
+
}
|
|
62
|
+
throw new ProviderError('auth_invalid');
|
|
63
|
+
}
|
|
64
|
+
// A credentials document written by a provider's own tool.
|
|
65
|
+
export async function readCredentialFile(file) {
|
|
66
|
+
let document;
|
|
67
|
+
try {
|
|
68
|
+
const stat = await fsp.stat(file);
|
|
69
|
+
if (stat.size > MAX_RESPONSE_BYTES)
|
|
70
|
+
throw new ProviderError('invalid_response');
|
|
71
|
+
document = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof ProviderError)
|
|
75
|
+
throw error;
|
|
76
|
+
throw new ProviderError(systemErrorCode(error) === 'ENOENT' ? 'auth_missing' : 'auth_invalid', { cause: error });
|
|
77
|
+
}
|
|
78
|
+
if (!isObject(document))
|
|
79
|
+
throw new ProviderError('auth_invalid');
|
|
80
|
+
return document;
|
|
81
|
+
}
|
|
82
|
+
function retryAfterSeconds(header) {
|
|
83
|
+
if (typeof header !== 'string')
|
|
84
|
+
return undefined;
|
|
85
|
+
const seconds = Number(header);
|
|
86
|
+
return Number.isFinite(seconds) ? seconds : Math.ceil((Date.parse(header) - Date.now()) / 1000);
|
|
87
|
+
}
|
|
88
|
+
function httpStatusError(response) {
|
|
89
|
+
const status = response.statusCode;
|
|
90
|
+
const code = status === 429 ? 'rate_limited' : status === 401 || status === 403 ? 'auth_rejected' : 'http_rejected';
|
|
91
|
+
const retryAfter = retryAfterSeconds(response.headers['retry-after']);
|
|
92
|
+
return new ProviderError(code, retryAfter === undefined ? {} : { retryAfterSeconds: retryAfter });
|
|
93
|
+
}
|
|
94
|
+
// One-shot settlement shared by the HTTP clients: the first outcome wins, the deadline is
|
|
95
|
+
// cleared, and a failure destroys every attached stream.
|
|
96
|
+
export function settleOnce(resolve, reject, timeoutMs) {
|
|
97
|
+
let settled = false;
|
|
98
|
+
const streams = [];
|
|
99
|
+
const deadline = setTimeout(() => {
|
|
100
|
+
fail(new ProviderError('request_timeout'));
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
const settle = () => {
|
|
103
|
+
if (settled)
|
|
104
|
+
return false;
|
|
105
|
+
settled = true;
|
|
106
|
+
clearTimeout(deadline);
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
function fail(error) {
|
|
110
|
+
if (!settle())
|
|
111
|
+
return;
|
|
112
|
+
for (const stream of streams)
|
|
113
|
+
stream.destroy();
|
|
114
|
+
reject(error);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
succeed: value => {
|
|
118
|
+
if (settle())
|
|
119
|
+
resolve(value);
|
|
120
|
+
},
|
|
121
|
+
fail,
|
|
122
|
+
attach: stream => {
|
|
123
|
+
streams.push(stream);
|
|
124
|
+
return stream;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Settles with the parsed JSON body. Handlers stay attached after settling: a destroyed
|
|
129
|
+
// stream can still emit error or aborted later.
|
|
130
|
+
export function readJsonResponse(response, settle, maxBytes = MAX_RESPONSE_BYTES) {
|
|
131
|
+
response.on('error', () => {
|
|
132
|
+
settle.fail(new ProviderError('transport_error'));
|
|
133
|
+
});
|
|
134
|
+
response.on('aborted', () => {
|
|
135
|
+
settle.fail(new ProviderError('transport_error'));
|
|
136
|
+
});
|
|
137
|
+
// A rejected response is destroyed on failure; its body is never read.
|
|
138
|
+
if (response.statusCode !== 200) {
|
|
139
|
+
settle.fail(httpStatusError(response));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let bytes = 0;
|
|
143
|
+
let chunks = [];
|
|
144
|
+
response.on('data', (chunk) => {
|
|
145
|
+
bytes += chunk.length;
|
|
146
|
+
if (bytes > maxBytes) {
|
|
147
|
+
chunks = [];
|
|
148
|
+
settle.fail(new ProviderError('response_too_large'));
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
chunks.push(chunk);
|
|
152
|
+
});
|
|
153
|
+
response.on('end', () => {
|
|
154
|
+
try {
|
|
155
|
+
settle.succeed(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
settle.fail(new ProviderError('invalid_response'));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export const getJson = (endpoint, { headers = {}, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS, requestImpl = https.get } = {}) => new Promise((resolve, reject) => {
|
|
163
|
+
if (!ALLOWED_ENDPOINTS.has(endpoint)) {
|
|
164
|
+
reject(new ProviderError('endpoint_not_allowed'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const settle = settleOnce(resolve, reject, timeoutMs);
|
|
168
|
+
try {
|
|
169
|
+
const request = settle.attach(requestImpl(new URL(endpoint), { headers, timeout: timeoutMs, agent: getQuotaAgent(), rejectUnauthorized: true }, response => {
|
|
170
|
+
readJsonResponse(settle.attach(response), settle);
|
|
171
|
+
}));
|
|
172
|
+
request.on('timeout', () => {
|
|
173
|
+
settle.fail(new ProviderError('request_timeout'));
|
|
174
|
+
});
|
|
175
|
+
request.on('error', error => {
|
|
176
|
+
settle.fail(new ProviderError(CERTIFICATE_ERRORS.has(String(systemErrorCode(error))) ? 'tls_certificate_error' : 'transport_error'));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
settle.fail(error instanceof ProviderError ? error : new ProviderError('transport_error', { cause: error }));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
// Files inside app.asar cannot be executed; electron-builder unpacks them next to it.
|
|
184
|
+
export function unpackedPath(file) {
|
|
185
|
+
return file.replace(/([\\/])app\.asar([\\/])/, '$1app.asar.unpacked$2');
|
|
186
|
+
}
|
|
187
|
+
function isValidBinary(file, platform = process.platform) {
|
|
188
|
+
if (typeof file !== 'string' || !file || hasControlChars(file) || /\.(cmd|bat|ps1)$/i.test(file))
|
|
189
|
+
return false;
|
|
190
|
+
return (platform === 'win32' ? path.win32 : path.posix).isAbsolute(file);
|
|
191
|
+
}
|
|
192
|
+
// Windows ships this interpreter; it is all the launch and discovery scripts need.
|
|
193
|
+
export function windowsPowerShell(env = process.env) {
|
|
194
|
+
const candidate = env.VIBE_POWERSHELL_BINARY ?? path.win32.join(env.SystemRoot ?? 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
195
|
+
if (!isValidBinary(candidate, 'win32'))
|
|
196
|
+
throw new ProviderError('invalid_binary');
|
|
197
|
+
return candidate;
|
|
198
|
+
}
|
|
199
|
+
// How each tool is installed: its command-line binaries in lookup order, and its desktop app.
|
|
200
|
+
// Launches and diagnostics both look tools up here.
|
|
201
|
+
export const TOOL_INSTALLS = {
|
|
202
|
+
claude: { binaries: ['claude'] },
|
|
203
|
+
codex: { binaries: ['codex'] },
|
|
204
|
+
// The Cursor Agent CLI; older installs only have the `agent` name.
|
|
205
|
+
cursor: { binaries: ['cursor-agent', 'agent'], app: { mac: 'Cursor', windows: ['cursor', 'Cursor.exe'] } },
|
|
206
|
+
antigravity: { binaries: [], app: { mac: 'Antigravity', windows: ['Antigravity', 'Antigravity.exe'] } }
|
|
207
|
+
};
|
|
208
|
+
const localAppData = (env, home) => env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
|
|
209
|
+
// Where a desktop app's per-user Windows installer puts its executable.
|
|
210
|
+
export const windowsAppPath = (app, env, home) => path.join(localAppData(env, home), 'Programs', ...app.windows);
|
|
211
|
+
// Per-user install locations that GUI-launched apps miss: Finder/Explorer do not load the
|
|
212
|
+
// shell profile that would put them on PATH.
|
|
213
|
+
const HOME_BIN_TOOLS = new Set([...TOOL_INSTALLS.claude.binaries, ...TOOL_INSTALLS.cursor.binaries]);
|
|
214
|
+
function knownInstallLocations(name, env, home, platform) {
|
|
215
|
+
const filename = platform === 'win32' ? `${name}.exe` : name;
|
|
216
|
+
const locations = [];
|
|
217
|
+
if (name === 'codex' && platform === 'win32')
|
|
218
|
+
locations.push(path.join(localAppData(env, home), 'Programs/OpenAI/Codex/bin/codex.exe'));
|
|
219
|
+
if (HOME_BIN_TOOLS.has(name))
|
|
220
|
+
locations.push(path.join(home, '.local/bin', filename));
|
|
221
|
+
return locations;
|
|
222
|
+
}
|
|
223
|
+
// The directories macOS path_helper adds to PATH (missing files simply add nothing).
|
|
224
|
+
async function macPathHelperDirectories() {
|
|
225
|
+
const extra = await fsp.readdir('/etc/paths.d').catch(() => []);
|
|
226
|
+
const files = ['/etc/paths', ...extra.map(entry => path.join('/etc/paths.d', entry))];
|
|
227
|
+
const contents = await Promise.all(files.map(file => fsp.readFile(file, 'utf8').catch(() => '')));
|
|
228
|
+
return contents.flatMap(text => text.split(/\r?\n/).filter(line => line.startsWith('/')));
|
|
229
|
+
}
|
|
230
|
+
// `VIBE_<NAME>_BINARY` overrides the search; otherwise known install locations, then PATH.
|
|
231
|
+
export async function findBinary(name, { env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
232
|
+
const override = env[`VIBE_${name.toUpperCase().replaceAll('-', '_')}_BINARY`];
|
|
233
|
+
if (override) {
|
|
234
|
+
if (!isValidBinary(override, platform))
|
|
235
|
+
throw new ProviderError('invalid_binary');
|
|
236
|
+
if (await isFile(override))
|
|
237
|
+
return override;
|
|
238
|
+
throw new ProviderError('binary_missing');
|
|
239
|
+
}
|
|
240
|
+
const filename = platform === 'win32' ? `${name}.exe` : name;
|
|
241
|
+
const directories = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean);
|
|
242
|
+
if (platform === 'darwin')
|
|
243
|
+
directories.push(...(await macPathHelperDirectories()));
|
|
244
|
+
const candidates = [...knownInstallLocations(name, env, home, platform), ...directories.map(directory => path.join(directory, filename))];
|
|
245
|
+
for (const candidate of candidates) {
|
|
246
|
+
if (isValidBinary(candidate, platform) && (await isFile(candidate)))
|
|
247
|
+
return candidate;
|
|
248
|
+
}
|
|
249
|
+
throw new ProviderError('binary_missing');
|
|
250
|
+
}
|
|
251
|
+
// Runs a binary without a shell and resolves with its stdout. The environment has no
|
|
252
|
+
// credential overrides unless `env` is given.
|
|
253
|
+
export const runProcess = (binary, args, { env, platform = process.platform, timeoutMs = DEFAULT_PROCESS_TIMEOUT_MS, maxBytes = MAX_RESPONSE_BYTES } = {}) => new Promise((resolve, reject) => {
|
|
254
|
+
if (!isValidBinary(binary, platform) || args.some(arg => arg.includes('\0'))) {
|
|
255
|
+
reject(new ProviderError('invalid_binary'));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const child = spawn(binary, args, {
|
|
259
|
+
env: env ?? cleanEnvironment({}, platform),
|
|
260
|
+
shell: false,
|
|
261
|
+
windowsHide: true,
|
|
262
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
263
|
+
});
|
|
264
|
+
let output = '';
|
|
265
|
+
let bytes = 0;
|
|
266
|
+
let failure = null;
|
|
267
|
+
const timer = setTimeout(() => {
|
|
268
|
+
failure = new ProviderError('process_timeout');
|
|
269
|
+
child.kill('SIGKILL');
|
|
270
|
+
}, timeoutMs);
|
|
271
|
+
child.stdout.on('data', (chunk) => {
|
|
272
|
+
bytes += chunk.length;
|
|
273
|
+
if (bytes > maxBytes) {
|
|
274
|
+
output = '';
|
|
275
|
+
failure = new ProviderError('response_too_large');
|
|
276
|
+
child.kill('SIGKILL');
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
output += chunk.toString();
|
|
280
|
+
});
|
|
281
|
+
child.on('error', () => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
reject(new ProviderError('process_failed'));
|
|
284
|
+
});
|
|
285
|
+
child.on('close', code => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
if (failure)
|
|
288
|
+
reject(failure);
|
|
289
|
+
else if (code !== 0)
|
|
290
|
+
reject(new ProviderError('process_failed'));
|
|
291
|
+
else
|
|
292
|
+
resolve(output);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Environment } from '../core/environment.ts';
|
|
2
|
+
import { type Clock, type GetJson, type RunProcess } from './common.ts';
|
|
3
|
+
import type { ParsedQuota, ProviderAdapter } from './types.ts';
|
|
4
|
+
export declare function parseCursorUsage(summary: unknown): ParsedQuota;
|
|
5
|
+
export declare function findCursorAgent(): Promise<string | null>;
|
|
6
|
+
export interface CursorProviderOptions {
|
|
7
|
+
home?: string;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
env?: Environment;
|
|
10
|
+
clock?: Clock;
|
|
11
|
+
getJson?: GetJson;
|
|
12
|
+
runProcess?: RunProcess;
|
|
13
|
+
}
|
|
14
|
+
export declare function createCursorProvider(options?: CursorProviderOptions): ProviderAdapter;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ProviderError } from "../core/errors.js";
|
|
4
|
+
import { isObject } from "../core/files.js";
|
|
5
|
+
import { normalizeWindow, safePlan } from "../core/model.js";
|
|
6
|
+
import { accountId, DEFAULT_ACCOUNT_KEY } from "../shared/catalog.js";
|
|
7
|
+
import { findBinary, getJson as defaultGetJson, isExpired, jwtClaims, QUOTA_ENDPOINTS, readCredentialFile, runProcess as defaultRunProcess, TOOL_INSTALLS } from "./common.js";
|
|
8
|
+
const USAGE_TIMEOUT_MS = 20000;
|
|
9
|
+
const SQLITE_TIMEOUT_MS = 5000;
|
|
10
|
+
// Percent-based plan scopes reported as `<scope>PercentUsed`.
|
|
11
|
+
const PLAN_SCOPES = {
|
|
12
|
+
auto: { labelKey: 'labelAutoScope' },
|
|
13
|
+
// Spent, it stops the API-priced models only; Auto goes on.
|
|
14
|
+
api: { labelKey: 'labelApiScope', detailKey: 'cursorApiLimitNote', partial: true, except: ['auto'] },
|
|
15
|
+
total: { labelKey: 'labelTotalUsage', detailKey: 'totalQuotaDesc', section: 'details' }
|
|
16
|
+
};
|
|
17
|
+
// Money pools reported in USD cents per usage group.
|
|
18
|
+
const SPEND_POOLS = {
|
|
19
|
+
onDemand: 'labelOnDemandSpend',
|
|
20
|
+
overall: 'labelOverallSpend',
|
|
21
|
+
pooled: 'labelPooledSpend'
|
|
22
|
+
};
|
|
23
|
+
const USAGE_GROUPS = ['individual', 'team'];
|
|
24
|
+
const SUMMARY_FIELDS = ['individualUsage', 'teamUsage', 'membershipType', 'billingCycleEnd'];
|
|
25
|
+
const poolAmounts = (pool) => ({ used: pool.used, limit: pool.limit, remaining: pool.remaining });
|
|
26
|
+
// Without a remaining amount the value says so; the UI words amounts it does have.
|
|
27
|
+
function poolValue(pool) {
|
|
28
|
+
if (pool.enabled === false)
|
|
29
|
+
return { valueKey: 'disabled' };
|
|
30
|
+
return typeof pool.remaining === 'number' && Number.isFinite(pool.remaining) ? {} : { valueKey: 'amountNotReported' };
|
|
31
|
+
}
|
|
32
|
+
export function parseCursorUsage(summary) {
|
|
33
|
+
if (!isObject(summary) || !SUMMARY_FIELDS.some(field => summary[field] !== undefined))
|
|
34
|
+
throw new ProviderError('invalid_response');
|
|
35
|
+
const resetsAt = summary.billingCycleEnd;
|
|
36
|
+
const windows = [];
|
|
37
|
+
const individual = isObject(summary.individualUsage) ? summary.individualUsage : {};
|
|
38
|
+
if (isObject(individual.plan)) {
|
|
39
|
+
const plan = individual.plan;
|
|
40
|
+
for (const [scope, keys] of Object.entries(PLAN_SCOPES)) {
|
|
41
|
+
windows.push(normalizeWindow({ id: `plan:${scope}`, ...keys, usedPercent: plan[`${scope}PercentUsed`], resetsAt }));
|
|
42
|
+
}
|
|
43
|
+
const bonus = isObject(plan.breakdown) ? plan.breakdown.bonus : undefined;
|
|
44
|
+
windows.push(normalizeWindow({
|
|
45
|
+
id: 'plan:spend',
|
|
46
|
+
labelKey: 'labelIncludedSpend',
|
|
47
|
+
section: 'details',
|
|
48
|
+
resetsAt,
|
|
49
|
+
amounts: { ...poolAmounts(plan), bonus },
|
|
50
|
+
...poolValue(plan)
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
for (const group of USAGE_GROUPS) {
|
|
54
|
+
const usage = summary[`${group}Usage`];
|
|
55
|
+
for (const [key, labelKey] of Object.entries(SPEND_POOLS)) {
|
|
56
|
+
const pool = isObject(usage) ? usage[key] : undefined;
|
|
57
|
+
if (!isObject(pool))
|
|
58
|
+
continue;
|
|
59
|
+
windows.push(normalizeWindow({
|
|
60
|
+
id: `${group}:${key}`,
|
|
61
|
+
labelKey,
|
|
62
|
+
group,
|
|
63
|
+
section: 'details',
|
|
64
|
+
resetsAt,
|
|
65
|
+
amounts: poolAmounts(pool),
|
|
66
|
+
...poolValue(pool)
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const membership = safePlan(summary.membershipType);
|
|
71
|
+
return {
|
|
72
|
+
windows,
|
|
73
|
+
...(membership ? { plan: membership } : {}),
|
|
74
|
+
...(windows.length ? {} : { status: 'no_limits_reported' })
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function readAccessToken({ home, platform, env, runProcess }) {
|
|
78
|
+
if (platform === 'win32') {
|
|
79
|
+
const auth = await readCredentialFile(path.join(env.APPDATA ?? path.join(home, 'AppData/Roaming'), 'Cursor/auth.json'));
|
|
80
|
+
return auth.accessToken;
|
|
81
|
+
}
|
|
82
|
+
if (platform !== 'darwin')
|
|
83
|
+
throw new ProviderError('unsupported_platform');
|
|
84
|
+
// Cursor keeps its access token in the editor's state database. `immutable=1` reads the live
|
|
85
|
+
// database without locking or mutating it.
|
|
86
|
+
const db = path.join(home, 'Library/Application Support/Cursor/User/globalStorage/state.vscdb');
|
|
87
|
+
const query = "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken' LIMIT 1;";
|
|
88
|
+
try {
|
|
89
|
+
return (await runProcess('/usr/bin/sqlite3', ['-readonly', `file:${db}?immutable=1`, query], { timeoutMs: SQLITE_TIMEOUT_MS })).trim();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw new ProviderError('auth_missing', { cause: error });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const COOKIE_USER = /^[A-Za-z0-9._-]+$/;
|
|
96
|
+
// The session cookie is `<user id>::<token>`; the user id is the last `|` segment of `sub`.
|
|
97
|
+
function sessionCookie(token, clock) {
|
|
98
|
+
const { sub, exp } = jwtClaims(token);
|
|
99
|
+
const user = typeof sub === 'string' ? (sub.split('|').at(-1) ?? '') : '';
|
|
100
|
+
if (!COOKIE_USER.test(user) || typeof exp !== 'number' || !Number.isFinite(exp))
|
|
101
|
+
throw new ProviderError('auth_invalid');
|
|
102
|
+
if (isExpired(exp * 1000, clock))
|
|
103
|
+
throw new ProviderError('auth_expired');
|
|
104
|
+
return `WorkosCursorSessionToken=${user}%3A%3A${token}`;
|
|
105
|
+
}
|
|
106
|
+
// The Cursor Agent CLI; null when not installed.
|
|
107
|
+
export async function findCursorAgent() {
|
|
108
|
+
for (const name of TOOL_INSTALLS.cursor.binaries) {
|
|
109
|
+
const binary = await findBinary(name).catch(() => null);
|
|
110
|
+
if (binary)
|
|
111
|
+
return binary;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
export function createCursorProvider(options = {}) {
|
|
116
|
+
const source = {
|
|
117
|
+
home: options.home ?? os.homedir(),
|
|
118
|
+
platform: options.platform ?? process.platform,
|
|
119
|
+
env: options.env ?? process.env,
|
|
120
|
+
runProcess: options.runProcess ?? defaultRunProcess
|
|
121
|
+
};
|
|
122
|
+
const getJson = options.getJson ?? defaultGetJson;
|
|
123
|
+
return {
|
|
124
|
+
id: 'cursor',
|
|
125
|
+
discover: () => Promise.resolve([
|
|
126
|
+
{ id: accountId('cursor', DEFAULT_ACCOUNT_KEY), providerId: 'cursor', labelKey: 'defaultAccount', primary: true, enabled: true }
|
|
127
|
+
]),
|
|
128
|
+
async fetch(account) {
|
|
129
|
+
if (account.configDir)
|
|
130
|
+
throw new ProviderError('unsupported_profile');
|
|
131
|
+
const token = await readAccessToken(source);
|
|
132
|
+
if (typeof token !== 'string' || !token.trim())
|
|
133
|
+
throw new ProviderError('auth_missing');
|
|
134
|
+
const usage = await getJson(QUOTA_ENDPOINTS.cursor, {
|
|
135
|
+
headers: { Accept: 'application/json', Cookie: sessionCookie(token, options.clock) },
|
|
136
|
+
timeoutMs: USAGE_TIMEOUT_MS
|
|
137
|
+
});
|
|
138
|
+
return parseCursorUsage(usage);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ProfileProviderId } from '../shared/catalog.ts';
|
|
2
|
+
import type { Account } from '../shared/types.ts';
|
|
3
|
+
export declare const shortHash: (value: string) => string;
|
|
4
|
+
export declare const defaultProfileDir: (providerId: ProfileProviderId, home: string) => string;
|
|
5
|
+
export declare function accountProfileDirs(providerId: ProfileProviderId, home: string, accounts: readonly Account[]): string[];
|
|
6
|
+
export declare const profileAccountId: (providerId: ProfileProviderId, configDir: string) => string;
|
|
7
|
+
export declare function profileLabel(providerId: ProfileProviderId, configDir: string): string;
|
|
8
|
+
export declare function profileFolders(providerId: ProfileProviderId, home: string): Promise<string[]>;
|
|
9
|
+
export declare function createProfileFolder(providerId: ProfileProviderId, home: string, isFree: (dir: string) => Promise<boolean>): Promise<string>;
|
|
10
|
+
export declare function isRegularFile(file: string): Promise<boolean>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Profile folders of the tools that keep one sign-in per folder (see PROFILE_DIRS): finding
|
|
2
|
+
// them in the home directory, and the id and name of the account each one holds.
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AppError, systemErrorCode } from "../core/errors.js";
|
|
6
|
+
import { accountId, PROFILE_DIRS } from "../shared/catalog.js";
|
|
7
|
+
import { sha256Prefix } from "./common.js";
|
|
8
|
+
const ID_HASH_LENGTH = 12;
|
|
9
|
+
// Numbered folders the app creates for new accounts: `.codex-2`, `.codex-3`, …
|
|
10
|
+
const FIRST_NEW_PROFILE = 2;
|
|
11
|
+
const MAX_NEW_PROFILE = 99;
|
|
12
|
+
const PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
13
|
+
// Sibling folders that are copies rather than profiles (`.claude-backup`, `.codex-old`).
|
|
14
|
+
const COPY_NAME = /(?:^|[-_])(backup|backups|history|old|archive|copy|bak)(?:[-_]|$)/i;
|
|
15
|
+
export const shortHash = (value) => sha256Prefix(value, ID_HASH_LENGTH);
|
|
16
|
+
export const defaultProfileDir = (providerId, home) => path.join(home, PROFILE_DIRS[providerId]);
|
|
17
|
+
// Where a tool keeps its settings and transcripts: the default profile folder and every folder
|
|
18
|
+
// an enabled account of the tool uses, each once.
|
|
19
|
+
export function accountProfileDirs(providerId, home, accounts) {
|
|
20
|
+
const used = accounts.filter(a => a.providerId === providerId && a.enabled).flatMap(a => (a.configDir ? [a.configDir] : []));
|
|
21
|
+
return [...new Set([defaultProfileDir(providerId, home), ...used].map(dir => path.resolve(dir)))];
|
|
22
|
+
}
|
|
23
|
+
export const profileAccountId = (providerId, configDir) => accountId(providerId, shortHash(path.resolve(configDir)));
|
|
24
|
+
// `.codex-work` is named `work`; any other folder keeps its own name.
|
|
25
|
+
export function profileLabel(providerId, configDir) {
|
|
26
|
+
const name = path.basename(configDir);
|
|
27
|
+
const prefix = `${PROFILE_DIRS[providerId]}-`;
|
|
28
|
+
const label = name === PROFILE_DIRS[providerId] ? '' : name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
|
29
|
+
return label || path.resolve(configDir);
|
|
30
|
+
}
|
|
31
|
+
// The default profile folder and its `-<name>` siblings that exist in `home`.
|
|
32
|
+
export async function profileFolders(providerId, home) {
|
|
33
|
+
const base = PROFILE_DIRS[providerId];
|
|
34
|
+
const entries = await fsp.readdir(home, { withFileTypes: true }).catch(() => []);
|
|
35
|
+
return entries
|
|
36
|
+
.filter(entry => {
|
|
37
|
+
if (!entry.isDirectory())
|
|
38
|
+
return false;
|
|
39
|
+
if (entry.name === base)
|
|
40
|
+
return true;
|
|
41
|
+
const suffix = entry.name.startsWith(`${base}-`) ? entry.name.slice(base.length + 1) : '';
|
|
42
|
+
return PROFILE_NAME.test(suffix) && !COPY_NAME.test(suffix);
|
|
43
|
+
})
|
|
44
|
+
.map(entry => path.join(home, entry.name));
|
|
45
|
+
}
|
|
46
|
+
// The first numbered profile folder in `home` a new account can have: a new one, private to the
|
|
47
|
+
// user, or an existing one that `isFree` (no account uses it and it holds no sign-in, as one a
|
|
48
|
+
// sign-in given up on leaves behind).
|
|
49
|
+
export async function createProfileFolder(providerId, home, isFree) {
|
|
50
|
+
for (let n = FIRST_NEW_PROFILE; n <= MAX_NEW_PROFILE; n++) {
|
|
51
|
+
const dir = path.join(home, `${PROFILE_DIRS[providerId]}-${n}`);
|
|
52
|
+
try {
|
|
53
|
+
await fsp.mkdir(dir, { mode: 0o700 });
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (systemErrorCode(error) !== 'EEXIST')
|
|
58
|
+
throw error;
|
|
59
|
+
if ((await fsp.lstat(dir)).isDirectory() && (await isFree(dir)))
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new AppError('invalid_path');
|
|
64
|
+
}
|
|
65
|
+
// A sign-in file counts only as a regular file: a symbolic link could point anywhere.
|
|
66
|
+
export async function isRegularFile(file) {
|
|
67
|
+
const stat = await fsp.lstat(file).catch(() => null);
|
|
68
|
+
return stat?.isFile() ?? false;
|
|
69
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
export type CertificateSource = Pick<typeof tls, 'getCACertificates'>;
|
|
4
|
+
export declare function createQuotaAgent({ tlsApi }?: {
|
|
5
|
+
tlsApi?: CertificateSource;
|
|
6
|
+
}): https.Agent;
|
|
7
|
+
export declare function getQuotaAgent(): https.Agent;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
import { ProviderError } from "../core/errors.js";
|
|
4
|
+
import { asArray } from "../core/files.js";
|
|
5
|
+
// Node's bundled roots alone do not represent Windows/macOS trust. This uses the platform
|
|
6
|
+
// store, keeps the configured default and extra roots, and applies only to quota requests:
|
|
7
|
+
// no global TLS state is changed.
|
|
8
|
+
export function createQuotaAgent({ tlsApi = tls } = {}) {
|
|
9
|
+
let roots;
|
|
10
|
+
try {
|
|
11
|
+
roots = [tlsApi.getCACertificates('default'), tlsApi.getCACertificates('system')];
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new ProviderError('trust_store_unavailable', { cause: error });
|
|
15
|
+
}
|
|
16
|
+
if (!roots.every(Array.isArray))
|
|
17
|
+
throw new ProviderError('trust_store_unavailable');
|
|
18
|
+
return new https.Agent({
|
|
19
|
+
ca: [...new Set(roots.flatMap(list => asArray(list).filter(root => typeof root === 'string')))],
|
|
20
|
+
rejectUnauthorized: true,
|
|
21
|
+
checkServerIdentity: tls.checkServerIdentity,
|
|
22
|
+
keepAlive: false
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
let quotaAgent;
|
|
26
|
+
export function getQuotaAgent() {
|
|
27
|
+
quotaAgent ??= createQuotaAgent();
|
|
28
|
+
return quotaAgent;
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { WindowInput } from '../core/model.ts';
|
|
2
|
+
import type { ProviderId } from '../shared/catalog.ts';
|
|
3
|
+
import type { Account, QuotaWindow } from '../shared/types.ts';
|
|
4
|
+
export interface FetchResult {
|
|
5
|
+
windows: WindowInput[];
|
|
6
|
+
plan?: string;
|
|
7
|
+
signedInAs?: string;
|
|
8
|
+
status?: 'no_limits_reported';
|
|
9
|
+
}
|
|
10
|
+
export interface ParsedQuota extends FetchResult {
|
|
11
|
+
windows: QuotaWindow[];
|
|
12
|
+
}
|
|
13
|
+
export type DiscoveredAccount = Omit<Account, 'origin'>;
|
|
14
|
+
export interface ProviderAdapter {
|
|
15
|
+
readonly id: ProviderId;
|
|
16
|
+
readonly fetchTimeoutMs?: number;
|
|
17
|
+
discover(): Promise<DiscoveredAccount[]>;
|
|
18
|
+
fetch(account: Account): Promise<FetchResult>;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AccountStatus, MeasuredAccount, ProviderState, QuotaWindow } from './types.ts';
|
|
2
|
+
export declare const HEALTHY_STATUSES: ReadonlySet<AccountStatus>;
|
|
3
|
+
export declare const isLowQuota: (remaining: number | null, threshold: number) => boolean;
|
|
4
|
+
export declare const needsSignIn: (account: MeasuredAccount) => boolean;
|
|
5
|
+
export type KnownWindow = QuotaWindow & {
|
|
6
|
+
remainingPercent: number;
|
|
7
|
+
};
|
|
8
|
+
export type CompactNote = 'reading' | 'waiting' | 'signIn' | 'openApp' | 'noLimits' | 'noData';
|
|
9
|
+
export interface CompactLine {
|
|
10
|
+
account: MeasuredAccount;
|
|
11
|
+
window: KnownWindow | null;
|
|
12
|
+
other: KnownWindow | null;
|
|
13
|
+
tighter: KnownWindow | null;
|
|
14
|
+
stale: boolean;
|
|
15
|
+
low: boolean;
|
|
16
|
+
tighterLow: boolean;
|
|
17
|
+
note: CompactNote | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function compactLines(state: ProviderState, lowQuotaPercent: number): CompactLine[];
|