opencode-antigravity-img 0.1.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/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # opencode-antigravity-img
2
+
3
+ OpenCode plugin for generating images using Gemini 3 Pro Image model via Google's Antigravity/CloudCode API.
4
+
5
+ ## Requirements
6
+
7
+ - [OpenCode](https://github.com/sst/opencode) installed
8
+ - Google One AI Premium subscription
9
+ - Authentication via [opencode-antigravity-auth](https://www.npmjs.com/package/opencode-antigravity-auth) plugin
10
+
11
+ ## Installation
12
+
13
+ 1. First, install and configure the authentication plugin:
14
+
15
+ ```bash
16
+ # Add to your opencode.json
17
+ "plugin": [
18
+ "opencode-antigravity-auth",
19
+ "opencode-antigravity-img"
20
+ ]
21
+ ```
22
+
23
+ 2. Run the auth plugin to authenticate with your Google account:
24
+
25
+ ```bash
26
+ opencode
27
+ # Use the authenticate command from antigravity-auth
28
+ ```
29
+
30
+ This creates `~/.config/opencode/antigravity-accounts.json` with your credentials.
31
+
32
+ ## Tools
33
+
34
+ ### generate_image
35
+
36
+ Generate an image from a text prompt.
37
+
38
+ **Arguments:**
39
+ - `prompt` (required): Text description of the image to generate
40
+ - `filename` (optional): Output filename (default: `generated_<timestamp>.png`)
41
+ - `output_dir` (optional): Output directory (default: current working directory)
42
+
43
+ **Example:**
44
+ ```
45
+ Generate an image of a sunset over mountains with a lake in the foreground
46
+ ```
47
+
48
+ **Output:**
49
+ - Image file saved to specified path
50
+ - Returns path, size, format, and remaining quota
51
+
52
+ ### image_quota
53
+
54
+ Check the remaining quota for the Gemini 3 Pro Image model.
55
+
56
+ **Arguments:** None
57
+
58
+ **Output:**
59
+ - Visual progress bar showing remaining quota percentage
60
+ - Time until quota resets
61
+
62
+ ## Image Details
63
+
64
+ - **Model**: Gemini 3 Pro Image
65
+ - **Resolution**: 1408x768 pixels
66
+ - **Format**: JPEG (typically 600KB - 1MB)
67
+ - **Generation time**: 10-30 seconds
68
+
69
+ ## Quota
70
+
71
+ Image generation uses a separate quota from text models. The quota typically resets daily. Use the `image_quota` tool to check your remaining quota before generating images.
72
+
73
+ ## Troubleshooting
74
+
75
+ ### "No Antigravity account found"
76
+
77
+ Make sure you've:
78
+ 1. Installed `opencode-antigravity-auth`
79
+ 2. Authenticated with your Google account
80
+ 3. The credentials file exists at `~/.config/opencode/antigravity-accounts.json`
81
+
82
+ ### "Rate limited" or generation fails
83
+
84
+ - Wait a few seconds and try again
85
+ - Check your quota with `image_quota`
86
+ - The plugin automatically tries multiple endpoints
87
+
88
+ ### Slow generation
89
+
90
+ Image generation typically takes 10-30 seconds. This is normal due to the complexity of image synthesis.
91
+
92
+ ## API Endpoints
93
+
94
+ The plugin uses Google's CloudCode API with fallback endpoints:
95
+ 1. `https://daily-cloudcode-pa.googleapis.com` (primary)
96
+ 2. `https://daily-cloudcode-pa.sandbox.googleapis.com` (fallback)
97
+ 3. `https://cloudcode-pa.googleapis.com` (production)
98
+
99
+ ## Related Plugins
100
+
101
+ - [opencode-antigravity-auth](https://www.npmjs.com/package/opencode-antigravity-auth) - Authentication (required)
102
+ - [opencode-antigravity-quota](https://www.npmjs.com/package/opencode-antigravity-quota) - Text model quota checking
103
+
104
+ ## License
105
+
106
+ MIT
package/bun.lock ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "opencode-antigravity-img",
7
+ "dependencies": {
8
+ "@opencode-ai/plugin": "^1.1.15",
9
+ "opencode-antigravity-auth": "^1.2.8",
10
+ },
11
+ "devDependencies": {
12
+ "@types/bun": "latest",
13
+ "@types/node": "^22.0.0",
14
+ "typescript": "^5",
15
+ },
16
+ },
17
+ },
18
+ "packages": {
19
+ "@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
20
+
21
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.21", "", { "dependencies": { "@opencode-ai/sdk": "1.1.21", "zod": "4.1.8" } }, "sha512-oAWVlKG7LACGFYawfdHGMN6e+6lyN6F+zPVncFUB99BrTl/TjELE5gTZwU7MalGpjwfU77yslBOZm4BXVAYGvw=="],
22
+
23
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.21", "", {}, "sha512-4M6lBjRPlPz99Rb5rS5ZqKrb0UDDxOT9VTG06JpNxvA7ynTd8C50ckc2NGzWtvjarmxfaAk1VeuBYN/cq2pIKQ=="],
24
+
25
+ "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
26
+
27
+ "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
28
+
29
+ "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
30
+
31
+ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
32
+
33
+ "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
34
+
35
+ "@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
36
+
37
+ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
38
+
39
+ "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
40
+
41
+ "arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
42
+
43
+ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
44
+
45
+ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
46
+
47
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
48
+
49
+ "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
50
+
51
+ "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
52
+
53
+ "opencode-antigravity-auth": ["opencode-antigravity-auth@1.2.8", "", { "dependencies": { "@openauthjs/openauth": "^0.4.3", "proper-lockfile": "^4.1.2", "xdg-basedir": "^5.1.0", "zod": "^3.24.0" }, "peerDependencies": { "typescript": "^5" } }, "sha512-ZLcZdUL3IBUM96WSsclu/4vR0uZLfDBF77b2XKEZcyt5dhOMbJouEkPnp8BEtV1Pu9oFl4eiVcfML7e2JMmrNg=="],
54
+
55
+ "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
56
+
57
+ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
58
+
59
+ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
60
+
61
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
62
+
63
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
64
+
65
+ "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
66
+
67
+ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
68
+
69
+ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
70
+
71
+ "opencode-antigravity-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
72
+ }
73
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { plugin, default } from "./src/index";
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "opencode-antigravity-img",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for Gemini image generation via Antigravity/CloudCode API",
5
+ "main": "src/index.ts",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "bun test",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "keywords": [
12
+ "opencode",
13
+ "plugin",
14
+ "antigravity",
15
+ "gemini",
16
+ "image",
17
+ "generation",
18
+ "google-cloud"
19
+ ],
20
+ "author": "Lorenzo Becchi (ominiverdi)",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ominiverdi/opencode-antigravity-img.git"
25
+ },
26
+ "homepage": "https://github.com/ominiverdi/opencode-antigravity-img#readme",
27
+ "private": false,
28
+ "devDependencies": {
29
+ "@types/bun": "latest",
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5"
32
+ },
33
+ "dependencies": {
34
+ "@opencode-ai/plugin": "^1.1.15",
35
+ "opencode-antigravity-auth": "^1.2.8"
36
+ }
37
+ }
package/src/api.ts ADDED
@@ -0,0 +1,303 @@
1
+ import {
2
+ ANTIGRAVITY_CLIENT_ID,
3
+ ANTIGRAVITY_CLIENT_SECRET,
4
+ GOOGLE_TOKEN_URL,
5
+ CLOUDCODE_BASE_URL,
6
+ CLOUDCODE_FALLBACK_URLS,
7
+ CLOUDCODE_METADATA,
8
+ IMAGE_MODEL,
9
+ IMAGE_GENERATION_TIMEOUT_MS,
10
+ } from "./constants";
11
+ import type {
12
+ Account,
13
+ TokenResponse,
14
+ LoadCodeAssistResponse,
15
+ CloudCodeQuotaResponse,
16
+ GenerateContentResponse,
17
+ ImageGenerationResult,
18
+ QuotaInfo,
19
+ } from "./types";
20
+
21
+ /**
22
+ * Refresh an access token using the refresh token
23
+ */
24
+ export async function refreshAccessToken(refreshToken: string): Promise<string> {
25
+ const params = new URLSearchParams({
26
+ client_id: ANTIGRAVITY_CLIENT_ID,
27
+ client_secret: ANTIGRAVITY_CLIENT_SECRET,
28
+ refresh_token: refreshToken,
29
+ grant_type: "refresh_token",
30
+ });
31
+
32
+ const response = await fetch(GOOGLE_TOKEN_URL, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
35
+ body: params.toString(),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`Token refresh failed (${response.status})`);
40
+ }
41
+
42
+ const data = (await response.json()) as TokenResponse;
43
+ return data.access_token;
44
+ }
45
+
46
+ /**
47
+ * Load code assist info to get project ID
48
+ */
49
+ export async function loadCodeAssist(accessToken: string): Promise<LoadCodeAssistResponse> {
50
+ const response = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:loadCodeAssist`, {
51
+ method: "POST",
52
+ headers: {
53
+ Authorization: `Bearer ${accessToken}`,
54
+ "Content-Type": "application/json",
55
+ "User-Agent": "antigravity",
56
+ },
57
+ body: JSON.stringify({ metadata: CLOUDCODE_METADATA }),
58
+ });
59
+
60
+ if (!response.ok) {
61
+ throw new Error(`loadCodeAssist failed (${response.status})`);
62
+ }
63
+
64
+ return (await response.json()) as LoadCodeAssistResponse;
65
+ }
66
+
67
+ /**
68
+ * Extract project ID from cloudaicompanionProject field
69
+ */
70
+ export function extractProjectId(project: string | { id?: string } | undefined): string | undefined {
71
+ if (!project) return undefined;
72
+ if (typeof project === "string") return project;
73
+ return project.id;
74
+ }
75
+
76
+ /**
77
+ * Fetch available models with quota info
78
+ */
79
+ export async function fetchAvailableModels(
80
+ accessToken: string,
81
+ projectId?: string
82
+ ): Promise<CloudCodeQuotaResponse> {
83
+ const payload = projectId ? { project: projectId } : {};
84
+
85
+ const response = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:fetchAvailableModels`, {
86
+ method: "POST",
87
+ headers: {
88
+ Authorization: `Bearer ${accessToken}`,
89
+ "Content-Type": "application/json",
90
+ "User-Agent": "antigravity",
91
+ },
92
+ body: JSON.stringify(payload),
93
+ });
94
+
95
+ if (!response.ok) {
96
+ throw new Error(`fetchAvailableModels failed (${response.status})`);
97
+ }
98
+
99
+ return (await response.json()) as CloudCodeQuotaResponse;
100
+ }
101
+
102
+ /**
103
+ * Get quota info for the image model
104
+ */
105
+ export async function getImageModelQuota(account: Account): Promise<QuotaInfo | null> {
106
+ try {
107
+ const accessToken = await refreshAccessToken(account.refreshToken);
108
+ let projectId = account.projectId || account.managedProjectId;
109
+
110
+ if (!projectId) {
111
+ const codeAssist = await loadCodeAssist(accessToken);
112
+ projectId = extractProjectId(codeAssist.cloudaicompanionProject);
113
+ }
114
+
115
+ const models = await fetchAvailableModels(accessToken, projectId);
116
+ const imageModel = models.models?.[IMAGE_MODEL];
117
+
118
+ if (!imageModel?.quotaInfo) {
119
+ return null;
120
+ }
121
+
122
+ const quota = imageModel.quotaInfo;
123
+ const remainingPercent = (quota.remainingFraction ?? 0) * 100;
124
+ const resetTime = quota.resetTime || "";
125
+
126
+ // Calculate reset in human readable
127
+ let resetIn = "N/A";
128
+ if (resetTime) {
129
+ const resetDate = new Date(resetTime);
130
+ const now = Date.now();
131
+ const diffMs = resetDate.getTime() - now;
132
+ if (diffMs > 0) {
133
+ const hours = Math.floor(diffMs / 3600000);
134
+ const mins = Math.floor((diffMs % 3600000) / 60000);
135
+ resetIn = `${hours}h ${mins}m`;
136
+ } else {
137
+ resetIn = "now";
138
+ }
139
+ }
140
+
141
+ return {
142
+ modelName: imageModel.displayName || IMAGE_MODEL,
143
+ remainingPercent,
144
+ resetTime,
145
+ resetIn,
146
+ };
147
+ } catch (error) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Generate an image using the Gemini 3 Pro Image model
154
+ */
155
+ export async function generateImage(
156
+ account: Account,
157
+ prompt: string
158
+ ): Promise<ImageGenerationResult> {
159
+ try {
160
+ // Get access token
161
+ const accessToken = await refreshAccessToken(account.refreshToken);
162
+
163
+ // Get project ID
164
+ let projectId = account.projectId || account.managedProjectId;
165
+ if (!projectId) {
166
+ const codeAssist = await loadCodeAssist(accessToken);
167
+ projectId = extractProjectId(codeAssist.cloudaicompanionProject);
168
+ }
169
+
170
+ if (!projectId) {
171
+ return { success: false, error: "Could not determine project ID" };
172
+ }
173
+
174
+ // Build request
175
+ const requestBody = {
176
+ project: projectId,
177
+ requestId: `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
178
+ model: IMAGE_MODEL,
179
+ userAgent: "antigravity",
180
+ requestType: "agent",
181
+ request: {
182
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
183
+ session_id: `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
184
+ generationConfig: {
185
+ responseModalities: ["TEXT", "IMAGE"],
186
+ },
187
+ },
188
+ };
189
+
190
+ // Try each endpoint
191
+ for (const baseUrl of CLOUDCODE_FALLBACK_URLS) {
192
+ try {
193
+ const controller = new AbortController();
194
+ const timeout = setTimeout(() => controller.abort(), IMAGE_GENERATION_TIMEOUT_MS);
195
+
196
+ const response = await fetch(
197
+ `${baseUrl}/v1internal:streamGenerateContent?alt=sse`,
198
+ {
199
+ method: "POST",
200
+ headers: {
201
+ Authorization: `Bearer ${accessToken}`,
202
+ "Content-Type": "application/json",
203
+ "User-Agent": "antigravity",
204
+ },
205
+ body: JSON.stringify(requestBody),
206
+ signal: controller.signal,
207
+ }
208
+ );
209
+
210
+ clearTimeout(timeout);
211
+
212
+ if (!response.ok) {
213
+ if (response.status === 429) {
214
+ // Rate limited, try next endpoint
215
+ continue;
216
+ }
217
+ const errorText = await response.text();
218
+ return { success: false, error: `HTTP ${response.status}: ${errorText.slice(0, 200)}` };
219
+ }
220
+
221
+ // Parse SSE response
222
+ const text = await response.text();
223
+ const result = parseSSEResponse(text);
224
+
225
+ if (result.success && result.imageData) {
226
+ // Get updated quota info
227
+ const quota = await getImageModelQuota(account);
228
+
229
+ return {
230
+ ...result,
231
+ quota: quota
232
+ ? {
233
+ remainingPercent: quota.remainingPercent,
234
+ resetTime: quota.resetTime,
235
+ }
236
+ : undefined,
237
+ };
238
+ }
239
+
240
+ return result;
241
+ } catch (err) {
242
+ if (err instanceof Error && err.name === "AbortError") {
243
+ continue; // Timeout, try next endpoint
244
+ }
245
+ // Network error, try next endpoint
246
+ continue;
247
+ }
248
+ }
249
+
250
+ return { success: false, error: "All endpoints failed" };
251
+ } catch (error) {
252
+ return {
253
+ success: false,
254
+ error: error instanceof Error ? error.message : String(error),
255
+ };
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Parse SSE response and extract image data
261
+ */
262
+ function parseSSEResponse(text: string): ImageGenerationResult {
263
+ const lines = text.split("\n");
264
+
265
+ for (const line of lines) {
266
+ if (!line.startsWith("data: ")) continue;
267
+
268
+ const jsonStr = line.slice(6);
269
+ if (jsonStr === "[DONE]") continue;
270
+
271
+ try {
272
+ const data = JSON.parse(jsonStr) as GenerateContentResponse;
273
+
274
+ // Check for error
275
+ if (data.error) {
276
+ return {
277
+ success: false,
278
+ error: `${data.error.code}: ${data.error.message}`,
279
+ };
280
+ }
281
+
282
+ // Look for image in response
283
+ const candidates = data.response?.candidates || [];
284
+ for (const candidate of candidates) {
285
+ const parts = candidate.content?.parts || [];
286
+ for (const part of parts) {
287
+ if (part.inlineData?.data && part.inlineData.mimeType?.startsWith("image/")) {
288
+ return {
289
+ success: true,
290
+ imageData: part.inlineData.data,
291
+ mimeType: part.inlineData.mimeType,
292
+ sizeBytes: Math.round((part.inlineData.data.length * 3) / 4), // Approximate decoded size
293
+ };
294
+ }
295
+ }
296
+ }
297
+ } catch {
298
+ // Skip unparseable lines
299
+ }
300
+ }
301
+
302
+ return { success: false, error: "No image in response" };
303
+ }
@@ -0,0 +1,42 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+
4
+ // OAuth credentials (same as antigravity-auth plugin)
5
+ export const ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
6
+ export const ANTIGRAVITY_CLIENT_SECRET = "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";
7
+ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
8
+
9
+ // CloudCode API
10
+ export const CLOUDCODE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
11
+ export const CLOUDCODE_FALLBACK_URLS = [
12
+ "https://daily-cloudcode-pa.googleapis.com",
13
+ "https://daily-cloudcode-pa.sandbox.googleapis.com",
14
+ "https://cloudcode-pa.googleapis.com",
15
+ ];
16
+
17
+ export const CLOUDCODE_METADATA = {
18
+ ideType: "ANTIGRAVITY",
19
+ platform: "PLATFORM_UNSPECIFIED",
20
+ pluginType: "GEMINI",
21
+ };
22
+
23
+ // Image generation
24
+ export const IMAGE_MODEL = "gemini-3-pro-image";
25
+ export const IMAGE_GENERATION_TIMEOUT_MS = 120_000;
26
+
27
+ // Config file paths
28
+ export const CONFIG_PATHS = [
29
+ join(homedir(), ".config", "opencode", "antigravity-accounts.json"),
30
+ join(homedir(), ".opencode", "antigravity-accounts.json"),
31
+ ];
32
+
33
+ // Command file for opencode discovery
34
+ export const COMMAND_DIR = join(homedir(), ".config", "opencode", "commands");
35
+ export const COMMAND_FILE = join(COMMAND_DIR, "generate-image.md");
36
+ export const COMMAND_CONTENT = `# Generate Image
37
+
38
+ Generate an image using Gemini 3 Pro Image model.
39
+
40
+ Prompt: $PROMPT
41
+ Output filename (optional): $FILENAME
42
+ `;
package/src/index.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { type Plugin, tool } from "@opencode-ai/plugin";
2
+ import * as fs from "fs/promises";
3
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { CONFIG_PATHS, COMMAND_DIR, COMMAND_FILE, COMMAND_CONTENT, IMAGE_MODEL } from "./constants";
6
+ import type { AccountsConfig, Account } from "./types";
7
+ import { generateImage, getImageModelQuota } from "./api";
8
+
9
+ // Create command file for opencode discovery
10
+ try {
11
+ if (!existsSync(COMMAND_DIR)) {
12
+ mkdirSync(COMMAND_DIR, { recursive: true });
13
+ }
14
+ if (!existsSync(COMMAND_FILE)) {
15
+ writeFileSync(COMMAND_FILE, COMMAND_CONTENT, "utf-8");
16
+ }
17
+ } catch {
18
+ // Non-fatal if command file creation fails
19
+ }
20
+
21
+ /**
22
+ * Load accounts from config file
23
+ */
24
+ async function loadAccounts(): Promise<AccountsConfig | null> {
25
+ for (const configPath of CONFIG_PATHS) {
26
+ if (existsSync(configPath)) {
27
+ try {
28
+ const content = await fs.readFile(configPath, "utf-8");
29
+ return JSON.parse(content) as AccountsConfig;
30
+ } catch {
31
+ continue;
32
+ }
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Get the first available account
40
+ */
41
+ async function getAccount(): Promise<Account | null> {
42
+ const config = await loadAccounts();
43
+ if (!config?.accounts?.length) return null;
44
+ return config.accounts[0] || null;
45
+ }
46
+
47
+ /**
48
+ * Format file size for display
49
+ */
50
+ function formatSize(bytes: number): string {
51
+ if (bytes < 1024) return `${bytes} B`;
52
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
53
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
54
+ }
55
+
56
+ /**
57
+ * Format quota for display
58
+ */
59
+ function formatQuota(percent: number): string {
60
+ if (percent <= 10) return `${percent.toFixed(0)}% (low)`;
61
+ if (percent <= 30) return `${percent.toFixed(0)}% (medium)`;
62
+ return `${percent.toFixed(0)}%`;
63
+ }
64
+
65
+ export const plugin: Plugin = async (ctx) => {
66
+ return {
67
+ tool: {
68
+ /**
69
+ * Generate an image using Gemini 3 Pro Image
70
+ */
71
+ generate_image: tool({
72
+ description:
73
+ "Generate an image using Gemini 3 Pro Image model. " +
74
+ "Provide a text prompt describing the image you want. " +
75
+ "Returns the path to the generated image file.",
76
+ args: {
77
+ prompt: tool.schema.string().describe("Text description of the image to generate"),
78
+ filename: tool.schema
79
+ .string()
80
+ .optional()
81
+ .describe("Output filename (default: generated_<timestamp>.png)"),
82
+ output_dir: tool.schema
83
+ .string()
84
+ .optional()
85
+ .describe("Output directory (default: current working directory)"),
86
+ },
87
+ async execute(args, context) {
88
+ const { prompt, filename, output_dir } = args;
89
+
90
+ if (!prompt?.trim()) {
91
+ return "Error: Please provide a prompt describing the image to generate.";
92
+ }
93
+
94
+ // Get account
95
+ const account = await getAccount();
96
+ if (!account) {
97
+ return (
98
+ "Error: No Antigravity account found.\n\n" +
99
+ "Please install and configure opencode-antigravity-auth first:\n" +
100
+ " 1. Add 'opencode-antigravity-auth' to your opencode plugins\n" +
101
+ " 2. Authenticate with your Google account\n\n" +
102
+ `Checked paths:\n${CONFIG_PATHS.map((p) => ` - ${p}`).join("\n")}`
103
+ );
104
+ }
105
+
106
+ context.metadata({ title: "Generating image..." });
107
+
108
+ // Generate image
109
+ const result = await generateImage(account, prompt);
110
+
111
+ if (!result.success || !result.imageData) {
112
+ return `Error generating image: ${result.error || "Unknown error"}`;
113
+ }
114
+
115
+ // Determine output path
116
+ const dir = output_dir || ctx.directory;
117
+ const ext = result.mimeType === "image/png" ? "png" : "jpg";
118
+ const name = filename || `generated_${Date.now()}.${ext}`;
119
+ const outputPath = join(dir, name);
120
+
121
+ // Ensure directory exists
122
+ const dirPath = dirname(outputPath);
123
+ if (!existsSync(dirPath)) {
124
+ await fs.mkdir(dirPath, { recursive: true });
125
+ }
126
+
127
+ // Decode and save image
128
+ const imageBuffer = Buffer.from(result.imageData, "base64");
129
+ await fs.writeFile(outputPath, imageBuffer);
130
+
131
+ const sizeStr = formatSize(imageBuffer.length);
132
+
133
+ context.metadata({
134
+ title: "Image generated",
135
+ metadata: {
136
+ path: outputPath,
137
+ size: sizeStr,
138
+ format: result.mimeType,
139
+ },
140
+ });
141
+
142
+ // Build response
143
+ let response = `Image generated successfully!\n\n`;
144
+ response += `Path: ${outputPath}\n`;
145
+ response += `Size: ${sizeStr}\n`;
146
+ response += `Format: ${result.mimeType}\n`;
147
+
148
+ if (result.quota) {
149
+ response += `\nQuota: ${formatQuota(result.quota.remainingPercent)} remaining`;
150
+ }
151
+
152
+ return response;
153
+ },
154
+ }),
155
+
156
+ /**
157
+ * Check quota for image generation model
158
+ */
159
+ image_quota: tool({
160
+ description:
161
+ "Check the remaining quota for the Gemini 3 Pro Image model. " +
162
+ "Shows percentage remaining and time until reset.",
163
+ args: {},
164
+ async execute(args, context) {
165
+ const account = await getAccount();
166
+ if (!account) {
167
+ return (
168
+ "Error: No Antigravity account found.\n" +
169
+ "Please configure opencode-antigravity-auth first."
170
+ );
171
+ }
172
+
173
+ context.metadata({ title: "Checking quota..." });
174
+
175
+ const quota = await getImageModelQuota(account);
176
+
177
+ if (!quota) {
178
+ return "Error: Could not fetch quota information.";
179
+ }
180
+
181
+ context.metadata({
182
+ title: "Quota",
183
+ metadata: {
184
+ remaining: `${quota.remainingPercent.toFixed(0)}%`,
185
+ resetIn: quota.resetIn,
186
+ },
187
+ });
188
+
189
+ // Visual progress bar
190
+ const barWidth = 20;
191
+ const filled = Math.round((quota.remainingPercent / 100) * barWidth);
192
+ const empty = barWidth - filled;
193
+ const bar = "#".repeat(filled) + ".".repeat(empty);
194
+
195
+ let response = `${quota.modelName}\n\n`;
196
+ response += `[${bar}] ${quota.remainingPercent.toFixed(0)}% remaining\n`;
197
+ response += `Resets in: ${quota.resetIn}`;
198
+
199
+ if (quota.resetTime) {
200
+ const resetDate = new Date(quota.resetTime);
201
+ response += ` (at ${resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})`;
202
+ }
203
+
204
+ return response;
205
+ },
206
+ }),
207
+ },
208
+ };
209
+ };
210
+
211
+ export default plugin;
package/src/types.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Account configuration (from antigravity-accounts.json)
2
+ export interface Account {
3
+ email: string;
4
+ refreshToken: string;
5
+ accessToken?: string;
6
+ projectId?: string;
7
+ managedProjectId?: string;
8
+ }
9
+
10
+ export interface AccountsConfig {
11
+ accounts: Account[];
12
+ }
13
+
14
+ // API responses
15
+ export interface TokenResponse {
16
+ access_token: string;
17
+ expires_in: number;
18
+ token_type: string;
19
+ }
20
+
21
+ export interface LoadCodeAssistResponse {
22
+ cloudaicompanionProject?: string | { id?: string };
23
+ currentTier?: { id?: string; name?: string };
24
+ paidTier?: { id?: string; name?: string };
25
+ }
26
+
27
+ export interface CloudCodeQuotaResponse {
28
+ models?: Record<string, ModelInfo>;
29
+ }
30
+
31
+ export interface ModelInfo {
32
+ displayName?: string;
33
+ model?: string;
34
+ quotaInfo?: {
35
+ remainingFraction?: number;
36
+ resetTime?: string;
37
+ };
38
+ }
39
+
40
+ // Image generation
41
+ export interface GenerateContentRequest {
42
+ project: string;
43
+ requestId: string;
44
+ model: string;
45
+ userAgent: string;
46
+ requestType: string;
47
+ request: {
48
+ contents: Array<{
49
+ role: string;
50
+ parts: Array<{ text: string }>;
51
+ }>;
52
+ session_id: string;
53
+ generationConfig: {
54
+ responseModalities: string[];
55
+ };
56
+ };
57
+ }
58
+
59
+ export interface GenerateContentResponse {
60
+ response?: {
61
+ candidates?: Array<{
62
+ content?: {
63
+ parts?: Array<{
64
+ text?: string;
65
+ thought?: boolean;
66
+ inlineData?: {
67
+ mimeType: string;
68
+ data: string;
69
+ };
70
+ }>;
71
+ };
72
+ finishReason?: string;
73
+ }>;
74
+ usageMetadata?: {
75
+ promptTokenCount?: number;
76
+ candidatesTokenCount?: number;
77
+ totalTokenCount?: number;
78
+ };
79
+ };
80
+ error?: {
81
+ code: number;
82
+ message: string;
83
+ status: string;
84
+ };
85
+ }
86
+
87
+ export interface ImageGenerationResult {
88
+ success: boolean;
89
+ imagePath?: string;
90
+ imageData?: string; // base64
91
+ mimeType?: string;
92
+ sizeBytes?: number;
93
+ error?: string;
94
+ quota?: {
95
+ remainingPercent: number;
96
+ resetTime: string;
97
+ };
98
+ }
99
+
100
+ export interface QuotaInfo {
101
+ modelName: string;
102
+ remainingPercent: number;
103
+ resetTime: string;
104
+ resetIn: string;
105
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "declaration": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "."
12
+ },
13
+ "include": ["src/**/*", "index.ts"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }