ag-quota 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts ADDED
@@ -0,0 +1,193 @@
1
+ /*
2
+ * ISC License
3
+ * Copyright (c) 2026 Philipp
4
+ */
5
+
6
+ import { readFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ /**
11
+ * Quota data source
12
+ * - "cloud": Fetch from Cloud Code API (requires opencode-antigravity-auth)
13
+ * - "local": Fetch from local language server process
14
+ * - "auto": Try cloud first, fallback to local
15
+ */
16
+ export type QuotaSource = "cloud" | "local" | "auto";
17
+
18
+ export interface QuotaIndicator {
19
+ threshold: number;
20
+ symbol: string;
21
+ }
22
+
23
+ /**
24
+ * Configuration options for the quota plugin
25
+ */
26
+ export interface QuotaConfig {
27
+ /**
28
+ * Where to fetch quota data from.
29
+ * - "cloud": Use Cloud Code API (requires opencode-antigravity-auth)
30
+ * - "local": Use local language server process
31
+ * - "auto": Try cloud first, fallback to local (default)
32
+ * @default "auto"
33
+ */
34
+ quotaSource?: QuotaSource;
35
+
36
+ /**
37
+ * Format string for quota display.
38
+ * Available placeholders:
39
+ * - {category} - Category name (Flash, Pro, Claude/GPT)
40
+ * - {percent} - Quota percentage (e.g., "85.5")
41
+ * - {resetIn} - Relative time until reset (e.g., "2h 30m")
42
+ * - {resetAt} - Absolute reset time (e.g., "10:30 PM")
43
+ * - {model} - Current model ID
44
+ *
45
+ * For all quotas mode, the format is applied per category and joined with separator.
46
+ * @default "{category}: {percent}% ({resetIn})"
47
+ */
48
+ format?: string;
49
+
50
+ /**
51
+ * Separator between categories when showing all quotas
52
+ * @default " | "
53
+ */
54
+ separator?: string;
55
+
56
+ /**
57
+ * Show all quota categories or only the current model's quota
58
+ * @default "all"
59
+ */
60
+ displayMode?: "all" | "current";
61
+
62
+ /**
63
+ * Always append quota info, even when unavailable
64
+ * @default true
65
+ */
66
+ alwaysAppend?: boolean;
67
+
68
+ /**
69
+ * The marker string used to separate quota info from the message
70
+ * @default "> AG Quota:"
71
+ */
72
+ quotaMarker?: string;
73
+
74
+ /**
75
+ * Polling interval in milliseconds
76
+ * @default 30000 (30 seconds)
77
+ */
78
+ pollingInterval?: number;
79
+
80
+ /**
81
+ * Array of quota usage percentages (remaining) that trigger alerts
82
+ * @default [0.5, 0.1, 0.05] (50%, 10%, 5%)
83
+ */
84
+ alertThresholds?: number[];
85
+
86
+ /**
87
+ * Visual indicators appended to quota percentages when remaining fraction is low.
88
+ * The most severe matching indicator is chosen.
89
+ *
90
+ * Example: with indicators [{threshold: 0.1, symbol: "⚠️"}, {threshold: 0.05, symbol: "⛔"}]
91
+ * a remainingFraction of 0.04 will show "⛔".
92
+ *
93
+ * @default [{threshold: 0.1, symbol: "⚠️"}, {threshold: 0.05, symbol: "⛔"}]
94
+ */
95
+ indicators?: QuotaIndicator[];
96
+ }
97
+
98
+ const DEFAULT_CONFIG: Required<QuotaConfig> = {
99
+ quotaSource: "auto",
100
+ format: "{category}: {percent}% ({resetIn})",
101
+ separator: " | ",
102
+ displayMode: "all",
103
+ alwaysAppend: true,
104
+ quotaMarker: "> AG Quota:",
105
+ pollingInterval: 30000,
106
+ alertThresholds: [0.5, 0.1, 0.05],
107
+ indicators: [
108
+ { threshold: 0.2, symbol: "⚠️" },
109
+ { threshold: 0.05, symbol: "🛑" },
110
+ ],
111
+ };
112
+
113
+ /**
114
+ * Load configuration from file system.
115
+ * Searches in order:
116
+ * 1. .opencode/ag-quota.json (project-local)
117
+ * 2. ~/.config/opencode/ag-quota.json (user global)
118
+ *
119
+ * @param projectDir - The project directory to search from
120
+ * @returns Merged configuration with defaults
121
+ */
122
+ export async function loadConfig(projectDir?: string): Promise<Required<QuotaConfig>> {
123
+ const paths: string[] = [];
124
+
125
+ // Project-local config
126
+ if (projectDir) {
127
+ paths.push(join(projectDir, ".opencode", "ag-quota.json"));
128
+ } else {
129
+ paths.push(join(process.cwd(), ".opencode", "ag-quota.json"));
130
+ }
131
+
132
+ // User global config
133
+ paths.push(join(homedir(), ".config", "opencode", "ag-quota.json"));
134
+
135
+ for (const configPath of paths) {
136
+ try {
137
+ const content = await readFile(configPath, "utf-8");
138
+ const userConfig = JSON.parse(content) as QuotaConfig;
139
+ return { ...DEFAULT_CONFIG, ...userConfig };
140
+ } catch {
141
+ // File doesn't exist or is invalid, try next
142
+ continue;
143
+ }
144
+ }
145
+
146
+ return DEFAULT_CONFIG;
147
+ }
148
+
149
+ /**
150
+ * Format a quota entry using the format string.
151
+ * Placeholders are only replaced if they exist in the format string.
152
+ */
153
+ export function formatQuotaEntry(
154
+ format: string,
155
+ data: {
156
+ category: string;
157
+ percent: string;
158
+ resetIn: string | null; // Relative: "2h 30m"
159
+ resetAt: string | null; // Absolute: "10:30 PM"
160
+ model: string;
161
+ },
162
+ ): string {
163
+ let result = format
164
+ .replace("{category}", data.category)
165
+ .replace("{percent}", data.percent)
166
+ .replace("{model}", data.model);
167
+
168
+ // Handle resetIn (relative time) - e.g., "2h 30m"
169
+ if (format.includes("{resetIn}")) {
170
+ if (data.resetIn) {
171
+ result = result.replace("{resetIn}", data.resetIn);
172
+ } else {
173
+ result = result
174
+ .replace(/\s*\(\{resetIn}\)/, "")
175
+ .replace(/\s*\{resetIn}/, "");
176
+ }
177
+ }
178
+
179
+ // Handle resetAt (absolute time) - e.g., "10:30 PM"
180
+ if (format.includes("{resetAt}")) {
181
+ if (data.resetAt) {
182
+ result = result.replace("{resetAt}", data.resetAt);
183
+ } else {
184
+ result = result
185
+ .replace(/\s*\(\{resetAt}\)/, "")
186
+ .replace(/\s*\{resetAt}/, "");
187
+ }
188
+ }
189
+
190
+ return result;
191
+ }
192
+
193
+ export { DEFAULT_CONFIG };
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { fetchAntigravityStatus, type ShellRunner } from "./index.js";
3
+
4
+ type HttpRequestModule = typeof import("node:http");
5
+ type HttpsRequestModule = typeof import("node:https");
6
+
7
+ type MockedHttpModule = HttpRequestModule & { request: ReturnType<typeof vi.fn> };
8
+ type MockedHttpsModule = HttpsRequestModule & { request: ReturnType<typeof vi.fn> };
9
+
10
+ vi.mock("node:http", () => ({ request: vi.fn() }));
11
+ vi.mock("node:https", () => ({ request: vi.fn() }));
12
+
13
+ describe("fetchAntigravityStatus", () => {
14
+ beforeEach(() => {
15
+ vi.resetAllMocks();
16
+ });
17
+
18
+ it("should fetch status correctly when discovery succeeds", async () => {
19
+ const http = (await import("node:http")) as unknown as MockedHttpModule;
20
+ const https = (await import("node:https")) as unknown as MockedHttpsModule;
21
+
22
+ const mockCsrf = "test-csrf-123";
23
+ const mockPort = 12345;
24
+
25
+ const shellRunner: ShellRunner = vi.fn().mockImplementation(async (cmd: string) => {
26
+ if (cmd.includes("ps aux")) {
27
+ return `user 123 0.0 0.1 1234 5678 ? Ss 12:00 0:00 /path/to/language_server --csrf_token=${mockCsrf} --extension_server_port=${mockPort}`;
28
+ }
29
+ if (cmd.includes("ss -tlnp")) {
30
+ return `LISTEN 0 128 127.0.0.1:${mockPort} 0.0.0.0:* users:(("language_server",pid=123,fd=4))`;
31
+ }
32
+ return "";
33
+ });
34
+
35
+ const mockResponse = {
36
+ userStatus: {
37
+ cascadeModelConfigData: {
38
+ clientModelConfigs: [
39
+ { modelName: "test-model", quotaInfo: { remainingFraction: 0.5 } },
40
+ ],
41
+ },
42
+ },
43
+ };
44
+
45
+ // Mock https.request to fail (trigger fallback to http)
46
+ https.request.mockImplementationOnce((_options: unknown, _cb: unknown) => {
47
+ let errorHandler: (() => void) | null = null;
48
+ const req = {
49
+ on: vi.fn().mockImplementation((event: string, handler: () => void) => {
50
+ if (event === "error") {
51
+ errorHandler = handler;
52
+ }
53
+ return req;
54
+ }),
55
+ write: vi.fn(),
56
+ end: vi.fn().mockImplementation(() => {
57
+ // Trigger error after end() to simulate connection failure
58
+ if (errorHandler) errorHandler();
59
+ }),
60
+ };
61
+ return req as unknown as ReturnType<HttpsRequestModule["request"]>;
62
+ });
63
+
64
+ // Mock http.request to succeed
65
+ http.request.mockImplementationOnce((_options: unknown, cb: unknown) => {
66
+ const mockRes = {
67
+ on: vi.fn().mockImplementation((event: string, handler: (arg?: unknown) => void) => {
68
+ if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse)));
69
+ if (event === "end") handler();
70
+ return mockRes;
71
+ }),
72
+ };
73
+ if (typeof cb === "function") {
74
+ cb(mockRes);
75
+ }
76
+ const req = {
77
+ on: vi.fn().mockReturnThis(),
78
+ write: vi.fn().mockReturnThis(),
79
+ end: vi.fn().mockReturnThis(),
80
+ };
81
+ return req as unknown as ReturnType<HttpRequestModule["request"]>;
82
+ });
83
+
84
+ const result = await fetchAntigravityStatus(shellRunner);
85
+
86
+ expect(result.userStatus).toEqual(mockResponse.userStatus);
87
+ expect(shellRunner).toHaveBeenCalledWith(expect.stringContaining("ps aux"));
88
+ });
89
+
90
+ it("should throw error when CSRF token is not found", async () => {
91
+ const shellRunner: ShellRunner = vi.fn().mockResolvedValue("");
92
+ await expect(fetchAntigravityStatus(shellRunner)).rejects.toThrow(
93
+ "Antigravity CSRF token not found",
94
+ );
95
+ });
96
+ });
package/src/index.ts ADDED
@@ -0,0 +1,379 @@
1
+ /*
2
+ ISC License
3
+
4
+ Copyright (c) 2025, Cristian Militaru
5
+
6
+ Permission to use, copy, modify, and/or distribute this software for any
7
+ purpose with or without fee is hereby granted, provided that the above
8
+ copyright notice and this permission notice appear in all copies.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17
+ */
18
+
19
+ import * as http from "node:http";
20
+ import * as https from "node:https";
21
+
22
+ export const API_ENDPOINTS = {
23
+ GET_USER_STATUS:
24
+ "/exa.language_server_pb.LanguageServerService/GetUserStatus",
25
+ };
26
+
27
+ export interface QuotaInfo {
28
+ remainingFraction: number;
29
+ resetTime?: string;
30
+ }
31
+
32
+ export interface ModelConfig {
33
+ modelName: string;
34
+ label?: string;
35
+ quotaInfo?: QuotaInfo;
36
+ }
37
+
38
+ export interface UserStatus {
39
+ cascadeModelConfigData?: {
40
+ clientModelConfigs?: ModelConfig[];
41
+ };
42
+ }
43
+
44
+ export interface UserStatusResponse {
45
+ userStatus: UserStatus;
46
+ timestamp: number;
47
+ }
48
+
49
+ export type ShellRunner = (cmd: string) => Promise<string>;
50
+
51
+ function makeRequest<T>(
52
+ port: number,
53
+ csrfToken: string,
54
+ path: string,
55
+ body: object,
56
+ ): Promise<T> {
57
+ return new Promise((resolve, reject) => {
58
+ const payload = JSON.stringify(body);
59
+ const options = {
60
+ hostname: "127.0.0.1",
61
+ port,
62
+ path,
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ "Content-Length": Buffer.byteLength(payload),
67
+ "X-Codeium-Csrf-Token": csrfToken,
68
+ "Connect-Protocol-Version": "1",
69
+ },
70
+ timeout: 2000,
71
+ };
72
+
73
+ const handleResponse = (response: http.IncomingMessage) => {
74
+ let data = "";
75
+ response.on("data", (chunk: Buffer) => {
76
+ data += chunk.toString();
77
+ });
78
+ response.on("end", () => {
79
+ try {
80
+ resolve(JSON.parse(data) as T);
81
+ } catch {
82
+ reject(new Error("JSON parse error"));
83
+ }
84
+ });
85
+ };
86
+
87
+ const req = https.request(
88
+ { ...options, rejectUnauthorized: false },
89
+ handleResponse,
90
+ );
91
+ req.on("error", () => {
92
+ const reqHttp = http.request(options, handleResponse);
93
+ reqHttp.on("error", (err) => reject(err));
94
+ reqHttp.write(payload);
95
+ reqHttp.end();
96
+ });
97
+ req.write(payload);
98
+ req.end();
99
+ });
100
+ }
101
+
102
+ export async function fetchAntigravityStatus(
103
+ runShell: ShellRunner,
104
+ ): Promise<UserStatusResponse> {
105
+ let procOutput = "";
106
+ try {
107
+ procOutput = await runShell(
108
+ 'ps aux | grep -E "csrf_token|language_server" | grep -v grep',
109
+ );
110
+ } catch {
111
+ procOutput = "";
112
+ }
113
+
114
+ const lines = procOutput.split("\n");
115
+ let csrfToken = "";
116
+ let cmdLinePort = 0;
117
+
118
+ for (const line of lines) {
119
+ const csrfMatch = line.match(/--csrf_token[=\s]+([\w-]+)/i);
120
+ if (csrfMatch?.[1]) csrfToken = csrfMatch[1];
121
+ const portMatch = line.match(/--extension_server_port[=\s]+(\d+)/i);
122
+ if (portMatch?.[1]) cmdLinePort = parseInt(portMatch[1], 10);
123
+ if (csrfToken && cmdLinePort) break;
124
+ }
125
+
126
+ if (!csrfToken) {
127
+ throw new Error(
128
+ "Antigravity CSRF token not found. Is the Language Server running?",
129
+ );
130
+ }
131
+
132
+ let netstatOutput = "";
133
+ try {
134
+ netstatOutput = await runShell(
135
+ 'ss -tlnp | grep -E "language_server|opencode|node"',
136
+ );
137
+ } catch {
138
+ netstatOutput = "";
139
+ }
140
+
141
+ const portMatches = netstatOutput.match(/:(\d+)/g);
142
+ let ports = portMatches
143
+ ? portMatches.map((p: string) => parseInt(p.replace(":", ""), 10))
144
+ : [];
145
+
146
+ if (cmdLinePort && !ports.includes(cmdLinePort)) {
147
+ ports.unshift(cmdLinePort);
148
+ }
149
+
150
+ ports = Array.from(new Set(ports));
151
+
152
+ if (ports.length === 0) {
153
+ throw new Error(
154
+ "No listening ports found for Antigravity. Check if the server is active.",
155
+ );
156
+ }
157
+
158
+ let userStatus: UserStatus | null = null;
159
+ let lastError: Error | null = null;
160
+
161
+ for (const p of ports) {
162
+ try {
163
+ const resp = await makeRequest<{ userStatus?: UserStatus }>(
164
+ p,
165
+ csrfToken,
166
+ API_ENDPOINTS.GET_USER_STATUS,
167
+ { metadata: { ideName: "opencode" } },
168
+ );
169
+ if (resp?.userStatus) {
170
+ userStatus = resp.userStatus;
171
+ break;
172
+ }
173
+ } catch (e) {
174
+ lastError = e instanceof Error ? e : new Error(String(e));
175
+ continue;
176
+ }
177
+ }
178
+
179
+ if (!userStatus) {
180
+ throw new Error(
181
+ `Could not communicate with Antigravity API. ${lastError?.message ?? ""}`,
182
+ );
183
+ }
184
+
185
+ return {
186
+ userStatus,
187
+ timestamp: Date.now(),
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Format relative time until target date (e.g., "2h 30m")
193
+ */
194
+ export function formatRelativeTime(targetDate: Date): string {
195
+ const now = new Date();
196
+ const diffMs = targetDate.getTime() - now.getTime();
197
+ if (diffMs <= 0) return "now";
198
+
199
+ const diffMins = Math.floor(diffMs / (1000 * 60));
200
+ const diffHours = Math.floor(diffMins / 60);
201
+ const remainingMins = diffMins % 60;
202
+
203
+ if (diffHours > 0) {
204
+ return `${diffHours}h ${remainingMins}m`;
205
+ }
206
+ return `${diffMins}m`;
207
+ }
208
+
209
+ /**
210
+ * Format absolute time of target date (e.g., "10:30 PM" or "22:30")
211
+ */
212
+ export function formatAbsoluteTime(targetDate: Date): string {
213
+ return targetDate.toLocaleTimeString(undefined, {
214
+ hour: "2-digit",
215
+ minute: "2-digit",
216
+ });
217
+ }
218
+
219
+ // Re-export config utilities
220
+ export {
221
+ loadConfig,
222
+ formatQuotaEntry,
223
+ DEFAULT_CONFIG,
224
+ type QuotaConfig,
225
+ type QuotaSource,
226
+ } from "./config";
227
+
228
+ // Re-export cloud utilities
229
+ export {
230
+ fetchCloudQuota,
231
+ type CloudQuotaResult,
232
+ type CloudAccountInfo,
233
+ } from "./cloud";
234
+
235
+ // ============================================================================
236
+ // Unified Quota Types and Functions
237
+ // ============================================================================
238
+
239
+ /**
240
+ * Unified category quota info (for the three model groups)
241
+ */
242
+ export interface CategoryQuota {
243
+ category: "Flash" | "Pro" | "Claude/GPT";
244
+ remainingFraction: number;
245
+ resetTime: Date | null;
246
+ }
247
+
248
+ /**
249
+ * Unified quota result from either source
250
+ */
251
+ export interface UnifiedQuotaResult {
252
+ source: "cloud" | "local";
253
+ categories: CategoryQuota[];
254
+ models: ModelConfig[];
255
+ timestamp: number;
256
+ }
257
+
258
+ /**
259
+ * Credentials for cloud quota fetching
260
+ */
261
+ export interface CloudAuthCredentials {
262
+ accessToken: string;
263
+ projectId?: string;
264
+ }
265
+
266
+ /**
267
+ * Categorize a model label into one of the three groups.
268
+ */
269
+ export function categorizeModel(label: string): "Flash" | "Pro" | "Claude/GPT" {
270
+ const lowerLabel = label.toLowerCase();
271
+ if (lowerLabel.includes("flash")) {
272
+ return "Flash";
273
+ }
274
+ if (lowerLabel.includes("gemini") || lowerLabel.includes("pro")) {
275
+ return "Pro";
276
+ }
277
+ return "Claude/GPT";
278
+ }
279
+
280
+ /**
281
+ * Group models into the three categories, taking the minimum quota per category.
282
+ */
283
+ export function groupModelsByCategory(models: ModelConfig[]): CategoryQuota[] {
284
+ const categories: Record<
285
+ string,
286
+ { remainingFraction: number; resetTime: Date | null }
287
+ > = {};
288
+
289
+ for (const model of models) {
290
+ const label = model.label || model.modelName || "";
291
+ const category = categorizeModel(label);
292
+ const fraction = model.quotaInfo?.remainingFraction ?? 0;
293
+ const resetTime = model.quotaInfo?.resetTime
294
+ ? new Date(model.quotaInfo.resetTime)
295
+ : null;
296
+
297
+ if (
298
+ !categories[category] ||
299
+ fraction < categories[category].remainingFraction
300
+ ) {
301
+ categories[category] = { remainingFraction: fraction, resetTime };
302
+ }
303
+ }
304
+
305
+ const result: CategoryQuota[] = [];
306
+ for (const cat of ["Flash", "Pro", "Claude/GPT"] as const) {
307
+ if (categories[cat]) {
308
+ result.push({
309
+ category: cat,
310
+ remainingFraction: categories[cat].remainingFraction,
311
+ resetTime: categories[cat].resetTime,
312
+ });
313
+ }
314
+ }
315
+
316
+ return result;
317
+ }
318
+
319
+ /**
320
+ * Fetch quota from either cloud or local source.
321
+ *
322
+ * @param source - "cloud", "local", or "auto" (try cloud first, fallback to local)
323
+ * @param shellRunner - Required for local source
324
+ * @param cloudAuth - Required for cloud source
325
+ * @returns Unified quota result
326
+ */
327
+ export async function fetchQuota(
328
+ source: "cloud" | "local" | "auto",
329
+ shellRunner?: ShellRunner,
330
+ cloudAuth?: CloudAuthCredentials,
331
+ ): Promise<UnifiedQuotaResult> {
332
+ // Import cloud module dynamically to avoid issues if not available
333
+ const { fetchCloudQuota } = await import("./cloud");
334
+
335
+ if (source === "cloud" || source === "auto") {
336
+ // Try cloud first
337
+ if (cloudAuth) {
338
+ try {
339
+ const cloudResult = await fetchCloudQuota(
340
+ cloudAuth.accessToken,
341
+ cloudAuth.projectId,
342
+ );
343
+ const categories = groupModelsByCategory(cloudResult.models);
344
+ return {
345
+ source: "cloud",
346
+ categories,
347
+ models: cloudResult.models,
348
+ timestamp: cloudResult.timestamp,
349
+ };
350
+ } catch (error) {
351
+ if (source === "cloud") {
352
+ throw error; // Don't fallback if explicitly requested cloud
353
+ }
354
+ // Fall through to local if auto
355
+ }
356
+ } else if (source === "cloud") {
357
+ throw new Error(
358
+ "Cloud access token not provided. Cannot fetch cloud quota.",
359
+ );
360
+ }
361
+ }
362
+
363
+ // Try local
364
+ if (!shellRunner) {
365
+ throw new Error("Shell runner required for local quota fetching");
366
+ }
367
+
368
+ const localResult = await fetchAntigravityStatus(shellRunner);
369
+ const models: ModelConfig[] =
370
+ localResult.userStatus.cascadeModelConfigData?.clientModelConfigs || [];
371
+ const categories = groupModelsByCategory(models);
372
+
373
+ return {
374
+ source: "local",
375
+ categories,
376
+ models,
377
+ timestamp: localResult.timestamp,
378
+ };
379
+ }