diff --git a/README.md b/README.md
index d992761a..9fe523c4 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr
- [**Amp**](docs/providers/amp.md) / free tier, bonus, credits
- [**Antigravity**](docs/providers/antigravity.md) / all models
+- [**Antigravity CLI**](docs/providers/antigravity-cli.md) / gemini pro, gemini flash, claude
- [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage)
- [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits
- [**Copilot**](docs/providers/copilot.md) / premium, chat, completions
diff --git a/docs/plugins/api.md b/docs/plugins/api.md
index 7e96a3e8..f54ae02d 100644
--- a/docs/plugins/api.md
+++ b/docs/plugins/api.md
@@ -230,7 +230,7 @@ const resp = ctx.host.http.request({
## Keychain (macOS only)
```typescript
-host.keychain.readGenericPassword(service: string): string
+host.keychain.readGenericPassword(service: string, account?: string): string
```
Reads a generic password from the macOS Keychain.
@@ -239,6 +239,7 @@ Reads a generic password from the macOS Keychain.
- **macOS only**: Throws on other platforms
- **Throws if not found**: Returns the password string if found, throws otherwise
+- **Optional account**: When `account` is provided, lookup is scoped to both service and account
### Example
diff --git a/docs/providers/antigravity-cli.md b/docs/providers/antigravity-cli.md
new file mode 100644
index 00000000..2ad6f76d
--- /dev/null
+++ b/docs/providers/antigravity-cli.md
@@ -0,0 +1,36 @@
+# Antigravity CLI
+
+Tracks Google Antigravity CLI (`agy`) Cloud Code quota.
+
+## Setup
+
+Sign in with the CLI first:
+
+```bash
+agy
+```
+
+OpenUsage uses the CLI keychain login. It does not start a browser OAuth flow.
+
+## Data Sources
+
+- Non-secret CLI context: `~/.gemini/antigravity-cli/`
+- Auth: the Antigravity CLI OS keyring login. On macOS, current CLI builds store this under keychain service `gemini`, account `antigravity`.
+- Quota APIs:
+ - `POST https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist`
+ - `POST https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels`
+ - `POST https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota`
+
+The provider does not read legacy Gemini OAuth files such as `~/.gemini/oauth_creds.json`.
+
+## Quota Lines
+
+- Gemini model IDs or labels containing `gemini` and `pro` -> `Gemini Pro`
+- Gemini model IDs or labels containing `gemini` and `flash` -> `Gemini Flash`
+- Other non-Gemini model pools -> `Claude`
+
+When multiple buckets map to the same line, OpenUsage shows the lowest remaining fraction.
+
+## Notes
+
+Official Antigravity docs describe a shared agent harness and shared settings between the CLI and Antigravity 2.0. OpenUsage tracks Antigravity CLI separately because the CLI uses different state and auth paths.
diff --git a/plugins/antigravity-cli/README.md b/plugins/antigravity-cli/README.md
new file mode 100644
index 00000000..be474a1d
--- /dev/null
+++ b/plugins/antigravity-cli/README.md
@@ -0,0 +1,15 @@
+# Antigravity CLI
+
+This provider reads Google Antigravity CLI (`agy`) usage from the Cloud Code quota APIs used by the CLI. It is separate from the Antigravity IDE and Gemini providers because `agy` stores state and auth differently.
+
+Authenticate with `agy` before enabling this provider:
+
+```sh
+agy
+```
+
+OpenUsage reads non-secret context only from `~/.gemini/antigravity-cli/`. It does not read legacy Gemini OAuth files such as `~/.gemini/oauth_creds.json`.
+
+Authentication comes from the Antigravity CLI OS keyring login. On macOS, current CLI builds store it under keychain service `gemini` and account `antigravity`. The provider accepts raw tokens, JSON OAuth-style payloads, and `go-keyring-base64:` wrapped values when the keychain returns them.
+
+Official Antigravity docs describe a shared agent harness and shared settings between the CLI and Antigravity 2.0. OpenUsage tracks Antigravity CLI separately so the auth path and implementation remain clear.
diff --git a/plugins/antigravity-cli/icon.svg b/plugins/antigravity-cli/icon.svg
new file mode 100644
index 00000000..4794cc58
--- /dev/null
+++ b/plugins/antigravity-cli/icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/plugins/antigravity-cli/plugin.js b/plugins/antigravity-cli/plugin.js
new file mode 100644
index 00000000..c81c684b
--- /dev/null
+++ b/plugins/antigravity-cli/plugin.js
@@ -0,0 +1,326 @@
+(function () {
+ const PROVIDER_ID = "antigravity-cli"
+ const CLI_STATE_DIR = "~/.gemini/antigravity-cli"
+ const KEYCHAIN_SERVICE = "gemini"
+ const KEYCHAIN_ACCOUNT = "antigravity"
+ const LOGIN_MESSAGE = "Not logged in. Run `agy` and complete Google sign-in first."
+ const REQUEST_FAILED_MESSAGE = "Antigravity CLI quota request failed. Check your connection and try again."
+ const INVALID_RESPONSE_MESSAGE = "Antigravity CLI quota response was invalid. Try again later."
+
+ const LOAD_CODE_ASSIST_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
+ const FETCH_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"
+ const RETRIEVE_QUOTA_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"
+ const QUOTA_PERIOD_MS = 5 * 60 * 60 * 1000
+
+ const IDE_METADATA = {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ duetProject: "default",
+ }
+
+ function trimString(value) {
+ return typeof value === "string" ? value.trim() : ""
+ }
+
+ function decodeBase64(ctx, text) {
+ try {
+ return ctx.base64.decode(text)
+ } catch (e) {
+ return null
+ }
+ }
+
+ function readKeychainValue(ctx) {
+ if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") {
+ return null
+ }
+
+ try {
+ var accountValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
+ if (accountValue) return accountValue
+ } catch (e) {
+ ctx.host.log.info("antigravity-cli account keychain read failed: " + String(e))
+ }
+ return null
+ }
+
+ function unwrapKeychainText(ctx, raw) {
+ var text = trimString(raw)
+ if (!text) return null
+ if (text.indexOf("go-keyring-base64:") === 0) {
+ var decoded = decodeBase64(ctx, text.slice("go-keyring-base64:".length))
+ text = trimString(decoded)
+ }
+ return text || null
+ }
+
+ function extractTokenFromObject(obj) {
+ if (!obj || typeof obj !== "object") return null
+
+ var directKeys = [
+ "access_token",
+ "accessToken",
+ "token",
+ "id_token",
+ "idToken",
+ "bearerToken",
+ "auth_token",
+ "authToken",
+ ]
+ for (var i = 0; i < directKeys.length; i += 1) {
+ var value = obj[directKeys[i]]
+ if (typeof value === "string" && value.trim()) return value.trim()
+ }
+
+ var nestedKeys = ["tokens", "oauth", "oauth2", "credentials", "auth"]
+ for (var j = 0; j < nestedKeys.length; j += 1) {
+ var nested = extractTokenFromObject(obj[nestedKeys[j]])
+ if (nested) return nested
+ }
+
+ return null
+ }
+
+ function extractAccessToken(ctx, raw) {
+ var text = unwrapKeychainText(ctx, raw)
+ if (!text) return null
+
+ var parsed = ctx.util.tryParseJson(text)
+ if (typeof parsed === "string" && parsed.trim()) return parsed.trim()
+ if (parsed) {
+ var token = extractTokenFromObject(parsed)
+ if (token) return token
+ return null
+ }
+
+ if (text.indexOf("Bearer ") === 0) return text.slice("Bearer ".length).trim() || null
+ return text
+ }
+
+ function readNonSecretCliContext(ctx) {
+ var context = {}
+ try {
+ if (ctx.host.fs.exists(CLI_STATE_DIR)) {
+ context.hasStateDir = true
+ }
+ } catch (e) {
+ context.hasStateDir = false
+ }
+ return context
+ }
+
+ function requestJson(ctx, url, token, body) {
+ var request = ctx.host.http && typeof ctx.host.http.request === "function"
+ ? ctx.host.http.request
+ : ctx.util.request
+ var resp
+ try {
+ resp = request({
+ method: "POST",
+ url: url,
+ headers: {
+ Authorization: "Bearer " + token,
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ "User-Agent": "agy",
+ },
+ bodyText: JSON.stringify(body || {}),
+ timeoutMs: 15000,
+ })
+ } catch (e) {
+ throw REQUEST_FAILED_MESSAGE
+ }
+ if (!resp || typeof resp.status !== "number" || !Number.isFinite(resp.status)) {
+ throw REQUEST_FAILED_MESSAGE
+ }
+ if (ctx.util.isAuthStatus(resp.status)) {
+ throw LOGIN_MESSAGE
+ }
+ if (resp.status < 200 || resp.status >= 300) {
+ throw "Antigravity CLI quota request failed (HTTP " + String(resp.status) + "). Try again later."
+ }
+ var data = ctx.util.tryParseJson(resp.bodyText)
+ if (!data || typeof data !== "object") {
+ throw INVALID_RESPONSE_MESSAGE
+ }
+ return data
+ }
+
+ function readFirstStringDeep(value, keys) {
+ if (!value || typeof value !== "object") return null
+ for (var i = 0; i < keys.length; i += 1) {
+ var v = value[keys[i]]
+ if (typeof v === "string" && v.trim()) return v.trim()
+ }
+ var values = Object.values(value)
+ for (var j = 0; j < values.length; j += 1) {
+ var found = readFirstStringDeep(values[j], keys)
+ if (found) return found
+ }
+ return null
+ }
+
+ function readPlan(loadCodeAssistData) {
+ var direct = loadCodeAssistData && loadCodeAssistData.userTier
+ if (direct && typeof direct.name === "string" && direct.name.trim()) return direct.name.trim()
+ var equivalent = readTierObjectName(loadCodeAssistData)
+ if (equivalent) return equivalent
+ return readFirstStringDeep(loadCodeAssistData, ["userTierName", "tierName", "planName"])
+ }
+
+ function readTierObjectName(value) {
+ if (!value || typeof value !== "object") return null
+ var tierKeys = ["userTier", "tier", "subscriptionTier", "plan"]
+ for (var i = 0; i < tierKeys.length; i += 1) {
+ var tier = value[tierKeys[i]]
+ if (tier && typeof tier === "object" && typeof tier.name === "string" && tier.name.trim()) {
+ return tier.name.trim()
+ }
+ }
+ var values = Object.values(value)
+ for (var j = 0; j < values.length; j += 1) {
+ var found = readTierObjectName(values[j])
+ if (found) return found
+ }
+ return null
+ }
+
+ function modelText(value) {
+ var parts = []
+ if (!value || typeof value !== "object") return ""
+ var keys = ["label", "displayName", "name", "model", "modelId", "model_id", "id"]
+ for (var i = 0; i < keys.length; i += 1) {
+ if (typeof value[keys[i]] === "string") parts.push(value[keys[i]])
+ }
+ return parts.join(" ").toLowerCase()
+ }
+
+ function poolForText(text) {
+ var lower = String(text || "").toLowerCase()
+ if (lower.indexOf("gemini") !== -1 && lower.indexOf("pro") !== -1) return "Gemini Pro"
+ if (lower.indexOf("gemini") !== -1 && lower.indexOf("flash") !== -1) return "Gemini Flash"
+ if (lower.indexOf("gemini") !== -1) return null
+ if (lower) return "Claude"
+ return null
+ }
+
+ function pushBucket(out, pool, remainingFraction, resetTime) {
+ if (!pool || !Number.isFinite(remainingFraction)) return
+ out.push({
+ pool: pool,
+ remainingFraction: remainingFraction,
+ resetTime: resetTime || null,
+ })
+ }
+
+ function collectFetchModelBuckets(value, out) {
+ if (Array.isArray(value)) {
+ for (var i = 0; i < value.length; i += 1) collectFetchModelBuckets(value[i], out)
+ return
+ }
+ if (!value || typeof value !== "object") return
+ if (value.isInternal) return
+
+ var quota = value.quotaInfo || value.quota || null
+ var remaining = quota && typeof quota.remainingFraction === "number"
+ ? quota.remainingFraction
+ : typeof value.remainingFraction === "number"
+ ? value.remainingFraction
+ : null
+ if (remaining !== null) {
+ var text = modelText(value)
+ if (text) {
+ pushBucket(out, poolForText(text), remaining, (quota && (quota.resetTime || quota.reset_time)) || value.resetTime || value.reset_time)
+ }
+ }
+
+ var children = Object.values(value)
+ for (var j = 0; j < children.length; j += 1) collectFetchModelBuckets(children[j], out)
+ }
+
+ function collectQuotaBuckets(value, out, inheritedText) {
+ if (Array.isArray(value)) {
+ for (var i = 0; i < value.length; i += 1) collectQuotaBuckets(value[i], out, inheritedText)
+ return
+ }
+ if (!value || typeof value !== "object") return
+
+ var text = (inheritedText || "") + " " + modelText(value)
+ if (typeof value.remainingFraction === "number") {
+ pushBucket(out, poolForText(text), value.remainingFraction, value.resetTime || value.reset_time)
+ }
+
+ var entries = Object.keys(value)
+ for (var j = 0; j < entries.length; j += 1) {
+ var key = entries[j]
+ collectQuotaBuckets(value[key], out, text + " " + key)
+ }
+ }
+
+ function dedupeBuckets(buckets) {
+ var byPool = {}
+ for (var i = 0; i < buckets.length; i += 1) {
+ var bucket = buckets[i]
+ if (!byPool[bucket.pool] || bucket.remainingFraction < byPool[bucket.pool].remainingFraction) {
+ byPool[bucket.pool] = bucket
+ }
+ }
+ return byPool
+ }
+
+ function lineForBucket(ctx, label, bucket) {
+ var clamped = Math.max(0, Math.min(1, Number(bucket.remainingFraction)))
+ var opts = {
+ label: label,
+ used: Math.round((1 - clamped) * 100),
+ limit: 100,
+ format: { kind: "percent" },
+ periodDurationMs: QUOTA_PERIOD_MS,
+ }
+ if (bucket.resetTime) {
+ var iso = ctx.util.toIso ? ctx.util.toIso(bucket.resetTime) : bucket.resetTime
+ if (iso) opts.resetsAt = iso
+ }
+ return ctx.line.progress(opts)
+ }
+
+ function buildLines(ctx, buckets) {
+ var byPool = dedupeBuckets(buckets)
+ var order = ["Gemini Pro", "Gemini Flash", "Claude"]
+ var lines = []
+ for (var i = 0; i < order.length; i += 1) {
+ var label = order[i]
+ if (byPool[label]) lines.push(lineForBucket(ctx, label, byPool[label]))
+ }
+ return lines
+ }
+
+ function probe(ctx) {
+ readNonSecretCliContext(ctx)
+
+ var token = extractAccessToken(ctx, readKeychainValue(ctx))
+ if (!token) throw LOGIN_MESSAGE
+
+ var loadData = requestJson(ctx, LOAD_CODE_ASSIST_URL, token, { metadata: IDE_METADATA })
+ var plan = readPlan(loadData)
+
+ var fetchData = requestJson(ctx, FETCH_MODELS_URL, token, { metadata: IDE_METADATA })
+ var buckets = []
+ collectFetchModelBuckets(fetchData, buckets)
+
+ if (buckets.length === 0) {
+ var quotaData = requestJson(ctx, RETRIEVE_QUOTA_URL, token, { metadata: IDE_METADATA })
+ collectQuotaBuckets(quotaData, buckets, "")
+ }
+
+ var lines = buildLines(ctx, buckets)
+ if (lines.length === 0) {
+ lines.push(ctx.line.badge({ label: "Status", text: "No quota data", color: "#a3a3a3" }))
+ }
+
+ return { plan: plan || undefined, lines: lines }
+ }
+
+ globalThis.__openusage_plugin = { id: PROVIDER_ID, probe: probe }
+})()
diff --git a/plugins/antigravity-cli/plugin.json b/plugins/antigravity-cli/plugin.json
new file mode 100644
index 00000000..5bf2ba67
--- /dev/null
+++ b/plugins/antigravity-cli/plugin.json
@@ -0,0 +1,14 @@
+{
+ "schemaVersion": 1,
+ "id": "antigravity-cli",
+ "name": "Antigravity CLI",
+ "version": "0.0.1",
+ "entry": "plugin.js",
+ "icon": "icon.svg",
+ "brandColor": "#4285F4",
+ "lines": [
+ { "type": "progress", "label": "Gemini Pro", "scope": "overview", "primaryOrder": 1 },
+ { "type": "progress", "label": "Gemini Flash", "scope": "overview", "primaryOrder": 2 },
+ { "type": "progress", "label": "Claude", "scope": "overview", "primaryOrder": 3 }
+ ]
+}
diff --git a/plugins/antigravity-cli/plugin.test.js b/plugins/antigravity-cli/plugin.test.js
new file mode 100644
index 00000000..77e766dd
--- /dev/null
+++ b/plugins/antigravity-cli/plugin.test.js
@@ -0,0 +1,284 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { makeCtx } from "../test-helpers.js"
+
+const LOAD_CODE_ASSIST_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
+const FETCH_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"
+const RETRIEVE_QUOTA_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"
+const LOGIN_MESSAGE = "Not logged in. Run `agy` and complete Google sign-in first."
+const REQUEST_FAILED_MESSAGE = "Antigravity CLI quota request failed. Check your connection and try again."
+const INVALID_RESPONSE_MESSAGE = "Antigravity CLI quota response was invalid. Try again later."
+
+const loadPlugin = async () => {
+ await import("./plugin.js")
+ return globalThis.__openusage_plugin
+}
+
+function setKeychain(ctx, value) {
+ ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => {
+ if (service === "gemini" && account === "antigravity") return value
+ return null
+ })
+}
+
+function mockResponses(ctx, responses) {
+ ctx.host.http.request.mockImplementation((opts) => {
+ const url = String(opts.url)
+ if (!responses[url]) throw new Error("unexpected url: " + url)
+ return responses[url](opts)
+ })
+}
+
+function json(status, body) {
+ return { status, bodyText: JSON.stringify(body) }
+}
+
+describe("antigravity-cli plugin", () => {
+ beforeEach(() => {
+ delete globalThis.__openusage_plugin
+ vi.resetModules()
+ })
+
+ it("loads a raw keychain bearer token and parses fetchAvailableModels quotaInfo", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "Bearer raw-token")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => json(200, { userTier: { name: "Google AI Ultra" } }),
+ [FETCH_MODELS_URL]: (opts) => {
+ expect(opts.headers.Authorization).toBe("Bearer raw-token")
+ return json(200, {
+ models: {
+ proHigh: {
+ displayName: "Gemini 3 Pro (High)",
+ model: "gemini-3-pro",
+ quotaInfo: { remainingFraction: 0.4, resetTime: "2026-05-21T00:00:00Z" },
+ },
+ proLow: {
+ displayName: "Gemini 3 Pro (Low)",
+ model: "gemini-3-pro-low",
+ quotaInfo: { remainingFraction: 0.9 },
+ },
+ flash: {
+ displayName: "Gemini Flash",
+ model: "gemini-3-flash",
+ quotaInfo: { remainingFraction: 0.8 },
+ },
+ claude: {
+ displayName: "Claude Sonnet",
+ model: "claude-sonnet",
+ quotaInfo: { remainingFraction: 0.25 },
+ },
+ },
+ })
+ },
+ })
+
+ const plugin = await loadPlugin()
+ const result = plugin.probe(ctx)
+
+ expect(plugin.id).toBe("antigravity-cli")
+ expect(result.plan).toBe("Google AI Ultra")
+ expect(result.lines.map((line) => line.label)).toEqual(["Gemini Pro", "Gemini Flash", "Claude"])
+ expect(result.lines.find((line) => line.label === "Gemini Pro").used).toBe(60)
+ expect(result.lines.find((line) => line.label === "Gemini Flash").used).toBe(20)
+ expect(result.lines.find((line) => line.label === "Claude").used).toBe(75)
+ expect(ctx.host.http.request).toHaveBeenCalledTimes(2)
+ })
+
+ it("loads an OAuth-style JSON keychain token", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, JSON.stringify({ access_token: "json-token", refresh_token: "refresh" }))
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => json(200, {}),
+ [FETCH_MODELS_URL]: (opts) => {
+ expect(opts.headers.Authorization).toBe("Bearer json-token")
+ return json(200, {
+ models: [{ label: "Gemini Pro", quotaInfo: { remainingFraction: 1 } }],
+ })
+ },
+ })
+
+ const plugin = await loadPlugin()
+ const result = plugin.probe(ctx)
+ expect(result.plan).toBeUndefined()
+ expect(result.lines.find((line) => line.label === "Gemini Pro").used).toBe(0)
+ })
+
+ it("loads a go-keyring-base64 wrapped JSON token", async () => {
+ const ctx = makeCtx()
+ const encoded = ctx.base64.encode(JSON.stringify({ tokens: { accessToken: "wrapped-token" } }))
+ setKeychain(ctx, "go-keyring-base64:" + encoded)
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => json(200, {}),
+ [FETCH_MODELS_URL]: (opts) => {
+ expect(opts.headers.Authorization).toBe("Bearer wrapped-token")
+ return json(200, {
+ models: [{ label: "Gemini Flash", model: "gemini-flash", quotaInfo: { remainingFraction: 0.55 } }],
+ })
+ },
+ })
+
+ const plugin = await loadPlugin()
+ const result = plugin.probe(ctx)
+ expect(result.lines.find((line) => line.label === "Gemini Flash").used).toBe(45)
+ })
+
+ it("throws agy login instruction when keychain entry is missing", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, null)
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
+ expect(ctx.host.http.request).not.toHaveBeenCalled()
+ })
+
+ it("does not fall back to service-wide Gemini keychain credentials", async () => {
+ const ctx = makeCtx()
+ ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => {
+ if (service === "gemini" && account === "antigravity") return null
+ if (service === "gemini" && account === undefined) return "wrong-gemini-token"
+ return null
+ })
+
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
+ expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledTimes(1)
+ expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("gemini", "antigravity")
+ expect(ctx.host.http.request).not.toHaveBeenCalled()
+ })
+
+ it("rejects JSON keychain payloads without an access token", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, JSON.stringify({ refresh_token: "refresh-only" }))
+
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
+ expect(ctx.host.http.request).not.toHaveBeenCalled()
+ })
+
+ it("falls back to retrieveUserQuota nested buckets when fetchAvailableModels lacks quota", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "token")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => json(200, { user: { tier: { name: "Ignored" } } }),
+ [FETCH_MODELS_URL]: () => json(200, { models: [{ displayName: "Gemini Pro", model: "gemini-pro" }] }),
+ [RETRIEVE_QUOTA_URL]: () => json(200, {
+ quota: {
+ pools: {
+ gemini_pro: {
+ buckets: [
+ { modelId: "gemini-3-pro", remainingFraction: 0.7 },
+ { modelId: "gemini-3-pro-high", remainingFraction: 0.2 },
+ ],
+ },
+ gemini_flash: {
+ buckets: [{ model_id: "gemini-3-flash", remainingFraction: 0.6 }],
+ },
+ third_party: {
+ claude: [{ modelId: "claude-sonnet", remainingFraction: 0.3 }],
+ },
+ },
+ },
+ }),
+ })
+
+ const plugin = await loadPlugin()
+ const result = plugin.probe(ctx)
+
+ expect(result.lines.find((line) => line.label === "Gemini Pro").used).toBe(80)
+ expect(result.lines.find((line) => line.label === "Gemini Flash").used).toBe(40)
+ expect(result.lines.find((line) => line.label === "Claude").used).toBe(70)
+ expect(ctx.host.http.request).toHaveBeenCalledTimes(3)
+ })
+
+ it("returns no quota badge for missing or empty quota responses", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "token")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => json(200, {}),
+ [FETCH_MODELS_URL]: () => json(200, { models: [] }),
+ [RETRIEVE_QUOTA_URL]: () => json(200, { quota: { pools: [] } }),
+ })
+
+ const plugin = await loadPlugin()
+ const result = plugin.probe(ctx)
+ expect(result.lines).toEqual([expect.objectContaining({ type: "badge", label: "Status", text: "No quota data" })])
+ })
+
+ it("throws agy login instruction on auth failure", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "expired")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => ({ status: 401, bodyText: "{}" }),
+ })
+
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
+ })
+
+ it("throws a clear request failure on transport errors", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "token")
+ ctx.host.http.request.mockImplementation(() => {
+ throw new Error("network down")
+ })
+
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(REQUEST_FAILED_MESSAGE)
+ })
+
+ it("throws a clear request failure for malformed HTTP responses", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "token")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => ({ bodyText: "{}" }),
+ })
+
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(REQUEST_FAILED_MESSAGE)
+ })
+
+ it("throws a clear invalid response error for non-JSON response bodies", async () => {
+ const ctx = makeCtx()
+ setKeychain(ctx, "token")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => ({ status: 200, bodyText: "login" }),
+ })
+
+ const plugin = await loadPlugin()
+ expect(() => plugin.probe(ctx)).toThrow(INVALID_RESPONSE_MESSAGE)
+ })
+
+ it("does not read legacy Gemini OAuth files", async () => {
+ const ctx = makeCtx()
+ const existsCalls = []
+ const readCalls = []
+ ctx.host.fs.exists = (path) => {
+ existsCalls.push(path)
+ if (
+ path === "~/.gemini/settings.json" ||
+ path === "~/.gemini/oauth_creds.json" ||
+ String(path).includes("@google/gemini-cli")
+ ) {
+ throw new Error("legacy Gemini path touched: " + path)
+ }
+ return path === "~/.gemini/antigravity-cli"
+ }
+ ctx.host.fs.readText = (path) => {
+ readCalls.push(path)
+ throw new Error("unexpected readText: " + path)
+ }
+ setKeychain(ctx, "token")
+ mockResponses(ctx, {
+ [LOAD_CODE_ASSIST_URL]: () => json(200, {}),
+ [FETCH_MODELS_URL]: () => json(200, {
+ models: [{ label: "Gemini Pro", model: "gemini-pro", quotaInfo: { remainingFraction: 0.5 } }],
+ }),
+ })
+
+ const plugin = await loadPlugin()
+ plugin.probe(ctx)
+
+ expect(readCalls).not.toContain("~/.gemini/oauth_creds.json")
+ expect(existsCalls).toContain("~/.gemini/antigravity-cli")
+ expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("gemini", "antigravity")
+ })
+})
diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs
index a39ac09d..5d84a81d 100644
--- a/src-tauri/src/plugin_engine/host_api.rs
+++ b/src-tauri/src/plugin_engine/host_api.rs
@@ -2201,16 +2201,42 @@ fn inject_keychain<'js>(
"readGenericPassword",
Function::new(
ctx.clone(),
- move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result {
+ move |ctx_inner: Ctx<'_>,
+ service: String,
+ account: Option|
+ -> rquickjs::Result {
if !cfg!(target_os = "macos") {
return Err(Exception::throw_message(
&ctx_inner,
"keychain API is only supported on macOS",
));
}
- log::info!("[plugin:{}] keychain read: service={}", pid_read, service);
+ let account = account.and_then(|value| {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+ });
+ let redacted_account = account.as_deref().map(redact_value);
+ if let Some(ref redacted) = redacted_account {
+ log::info!(
+ "[plugin:{}] keychain read: service={}, account={}",
+ pid_read,
+ service,
+ redacted
+ );
+ } else {
+ log::info!("[plugin:{}] keychain read: service={}", pid_read, service);
+ }
+ let args = if let Some(ref account) = account {
+ keychain_find_generic_password_args_for_account(&service, account)
+ } else {
+ keychain_find_generic_password_args(&service)
+ };
let output = std::process::Command::new("security")
- .args(keychain_find_generic_password_args(&service))
+ .args(args)
.output()
.map_err(|e| {
Exception::throw_message(
@@ -2222,23 +2248,42 @@ fn inject_keychain<'js>(
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let first_line = stderr.lines().next().unwrap_or("").trim();
- log::warn!(
- "[plugin:{}] keychain read miss: service={}, error={}",
- pid_read,
- service,
- first_line
- );
+ if let Some(ref redacted) = redacted_account {
+ log::warn!(
+ "[plugin:{}] keychain read miss: service={}, account={}, error={}",
+ pid_read,
+ service,
+ redacted,
+ first_line
+ );
+ } else {
+ log::warn!(
+ "[plugin:{}] keychain read miss: service={}, error={}",
+ pid_read,
+ service,
+ first_line
+ );
+ }
return Err(Exception::throw_message(
&ctx_inner,
&format!("keychain item not found: {}", first_line),
));
}
- log::info!(
- "[plugin:{}] keychain read hit: service={}",
- pid_read,
- service
- );
+ if let Some(ref redacted) = redacted_account {
+ log::info!(
+ "[plugin:{}] keychain read hit: service={}, account={}",
+ pid_read,
+ service,
+ redacted
+ );
+ } else {
+ log::info!(
+ "[plugin:{}] keychain read hit: service={}",
+ pid_read,
+ service
+ );
+ }
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
},
)?,