@ipv9/tokentracker-cli 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +464 -0
- package/README.ko.md +464 -0
- package/README.md +476 -0
- package/README.zh-CN.md +469 -0
- package/bin/tracker.js +52 -0
- package/dashboard/dist/app-icon.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
- package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
- package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
- package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
- package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
- package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
- package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
- package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
- package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
- package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
- package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
- package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
- package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
- package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
- package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
- package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
- package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
- package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
- package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
- package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
- package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
- package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
- package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
- package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
- package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
- package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
- package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
- package/dashboard/dist/brand-logos/antigravity.svg +1 -0
- package/dashboard/dist/brand-logos/claude-code.svg +1 -0
- package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
- package/dashboard/dist/brand-logos/codex.svg +15 -0
- package/dashboard/dist/brand-logos/copilot.svg +1 -0
- package/dashboard/dist/brand-logos/cursor.svg +1 -0
- package/dashboard/dist/brand-logos/gemini.svg +1 -0
- package/dashboard/dist/brand-logos/grok.svg +1 -0
- package/dashboard/dist/brand-logos/hermes.svg +1 -0
- package/dashboard/dist/brand-logos/kilo.svg +4 -0
- package/dashboard/dist/brand-logos/kimi.svg +1 -0
- package/dashboard/dist/brand-logos/kiro.svg +1 -0
- package/dashboard/dist/brand-logos/openclaw.svg +22 -0
- package/dashboard/dist/brand-logos/opencode.svg +3 -0
- package/dashboard/dist/clawd/happy.svg +161 -0
- package/dashboard/dist/clawd/idle/collapse.svg +101 -0
- package/dashboard/dist/clawd/idle/doze.svg +72 -0
- package/dashboard/dist/clawd/idle/follow.svg +64 -0
- package/dashboard/dist/clawd/idle/living.svg +196 -0
- package/dashboard/dist/clawd/idle/look.svg +115 -0
- package/dashboard/dist/clawd/idle/yawn.svg +158 -0
- package/dashboard/dist/clawd/mini/alert.svg +129 -0
- package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
- package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
- package/dashboard/dist/clawd/mini/enter.svg +115 -0
- package/dashboard/dist/clawd/mini/happy.svg +124 -0
- package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
- package/dashboard/dist/clawd/mini/idle.svg +83 -0
- package/dashboard/dist/clawd/mini/peek.svg +82 -0
- package/dashboard/dist/clawd/mini/sleep.svg +112 -0
- package/dashboard/dist/clawd/react/double.svg +108 -0
- package/dashboard/dist/clawd/react/drag.svg +102 -0
- package/dashboard/dist/clawd/react/left.svg +102 -0
- package/dashboard/dist/clawd/react/right.svg +102 -0
- package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
- package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
- package/dashboard/dist/clawd/sleep/wake.svg +277 -0
- package/dashboard/dist/clawd/static-base.svg +21 -0
- package/dashboard/dist/clawd/status/disconnected.svg +136 -0
- package/dashboard/dist/clawd/status/error.svg +94 -0
- package/dashboard/dist/clawd/status/notification.svg +134 -0
- package/dashboard/dist/clawd/working/building.svg +231 -0
- package/dashboard/dist/clawd/working/carrying.svg +107 -0
- package/dashboard/dist/clawd/working/conducting.svg +118 -0
- package/dashboard/dist/clawd/working/confused.svg +113 -0
- package/dashboard/dist/clawd/working/debugger.svg +91 -0
- package/dashboard/dist/clawd/working/juggling.svg +82 -0
- package/dashboard/dist/clawd/working/overheated.svg +75 -0
- package/dashboard/dist/clawd/working/pushing.svg +125 -0
- package/dashboard/dist/clawd/working/sweeping.svg +79 -0
- package/dashboard/dist/clawd/working/thinking.svg +119 -0
- package/dashboard/dist/clawd/working/typing.svg +153 -0
- package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
- package/dashboard/dist/clawd/working/wizard.svg +98 -0
- package/dashboard/dist/dashboard-dark.png +0 -0
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/feed.xml +34 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +355 -0
- package/dashboard/dist/llms.txt +53 -0
- package/dashboard/dist/neon_bg.png +0 -0
- package/dashboard/dist/robots.txt +7 -0
- package/dashboard/dist/share.html +140 -0
- package/dashboard/dist/sitemap.xml +27 -0
- package/dashboard/dist/widgets-overview.png +0 -0
- package/package.json +78 -0
- package/scripts/install-local-service.sh +140 -0
- package/scripts/uninstall-local-service.sh +21 -0
- package/src/cli.js +95 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/diagnostics.js +38 -0
- package/src/commands/doctor.js +96 -0
- package/src/commands/init.js +1195 -0
- package/src/commands/serve.js +351 -0
- package/src/commands/status.js +609 -0
- package/src/commands/sync.js +2285 -0
- package/src/commands/uninstall.js +189 -0
- package/src/commands/wrapped.js +150 -0
- package/src/lib/antigravity-paths.js +48 -0
- package/src/lib/browser-auth.js +281 -0
- package/src/lib/categorizer-utils.js +232 -0
- package/src/lib/claude-categorizer.js +1319 -0
- package/src/lib/claude-config.js +204 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +350 -0
- package/src/lib/codex-context-breakdown.js +400 -0
- package/src/lib/codex-rollout-parser.js +623 -0
- package/src/lib/codex-token-refresh.js +112 -0
- package/src/lib/cursor-config.js +425 -0
- package/src/lib/debug-flags.js +7 -0
- package/src/lib/diagnostics.js +272 -0
- package/src/lib/doctor.js +338 -0
- package/src/lib/fs.js +91 -0
- package/src/lib/gemini-config.js +288 -0
- package/src/lib/grok-hook.js +351 -0
- package/src/lib/init-flow.js +42 -0
- package/src/lib/local-api.js +1861 -0
- package/src/lib/openclaw-hook.js +420 -0
- package/src/lib/openclaw-session-plugin.js +520 -0
- package/src/lib/opencode-config.js +99 -0
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/curated-overrides.json +79 -0
- package/src/lib/pricing/index.js +143 -0
- package/src/lib/pricing/litellm-fetcher.js +172 -0
- package/src/lib/pricing/matcher.js +255 -0
- package/src/lib/pricing/seed-snapshot.json +1 -0
- package/src/lib/progress.js +76 -0
- package/src/lib/project-usage-purge.js +106 -0
- package/src/lib/prompt.js +24 -0
- package/src/lib/proxy-env.js +131 -0
- package/src/lib/rollout.js +8442 -0
- package/src/lib/runtime-config.js +122 -0
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +1127 -0
- package/src/lib/source-metadata.js +48 -0
- package/src/lib/sqlite-reader.js +136 -0
- package/src/lib/static-server.js +59 -0
- package/src/lib/subscriptions.js +453 -0
- package/src/lib/tracker-paths.js +16 -0
- package/src/lib/upload-throttle.js +148 -0
- package/src/lib/usage-limits.js +1806 -0
- package/src/lib/wrapped-aggregator.js +225 -0
|
@@ -0,0 +1,1806 @@
|
|
|
1
|
+
const cp = require("node:child_process");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const http = require("node:http");
|
|
6
|
+
const https = require("node:https");
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
detectClaudeCodeSubscriptionDetails,
|
|
10
|
+
readClaudeCodeAccessToken,
|
|
11
|
+
readCodexAccessToken,
|
|
12
|
+
readCodexAuthBundle,
|
|
13
|
+
} = require("./subscriptions");
|
|
14
|
+
const {
|
|
15
|
+
isTokenStale,
|
|
16
|
+
refreshCodexTokens,
|
|
17
|
+
persistRefreshedAuth,
|
|
18
|
+
} = require("./codex-token-refresh");
|
|
19
|
+
const {
|
|
20
|
+
isCursorInstalled,
|
|
21
|
+
extractCursorSessionToken,
|
|
22
|
+
fetchCursorUsageSummary,
|
|
23
|
+
} = require("./cursor-config");
|
|
24
|
+
|
|
25
|
+
// 2-minute in-memory cache
|
|
26
|
+
let cache = { data: null, fetchedAt: 0 };
|
|
27
|
+
const CACHE_TTL_MS = 2 * 60 * 1000;
|
|
28
|
+
const DEFAULT_PROVIDER_TIMEOUT_MS = 15_000;
|
|
29
|
+
const ANTIGRAVITY_LIMITS_CACHE_FILE = "usage-limits-cache.json";
|
|
30
|
+
const ANTIGRAVITY_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
31
|
+
const ANTIGRAVITY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
function clampPercent(value) {
|
|
34
|
+
if (value === null || value === undefined || value === "") return null;
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
if (!Number.isFinite(n)) return null;
|
|
37
|
+
if (n <= 0) return 0;
|
|
38
|
+
if (n >= 100) return 100;
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildWindow({ usedPercent, resetAt }) {
|
|
43
|
+
const pct = clampPercent(usedPercent);
|
|
44
|
+
if (pct === null) return null;
|
|
45
|
+
return {
|
|
46
|
+
used_percent: pct,
|
|
47
|
+
reset_at: typeof resetAt === "string" && resetAt ? resetAt : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeJwtPayload(token) {
|
|
52
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
53
|
+
const parts = token.split(".");
|
|
54
|
+
if (parts.length < 2) return null;
|
|
55
|
+
const payload = parts[1];
|
|
56
|
+
const padLen = (4 - (payload.length % 4)) % 4;
|
|
57
|
+
const padded = payload + "=".repeat(padLen);
|
|
58
|
+
const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(Buffer.from(base64, "base64").toString("utf8"));
|
|
61
|
+
} catch (_error) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sleepMs(ms) {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mergeAbortSignals(signalA, signalB) {
|
|
71
|
+
if (!signalA) return signalB;
|
|
72
|
+
if (!signalB) return signalA;
|
|
73
|
+
if (typeof AbortSignal.any === "function") {
|
|
74
|
+
return AbortSignal.any([signalA, signalB]);
|
|
75
|
+
}
|
|
76
|
+
return signalA;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function withFetchTimeout(fetchImpl, timeoutMs) {
|
|
80
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return fetchImpl;
|
|
81
|
+
return (url, options = {}) => {
|
|
82
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
83
|
+
return fetchImpl(url, {
|
|
84
|
+
...options,
|
|
85
|
+
signal: mergeAbortSignals(options.signal, timeoutSignal),
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withProviderTimeout(promise, label, timeoutMs) {
|
|
91
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
92
|
+
let timer;
|
|
93
|
+
const timeout = new Promise((_, reject) => {
|
|
94
|
+
timer = setTimeout(() => {
|
|
95
|
+
reject(new Error(`${label} usage request timed out.`));
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
});
|
|
98
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttempts = 3 } = {}) {
|
|
102
|
+
const url = "https://api.anthropic.com/api/oauth/usage";
|
|
103
|
+
const headers = {
|
|
104
|
+
Authorization: `Bearer ${accessToken}`,
|
|
105
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
106
|
+
Accept: "application/json",
|
|
107
|
+
};
|
|
108
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
109
|
+
const res = await fetchImpl(url, { method: "GET", headers });
|
|
110
|
+
if (res.status === 401) {
|
|
111
|
+
throw new Error("Claude token expired — run `claude` once to refresh.");
|
|
112
|
+
}
|
|
113
|
+
if ((res.status === 429 || res.status === 503) && attempt < maxAttempts - 1) {
|
|
114
|
+
const ra = res.headers.get("retry-after");
|
|
115
|
+
const sec = ra ? Number.parseInt(ra, 10) : NaN;
|
|
116
|
+
const delayMs = Number.isFinite(sec) && sec > 0 ? Math.min(sec * 1000, 30_000) : 1500 * (attempt + 1);
|
|
117
|
+
await sleepMs(delayMs);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
if (res.status === 429) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"Claude API rate limited (429) — wait ~1 minute and refresh.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`Claude API returned ${res.status}`);
|
|
127
|
+
}
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
return {
|
|
130
|
+
five_hour: body.five_hour ?? null,
|
|
131
|
+
seven_day: body.seven_day ?? null,
|
|
132
|
+
seven_day_opus: body.seven_day_opus ?? null,
|
|
133
|
+
extra_usage: body.extra_usage ?? null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Classify a wham window by `limit_window_seconds` rather than its slot name.
|
|
139
|
+
// 18000s = 5h session window. 604800s = 7d weekly window. Free-tier accounts only get a
|
|
140
|
+
// weekly window, often delivered in the `primary_window` slot — naive position-based
|
|
141
|
+
// reading mislabels it as "5h". Aligned with steipete/CodexBar's rate-window normalizer.
|
|
142
|
+
const CODEX_SESSION_WINDOW_SECONDS = 18000;
|
|
143
|
+
const CODEX_WEEKLY_WINDOW_SECONDS = 604800;
|
|
144
|
+
|
|
145
|
+
function classifyCodexWindow(window) {
|
|
146
|
+
if (!window || typeof window !== "object") return null;
|
|
147
|
+
const seconds = Number(window.limit_window_seconds);
|
|
148
|
+
if (!Number.isFinite(seconds)) return null;
|
|
149
|
+
if (seconds === CODEX_SESSION_WINDOW_SECONDS) return "session";
|
|
150
|
+
if (seconds === CODEX_WEEKLY_WINDOW_SECONDS) return "weekly";
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeCodexRateWindows(rateLimit) {
|
|
155
|
+
const candidates = [rateLimit?.primary_window, rateLimit?.secondary_window].filter(
|
|
156
|
+
(w) => w && typeof w === "object",
|
|
157
|
+
);
|
|
158
|
+
let session = null;
|
|
159
|
+
let weekly = null;
|
|
160
|
+
for (const w of candidates) {
|
|
161
|
+
const kind = classifyCodexWindow(w);
|
|
162
|
+
if (kind === "session" && !session) session = w;
|
|
163
|
+
else if (kind === "weekly" && !weekly) weekly = w;
|
|
164
|
+
}
|
|
165
|
+
// Fall back to positional read only if classification failed for both — preserves data
|
|
166
|
+
// from unexpected window durations rather than dropping it silently.
|
|
167
|
+
if (!session && !weekly && candidates.length > 0) {
|
|
168
|
+
return {
|
|
169
|
+
primary_window: candidates[0] ?? null,
|
|
170
|
+
secondary_window: candidates[1] ?? null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { primary_window: session, secondary_window: weekly };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function fetchCodexUsageLimits(
|
|
177
|
+
accessToken,
|
|
178
|
+
{ fetchImpl = fetch, accountId = null } = {},
|
|
179
|
+
) {
|
|
180
|
+
const headers = {
|
|
181
|
+
Authorization: `Bearer ${accessToken}`,
|
|
182
|
+
Accept: "application/json",
|
|
183
|
+
};
|
|
184
|
+
// The wham endpoint rejects some plan tiers without an explicit account id — match
|
|
185
|
+
// CodexBar's request shape so free / multi-account users don't see opaque 4xx.
|
|
186
|
+
if (accountId) {
|
|
187
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const res = await fetchImpl("https://chatgpt.com/backend-api/wham/usage", {
|
|
191
|
+
method: "GET",
|
|
192
|
+
headers,
|
|
193
|
+
});
|
|
194
|
+
// 401/403/404 from wham means "no usage data available for this auth state" — render
|
|
195
|
+
// a neutral empty state instead of a red "Fetch failed" error.
|
|
196
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
197
|
+
return { primary_window: null, secondary_window: null };
|
|
198
|
+
}
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
throw new Error(`Codex API returned ${res.status}`);
|
|
201
|
+
}
|
|
202
|
+
const body = await res.json();
|
|
203
|
+
return normalizeCodexRateWindows(body.rate_limit || {});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cursorPercentFromCentsUsedLimit(usedRaw, limitRaw) {
|
|
207
|
+
const used = Number(usedRaw);
|
|
208
|
+
const limit = Number(limitRaw);
|
|
209
|
+
if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return null;
|
|
210
|
+
return clampPercent((used / limit) * 100);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function normalizeCursorUsageSummary(body) {
|
|
214
|
+
const plan = body?.individualUsage?.plan || null;
|
|
215
|
+
const indOnDemand = body?.individualUsage?.onDemand || null;
|
|
216
|
+
const teamOnDemand = body?.teamUsage?.onDemand || null;
|
|
217
|
+
const billingCycleEnd = typeof body?.billingCycleEnd === "string" ? body.billingCycleEnd : null;
|
|
218
|
+
const autoPercent = clampPercent(plan?.autoPercentUsed);
|
|
219
|
+
const apiPercent = clampPercent(plan?.apiPercentUsed);
|
|
220
|
+
|
|
221
|
+
// Prefer totalPercentUsed, then Auto/API lanes (aligned with CodexBar): raw plan used/limit
|
|
222
|
+
// are often cents where limit can be price/cap semantics — do not prefer that over percent lanes.
|
|
223
|
+
let planPercent = clampPercent(plan?.totalPercentUsed);
|
|
224
|
+
if (planPercent === null) {
|
|
225
|
+
if (autoPercent !== null && apiPercent !== null) {
|
|
226
|
+
planPercent = clampPercent((autoPercent + apiPercent) / 2);
|
|
227
|
+
} else if (apiPercent !== null) {
|
|
228
|
+
planPercent = apiPercent;
|
|
229
|
+
} else if (autoPercent !== null) {
|
|
230
|
+
planPercent = autoPercent;
|
|
231
|
+
} else {
|
|
232
|
+
const fromPlanCents = cursorPercentFromCentsUsedLimit(plan?.used, plan?.limit);
|
|
233
|
+
if (fromPlanCents !== null) planPercent = fromPlanCents;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (planPercent === null) {
|
|
237
|
+
const fromInd = cursorPercentFromCentsUsedLimit(indOnDemand?.used, indOnDemand?.limit);
|
|
238
|
+
if (fromInd !== null) planPercent = fromInd;
|
|
239
|
+
}
|
|
240
|
+
if (planPercent === null) {
|
|
241
|
+
const fromTeam = cursorPercentFromCentsUsedLimit(teamOnDemand?.used, teamOnDemand?.limit);
|
|
242
|
+
if (fromTeam !== null) planPercent = fromTeam;
|
|
243
|
+
}
|
|
244
|
+
// Enterprise / team: individualUsage.plan often stays at 0% while pooled usage is on teamUsage.onDemand.
|
|
245
|
+
if (planPercent === 0) {
|
|
246
|
+
const fromInd = cursorPercentFromCentsUsedLimit(indOnDemand?.used, indOnDemand?.limit);
|
|
247
|
+
if (fromInd !== null && fromInd > 0) planPercent = fromInd;
|
|
248
|
+
}
|
|
249
|
+
if (planPercent === 0) {
|
|
250
|
+
const fromTeam = cursorPercentFromCentsUsedLimit(teamOnDemand?.used, teamOnDemand?.limit);
|
|
251
|
+
if (fromTeam !== null && fromTeam > 0) planPercent = fromTeam;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Team / enterprise: headline usage is the pooled quota (teamUsage.onDemand), not individual lanes.
|
|
255
|
+
const limitType = typeof body?.limitType === "string" ? body.limitType : "";
|
|
256
|
+
const membershipTypeStr = typeof body?.membershipType === "string" ? body.membershipType : "";
|
|
257
|
+
const preferTeamPool =
|
|
258
|
+
limitType === "team" ||
|
|
259
|
+
membershipTypeStr === "enterprise" ||
|
|
260
|
+
membershipTypeStr === "team";
|
|
261
|
+
if (preferTeamPool) {
|
|
262
|
+
const teamHeadline = cursorPercentFromCentsUsedLimit(teamOnDemand?.used, teamOnDemand?.limit);
|
|
263
|
+
if (teamHeadline !== null && (planPercent === null || planPercent === 0)) {
|
|
264
|
+
planPercent = teamHeadline;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
membership_type: typeof body?.membershipType === "string" ? body.membershipType : null,
|
|
270
|
+
primary_window: buildWindow({ usedPercent: planPercent, resetAt: billingCycleEnd }),
|
|
271
|
+
secondary_window: buildWindow({ usedPercent: autoPercent, resetAt: billingCycleEnd }),
|
|
272
|
+
tertiary_window: buildWindow({ usedPercent: apiPercent, resetAt: billingCycleEnd }),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function fetchCursorLimits({ home, fetchImpl = fetch } = {}) {
|
|
277
|
+
if (!isCursorInstalled({ home })) {
|
|
278
|
+
return { configured: false };
|
|
279
|
+
}
|
|
280
|
+
const auth = extractCursorSessionToken({ home });
|
|
281
|
+
if (!auth?.cookie) {
|
|
282
|
+
return { configured: false };
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const body = await fetchCursorUsageSummary({ cookie: auth.cookie, fetchImpl });
|
|
286
|
+
return {
|
|
287
|
+
configured: true,
|
|
288
|
+
error: null,
|
|
289
|
+
...normalizeCursorUsageSummary(body),
|
|
290
|
+
};
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return {
|
|
293
|
+
configured: true,
|
|
294
|
+
error: error?.message || "Unknown error",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function resolveKimiHome({ home, env } = {}) {
|
|
300
|
+
const explicit = typeof env?.KIMI_HOME === "string" ? env.KIMI_HOME.trim() : "";
|
|
301
|
+
if (explicit) return path.resolve(explicit);
|
|
302
|
+
const base = home || os.homedir();
|
|
303
|
+
// Prefer the official Kimi Code (@moonshot-ai/kimi-code, ~/.kimi-code) when it
|
|
304
|
+
// holds a login — its credential file shape (kimi-code.json) and the
|
|
305
|
+
// auth/usages endpoints are identical to the legacy kimi-cli (~/.kimi), so the
|
|
306
|
+
// existing fetch path works unchanged. Fall back to legacy when kimi-code has
|
|
307
|
+
// no credentials, keeping old kimi-cli users untouched.
|
|
308
|
+
const explicitCode = typeof env?.KIMI_CODE_HOME === "string" ? env.KIMI_CODE_HOME.trim() : "";
|
|
309
|
+
const codeHome = explicitCode ? path.resolve(explicitCode) : path.join(base, ".kimi-code");
|
|
310
|
+
const codeCredsPath = path.join(codeHome, "credentials", "kimi-code.json");
|
|
311
|
+
try {
|
|
312
|
+
const raw = fs.readFileSync(codeCredsPath, "utf8").trim();
|
|
313
|
+
if (raw && JSON.parse(raw)?.access_token) return codeHome;
|
|
314
|
+
} catch { /* missing / empty / corrupt — fall through to legacy */ }
|
|
315
|
+
return path.join(base, ".kimi");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function loadKimiCredentials({ home, env } = {}) {
|
|
319
|
+
const kimiHome = resolveKimiHome({ home, env });
|
|
320
|
+
const credsPath = path.join(kimiHome, "credentials", "kimi-code.json");
|
|
321
|
+
if (!fs.existsSync(credsPath)) return null;
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(fs.readFileSync(credsPath, "utf8"));
|
|
324
|
+
} catch (_error) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function saveKimiCredentials(creds, { home, env } = {}) {
|
|
330
|
+
const kimiHome = resolveKimiHome({ home, env });
|
|
331
|
+
const credsPath = path.join(kimiHome, "credentials", "kimi-code.json");
|
|
332
|
+
fs.mkdirSync(path.dirname(credsPath), { recursive: true });
|
|
333
|
+
fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hasKimiConfig({ home, env } = {}) {
|
|
337
|
+
return fs.existsSync(path.join(resolveKimiHome({ home, env }), "config.toml"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function kimiNumber(value) {
|
|
341
|
+
if (value === null || value === undefined || value === "") return null;
|
|
342
|
+
const n = Number(value);
|
|
343
|
+
return Number.isFinite(n) ? n : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function kimiResetTime(value) {
|
|
347
|
+
if (typeof value !== "string" || !value) return null;
|
|
348
|
+
const ts = Date.parse(value);
|
|
349
|
+
return Number.isFinite(ts) && ts > 0 ? new Date(ts).toISOString() : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function kimiWindowFromUsage(data) {
|
|
353
|
+
if (!data || typeof data !== "object") return null;
|
|
354
|
+
const limit = kimiNumber(data.limit);
|
|
355
|
+
if (!Number.isFinite(limit) || limit <= 0) return null;
|
|
356
|
+
let used = kimiNumber(data.used);
|
|
357
|
+
if (used === null) {
|
|
358
|
+
const remaining = kimiNumber(data.remaining);
|
|
359
|
+
if (remaining !== null) used = limit - remaining;
|
|
360
|
+
}
|
|
361
|
+
if (!Number.isFinite(used)) return null;
|
|
362
|
+
return buildWindow({
|
|
363
|
+
usedPercent: (used / limit) * 100,
|
|
364
|
+
resetAt: kimiResetTime(data.resetTime || data.reset_at || data.resetAt),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function normalizeKimiUsageResponse(body) {
|
|
369
|
+
const firstLimit = Array.isArray(body?.limits) ? body.limits[0] : null;
|
|
370
|
+
const detail = firstLimit?.detail && typeof firstLimit.detail === "object" ? firstLimit.detail : firstLimit;
|
|
371
|
+
const parallelLimit = kimiNumber(body?.parallel?.limit);
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
membership_level: typeof body?.user?.membership?.level === "string" ? body.user.membership.level : null,
|
|
375
|
+
subscription_type: typeof body?.subType === "string" ? body.subType : null,
|
|
376
|
+
parallel_limit: parallelLimit !== null ? parallelLimit : null,
|
|
377
|
+
primary_window: kimiWindowFromUsage(body?.usage),
|
|
378
|
+
secondary_window: kimiWindowFromUsage(detail),
|
|
379
|
+
tertiary_window: kimiWindowFromUsage(body?.totalQuota),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function kimiCredentialsExpired(creds, nowMs = Date.now()) {
|
|
384
|
+
const expiresAt = Number(creds?.expires_at);
|
|
385
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false;
|
|
386
|
+
return expiresAt * 1000 <= nowMs + 30_000;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function refreshKimiAccessToken({ refreshToken, home, env, fetchImpl = fetch } = {}) {
|
|
390
|
+
if (typeof refreshToken !== "string" || !refreshToken.trim()) {
|
|
391
|
+
throw new Error("Not logged in to Kimi. Run 'kimi' in Terminal to authenticate.");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const body = new URLSearchParams({
|
|
395
|
+
client_id: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
|
396
|
+
grant_type: "refresh_token",
|
|
397
|
+
refresh_token: refreshToken,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const res = await fetchImpl("https://auth.kimi.com/api/oauth/token", {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: {
|
|
403
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
404
|
+
"X-Msh-Platform": "kimi_cli",
|
|
405
|
+
},
|
|
406
|
+
body,
|
|
407
|
+
});
|
|
408
|
+
if (res.status === 401 || res.status === 403) {
|
|
409
|
+
throw new Error("Not logged in to Kimi. Run 'kimi' in Terminal to authenticate.");
|
|
410
|
+
}
|
|
411
|
+
if (!res.ok) {
|
|
412
|
+
throw new Error(`Kimi token refresh failed (HTTP ${res.status})`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const json = await res.json();
|
|
416
|
+
if (!json?.access_token) {
|
|
417
|
+
throw new Error("Could not parse Kimi token refresh response");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const expiresIn = Number(json.expires_in);
|
|
421
|
+
const next = {
|
|
422
|
+
access_token: String(json.access_token),
|
|
423
|
+
refresh_token: String(json.refresh_token || refreshToken),
|
|
424
|
+
expires_at: Date.now() / 1000 + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 900),
|
|
425
|
+
scope: String(json.scope || "kimi-code"),
|
|
426
|
+
token_type: String(json.token_type || "Bearer"),
|
|
427
|
+
expires_in: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 900,
|
|
428
|
+
};
|
|
429
|
+
saveKimiCredentials(next, { home, env });
|
|
430
|
+
return next.access_token;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function fetchKimiUsage(accessToken, { fetchImpl = fetch } = {}) {
|
|
434
|
+
const res = await fetchImpl("https://api.kimi.com/coding/v1/usages", {
|
|
435
|
+
method: "GET",
|
|
436
|
+
headers: {
|
|
437
|
+
Authorization: `Bearer ${accessToken}`,
|
|
438
|
+
Accept: "application/json",
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
if (res.status === 401) {
|
|
442
|
+
throw new Error("token_expired");
|
|
443
|
+
}
|
|
444
|
+
if (!res.ok) {
|
|
445
|
+
throw new Error(`Kimi API returned ${res.status}`);
|
|
446
|
+
}
|
|
447
|
+
return res.json();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function fetchKimiLimits({ home, env, fetchImpl = fetch } = {}) {
|
|
451
|
+
if (!hasKimiConfig({ home, env })) {
|
|
452
|
+
return { configured: false };
|
|
453
|
+
}
|
|
454
|
+
const creds = loadKimiCredentials({ home, env });
|
|
455
|
+
let accessToken = typeof creds?.access_token === "string" ? creds.access_token.trim() : "";
|
|
456
|
+
if (!accessToken) {
|
|
457
|
+
return { configured: false };
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
if (kimiCredentialsExpired(creds) && creds?.refresh_token) {
|
|
461
|
+
accessToken = await refreshKimiAccessToken({
|
|
462
|
+
refreshToken: creds.refresh_token,
|
|
463
|
+
home,
|
|
464
|
+
env,
|
|
465
|
+
fetchImpl,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
let body;
|
|
469
|
+
try {
|
|
470
|
+
body = await fetchKimiUsage(accessToken, { fetchImpl });
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (error?.message === "token_expired" && creds?.refresh_token) {
|
|
473
|
+
accessToken = await refreshKimiAccessToken({
|
|
474
|
+
refreshToken: creds.refresh_token,
|
|
475
|
+
home,
|
|
476
|
+
env,
|
|
477
|
+
fetchImpl,
|
|
478
|
+
});
|
|
479
|
+
body = await fetchKimiUsage(accessToken, { fetchImpl });
|
|
480
|
+
} else {
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
configured: true,
|
|
486
|
+
error: null,
|
|
487
|
+
...normalizeKimiUsageResponse(body),
|
|
488
|
+
};
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return {
|
|
491
|
+
configured: true,
|
|
492
|
+
error: error?.message || "Unknown error",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function resolveGeminiHome({ home, env } = {}) {
|
|
498
|
+
const explicit = typeof env?.GEMINI_HOME === "string" ? env.GEMINI_HOME.trim() : "";
|
|
499
|
+
return explicit ? path.resolve(explicit) : path.join(home, ".gemini");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function loadGeminiSettings({ home, env } = {}) {
|
|
503
|
+
const geminiHome = resolveGeminiHome({ home, env });
|
|
504
|
+
const settingsPath = path.join(geminiHome, "settings.json");
|
|
505
|
+
if (!fs.existsSync(settingsPath)) return null;
|
|
506
|
+
try {
|
|
507
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
508
|
+
} catch (_error) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function loadGeminiCredentials({ home, env } = {}) {
|
|
514
|
+
const geminiHome = resolveGeminiHome({ home, env });
|
|
515
|
+
const credsPath = path.join(geminiHome, "oauth_creds.json");
|
|
516
|
+
if (!fs.existsSync(credsPath)) return null;
|
|
517
|
+
try {
|
|
518
|
+
return JSON.parse(fs.readFileSync(credsPath, "utf8"));
|
|
519
|
+
} catch (_error) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolveSymlinkOnce(filePath) {
|
|
525
|
+
try {
|
|
526
|
+
const resolved = fs.readlinkSync(filePath);
|
|
527
|
+
return path.isAbsolute(resolved)
|
|
528
|
+
? resolved
|
|
529
|
+
: path.join(path.dirname(filePath), resolved);
|
|
530
|
+
} catch (_error) {
|
|
531
|
+
return filePath;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function expandGeminiExecutableCandidates({ home } = {}) {
|
|
536
|
+
const candidates = [];
|
|
537
|
+
const add = (filePath) => {
|
|
538
|
+
if (typeof filePath === "string" && filePath && !candidates.includes(filePath)) {
|
|
539
|
+
candidates.push(filePath);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const nvmDir = path.join(home || os.homedir(), ".nvm", "versions", "node");
|
|
544
|
+
try {
|
|
545
|
+
for (const version of fs.readdirSync(nvmDir)) {
|
|
546
|
+
add(path.join(nvmDir, version, "bin", "gemini"));
|
|
547
|
+
}
|
|
548
|
+
} catch (_error) {}
|
|
549
|
+
|
|
550
|
+
add(path.join(path.dirname(resolveSymlinkOnce(process.execPath)), "gemini"));
|
|
551
|
+
add("/opt/homebrew/bin/gemini");
|
|
552
|
+
add("/usr/local/bin/gemini");
|
|
553
|
+
|
|
554
|
+
return candidates;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function extractGeminiOauthClientCredentials({ commandRunner, home } = {}) {
|
|
558
|
+
const result = runCommand(commandRunner, "which", ["gemini"], { timeout: 2000 });
|
|
559
|
+
const geminiPath = typeof result?.stdout === "string" ? result.stdout.trim() : "";
|
|
560
|
+
|
|
561
|
+
const geminiPaths = [
|
|
562
|
+
...(geminiPath ? [geminiPath] : []),
|
|
563
|
+
...expandGeminiExecutableCandidates({ home }),
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
for (const candidateGeminiPath of geminiPaths) {
|
|
567
|
+
if (!fs.existsSync(candidateGeminiPath)) continue;
|
|
568
|
+
const realPath = resolveSymlinkOnce(candidateGeminiPath);
|
|
569
|
+
const binDir = path.dirname(realPath);
|
|
570
|
+
const baseDir = path.dirname(binDir);
|
|
571
|
+
const bundleDir = path.dirname(realPath);
|
|
572
|
+
const candidates = [
|
|
573
|
+
path.join(baseDir, "libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"),
|
|
574
|
+
path.join(baseDir, "lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"),
|
|
575
|
+
path.join(baseDir, "share/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"),
|
|
576
|
+
path.join(baseDir, "../gemini-cli-core/dist/src/code_assist/oauth2.js"),
|
|
577
|
+
path.join(baseDir, "node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"),
|
|
578
|
+
];
|
|
579
|
+
if (path.basename(bundleDir) === "bundle") {
|
|
580
|
+
candidates.push(realPath);
|
|
581
|
+
try {
|
|
582
|
+
for (const file of fs.readdirSync(bundleDir)) {
|
|
583
|
+
if (/^chunk-.*\.js$/.test(file)) {
|
|
584
|
+
candidates.push(path.join(bundleDir, file));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch (_error) {}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
for (const candidate of candidates) {
|
|
591
|
+
if (!fs.existsSync(candidate)) continue;
|
|
592
|
+
try {
|
|
593
|
+
const content = fs.readFileSync(candidate, "utf8");
|
|
594
|
+
const clientId = content.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/)?.[1] || null;
|
|
595
|
+
const clientSecret = content.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/)?.[1] || null;
|
|
596
|
+
if (clientId && clientSecret) {
|
|
597
|
+
return { clientId, clientSecret };
|
|
598
|
+
}
|
|
599
|
+
} catch (_error) {}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function refreshGeminiAccessToken({
|
|
606
|
+
refreshToken,
|
|
607
|
+
home,
|
|
608
|
+
env,
|
|
609
|
+
fetchImpl = fetch,
|
|
610
|
+
commandRunner,
|
|
611
|
+
}) {
|
|
612
|
+
const oauthClient = extractGeminiOauthClientCredentials({ commandRunner, home });
|
|
613
|
+
if (!oauthClient?.clientId || !oauthClient?.clientSecret) {
|
|
614
|
+
throw new Error("Gemini API error: Could not find Gemini CLI OAuth configuration");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const body = new URLSearchParams({
|
|
618
|
+
client_id: oauthClient.clientId,
|
|
619
|
+
client_secret: oauthClient.clientSecret,
|
|
620
|
+
refresh_token: refreshToken,
|
|
621
|
+
grant_type: "refresh_token",
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const res = await fetchImpl("https://oauth2.googleapis.com/token", {
|
|
625
|
+
method: "POST",
|
|
626
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
627
|
+
body,
|
|
628
|
+
});
|
|
629
|
+
if (!res.ok) {
|
|
630
|
+
throw new Error("Not logged in to Gemini. Run 'gemini' in Terminal to authenticate.");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const json = await res.json();
|
|
634
|
+
if (!json?.access_token) {
|
|
635
|
+
throw new Error("Could not parse Gemini usage: invalid token refresh response");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const geminiHome = resolveGeminiHome({ home, env });
|
|
639
|
+
const credsPath = path.join(geminiHome, "oauth_creds.json");
|
|
640
|
+
try {
|
|
641
|
+
const creds = loadGeminiCredentials({ home, env }) || {};
|
|
642
|
+
creds.access_token = json.access_token;
|
|
643
|
+
if (json.id_token) creds.id_token = json.id_token;
|
|
644
|
+
if (typeof json.expires_in === "number" && Number.isFinite(json.expires_in)) {
|
|
645
|
+
creds.expiry_date = Date.now() + json.expires_in * 1000;
|
|
646
|
+
}
|
|
647
|
+
fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2));
|
|
648
|
+
} catch (_error) {}
|
|
649
|
+
|
|
650
|
+
return json.access_token;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function loadGeminiCodeAssistStatus(accessToken, { fetchImpl = fetch } = {}) {
|
|
654
|
+
const res = await fetchImpl("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
|
|
655
|
+
method: "POST",
|
|
656
|
+
headers: {
|
|
657
|
+
Authorization: `Bearer ${accessToken}`,
|
|
658
|
+
"Content-Type": "application/json",
|
|
659
|
+
},
|
|
660
|
+
body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", pluginType: "GEMINI" } }),
|
|
661
|
+
});
|
|
662
|
+
if (!res.ok) {
|
|
663
|
+
return { tier: null, projectId: null };
|
|
664
|
+
}
|
|
665
|
+
const json = await res.json();
|
|
666
|
+
const tier = typeof json?.currentTier?.id === "string" ? json.currentTier.id : null;
|
|
667
|
+
const rawProject = json?.cloudaicompanionProject;
|
|
668
|
+
const projectId =
|
|
669
|
+
typeof rawProject === "string"
|
|
670
|
+
? rawProject.trim() || null
|
|
671
|
+
: typeof rawProject?.id === "string"
|
|
672
|
+
? rawProject.id
|
|
673
|
+
: typeof rawProject?.projectId === "string"
|
|
674
|
+
? rawProject.projectId
|
|
675
|
+
: null;
|
|
676
|
+
return { tier, projectId };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function normalizeGeminiModelBuckets(buckets) {
|
|
680
|
+
if (!Array.isArray(buckets)) return [];
|
|
681
|
+
const byModel = new Map();
|
|
682
|
+
for (const bucket of buckets) {
|
|
683
|
+
const modelId = typeof bucket?.modelId === "string" ? bucket.modelId : null;
|
|
684
|
+
const remainingFraction = Number(bucket?.remainingFraction);
|
|
685
|
+
if (!modelId || !Number.isFinite(remainingFraction)) continue;
|
|
686
|
+
const existing = byModel.get(modelId);
|
|
687
|
+
if (!existing || remainingFraction < existing.remainingFraction) {
|
|
688
|
+
byModel.set(modelId, {
|
|
689
|
+
model_id: modelId,
|
|
690
|
+
remainingFraction,
|
|
691
|
+
reset_at: parseAntigravityDate(bucket?.resetTime),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return Array.from(byModel.values()).sort((a, b) => a.model_id.localeCompare(b.model_id));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function isGeminiFlashLiteModel(id) {
|
|
699
|
+
return String(id || "").toLowerCase().includes("flash-lite");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function isGeminiFlashModel(id) {
|
|
703
|
+
const lower = String(id || "").toLowerCase();
|
|
704
|
+
return lower.includes("flash") && !isGeminiFlashLiteModel(lower);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isGeminiProModel(id) {
|
|
708
|
+
return String(id || "").toLowerCase().includes("pro");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function normalizeGeminiQuotaResponse({ buckets, email, tier }) {
|
|
712
|
+
const models = normalizeGeminiModelBuckets(buckets);
|
|
713
|
+
if (!models.length) {
|
|
714
|
+
throw new Error("Could not parse Gemini usage: no quota buckets in response");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const pickLowest = (predicate) =>
|
|
718
|
+
models
|
|
719
|
+
.filter((model) => predicate(model.model_id))
|
|
720
|
+
.sort((a, b) => a.remainingFraction - b.remainingFraction)[0] || null;
|
|
721
|
+
|
|
722
|
+
const plan =
|
|
723
|
+
tier === "standard-tier"
|
|
724
|
+
? "Paid"
|
|
725
|
+
: tier === "legacy-tier"
|
|
726
|
+
? "Legacy"
|
|
727
|
+
: tier === "free-tier"
|
|
728
|
+
? "Free"
|
|
729
|
+
: null;
|
|
730
|
+
|
|
731
|
+
const pro = pickLowest(isGeminiProModel);
|
|
732
|
+
const flash = pickLowest(isGeminiFlashModel);
|
|
733
|
+
const flashLite = pickLowest(isGeminiFlashLiteModel);
|
|
734
|
+
const fallback = !pro && !flash && !flashLite
|
|
735
|
+
? [...models].sort((a, b) => a.remainingFraction - b.remainingFraction)[0]
|
|
736
|
+
: null;
|
|
737
|
+
|
|
738
|
+
const toWindow = (model) =>
|
|
739
|
+
model
|
|
740
|
+
? buildWindow({
|
|
741
|
+
usedPercent: 100 - model.remainingFraction * 100,
|
|
742
|
+
resetAt: model.reset_at,
|
|
743
|
+
})
|
|
744
|
+
: null;
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
account_email: email || null,
|
|
748
|
+
account_plan: plan,
|
|
749
|
+
primary_window: toWindow(pro || fallback),
|
|
750
|
+
secondary_window: toWindow(flash),
|
|
751
|
+
tertiary_window: toWindow(flashLite),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function fetchGeminiLimits({ home, env, fetchImpl = fetch, commandRunner } = {}) {
|
|
756
|
+
const settings = loadGeminiSettings({ home, env });
|
|
757
|
+
const selectedType = settings?.security?.auth?.selectedType ?? null;
|
|
758
|
+
if (!settings && !loadGeminiCredentials({ home, env })) {
|
|
759
|
+
return { configured: false };
|
|
760
|
+
}
|
|
761
|
+
if (selectedType === "api-key") {
|
|
762
|
+
return { configured: true, error: "Gemini API key auth not supported. Use Google account (OAuth) instead." };
|
|
763
|
+
}
|
|
764
|
+
if (selectedType === "vertex-ai") {
|
|
765
|
+
return { configured: true, error: "Gemini Vertex AI auth not supported. Use Google account (OAuth) instead." };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const creds = loadGeminiCredentials({ home, env });
|
|
769
|
+
if (!creds?.access_token) {
|
|
770
|
+
return { configured: true, error: "Not logged in to Gemini. Run 'gemini' in Terminal to authenticate." };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
try {
|
|
774
|
+
let accessToken = creds.access_token;
|
|
775
|
+
const expiry = Number(creds.expiry_date);
|
|
776
|
+
if (Number.isFinite(expiry) && expiry > 0 && expiry < Date.now() && creds.refresh_token) {
|
|
777
|
+
accessToken = await refreshGeminiAccessToken({
|
|
778
|
+
refreshToken: creds.refresh_token,
|
|
779
|
+
home,
|
|
780
|
+
env,
|
|
781
|
+
fetchImpl,
|
|
782
|
+
commandRunner,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const claims = decodeJwtPayload(creds.id_token);
|
|
787
|
+
const codeAssist = await loadGeminiCodeAssistStatus(accessToken, { fetchImpl });
|
|
788
|
+
const res = await fetchImpl("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
|
|
789
|
+
method: "POST",
|
|
790
|
+
headers: {
|
|
791
|
+
Authorization: `Bearer ${accessToken}`,
|
|
792
|
+
"Content-Type": "application/json",
|
|
793
|
+
},
|
|
794
|
+
body: JSON.stringify(codeAssist.projectId ? { project: codeAssist.projectId } : {}),
|
|
795
|
+
});
|
|
796
|
+
if (res.status === 401) {
|
|
797
|
+
throw new Error("Not logged in to Gemini. Run 'gemini' in Terminal to authenticate.");
|
|
798
|
+
}
|
|
799
|
+
if (!res.ok) {
|
|
800
|
+
throw new Error(`Gemini API error: HTTP ${res.status}`);
|
|
801
|
+
}
|
|
802
|
+
const json = await res.json();
|
|
803
|
+
return {
|
|
804
|
+
configured: true,
|
|
805
|
+
error: null,
|
|
806
|
+
...normalizeGeminiQuotaResponse({
|
|
807
|
+
buckets: json?.buckets,
|
|
808
|
+
email: claims?.email || null,
|
|
809
|
+
tier: codeAssist.tier,
|
|
810
|
+
}),
|
|
811
|
+
};
|
|
812
|
+
} catch (error) {
|
|
813
|
+
return {
|
|
814
|
+
configured: true,
|
|
815
|
+
error: error?.message || "Unknown error",
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function runCommand(commandRunner, command, args, options = {}) {
|
|
821
|
+
const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
|
|
822
|
+
return runner(command, args, {
|
|
823
|
+
encoding: "utf8",
|
|
824
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
825
|
+
...options,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function whichBinary(binary, { commandRunner } = {}) {
|
|
830
|
+
const result = runCommand(commandRunner, "which", [binary], { timeout: 2000 });
|
|
831
|
+
if (result?.error || result?.status !== 0) return null;
|
|
832
|
+
const stdout = typeof result?.stdout === "string" ? result.stdout.trim() : "";
|
|
833
|
+
return stdout ? stdout.split("\n")[0] : null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function isBinaryAvailable(binary, { commandRunner } = {}) {
|
|
837
|
+
return whichBinary(binary, { commandRunner }) !== null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function stripAnsi(text) {
|
|
841
|
+
return String(text || "").replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, "");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function extractFirstNumber(text) {
|
|
845
|
+
const match = String(text || "").match(/-?\d+(?:\.\d+)?/);
|
|
846
|
+
return match ? Number(match[0]) : null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function parseMonthDayResetDate(dateStr, now = new Date()) {
|
|
850
|
+
if (typeof dateStr !== "string") return null;
|
|
851
|
+
const match = dateStr.match(/(\d{2})\/(\d{2})/);
|
|
852
|
+
if (!match) return null;
|
|
853
|
+
const month = Number(match[1]);
|
|
854
|
+
const day = Number(match[2]);
|
|
855
|
+
if (!Number.isFinite(month) || !Number.isFinite(day)) return null;
|
|
856
|
+
const currentYear = now.getUTCFullYear();
|
|
857
|
+
|
|
858
|
+
let candidate = new Date(Date.UTC(currentYear, month - 1, day, 0, 0, 0, 0));
|
|
859
|
+
if (candidate.getTime() <= now.getTime()) {
|
|
860
|
+
candidate = new Date(Date.UTC(currentYear + 1, month - 1, day, 0, 0, 0, 0));
|
|
861
|
+
}
|
|
862
|
+
return candidate.toISOString();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function isKiroUsageOutputComplete(output) {
|
|
866
|
+
const lowered = stripAnsi(output).toLowerCase();
|
|
867
|
+
return lowered.includes("covered in plan")
|
|
868
|
+
|| lowered.includes("resets on")
|
|
869
|
+
|| lowered.includes("bonus credits")
|
|
870
|
+
|| lowered.includes("plan:")
|
|
871
|
+
|| lowered.includes("managed by admin")
|
|
872
|
+
|| lowered.includes("managed by organization");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function parseKiroUsageOutput(output, { now = new Date() } = {}) {
|
|
876
|
+
const stripped = stripAnsi(output).trim();
|
|
877
|
+
if (!stripped) {
|
|
878
|
+
throw new Error("Failed to parse Kiro usage: empty output");
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const lowered = stripped.toLowerCase();
|
|
882
|
+
if (
|
|
883
|
+
lowered.includes("not logged in")
|
|
884
|
+
|| lowered.includes("login required")
|
|
885
|
+
|| lowered.includes("failed to initialize auth portal")
|
|
886
|
+
|| lowered.includes("kiro-cli login")
|
|
887
|
+
|| lowered.includes("oauth error")
|
|
888
|
+
) {
|
|
889
|
+
throw new Error("Not logged in to Kiro. Run 'kiro-cli login' first.");
|
|
890
|
+
}
|
|
891
|
+
if (lowered.includes("could not retrieve usage information")) {
|
|
892
|
+
throw new Error("Failed to parse Kiro usage: Kiro CLI could not retrieve usage information.");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
let planName = "Kiro";
|
|
896
|
+
const legacyPlan = stripped.match(/\|\s*(KIRO\s+\w+)/);
|
|
897
|
+
if (legacyPlan?.[1]) {
|
|
898
|
+
planName = legacyPlan[1].trim();
|
|
899
|
+
}
|
|
900
|
+
const modernPlan = stripped.match(/Plan:\s*(.+)/);
|
|
901
|
+
if (modernPlan?.[1]) {
|
|
902
|
+
planName = modernPlan[1].split("\n")[0].trim() || planName;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/i);
|
|
906
|
+
const primaryReset = resetMatch ? parseMonthDayResetDate(resetMatch[1], now) : null;
|
|
907
|
+
|
|
908
|
+
let creditsPercent = null;
|
|
909
|
+
const percentMatch = stripped.match(/█+\s*(\d+)%/);
|
|
910
|
+
if (percentMatch?.[1]) {
|
|
911
|
+
creditsPercent = clampPercent(Number(percentMatch[1]));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
let creditsUsed = null;
|
|
915
|
+
let creditsTotal = null;
|
|
916
|
+
const coveredMatch = stripped.match(/\((\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\s+covered/i);
|
|
917
|
+
if (coveredMatch?.[1] && coveredMatch?.[2]) {
|
|
918
|
+
creditsUsed = Number(coveredMatch[1]);
|
|
919
|
+
creditsTotal = Number(coveredMatch[2]);
|
|
920
|
+
}
|
|
921
|
+
if (creditsPercent === null && creditsUsed !== null && creditsTotal && creditsTotal > 0) {
|
|
922
|
+
creditsPercent = clampPercent((creditsUsed / creditsTotal) * 100);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const managedPlan = lowered.includes("managed by admin") || lowered.includes("managed by organization");
|
|
926
|
+
if (creditsPercent === null && creditsUsed === null && managedPlan) {
|
|
927
|
+
return {
|
|
928
|
+
plan_name: planName,
|
|
929
|
+
primary_window: buildWindow({ usedPercent: 0, resetAt: null }),
|
|
930
|
+
secondary_window: null,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
if (creditsPercent === null && creditsUsed === null) {
|
|
934
|
+
throw new Error("Failed to parse Kiro usage: usage output format may have changed.");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let bonusWindow = null;
|
|
938
|
+
const bonusMatch = stripped.match(/Bonus credits:[\s\S]*?(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)/i);
|
|
939
|
+
const expiryMatch = stripped.match(/expires in (\d+) days?/i);
|
|
940
|
+
if (bonusMatch?.[1] && bonusMatch?.[2]) {
|
|
941
|
+
const bonusUsed = Number(bonusMatch[1]);
|
|
942
|
+
const bonusTotal = Number(bonusMatch[2]);
|
|
943
|
+
const bonusPct = bonusTotal > 0 ? clampPercent((bonusUsed / bonusTotal) * 100) : 0;
|
|
944
|
+
let bonusReset = null;
|
|
945
|
+
if (expiryMatch?.[1]) {
|
|
946
|
+
const days = Number(expiryMatch[1]);
|
|
947
|
+
if (Number.isFinite(days) && days >= 0) {
|
|
948
|
+
bonusReset = new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
bonusWindow = buildWindow({ usedPercent: bonusPct, resetAt: bonusReset });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return {
|
|
955
|
+
plan_name: planName,
|
|
956
|
+
primary_window: buildWindow({ usedPercent: creditsPercent, resetAt: primaryReset }),
|
|
957
|
+
secondary_window: bonusWindow,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
962
|
+
// GitHub Copilot — `GET https://api.github.com/copilot_internal/user`
|
|
963
|
+
// Reuses the OAuth token from the user's existing Copilot install
|
|
964
|
+
// (`~/.config/github-copilot/{apps,hosts}.json`). No device flow needed.
|
|
965
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
966
|
+
|
|
967
|
+
function readCopilotOauthToken({ home = require("node:os").homedir() } = {}) {
|
|
968
|
+
const candidates = [
|
|
969
|
+
path.join(home, ".config", "github-copilot", "apps.json"),
|
|
970
|
+
path.join(home, ".config", "github-copilot", "hosts.json"),
|
|
971
|
+
];
|
|
972
|
+
// Keys are either "github.com", "github.example.com" (enterprise), or a
|
|
973
|
+
// composite like "github.com:Iv1.b507a08c87ecfe98". We always hit
|
|
974
|
+
// api.github.com, so prefer the public-host token; only fall back to
|
|
975
|
+
// whatever's there if no public-host entry exists.
|
|
976
|
+
let fallback = null;
|
|
977
|
+
for (const filePath of candidates) {
|
|
978
|
+
if (!fs.existsSync(filePath)) continue;
|
|
979
|
+
let parsed;
|
|
980
|
+
try {
|
|
981
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
982
|
+
} catch (_e) {
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
986
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
987
|
+
if (!value || typeof value !== "object") continue;
|
|
988
|
+
const token = typeof value.oauth_token === "string" ? value.oauth_token : "";
|
|
989
|
+
if (!token) continue;
|
|
990
|
+
const host = String(key).split(":")[0];
|
|
991
|
+
if (host === "github.com") return token;
|
|
992
|
+
if (!fallback) fallback = token;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return fallback;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function copilotRequestHeaders(token) {
|
|
999
|
+
return {
|
|
1000
|
+
Authorization: `token ${token}`,
|
|
1001
|
+
Accept: "application/json",
|
|
1002
|
+
"Editor-Version": "vscode/1.96.2",
|
|
1003
|
+
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
1004
|
+
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
1005
|
+
"X-Github-Api-Version": "2025-04-01",
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function copilotResetIso(value) {
|
|
1010
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
1011
|
+
const trimmed = value.trim();
|
|
1012
|
+
// Accept "YYYY-MM-DD" or full ISO timestamps
|
|
1013
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
|
|
1014
|
+
const ts = Date.parse(dateOnly ? `${trimmed}T00:00:00Z` : trimmed);
|
|
1015
|
+
if (!Number.isFinite(ts)) return null;
|
|
1016
|
+
return new Date(ts).toISOString();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function buildCopilotWindow(snapshot, resetIso) {
|
|
1020
|
+
if (!snapshot || typeof snapshot !== "object") return null;
|
|
1021
|
+
const entitlement = Number(snapshot.entitlement);
|
|
1022
|
+
const remaining = Number(snapshot.remaining);
|
|
1023
|
+
const percentRemaining = Number(snapshot.percent_remaining);
|
|
1024
|
+
const allZero = (!entitlement || entitlement <= 0) && (!remaining || remaining <= 0) && (!percentRemaining || percentRemaining <= 0);
|
|
1025
|
+
if (allZero) return null;
|
|
1026
|
+
let usedPercent;
|
|
1027
|
+
if (Number.isFinite(percentRemaining)) {
|
|
1028
|
+
usedPercent = 100 - percentRemaining;
|
|
1029
|
+
} else if (Number.isFinite(entitlement) && entitlement > 0 && Number.isFinite(remaining)) {
|
|
1030
|
+
usedPercent = ((entitlement - remaining) / entitlement) * 100;
|
|
1031
|
+
} else {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
return buildWindow({ usedPercent, resetAt: resetIso });
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function describeCopilotOtelStatus({ home, env = process.env } = {}) {
|
|
1038
|
+
const resolvedHome = home || env.HOME || require("node:os").homedir();
|
|
1039
|
+
const enabled = String(env.COPILOT_OTEL_ENABLED || "").toLowerCase() === "true";
|
|
1040
|
+
const exporterType = String(env.COPILOT_OTEL_EXPORTER_TYPE || "").toLowerCase();
|
|
1041
|
+
const explicitPath = typeof env.COPILOT_OTEL_FILE_EXPORTER_PATH === "string"
|
|
1042
|
+
? env.COPILOT_OTEL_FILE_EXPORTER_PATH
|
|
1043
|
+
: "";
|
|
1044
|
+
const defaultDir = path.join(resolvedHome, ".copilot", "otel");
|
|
1045
|
+
let hasFiles = false;
|
|
1046
|
+
try {
|
|
1047
|
+
if (fs.existsSync(defaultDir)) {
|
|
1048
|
+
hasFiles = fs.readdirSync(defaultDir).some((entry) => entry.endsWith(".jsonl"));
|
|
1049
|
+
}
|
|
1050
|
+
} catch (_e) {}
|
|
1051
|
+
if (!hasFiles && explicitPath && fs.existsSync(explicitPath)) hasFiles = true;
|
|
1052
|
+
return {
|
|
1053
|
+
otel_enabled: enabled && (exporterType === "" || exporterType === "file"),
|
|
1054
|
+
otel_exporter_type: exporterType || null,
|
|
1055
|
+
otel_path: explicitPath || null,
|
|
1056
|
+
otel_default_dir: defaultDir,
|
|
1057
|
+
otel_has_files: hasFiles,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function fetchCopilotLimits({ home, env = process.env, fetchImpl = fetch } = {}) {
|
|
1062
|
+
const otel = describeCopilotOtelStatus({ home, env });
|
|
1063
|
+
const token = readCopilotOauthToken({ home: home || (env.HOME || require("node:os").homedir()) });
|
|
1064
|
+
if (!token) return { configured: false, ...otel };
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
1067
|
+
const res = await fetchImpl("https://api.github.com/copilot_internal/user", {
|
|
1068
|
+
method: "GET",
|
|
1069
|
+
headers: copilotRequestHeaders(token),
|
|
1070
|
+
});
|
|
1071
|
+
if (res.status === 401 || res.status === 403) {
|
|
1072
|
+
throw new Error("GitHub Copilot token rejected. Re-authenticate via GitHub Copilot CLI/extension.");
|
|
1073
|
+
}
|
|
1074
|
+
if (!res.ok) {
|
|
1075
|
+
throw new Error(`GitHub Copilot API error: HTTP ${res.status}`);
|
|
1076
|
+
}
|
|
1077
|
+
const json = await res.json();
|
|
1078
|
+
const planName = typeof json?.copilot_plan === "string" && json.copilot_plan
|
|
1079
|
+
? json.copilot_plan.charAt(0).toUpperCase() + json.copilot_plan.slice(1)
|
|
1080
|
+
: null;
|
|
1081
|
+
const resetIso = copilotResetIso(json?.quota_reset_date);
|
|
1082
|
+
const snapshots = json?.quota_snapshots || {};
|
|
1083
|
+
const premiumWindow = buildCopilotWindow(snapshots.premium_interactions, resetIso);
|
|
1084
|
+
const chatWindow = buildCopilotWindow(snapshots.chat, resetIso);
|
|
1085
|
+
|
|
1086
|
+
if (!premiumWindow && !chatWindow) {
|
|
1087
|
+
return { configured: true, error: null, plan_name: planName, primary_window: null, secondary_window: null, ...otel };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
configured: true,
|
|
1092
|
+
error: null,
|
|
1093
|
+
plan_name: planName,
|
|
1094
|
+
primary_window: premiumWindow,
|
|
1095
|
+
secondary_window: chatWindow,
|
|
1096
|
+
...otel,
|
|
1097
|
+
};
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
return { configured: true, error: error?.message || "Unknown error", ...otel };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function fetchKiroLimits({ commandRunner, now = new Date() } = {}) {
|
|
1104
|
+
if (!isBinaryAvailable("kiro-cli", { commandRunner })) {
|
|
1105
|
+
return { configured: false };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const result = runCommand(
|
|
1109
|
+
commandRunner,
|
|
1110
|
+
"kiro-cli",
|
|
1111
|
+
["chat", "--no-interactive", "/usage"],
|
|
1112
|
+
{
|
|
1113
|
+
timeout: 20_000,
|
|
1114
|
+
env: { ...process.env, TERM: "xterm-256color" },
|
|
1115
|
+
},
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
const stdout = typeof result?.stdout === "string" ? result.stdout : "";
|
|
1119
|
+
const stderr = typeof result?.stderr === "string" ? result.stderr : "";
|
|
1120
|
+
const output = stderr.trim() || stdout.trim();
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
if (result?.error?.code === "ETIMEDOUT" && !isKiroUsageOutputComplete(output)) {
|
|
1124
|
+
throw new Error("Kiro CLI timed out.");
|
|
1125
|
+
}
|
|
1126
|
+
if (!output && result?.status !== 0) {
|
|
1127
|
+
throw new Error(`Kiro CLI failed with status ${result.status}.`);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return {
|
|
1131
|
+
configured: true,
|
|
1132
|
+
error: null,
|
|
1133
|
+
...parseKiroUsageOutput(output, { now }),
|
|
1134
|
+
};
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
return {
|
|
1137
|
+
configured: true,
|
|
1138
|
+
error: error?.message || "Unknown error",
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function parseProcessLine(line) {
|
|
1144
|
+
const match = String(line || "")
|
|
1145
|
+
.trim()
|
|
1146
|
+
.match(/^(\d+)\s+(.*)$/);
|
|
1147
|
+
if (!match) return null;
|
|
1148
|
+
return {
|
|
1149
|
+
pid: Number(match[1]),
|
|
1150
|
+
command: match[2],
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function isAntigravityCommandLine(command) {
|
|
1155
|
+
const lower = String(command || "").toLowerCase();
|
|
1156
|
+
return lower.includes("language_server")
|
|
1157
|
+
&& (
|
|
1158
|
+
(lower.includes("--app_data_dir") && lower.includes("antigravity"))
|
|
1159
|
+
|| lower.includes("/antigravity/")
|
|
1160
|
+
|| lower.includes("\\antigravity\\")
|
|
1161
|
+
|| lower.includes("--override_ide_name antigravity")
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function extractCommandFlag(command, flag) {
|
|
1166
|
+
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1167
|
+
const match = String(command || "").match(new RegExp(`${escaped}[=\\s]+([^\\s]+)`, "i"));
|
|
1168
|
+
return match?.[1] || null;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function detectAntigravityProcess({ commandRunner } = {}) {
|
|
1172
|
+
const result = runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], {
|
|
1173
|
+
timeout: 4000,
|
|
1174
|
+
});
|
|
1175
|
+
const lines = String(result?.stdout || "").split("\n");
|
|
1176
|
+
|
|
1177
|
+
let sawProcess = false;
|
|
1178
|
+
for (const line of lines) {
|
|
1179
|
+
const parsed = parseProcessLine(line);
|
|
1180
|
+
if (!parsed) continue;
|
|
1181
|
+
if (!isAntigravityCommandLine(parsed.command)) continue;
|
|
1182
|
+
sawProcess = true;
|
|
1183
|
+
const csrfToken = extractCommandFlag(parsed.command, "--csrf_token");
|
|
1184
|
+
if (!csrfToken) continue;
|
|
1185
|
+
const extensionPort = extractFirstNumber(extractCommandFlag(parsed.command, "--extension_server_port"));
|
|
1186
|
+
return {
|
|
1187
|
+
configured: true,
|
|
1188
|
+
pid: parsed.pid,
|
|
1189
|
+
csrfToken,
|
|
1190
|
+
extensionPort: Number.isFinite(extensionPort) ? extensionPort : null,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (sawProcess) {
|
|
1195
|
+
return { configured: true, error: "Antigravity CSRF token not found. Restart Antigravity and retry." };
|
|
1196
|
+
}
|
|
1197
|
+
return { configured: false };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function resolveAntigravityLimitsCachePath({ home } = {}) {
|
|
1201
|
+
return path.join(home || os.homedir(), ".tokentracker", "tracker", ANTIGRAVITY_LIMITS_CACHE_FILE);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function parseTimeMs(value) {
|
|
1205
|
+
if (typeof value !== "string" || !value) return null;
|
|
1206
|
+
const ts = Date.parse(value);
|
|
1207
|
+
return Number.isFinite(ts) && ts > 0 ? ts : null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function isCacheWindowUsable(window, { cachedAtMs, nowMs } = {}) {
|
|
1211
|
+
if (!window || typeof window !== "object") return false;
|
|
1212
|
+
const resetAtMs = parseTimeMs(window.reset_at);
|
|
1213
|
+
if (resetAtMs !== null) return resetAtMs > nowMs;
|
|
1214
|
+
return Number.isFinite(cachedAtMs)
|
|
1215
|
+
&& nowMs - cachedAtMs <= ANTIGRAVITY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function hasAntigravityWindow(limits) {
|
|
1219
|
+
return Boolean(limits?.primary_window || limits?.secondary_window || limits?.tertiary_window);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function normalizeAntigravityCachedLimits(raw, { nowMs = Date.now() } = {}) {
|
|
1223
|
+
const cachedAtMs = parseTimeMs(raw?.cached_at);
|
|
1224
|
+
if (!Number.isFinite(cachedAtMs)) return null;
|
|
1225
|
+
if (cachedAtMs > nowMs + 60_000) return null;
|
|
1226
|
+
if (nowMs - cachedAtMs > ANTIGRAVITY_LIMITS_CACHE_MAX_AGE_MS) return null;
|
|
1227
|
+
|
|
1228
|
+
const cached = {
|
|
1229
|
+
configured: true,
|
|
1230
|
+
error: null,
|
|
1231
|
+
account_email: typeof raw?.account_email === "string" ? raw.account_email : null,
|
|
1232
|
+
account_plan: typeof raw?.account_plan === "string" ? raw.account_plan : null,
|
|
1233
|
+
primary_window: isCacheWindowUsable(raw?.primary_window, { cachedAtMs, nowMs }) ? raw.primary_window : null,
|
|
1234
|
+
secondary_window: isCacheWindowUsable(raw?.secondary_window, { cachedAtMs, nowMs }) ? raw.secondary_window : null,
|
|
1235
|
+
tertiary_window: isCacheWindowUsable(raw?.tertiary_window, { cachedAtMs, nowMs }) ? raw.tertiary_window : null,
|
|
1236
|
+
cached: true,
|
|
1237
|
+
cached_at: raw.cached_at,
|
|
1238
|
+
};
|
|
1239
|
+
return hasAntigravityWindow(cached) ? cached : null;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function readAntigravityLimitsCache({ home, nowMs = Date.now() } = {}) {
|
|
1243
|
+
const cachePath = resolveAntigravityLimitsCachePath({ home });
|
|
1244
|
+
try {
|
|
1245
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
|
|
1246
|
+
return normalizeAntigravityCachedLimits(parsed?.antigravity, { nowMs });
|
|
1247
|
+
} catch (_error) {
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function writeAntigravityLimitsCache(limits, { home, nowMs = Date.now() } = {}) {
|
|
1253
|
+
if (!limits?.configured || limits.error || !hasAntigravityWindow(limits)) return;
|
|
1254
|
+
const cachePath = resolveAntigravityLimitsCachePath({ home });
|
|
1255
|
+
const payload = {
|
|
1256
|
+
antigravity: {
|
|
1257
|
+
account_email: limits.account_email || null,
|
|
1258
|
+
account_plan: limits.account_plan || null,
|
|
1259
|
+
primary_window: limits.primary_window || null,
|
|
1260
|
+
secondary_window: limits.secondary_window || null,
|
|
1261
|
+
tertiary_window: limits.tertiary_window || null,
|
|
1262
|
+
cached_at: new Date(nowMs).toISOString(),
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
try {
|
|
1266
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
1267
|
+
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
|
1268
|
+
fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
1269
|
+
fs.renameSync(tmpPath, cachePath);
|
|
1270
|
+
} catch (_error) {}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function resolveLsofBinary({ commandRunner } = {}) {
|
|
1274
|
+
for (const candidate of ["/usr/sbin/lsof", "/usr/bin/lsof"]) {
|
|
1275
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1276
|
+
}
|
|
1277
|
+
return whichBinary("lsof", { commandRunner });
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function parseListeningPorts(output) {
|
|
1281
|
+
const matches = String(output || "").matchAll(/:(\d+)\s+\(LISTEN\)/g);
|
|
1282
|
+
const ports = new Set();
|
|
1283
|
+
for (const match of matches) {
|
|
1284
|
+
const port = Number(match[1]);
|
|
1285
|
+
if (Number.isFinite(port)) {
|
|
1286
|
+
ports.add(port);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return Array.from(ports).sort((a, b) => a - b);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function listAntigravityPorts(pid, { commandRunner } = {}) {
|
|
1293
|
+
const lsof = resolveLsofBinary({ commandRunner });
|
|
1294
|
+
if (!lsof) {
|
|
1295
|
+
throw new Error("Antigravity port detection needs lsof. Install it, then retry.");
|
|
1296
|
+
}
|
|
1297
|
+
const result = runCommand(
|
|
1298
|
+
commandRunner,
|
|
1299
|
+
lsof,
|
|
1300
|
+
["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)],
|
|
1301
|
+
{ timeout: 4000 },
|
|
1302
|
+
);
|
|
1303
|
+
const ports = parseListeningPorts(result?.stdout);
|
|
1304
|
+
if (!ports.length) {
|
|
1305
|
+
throw new Error("Antigravity is running but not exposing ports yet. Try again in a few seconds.");
|
|
1306
|
+
}
|
|
1307
|
+
return ports;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function antigravityDefaultBody() {
|
|
1311
|
+
return {
|
|
1312
|
+
metadata: {
|
|
1313
|
+
ideName: "antigravity",
|
|
1314
|
+
extensionName: "antigravity",
|
|
1315
|
+
ideVersion: "unknown",
|
|
1316
|
+
locale: "en",
|
|
1317
|
+
},
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function antigravityUnleashBody() {
|
|
1322
|
+
return {
|
|
1323
|
+
context: {
|
|
1324
|
+
properties: {
|
|
1325
|
+
devMode: "false",
|
|
1326
|
+
extensionVersion: "unknown",
|
|
1327
|
+
hasAnthropicModelAccess: "true",
|
|
1328
|
+
ide: "antigravity",
|
|
1329
|
+
ideVersion: "unknown",
|
|
1330
|
+
installationId: "tokentracker",
|
|
1331
|
+
language: "UNSPECIFIED",
|
|
1332
|
+
os: "macos",
|
|
1333
|
+
requestedModelId: "MODEL_UNSPECIFIED",
|
|
1334
|
+
},
|
|
1335
|
+
},
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function requestLocalJson({
|
|
1340
|
+
scheme,
|
|
1341
|
+
port,
|
|
1342
|
+
path,
|
|
1343
|
+
body,
|
|
1344
|
+
csrfToken,
|
|
1345
|
+
timeoutMs = 8000,
|
|
1346
|
+
requestFn,
|
|
1347
|
+
}) {
|
|
1348
|
+
if (typeof requestFn === "function") {
|
|
1349
|
+
return requestFn({ scheme, port, path, body, csrfToken, timeoutMs });
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const client = scheme === "https" ? https : http;
|
|
1353
|
+
return new Promise((resolve, reject) => {
|
|
1354
|
+
const rawBody = JSON.stringify(body);
|
|
1355
|
+
const req = client.request(
|
|
1356
|
+
{
|
|
1357
|
+
hostname: "127.0.0.1",
|
|
1358
|
+
port,
|
|
1359
|
+
path,
|
|
1360
|
+
method: "POST",
|
|
1361
|
+
rejectUnauthorized: false,
|
|
1362
|
+
timeout: timeoutMs,
|
|
1363
|
+
headers: {
|
|
1364
|
+
"Content-Type": "application/json",
|
|
1365
|
+
"Content-Length": Buffer.byteLength(rawBody),
|
|
1366
|
+
"Connect-Protocol-Version": "1",
|
|
1367
|
+
"X-Codeium-Csrf-Token": csrfToken,
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1370
|
+
(res) => {
|
|
1371
|
+
let data = "";
|
|
1372
|
+
res.on("data", (chunk) => {
|
|
1373
|
+
data += chunk;
|
|
1374
|
+
});
|
|
1375
|
+
res.on("end", () => {
|
|
1376
|
+
if (res.statusCode !== 200) {
|
|
1377
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
try {
|
|
1381
|
+
resolve(JSON.parse(data));
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
reject(new Error(`Invalid JSON response: ${error.message}`));
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
},
|
|
1387
|
+
);
|
|
1388
|
+
req.on("error", reject);
|
|
1389
|
+
req.on("timeout", () => {
|
|
1390
|
+
req.destroy(new Error("timeout"));
|
|
1391
|
+
});
|
|
1392
|
+
req.write(rawBody);
|
|
1393
|
+
req.end();
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function antigravityCodeIsOk(code) {
|
|
1398
|
+
if (code === null || code === undefined) return true;
|
|
1399
|
+
if (typeof code === "number") return code === 0;
|
|
1400
|
+
if (typeof code === "string") {
|
|
1401
|
+
const lower = code.toLowerCase();
|
|
1402
|
+
return lower === "ok" || lower === "success" || lower === "0";
|
|
1403
|
+
}
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function parseAntigravityDate(value) {
|
|
1408
|
+
if (typeof value === "string" && value) {
|
|
1409
|
+
const numeric = Number(value);
|
|
1410
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
1411
|
+
return new Date(numeric * 1000).toISOString();
|
|
1412
|
+
}
|
|
1413
|
+
if (Number.isFinite(numeric)) return null;
|
|
1414
|
+
const iso = Date.parse(value);
|
|
1415
|
+
if (Number.isFinite(iso) && iso > 0) return new Date(iso).toISOString();
|
|
1416
|
+
}
|
|
1417
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
1418
|
+
return new Date(value * 1000).toISOString();
|
|
1419
|
+
}
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function parseAntigravityModelConfigs(configs) {
|
|
1424
|
+
if (!Array.isArray(configs)) return [];
|
|
1425
|
+
return configs
|
|
1426
|
+
.map((config) => {
|
|
1427
|
+
const quota = config?.quotaInfo || null;
|
|
1428
|
+
if (!quota) return null;
|
|
1429
|
+
return {
|
|
1430
|
+
label: typeof config?.label === "string" ? config.label : "",
|
|
1431
|
+
model_id: typeof config?.modelOrAlias?.model === "string" ? config.modelOrAlias.model : "",
|
|
1432
|
+
remaining_fraction:
|
|
1433
|
+
typeof quota?.remainingFraction === "number" && Number.isFinite(quota.remainingFraction)
|
|
1434
|
+
? quota.remainingFraction
|
|
1435
|
+
: null,
|
|
1436
|
+
reset_at: parseAntigravityDate(quota?.resetTime),
|
|
1437
|
+
};
|
|
1438
|
+
})
|
|
1439
|
+
.filter(Boolean);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function antigravityFamily(model) {
|
|
1443
|
+
const text = `${model?.label || ""} ${model?.model_id || ""}`.toLowerCase();
|
|
1444
|
+
if (text.includes("claude")) return "claude";
|
|
1445
|
+
if (text.includes("gemini") && text.includes("pro")) return "gemini_pro";
|
|
1446
|
+
if (text.includes("gemini") && text.includes("flash")) return "gemini_flash";
|
|
1447
|
+
return "unknown";
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function antigravityPriority(model) {
|
|
1451
|
+
const text = `${model?.label || ""} ${model?.model_id || ""}`.toLowerCase();
|
|
1452
|
+
if (text.includes("lite") || text.includes("autocomplete") || text.includes("tab_")) return null;
|
|
1453
|
+
if (antigravityFamily(model) === "gemini_pro") {
|
|
1454
|
+
return text.includes("pro-low") || (text.includes("pro") && text.includes("low")) ? 0 : 1;
|
|
1455
|
+
}
|
|
1456
|
+
return 0;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function chooseAntigravityModel(models, family) {
|
|
1460
|
+
const candidates = models
|
|
1461
|
+
.filter((model) => antigravityFamily(model) === family)
|
|
1462
|
+
.map((model) => ({
|
|
1463
|
+
model,
|
|
1464
|
+
priority: antigravityPriority(model),
|
|
1465
|
+
remaining:
|
|
1466
|
+
typeof model.remaining_fraction === "number"
|
|
1467
|
+
? model.remaining_fraction
|
|
1468
|
+
: Number.POSITIVE_INFINITY,
|
|
1469
|
+
}))
|
|
1470
|
+
.filter((entry) => entry.priority !== null);
|
|
1471
|
+
|
|
1472
|
+
if (!candidates.length) return null;
|
|
1473
|
+
candidates.sort((a, b) => {
|
|
1474
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
1475
|
+
return a.remaining - b.remaining;
|
|
1476
|
+
});
|
|
1477
|
+
return candidates[0].model;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function makeAntigravityWindow(model) {
|
|
1481
|
+
if (!model) return null;
|
|
1482
|
+
const remaining = typeof model.remaining_fraction === "number" ? model.remaining_fraction * 100 : 0;
|
|
1483
|
+
return buildWindow({
|
|
1484
|
+
usedPercent: 100 - remaining,
|
|
1485
|
+
resetAt: model.reset_at,
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function normalizeAntigravityResponse(body, { fallbackToConfigs = false } = {}) {
|
|
1490
|
+
if (!antigravityCodeIsOk(body?.code)) {
|
|
1491
|
+
throw new Error(`Antigravity API error: ${body?.code}`);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const userStatus = body?.userStatus || null;
|
|
1495
|
+
const configs = fallbackToConfigs
|
|
1496
|
+
? body?.clientModelConfigs
|
|
1497
|
+
: userStatus?.cascadeModelConfigData?.clientModelConfigs;
|
|
1498
|
+
const models = parseAntigravityModelConfigs(configs);
|
|
1499
|
+
if (!models.length) {
|
|
1500
|
+
throw new Error("Could not parse Antigravity quota: no quota models available.");
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const primary = chooseAntigravityModel(models, "claude");
|
|
1504
|
+
const secondary = chooseAntigravityModel(models, "gemini_pro");
|
|
1505
|
+
const tertiary = chooseAntigravityModel(models, "gemini_flash");
|
|
1506
|
+
const fallback = !primary && !secondary && !tertiary
|
|
1507
|
+
? [...models].sort((a, b) => {
|
|
1508
|
+
const aRemaining = typeof a.remaining_fraction === "number" ? a.remaining_fraction : Number.POSITIVE_INFINITY;
|
|
1509
|
+
const bRemaining = typeof b.remaining_fraction === "number" ? b.remaining_fraction : Number.POSITIVE_INFINITY;
|
|
1510
|
+
return aRemaining - bRemaining;
|
|
1511
|
+
})[0]
|
|
1512
|
+
: null;
|
|
1513
|
+
|
|
1514
|
+
return {
|
|
1515
|
+
account_email: typeof userStatus?.email === "string" ? userStatus.email : null,
|
|
1516
|
+
account_plan:
|
|
1517
|
+
userStatus?.planStatus?.planInfo?.planDisplayName
|
|
1518
|
+
|| userStatus?.planStatus?.planInfo?.displayName
|
|
1519
|
+
|| userStatus?.planStatus?.planInfo?.productName
|
|
1520
|
+
|| userStatus?.planStatus?.planInfo?.planName
|
|
1521
|
+
|| userStatus?.planStatus?.planInfo?.planShortName
|
|
1522
|
+
|| null,
|
|
1523
|
+
primary_window: makeAntigravityWindow(primary || fallback),
|
|
1524
|
+
secondary_window: makeAntigravityWindow(secondary),
|
|
1525
|
+
tertiary_window: makeAntigravityWindow(tertiary),
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
async function probeAntigravityPort(port, csrfToken, { timeoutMs, requestFn } = {}) {
|
|
1530
|
+
try {
|
|
1531
|
+
await requestLocalJson({
|
|
1532
|
+
scheme: "https",
|
|
1533
|
+
port,
|
|
1534
|
+
path: "/exa.language_server_pb.LanguageServerService/GetUnleashData",
|
|
1535
|
+
body: antigravityUnleashBody(),
|
|
1536
|
+
csrfToken,
|
|
1537
|
+
timeoutMs,
|
|
1538
|
+
requestFn,
|
|
1539
|
+
});
|
|
1540
|
+
return true;
|
|
1541
|
+
} catch (_error) {
|
|
1542
|
+
return false;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
async function fetchAntigravityLimits({ home, commandRunner, requestFn, timeoutMs = 8000, nowMs = Date.now() } = {}) {
|
|
1547
|
+
const processInfo = detectAntigravityProcess({ commandRunner });
|
|
1548
|
+
if (!processInfo.configured) {
|
|
1549
|
+
return readAntigravityLimitsCache({ home, nowMs }) || { configured: false };
|
|
1550
|
+
}
|
|
1551
|
+
if (processInfo.error) {
|
|
1552
|
+
return { configured: true, error: processInfo.error };
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const finalize = (payload, normalizeOptions) => {
|
|
1556
|
+
const result = {
|
|
1557
|
+
configured: true,
|
|
1558
|
+
error: null,
|
|
1559
|
+
...normalizeAntigravityResponse(payload, normalizeOptions),
|
|
1560
|
+
};
|
|
1561
|
+
writeAntigravityLimitsCache(result, { home, nowMs });
|
|
1562
|
+
return result;
|
|
1563
|
+
};
|
|
1564
|
+
|
|
1565
|
+
try {
|
|
1566
|
+
const ports = listAntigravityPorts(processInfo.pid, { commandRunner });
|
|
1567
|
+
let workingPort = null;
|
|
1568
|
+
for (const port of ports) {
|
|
1569
|
+
if (await probeAntigravityPort(port, processInfo.csrfToken, { timeoutMs, requestFn })) {
|
|
1570
|
+
workingPort = port;
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (!workingPort) {
|
|
1575
|
+
throw new Error("Antigravity port detection failed: no working API port found");
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
try {
|
|
1579
|
+
const userStatus = await requestLocalJson({
|
|
1580
|
+
scheme: "https",
|
|
1581
|
+
port: workingPort,
|
|
1582
|
+
path: "/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
|
1583
|
+
body: antigravityDefaultBody(),
|
|
1584
|
+
csrfToken: processInfo.csrfToken,
|
|
1585
|
+
timeoutMs,
|
|
1586
|
+
requestFn,
|
|
1587
|
+
});
|
|
1588
|
+
return finalize(userStatus);
|
|
1589
|
+
} catch (primaryError) {
|
|
1590
|
+
const fallbackPort =
|
|
1591
|
+
Number.isFinite(processInfo.extensionPort) && processInfo.extensionPort > 0
|
|
1592
|
+
? processInfo.extensionPort
|
|
1593
|
+
: workingPort;
|
|
1594
|
+
const modelConfigs = await requestLocalJson({
|
|
1595
|
+
scheme: fallbackPort === workingPort ? "https" : "http",
|
|
1596
|
+
port: fallbackPort,
|
|
1597
|
+
path: "/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs",
|
|
1598
|
+
body: antigravityDefaultBody(),
|
|
1599
|
+
csrfToken: processInfo.csrfToken,
|
|
1600
|
+
timeoutMs,
|
|
1601
|
+
requestFn,
|
|
1602
|
+
});
|
|
1603
|
+
return finalize(modelConfigs, { fallbackToConfigs: true });
|
|
1604
|
+
}
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
const message = error?.message === "timeout"
|
|
1607
|
+
? "Antigravity quota request timed out."
|
|
1608
|
+
: error?.message || "Unknown error";
|
|
1609
|
+
return {
|
|
1610
|
+
configured: true,
|
|
1611
|
+
error: message,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function toTitleCase(s) {
|
|
1617
|
+
return s.split(/\s+/).filter(Boolean)
|
|
1618
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Normalize a plan tier name: free/empty/placeholder -> null; otherwise strip the
|
|
1622
|
+
// leading brand word and Title Case the rest.
|
|
1623
|
+
function normalizePlanLabel(raw, brand) {
|
|
1624
|
+
if (raw == null) return null;
|
|
1625
|
+
let s = String(raw).trim();
|
|
1626
|
+
if (!s) return null;
|
|
1627
|
+
const lower = s.toLowerCase();
|
|
1628
|
+
if (["free", "none", "unknown"].includes(lower)) return null;
|
|
1629
|
+
if (brand && lower === brand.toLowerCase()) return null; // e.g. Kiro defaults plan_name to "Kiro" when parse fails
|
|
1630
|
+
if (brand) {
|
|
1631
|
+
s = s.replace(new RegExp("^" + brand + "\\s+", "i"), "").trim();
|
|
1632
|
+
if (!s) return null;
|
|
1633
|
+
}
|
|
1634
|
+
return toTitleCase(s);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Attach plan_label only to a configured, error-free provider object (immutable).
|
|
1638
|
+
function withPlanLabel(obj, raw, brand) {
|
|
1639
|
+
if (!obj || !obj.configured || obj.error) return obj;
|
|
1640
|
+
return { ...obj, plan_label: normalizePlanLabel(raw, brand) };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
async function getUsageLimits({
|
|
1644
|
+
home,
|
|
1645
|
+
env,
|
|
1646
|
+
platform,
|
|
1647
|
+
securityRunner,
|
|
1648
|
+
fetchImpl = fetch,
|
|
1649
|
+
commandRunner,
|
|
1650
|
+
requestFn,
|
|
1651
|
+
now = new Date(),
|
|
1652
|
+
providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS,
|
|
1653
|
+
} = {}) {
|
|
1654
|
+
const nowMs = Date.now();
|
|
1655
|
+
if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
|
|
1656
|
+
return cache.data;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const [claudeToken, claudeSubscription, codexAuth] = await Promise.all([
|
|
1660
|
+
Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner, home })),
|
|
1661
|
+
Promise.resolve().then(() => detectClaudeCodeSubscriptionDetails({ platform, securityRunner, home })),
|
|
1662
|
+
readCodexAuthBundle({ home, env }),
|
|
1663
|
+
]);
|
|
1664
|
+
const claudePlanType = claudeSubscription?.planType || null;
|
|
1665
|
+
|
|
1666
|
+
// Proactively refresh Codex tokens that are >8 days stale, mirroring CodexBar's
|
|
1667
|
+
// CodexTokenRefresher.swift. Without this, users who logged in once and didn't run
|
|
1668
|
+
// `codex` for >a week get wham 401 → "Fetch failed" (issue #52). Best-effort: any
|
|
1669
|
+
// refresh failure falls through to using the existing (possibly stale) token, then the
|
|
1670
|
+
// 4xx graceful path in fetchCodexUsageLimits surfaces a neutral state instead of red.
|
|
1671
|
+
let refreshError = null;
|
|
1672
|
+
let codexAuthRefreshed = codexAuth;
|
|
1673
|
+
if (codexAuth && isTokenStale(codexAuth.lastRefresh) && codexAuth.refreshToken) {
|
|
1674
|
+
try {
|
|
1675
|
+
const newTokens = await refreshCodexTokens({
|
|
1676
|
+
refreshToken: codexAuth.refreshToken,
|
|
1677
|
+
fetchImpl,
|
|
1678
|
+
});
|
|
1679
|
+
const updatedAuth = await persistRefreshedAuth(
|
|
1680
|
+
codexAuth.authPath,
|
|
1681
|
+
codexAuth.authJson,
|
|
1682
|
+
newTokens,
|
|
1683
|
+
);
|
|
1684
|
+
codexAuthRefreshed = {
|
|
1685
|
+
...codexAuth,
|
|
1686
|
+
accessToken: newTokens.access_token,
|
|
1687
|
+
refreshToken: newTokens.refresh_token,
|
|
1688
|
+
lastRefresh: updatedAuth.last_refresh,
|
|
1689
|
+
authJson: updatedAuth,
|
|
1690
|
+
};
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
refreshError = err;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const codexToken = codexAuthRefreshed?.accessToken || null;
|
|
1697
|
+
const codexAccountId = codexAuthRefreshed?.accountId || null;
|
|
1698
|
+
const codexPlanType = codexAuthRefreshed?.planType || null;
|
|
1699
|
+
|
|
1700
|
+
const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
|
|
1701
|
+
const [claudeResult, codexResult, cursor, kimi, gemini, kiro, antigravity, copilot] = await Promise.all([
|
|
1702
|
+
claudeToken
|
|
1703
|
+
? withProviderTimeout(fetchClaudeUsageLimits(claudeToken, { fetchImpl: providerFetch, maxAttempts: 1 }), "Claude", providerTimeoutMs).then(
|
|
1704
|
+
(value) => ({ status: "fulfilled", value }),
|
|
1705
|
+
(reason) => ({ status: "rejected", reason }),
|
|
1706
|
+
)
|
|
1707
|
+
: Promise.resolve(null),
|
|
1708
|
+
codexToken
|
|
1709
|
+
? withProviderTimeout(
|
|
1710
|
+
fetchCodexUsageLimits(codexToken, { fetchImpl: providerFetch, accountId: codexAccountId }),
|
|
1711
|
+
"Codex",
|
|
1712
|
+
providerTimeoutMs,
|
|
1713
|
+
).then(
|
|
1714
|
+
(value) => ({ status: "fulfilled", value }),
|
|
1715
|
+
(reason) => ({ status: "rejected", reason }),
|
|
1716
|
+
)
|
|
1717
|
+
: Promise.resolve(null),
|
|
1718
|
+
withProviderTimeout(fetchCursorLimits({ home, fetchImpl: providerFetch }), "Cursor", providerTimeoutMs)
|
|
1719
|
+
.catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
|
|
1720
|
+
withProviderTimeout(fetchKimiLimits({ home, env, fetchImpl: providerFetch }), "Kimi", providerTimeoutMs)
|
|
1721
|
+
.catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
|
|
1722
|
+
withProviderTimeout(fetchGeminiLimits({ home, env, fetchImpl: providerFetch, commandRunner }), "Gemini", providerTimeoutMs)
|
|
1723
|
+
.catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
|
|
1724
|
+
Promise.resolve().then(() => fetchKiroLimits({ commandRunner, now })),
|
|
1725
|
+
fetchAntigravityLimits({ home, commandRunner, requestFn, nowMs }),
|
|
1726
|
+
withProviderTimeout(fetchCopilotLimits({ home, env, fetchImpl: providerFetch }), "GitHub Copilot", providerTimeoutMs)
|
|
1727
|
+
.catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
|
|
1728
|
+
]);
|
|
1729
|
+
|
|
1730
|
+
let claude;
|
|
1731
|
+
if (!claudeToken) {
|
|
1732
|
+
claude = { configured: false };
|
|
1733
|
+
} else if (!claudeResult || claudeResult.status === "rejected") {
|
|
1734
|
+
claude = { configured: true, error: claudeResult?.reason?.message || "Unknown error" };
|
|
1735
|
+
} else {
|
|
1736
|
+
claude = {
|
|
1737
|
+
configured: true,
|
|
1738
|
+
error: null,
|
|
1739
|
+
five_hour: claudeResult.value.five_hour,
|
|
1740
|
+
seven_day: claudeResult.value.seven_day,
|
|
1741
|
+
seven_day_opus: claudeResult.value.seven_day_opus,
|
|
1742
|
+
extra_usage: claudeResult.value.extra_usage,
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
let codex;
|
|
1747
|
+
if (!codexToken) {
|
|
1748
|
+
codex = { configured: false };
|
|
1749
|
+
} else if (refreshError && refreshError.code === "REFRESH_TOKEN_EXPIRED") {
|
|
1750
|
+
// Refresh token is dead — the user must re-run `codex` to log in again. Surface a
|
|
1751
|
+
// specific, actionable message rather than the generic "Fetch failed".
|
|
1752
|
+
codex = {
|
|
1753
|
+
configured: true,
|
|
1754
|
+
error: refreshError.message,
|
|
1755
|
+
auth_action_required: "reauth",
|
|
1756
|
+
};
|
|
1757
|
+
} else if (!codexResult || codexResult.status === "rejected") {
|
|
1758
|
+
codex = { configured: true, error: codexResult?.reason?.message || "Unknown error" };
|
|
1759
|
+
} else {
|
|
1760
|
+
codex = {
|
|
1761
|
+
configured: true,
|
|
1762
|
+
error: null,
|
|
1763
|
+
plan_type: codexPlanType || null,
|
|
1764
|
+
primary_window: codexResult.value.primary_window,
|
|
1765
|
+
secondary_window: codexResult.value.secondary_window,
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const data = {
|
|
1770
|
+
fetched_at: new Date(nowMs).toISOString(),
|
|
1771
|
+
claude: withPlanLabel(claude, claudePlanType, "Claude"),
|
|
1772
|
+
codex: withPlanLabel(codex, codex.plan_type, "Codex"),
|
|
1773
|
+
cursor: withPlanLabel(cursor, cursor.membership_type, "Cursor"),
|
|
1774
|
+
kimi: withPlanLabel(kimi, kimi.subscription_type || kimi.membership_level, "Kimi"),
|
|
1775
|
+
gemini: withPlanLabel(gemini, gemini.account_plan, "Gemini"),
|
|
1776
|
+
kiro: withPlanLabel(kiro, kiro.plan_name, "Kiro"),
|
|
1777
|
+
antigravity: withPlanLabel(antigravity, antigravity.account_plan, "Antigravity"),
|
|
1778
|
+
copilot: withPlanLabel(copilot, copilot.plan_name, "Copilot"),
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
cache = { data, fetchedAt: nowMs };
|
|
1782
|
+
return data;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function resetUsageLimitsCache() {
|
|
1786
|
+
cache = { data: null, fetchedAt: 0 };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
module.exports = {
|
|
1790
|
+
getUsageLimits,
|
|
1791
|
+
normalizePlanLabel,
|
|
1792
|
+
resetUsageLimitsCache,
|
|
1793
|
+
extractGeminiOauthClientCredentials,
|
|
1794
|
+
loadKimiCredentials,
|
|
1795
|
+
normalizeCursorUsageSummary,
|
|
1796
|
+
normalizeGeminiQuotaResponse,
|
|
1797
|
+
normalizeKimiUsageResponse,
|
|
1798
|
+
parseKiroUsageOutput,
|
|
1799
|
+
normalizeAntigravityResponse,
|
|
1800
|
+
parseListeningPorts,
|
|
1801
|
+
detectAntigravityProcess,
|
|
1802
|
+
fetchAntigravityLimits,
|
|
1803
|
+
fetchCopilotLimits,
|
|
1804
|
+
readCopilotOauthToken,
|
|
1805
|
+
describeCopilotOtelStatus,
|
|
1806
|
+
};
|