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/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "ag-quota",
3
+ "version": "0.0.2",
4
+ "description": "Antigravity quota fetching library and CLI",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "ag-quota": "./bin/ag-quota.js"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "bin",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "build": "bun run build:lib && bun run build:cli",
26
+ "build:lib": "tsc",
27
+ "build:cli": "bun build src/cli.ts --outfile bin/ag-quota.js --target node",
28
+ "prepublishOnly": "bun run build",
29
+ "typecheck": "tsc --noEmit",
30
+ "ag-quota": "bun src/cli.ts"
31
+ },
32
+ "keywords": [
33
+ "antigravity",
34
+ "quota",
35
+ "windsurf",
36
+ "codeium",
37
+ "cli"
38
+ ],
39
+ "author": "Philipp",
40
+ "license": "ISC",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/phibkro/opencode-quota-display.git",
44
+ "directory": "packages/ag-quota"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/phibkro/opencode-quota-display/issues"
48
+ },
49
+ "homepage": "https://github.com/phibkro/opencode-quota-display/tree/main/packages/ag-quota#readme",
50
+ "engines": {
51
+ "node": ">=18"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.0.0",
55
+ "typescript": "^5.9.3"
56
+ }
57
+ }
@@ -0,0 +1,136 @@
1
+ /*
2
+ * Authentication helper for the CLI.
3
+ *
4
+ * This logic is duplicated here specifically for the CLI to be standalone
5
+ * and user-friendly, without polluting the core library exports which
6
+ * should remain pure and environment-agnostic.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ // ============================================================================
14
+ // Constants
15
+ // ============================================================================
16
+
17
+ const ANTIGRAVITY_CLIENT_ID =
18
+ "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
19
+ const ANTIGRAVITY_CLIENT_SECRET = "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";
20
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
21
+
22
+ // ============================================================================
23
+ // Types
24
+ // ============================================================================
25
+
26
+ interface StoredAccount {
27
+ email: string;
28
+ refreshToken: string;
29
+ projectId?: string;
30
+ managedProjectId?: string;
31
+ addedAt: number;
32
+ lastUsed: number;
33
+ }
34
+
35
+ interface AccountsFile {
36
+ version: number;
37
+ accounts: StoredAccount[];
38
+ activeIndex: number;
39
+ }
40
+
41
+ interface TokenResponse {
42
+ access_token: string;
43
+ expires_in: number;
44
+ token_type: string;
45
+ }
46
+
47
+ export interface CLICloudCredentials {
48
+ accessToken: string;
49
+ projectId?: string;
50
+ email: string;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Logic
55
+ // ============================================================================
56
+
57
+ function getAccountsFilePath(): string {
58
+ return join(homedir(), ".config", "opencode", "antigravity-accounts.json");
59
+ }
60
+
61
+ function loadAccounts(): AccountsFile {
62
+ const accountsPath = getAccountsFilePath();
63
+
64
+ try {
65
+ const content = readFileSync(accountsPath, "utf-8");
66
+ const data = JSON.parse(content) as AccountsFile;
67
+
68
+ if (!data.accounts || data.accounts.length === 0) {
69
+ throw new Error("No accounts found in antigravity-accounts.json");
70
+ }
71
+
72
+ return data;
73
+ } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
75
+ throw new Error("Antigravity accounts file not found.");
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ async function refreshAccessToken(refreshToken: string): Promise<string> {
82
+ const response = await fetch(TOKEN_URL, {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/x-www-form-urlencoded",
86
+ },
87
+ body: new URLSearchParams({
88
+ client_id: ANTIGRAVITY_CLIENT_ID,
89
+ client_secret: ANTIGRAVITY_CLIENT_SECRET,
90
+ refresh_token: refreshToken,
91
+ grant_type: "refresh_token",
92
+ }).toString(),
93
+ });
94
+
95
+ if (!response.ok) {
96
+ const errorText = await response.text();
97
+ throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
98
+ }
99
+
100
+ const data = (await response.json()) as TokenResponse;
101
+ return data.access_token;
102
+ }
103
+
104
+ /**
105
+ * Attempt to get cloud credentials from the local environment.
106
+ * Returns null if no credentials found or file missing.
107
+ * Throws if file exists but is invalid or refresh fails.
108
+ */
109
+ export async function getCLICloudCredentials(): Promise<CLICloudCredentials | null> {
110
+ try {
111
+ // Load accounts
112
+ const accountsFile = loadAccounts();
113
+ const activeAccount =
114
+ accountsFile.accounts[accountsFile.activeIndex] ?? accountsFile.accounts[0];
115
+
116
+ if (!activeAccount) {
117
+ return null;
118
+ }
119
+
120
+ // Get access token
121
+ const accessToken = await refreshAccessToken(activeAccount.refreshToken);
122
+
123
+ return {
124
+ accessToken,
125
+ projectId: activeAccount.projectId,
126
+ email: activeAccount.email,
127
+ };
128
+ } catch (error) {
129
+ // If file not found, just return null (not available)
130
+ if (error instanceof Error && error.message.includes("not found")) {
131
+ return null;
132
+ }
133
+ // If other error (parsing, network), throw it
134
+ throw error;
135
+ }
136
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * ISC License
4
+ * Copyright (c) 2026 Philipp
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+ import {
9
+ fetchQuota,
10
+ formatRelativeTime,
11
+ type ShellRunner,
12
+ type QuotaSource,
13
+ type CloudAuthCredentials,
14
+ } from "./index";
15
+ import { getCLICloudCredentials } from "./cli-auth";
16
+
17
+ const shellRunner: ShellRunner = async (cmd: string) =>
18
+ execSync(cmd).toString();
19
+
20
+ function parseArgs(): { source: QuotaSource; json: boolean; help: boolean; token: string; projectId: string } {
21
+ const args = process.argv.slice(2);
22
+ let source: QuotaSource = "auto";
23
+ let json = false;
24
+ let help = false;
25
+ let token = "";
26
+ let projectId = "";
27
+
28
+ for (const arg of args) {
29
+ if (arg === "--json") {
30
+ json = true;
31
+ } else if (arg === "--help" || arg === "-h") {
32
+ help = true;
33
+ } else if (arg === "--source=cloud" || arg === "-s=cloud") {
34
+ source = "cloud";
35
+ } else if (arg === "--source=local" || arg === "-s=local") {
36
+ source = "local";
37
+ } else if (arg === "--source=auto" || arg === "-s=auto") {
38
+ source = "auto";
39
+ } else if (arg.startsWith("--token=")) {
40
+ token = arg.split("=")[1];
41
+ } else if (arg.startsWith("--project-id=")) {
42
+ projectId = arg.split("=")[1];
43
+ } else if (arg.startsWith("--source=") || arg.startsWith("-s=")) {
44
+ const value = arg.split("=")[1];
45
+ console.error(`Invalid source: ${value}. Use 'cloud', 'local', or 'auto'.`);
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ return { source, json, help, token, projectId };
51
+ }
52
+
53
+ async function run() {
54
+ const { source, json: isJson, help: isHelp, token, projectId } = parseArgs();
55
+
56
+ if (isHelp) {
57
+ console.log(`
58
+ Usage: ag-quota [options]
59
+
60
+ Options:
61
+ --source=<cloud|local|auto> Quota source (default: auto)
62
+ -s=<cloud|local|auto> Alias for --source
63
+ --token=<token> Access token (override auto-discovery)
64
+ --project-id=<id> Google Cloud Project ID (optional)
65
+ --json Output result as JSON
66
+ -h, --help Show this help message
67
+
68
+ Sources:
69
+ cloud Fetch from Cloud Code API (uses auto-discovery or --token)
70
+ local Fetch from local language server process
71
+ auto Try cloud first, fallback to local (default)
72
+
73
+ Examples:
74
+ ag-quota # Auto-detect (tries cloud then local)
75
+ ag-quota --source=cloud # Force cloud (auto-discover token)
76
+ ag-quota --token=... # Force cloud with specific token
77
+ ag-quota --source=local # Force local source
78
+ ag-quota --json # Output as JSON
79
+ `);
80
+ process.exit(0);
81
+ }
82
+
83
+ try {
84
+ // Resolve credentials
85
+ let cloudAuth: CloudAuthCredentials | undefined = token ? { accessToken: token, projectId } : undefined;
86
+
87
+ // If no token provided, try to auto-discover
88
+ if (!cloudAuth && (source === "cloud" || source === "auto")) {
89
+ const creds = await getCLICloudCredentials();
90
+ if (creds) {
91
+ cloudAuth = { accessToken: creds.accessToken, projectId: creds.projectId };
92
+ }
93
+ }
94
+
95
+ if (source === "cloud" && !cloudAuth) {
96
+ throw new Error("Cloud credentials not found. Run 'opencode auth login' or provide --token.");
97
+ }
98
+
99
+ const result = await fetchQuota(source, shellRunner, cloudAuth);
100
+
101
+ if (isJson) {
102
+ console.log(
103
+ JSON.stringify(
104
+ {
105
+ source: result.source,
106
+ timestamp: result.timestamp,
107
+ categories: result.categories.map((cat) => ({
108
+ name: cat.category,
109
+ remainingFraction: cat.remainingFraction,
110
+ remainingPercentage: parseFloat(
111
+ (cat.remainingFraction * 100).toFixed(1),
112
+ ),
113
+ resetTime: cat.resetTime?.toISOString() ?? null,
114
+ resetsIn: cat.resetTime
115
+ ? formatRelativeTime(cat.resetTime)
116
+ : null,
117
+ })),
118
+ },
119
+ null,
120
+ 2,
121
+ ),
122
+ );
123
+ return;
124
+ }
125
+
126
+ const sourceLabel = result.source === "cloud" ? "Cloud API" : "Local Server";
127
+ console.log(
128
+ `\nAntigravity Quotas (Source: ${sourceLabel}, ${new Date(result.timestamp).toLocaleTimeString()}):`,
129
+ );
130
+ console.log(
131
+ "------------------------------------------------------------",
132
+ );
133
+
134
+ for (const cat of result.categories) {
135
+ const remaining = (cat.remainingFraction * 100).toFixed(1);
136
+ let output = `${cat.category.padEnd(20)}: ${remaining.padStart(5)}% remaining`;
137
+ if (cat.resetTime) {
138
+ output += ` (Resets in: ${formatRelativeTime(cat.resetTime)})`;
139
+ }
140
+ console.log(output);
141
+ }
142
+ console.log("");
143
+ } catch (error: unknown) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ if (isJson) {
146
+ console.log(JSON.stringify({ error: message }, null, 2));
147
+ } else {
148
+ console.error("Error:", message);
149
+ }
150
+ process.exit(1);
151
+ }
152
+ }
153
+
154
+ run();
package/src/cloud.ts ADDED
@@ -0,0 +1,195 @@
1
+ /*
2
+ * ISC License
3
+ * Copyright (c) 2025, Cristian Militaru
4
+ * Copyright (c) 2026, Philipp
5
+ *
6
+ * Cloud quota fetching via Google Cloud Code API.
7
+ */
8
+
9
+ // ============================================================================
10
+ // Constants from opencode-antigravity-auth
11
+ // ============================================================================
12
+
13
+ // Endpoint fallback order (daily → autopush → prod)
14
+ const CLOUDCODE_ENDPOINTS = [
15
+ "https://daily-cloudcode-pa.sandbox.googleapis.com",
16
+ "https://autopush-cloudcode-pa.sandbox.googleapis.com",
17
+ "https://cloudcode-pa.googleapis.com",
18
+ ] as const;
19
+
20
+ // Headers matching opencode-antigravity-auth
21
+ const CLOUDCODE_HEADERS = {
22
+ "Content-Type": "application/json",
23
+ "User-Agent": "antigravity/1.11.5 windows/amd64",
24
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
25
+ "Client-Metadata":
26
+ '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
27
+ } as const;
28
+
29
+ // ============================================================================
30
+ // Types
31
+ // ============================================================================
32
+
33
+ interface CloudQuotaInfo {
34
+ remainingFraction?: number;
35
+ resetTime?: string;
36
+ }
37
+
38
+ interface CloudModelInfo {
39
+ displayName?: string;
40
+ model?: string;
41
+ quotaInfo?: CloudQuotaInfo;
42
+ supportsImages?: boolean;
43
+ supportsThinking?: boolean;
44
+ recommended?: boolean;
45
+ }
46
+
47
+ interface FetchModelsResponse {
48
+ models?: Record<string, CloudModelInfo>;
49
+ }
50
+
51
+ /**
52
+ * Unified quota info structure (shared between cloud and local)
53
+ */
54
+ export interface QuotaInfo {
55
+ remainingFraction: number;
56
+ resetTime?: string;
57
+ }
58
+
59
+ /**
60
+ * Unified model config structure (shared between cloud and local)
61
+ */
62
+ export interface ModelConfig {
63
+ modelName: string;
64
+ label?: string;
65
+ quotaInfo?: QuotaInfo;
66
+ }
67
+
68
+ /**
69
+ * Cloud-specific account info
70
+ */
71
+ export interface CloudAccountInfo {
72
+ email?: string;
73
+ projectId?: string;
74
+ }
75
+
76
+ /**
77
+ * Result from cloud quota fetch
78
+ */
79
+ export interface CloudQuotaResult {
80
+ account: CloudAccountInfo;
81
+ models: ModelConfig[];
82
+ timestamp: number;
83
+ }
84
+
85
+ // ============================================================================
86
+ // API Fetching
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Fetch available models with quota from the Cloud Code API.
91
+ */
92
+ async function fetchAvailableModels(
93
+ accessToken: string,
94
+ projectId?: string,
95
+ ): Promise<FetchModelsResponse> {
96
+ const payload = projectId ? { project: projectId } : {};
97
+ let lastError: Error | null = null;
98
+
99
+ const headers: Record<string, string> = {
100
+ ...CLOUDCODE_HEADERS,
101
+ Authorization: `Bearer ${accessToken}`,
102
+ };
103
+
104
+ for (const endpoint of CLOUDCODE_ENDPOINTS) {
105
+ try {
106
+ const url = `${endpoint}/v1internal:fetchAvailableModels`;
107
+
108
+ const response = await fetch(url, {
109
+ method: "POST",
110
+ headers,
111
+ body: JSON.stringify(payload),
112
+ });
113
+
114
+ if (response.status === 401) {
115
+ throw new Error(
116
+ "Authorization expired or invalid.",
117
+ );
118
+ }
119
+
120
+ if (response.status === 403) {
121
+ throw new Error("Access forbidden (403). Check your account permissions.");
122
+ }
123
+
124
+ if (!response.ok) {
125
+ const text = await response.text();
126
+ throw new Error(`Cloud Code API error ${response.status}: ${text.slice(0, 200)}`);
127
+ }
128
+
129
+ return (await response.json()) as FetchModelsResponse;
130
+ } catch (error) {
131
+ lastError = error instanceof Error ? error : new Error(String(error));
132
+ // Auth errors should not fallback to other endpoints
133
+ if (
134
+ lastError.message.includes("Authorization") ||
135
+ lastError.message.includes("forbidden") ||
136
+ lastError.message.includes("invalid_grant")
137
+ ) {
138
+ throw lastError;
139
+ }
140
+ // Try next endpoint
141
+ }
142
+ }
143
+
144
+ throw lastError || new Error("All Cloud Code API endpoints failed");
145
+ }
146
+
147
+ // ============================================================================
148
+ // Main Export
149
+ // ============================================================================
150
+
151
+ /**
152
+ * Fetch quota information from the Cloud Code API.
153
+ *
154
+ * @param accessToken - Valid OAuth2 access token
155
+ * @param projectId - Optional Google Cloud project ID
156
+ * @returns CloudQuotaResult with account info and model quotas
157
+ * @throws Error if API fails
158
+ */
159
+ export async function fetchCloudQuota(
160
+ accessToken: string,
161
+ projectId?: string,
162
+ ): Promise<CloudQuotaResult> {
163
+ if (!accessToken) {
164
+ throw new Error("Access token is required for cloud quota fetching");
165
+ }
166
+
167
+ // Fetch quota data
168
+ const response = await fetchAvailableModels(accessToken, projectId);
169
+
170
+ // Convert to unified format
171
+ const models: ModelConfig[] = [];
172
+
173
+ if (response.models) {
174
+ for (const [modelKey, info] of Object.entries(response.models)) {
175
+ if (!info.quotaInfo) continue;
176
+
177
+ models.push({
178
+ modelName: info.model || modelKey,
179
+ label: info.displayName || modelKey,
180
+ quotaInfo: {
181
+ remainingFraction: info.quotaInfo.remainingFraction ?? 0,
182
+ resetTime: info.quotaInfo.resetTime,
183
+ },
184
+ });
185
+ }
186
+ }
187
+
188
+ return {
189
+ account: {
190
+ projectId,
191
+ },
192
+ models,
193
+ timestamp: Date.now(),
194
+ };
195
+ }