diff --git a/src/main/rate-limits/antigravity-usage-command.ts b/src/main/rate-limits/antigravity-usage-command.ts index 7d58f98cf57d..cd9f0eeededc 100644 --- a/src/main/rate-limits/antigravity-usage-command.ts +++ b/src/main/rate-limits/antigravity-usage-command.ts @@ -36,6 +36,15 @@ export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [ */ export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000 +// Before agy 1.1.11, `/usage` in print mode spends a model turn (agy's bundled changelog). +export const ANTIGRAVITY_MIN_USAGE_VERSION = '1.1.11' + +/** The version probe; free in every sense — no conversation, no quota. */ +export const ANTIGRAVITY_VERSION_ARGS: readonly string[] = ['--version'] + +/** Why bound it: the probe runs before every quota read, so a wedged CLI must not stall the cycle. */ +export const ANTIGRAVITY_VERSION_TIMEOUT_MS = 5_000 + /** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */ export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024 diff --git a/src/main/rate-limits/antigravity-usage-error.test.ts b/src/main/rate-limits/antigravity-usage-error.test.ts new file mode 100644 index 000000000000..e737c8cb42de --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-error.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { classifyAntigravityUsageFailure } from './antigravity-usage-error' + +/** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */ +const REAL_RESOURCE_EXHAUSTED = + 'error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.\n' + + 'AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true,"error_id":"6baa4ce9-c373-4086-bb0d-fbb8421bacf3-1"}' + +function agyError(fields: Record): string { + return `AGY_ERROR: ${JSON.stringify(fields)}` +} + +describe('classifyAntigravityUsageFailure', () => { + it('reads the real RESOURCE_EXHAUSTED diagnostic as rate limiting, not as a sign-out', () => { + const failure = classifyAntigravityUsageFailure(REAL_RESOURCE_EXHAUSTED) + + expect(failure).toEqual({ failureKind: 'rate-limited', signedOut: false }) + }) + + it.each([ + ['status', { status: 'UNAUTHENTICATED' }], + ['http code', { error_code: 401 }] + ])('reads an unauthenticated %s as a missing sign-in', (_label, fields) => { + expect(classifyAntigravityUsageFailure(agyError(fields))).toEqual({ + failureKind: 'missing-credentials', + signedOut: true + }) + }) + + it('separates an unentitled account from a signed-out one', () => { + // Why this matters: telling a signed-in user to sign in sends them to re-auth that fixes + // nothing. + expect(classifyAntigravityUsageFailure(agyError({ status: 'PERMISSION_DENIED' }))).toEqual({ + failureKind: 'no-subscription', + signedOut: false + }) + }) + + it('reads a 5xx as a server failure', () => { + expect(classifyAntigravityUsageFailure(agyError({ error_code: 503 }))).toEqual({ + failureKind: 'server', + signedOut: false + }) + }) + + it.each([ + 'You are not logged into Antigravity.', + 'Error: not signed in. Run `agy` to continue.', + 'please sign in to continue', + 'AUTHENTICATION REQUIRED' + ])('falls back to the sentence when no structured error is printed: %s', (output) => { + // Why a fallback at all: the structured line is the stable signal, but not every path prints + // one, and a signed-out account must never be reported as an unreadable payload. + expect(classifyAntigravityUsageFailure(output)).toEqual({ + failureKind: 'missing-credentials', + signedOut: true + }) + }) + + it('prefers the structured status over a sentence that disagrees with it', () => { + const output = `not logged in\n${agyError({ status: 'RESOURCE_EXHAUSTED' })}` + + expect(classifyAntigravityUsageFailure(output)).toEqual({ + failureKind: 'rate-limited', + signedOut: false + }) + }) + + it.each([ + ['a successful read', '{"status":"SUCCESS","command":{"name":"usage"}}'], + ['unrelated noise', 'I0926 quota_manager.go:45] doRefreshQuota: starting reload'], + ['an unparsable structured line', 'AGY_ERROR: {not json'], + ['an unrecognised status', agyError({ status: 'SOMETHING_NEW' })], + ['empty output', ''] + ])('returns null for %s rather than inventing a cause', (_label, output) => { + expect(classifyAntigravityUsageFailure(output)).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-error.ts b/src/main/rate-limits/antigravity-usage-error.ts new file mode 100644 index 000000000000..456527c2d110 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-error.ts @@ -0,0 +1,83 @@ +import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types' + +const AGY_ERROR_PREFIX = 'AGY_ERROR:' + +const SIGNED_OUT_PHRASES = [ + 'not logged into antigravity', + 'not logged in', + 'not signed in', + 'not authenticated', + 'not-authenticated', + 'unauthenticated', + 'run agy login', + 'please sign in', + 'please log in', + 'sign in to antigravity', + 'no credentials', + 'authentication required' +] as const + +export type AntigravityUsageFailure = { + failureKind: UsageRateLimitFailureKind + /** Missing authentication requires sign-in guidance. */ + signedOut: boolean +} + +type StructuredAgyError = { status?: unknown; error_code?: unknown } + +function parseStructuredErrors(output: string): StructuredAgyError[] { + const parsed: StructuredAgyError[] = [] + for (const line of output.split('\n')) { + const index = line.indexOf(AGY_ERROR_PREFIX) + if (index === -1) { + continue + } + const payload = line.slice(index + AGY_ERROR_PREFIX.length).trim() + if (!payload.startsWith('{')) { + continue + } + try { + const value: unknown = JSON.parse(payload) + if (typeof value === 'object' && value !== null) { + parsed.push(value) + } + } catch { + continue + } + } + return parsed +} + +function classifyStructured(error: StructuredAgyError): AntigravityUsageFailure | null { + const status = typeof error.status === 'string' ? error.status.toUpperCase() : '' + const code = typeof error.error_code === 'number' ? error.error_code : null + if (status === 'UNAUTHENTICATED' || code === 401) { + return { failureKind: 'missing-credentials', signedOut: true } + } + if (status === 'PERMISSION_DENIED' || code === 403) { + // The session is real; the account simply is not entitled to what was asked for. + return { failureKind: 'no-subscription', signedOut: false } + } + if (status === 'RESOURCE_EXHAUSTED' || code === 429) { + return { failureKind: 'rate-limited', signedOut: false } + } + if (code !== null && code >= 500 && code < 600) { + return { failureKind: 'server', signedOut: false } + } + return null +} + +// Structured errors outrank prose; an unknown diagnostic supplies no failure cause. +export function classifyAntigravityUsageFailure(output: string): AntigravityUsageFailure | null { + for (const error of parseStructuredErrors(output)) { + const classified = classifyStructured(error) + if (classified) { + return classified + } + } + const lowered = output.toLowerCase() + if (SIGNED_OUT_PHRASES.some((phrase) => lowered.includes(phrase))) { + return { failureKind: 'missing-credentials', signedOut: true } + } + return null +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index f05fc7bc7a5d..911861a9265d 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -3,7 +3,7 @@ import { fetchAntigravityRateLimits, resetAntigravityUsageSupportForTests } from './antigravity-usage-fetcher' -import { ANTIGRAVITY_USAGE_ARGS } from './antigravity-usage-command' +import { ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_VERSION_ARGS } from './antigravity-usage-command' import type { ProcessResult } from '../../shared/child-process/process-spec' const USAGE_ENVELOPE = JSON.stringify({ @@ -38,13 +38,24 @@ function processResult(overrides: Partial = {}): ProcessResult { function harness( options: { result?: ProcessResult + versionResult?: ProcessResult runCommand?: ReturnType program?: string | null env?: NodeJS.ProcessEnv } = {} ) { + // Why the per-call split: the fetcher probes `--version` before the quota read, and a fixture + // that answers both alike either fakes a version line into `/usage` or a usage envelope into the + // version probe. const runCommand = - options.runCommand ?? vi.fn().mockResolvedValue(options.result ?? processResult()) + options.runCommand ?? + vi + .fn() + .mockImplementation(async (spec: { args?: readonly string[] }) => + spec.args?.[0] === '--version' + ? (options.versionResult ?? processResult({ stdout: 'agy version 1.2.11\n' })) + : (options.result ?? processResult()) + ) // Why the `in` check and not `??`: an explicit `program: null` is the absent-CLI case. const resolveCommand = vi .fn() @@ -103,12 +114,13 @@ describe('fetchAntigravityRateLimits', () => { } }) - it('runs the resolved absolute path with the quota arguments and the login-shell env', async () => { + it('probes the version, then runs the quota read with the resolved path and login-shell env', async () => { const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }) await h.fetch() - expect(h.runCommand).toHaveBeenCalledTimes(1) - const spec = h.runCommand.mock.calls[0]![0] + expect(h.runCommand).toHaveBeenCalledTimes(2) + expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS) + const spec = h.runCommand.mock.calls[1]![0] expect(spec.program).toBe('/Users/x/.local/bin/agy') expect(spec.args).toEqual(ANTIGRAVITY_USAGE_ARGS) // Why the login-shell PATH: agy installs to ~/.local/bin, which Electron's inherited PATH omits. @@ -141,6 +153,43 @@ describe('fetchAntigravityRateLimits', () => { expect(result.error).toContain('Sign in with `agy`') }) + it('reports an auth failure on stderr as sign-in guidance, not as a broken read', async () => { + // Why pinned: the other shape of "signed out" — a non-zero exit with an auth diagnostic — used + // to land in the parse-failure branch as a generic refresh error. + const result = await harness({ + result: processResult({ code: 1, stderr: 'Not authenticated. Run agy login.' }) + }).fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.error).toContain('Sign in with `agy`') + }) + + it('reports a bare "Not authenticated." failure on stderr as sign-in guidance', async () => { + const result = await harness({ + result: processResult({ code: 1, stderr: 'Not authenticated.\n' }) + }).fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.error).toContain('Sign in with `agy`') + }) + + it('does not classify non-auth stderr as signed-out on non-zero exit', async () => { + // Why pinned: stderr containing unrelated words like "oauth" or "author" must not be misclassified + // as missing-credentials. + const result = await harness({ + result: processResult({ + code: 1, + stderr: 'Failed to contact https://oauth2.googleapis.com/token: connection refused' + }) + }).fetch() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + expect(result.error).toContain('did not report a quota (exit 1)') + }) + it('reports a timeout as its own failure kind', async () => { const result = await harness({ result: processResult({ timedOut: true }) }).fetch() @@ -216,13 +265,13 @@ describe('agy versions that answer /usage as a prompt', () => { it('never spawns agy again once a turn was spent', async () => { const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) }) await h.fetch() - expect(h.runCommand).toHaveBeenCalledTimes(1) + expect(h.runCommand).toHaveBeenCalledTimes(2) // Why: the evidence costs a turn of the user's quota, so rediscovering it on a 15-minute // cadence would keep paying for the same answer. await h.fetch() await h.fetch() - expect(h.runCommand).toHaveBeenCalledTimes(1) + expect(h.runCommand).toHaveBeenCalledTimes(2) }) it('does not latch when the usage payload parsed, whatever else the envelope says', async () => { @@ -234,7 +283,7 @@ describe('agy versions that answer /usage as a prompt', () => { expect(first.status).toBe('ok') expect(second.status).toBe('ok') - expect(h.runCommand).toHaveBeenCalledTimes(2) + expect(h.runCommand).toHaveBeenCalledTimes(4) }) it('does not latch on an empty or unparsable answer', async () => { @@ -245,6 +294,129 @@ describe('agy versions that answer /usage as a prompt', () => { // Why: a transient failure is not evidence that the command is unsupported. expect(first.status).toBe('error') expect(second.status).toBe('error') + expect(h.runCommand).toHaveBeenCalledTimes(4) + }) +}) + +describe('the agy version gate', () => { + beforeEach(() => { + resetAntigravityUsageSupportForTests() + }) + + it('never runs the quota read below the floor', async () => { + const h = harness({ + versionResult: processResult({ stdout: 'agy version 1.1.10\n' }), + result: processResult({ stdout: USAGE_ENVELOPE }) + }) + const result = await h.fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('needs agy 1.1.11 or newer (found 1.1.10)') + // Why the assertion: below the floor `/usage` is a billed model turn, so it must not run at all. + expect(h.runCommand).toHaveBeenCalledTimes(1) + expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS) + }) + + it('holds a prerelease below the floor', async () => { + // Why pinned: `1.1.11-rc.1` sorts below stable `1.1.11`; reading the core alone would let a + // release candidate spawn the billable read early. + const h = harness({ versionResult: processResult({ stdout: 'agy version 1.1.11-rc.1\n' }) }) + const result = await h.fetch() + + expect(result.status).toBe('unavailable') + expect(result.error).toContain('found 1.1.11-rc.1') + expect(h.runCommand).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['a garbled version line', processResult({ stdout: 'agy version unknown\n' })], + ['a failed version probe', processResult({ code: 1, stderr: 'boom' })] + ])('reports %s instead of guessing', async (_label, versionResult) => { + const h = harness({ versionResult }) + const result = await h.fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('version could not be read') + expect(h.runCommand).toHaveBeenCalledTimes(1) + }) + + it('runs the quota read at exactly the floor', async () => { + const h = harness({ + versionResult: processResult({ stdout: 'agy version 1.1.11\n' }), + result: processResult({ stdout: USAGE_ENVELOPE }) + }) + const result = await h.fetch() + + expect(result.status).toBe('ok') + expect(h.runCommand).toHaveBeenCalledTimes(2) + }) + + it('reads the version from stderr if stdout is empty on exit 0', async () => { + const h = harness({ + versionResult: processResult({ stdout: '', stderr: 'agy version 1.1.11\n' }), + result: processResult({ stdout: USAGE_ENVELOPE }) + }) + const result = await h.fetch() + + expect(result.status).toBe('ok') expect(h.runCommand).toHaveBeenCalledTimes(2) }) }) + +describe('quota read safety and diagnostic precedence', () => { + beforeEach(() => resetAntigravityUsageSupportForTests()) + + it.each([ + processResult({ stdout: '1.2.14', timedOut: true }), + processResult({ stdout: '1.2.14', signal: 'SIGTERM' }), + processResult({ stdout: '1.2.14', code: 1 }) + ])('does not read quota after an unsuccessful version probe: %j', async (versionResult) => { + const h = harness({ versionResult }) + const result = await h.fetch() + expect(result.status).toBe('unavailable') + expect(h.runCommand).toHaveBeenCalledTimes(1) + }) + + it('accepts a quota reading ahead of unrelated auth diagnostics', async () => { + const result = await harness({ + result: processResult({ + stdout: USAGE_ENVELOPE, + stderr: 'Not authenticated. Run agy login.' + }) + }).fetch() + expect(result.status).toBe('ok') + }) + + it('latches a model turn ahead of authentication guidance', async () => { + const h = harness({ + result: processResult({ stdout: MODEL_TURN_ENVELOPE, stderr: 'not logged into antigravity' }) + }) + expect((await h.fetch()).error).toContain('answers `/usage` as a prompt') + await h.fetch() + expect(h.runCommand).toHaveBeenCalledTimes(2) + }) + + it('prefers a structured quota failure over conflicting sign-in wording', async () => { + const result = await harness({ + result: processResult({ + stderr: 'not logged in\nAGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":429}' + }) + }).fetch() + expect(result.usageMetadata?.failureKind).toBe('rate-limited') + expect(result.error).not.toContain('Sign in') + }) + + it('rechecks the version after an in-session CLI upgrade', async () => { + const runCommand = vi + .fn() + .mockResolvedValueOnce(processResult({ stdout: '1.1.10' })) + .mockResolvedValueOnce(processResult({ stdout: '1.2.14' })) + .mockResolvedValueOnce(processResult({ stdout: USAGE_ENVELOPE })) + const h = harness({ runCommand }) + expect((await h.fetch()).status).toBe('unavailable') + expect((await h.fetch()).status).toBe('ok') + expect(runCommand).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 9a1d8faf7f85..2097989e062a 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,21 +1,29 @@ import { runProcess } from '../../shared/child-process/run-process' +import { hasReachedAppVersion, parseCliVersion } from '../../shared/app-version' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' import { + ANTIGRAVITY_MIN_USAGE_VERSION, ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES, ANTIGRAVITY_USAGE_TIMEOUT_MS, + ANTIGRAVITY_VERSION_ARGS, + ANTIGRAVITY_VERSION_TIMEOUT_MS, antigravityCommandName } from './antigravity-usage-command' import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' -/** - * Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this - * instead of a usage envelope, so the text is the only thing that separates "signed out" from - * "answered nothing". - */ -const NOT_SIGNED_IN_MARKER = 'not logged into antigravity' +import { classifyAntigravityUsageFailure } from './antigravity-usage-error' + +const FAILURE_REASONS: Partial> = { + 'rate-limited': + 'Antigravity usage is not available right now. The Antigravity API is rate-limiting this account.', + 'no-subscription': + 'Antigravity usage is not available. This account is signed in but not entitled to Antigravity quota.', + server: + 'Antigravity usage is not available right now. The Antigravity API returned a server error.' +} const UNSUPPORTED_USAGE_COMMAND_REASON = 'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.' @@ -66,6 +74,10 @@ function unavailable( } } +function spawnFailureMessage(error: unknown): string { + return `Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.` +} + function failed( message: string, failureKind: UsageRateLimitFailureKind, @@ -120,6 +132,34 @@ export async function fetchAntigravityRateLimits( ) } + // Recheck each read: the CLI can be replaced while Orca runs; unsupported reads spend quota. + let versionRun: Awaited> + try { + versionRun = await run({ + program, + args: ANTIGRAVITY_VERSION_ARGS, + env, + timeoutMs: ANTIGRAVITY_VERSION_TIMEOUT_MS, + maxOutputBytes: ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES, + signal: options.signal + }) + } catch (error) { + return failed(spawnFailureMessage(error), 'cli-unavailable', now()) + } + const version = + versionRun.code === 0 && !versionRun.timedOut && versionRun.signal === null + ? parseCliVersion(versionRun.stdout.trim() ? versionRun.stdout : versionRun.stderr) + : null + if (!version || !hasReachedAppVersion(version, ANTIGRAVITY_MIN_USAGE_VERSION)) { + return unavailable( + version + ? `Antigravity usage needs agy ${ANTIGRAVITY_MIN_USAGE_VERSION} or newer (found ${version}). Update the agy CLI to show quota in the status bar.` + : `Antigravity usage is unavailable because the agy CLI version could not be read. Update agy to ${ANTIGRAVITY_MIN_USAGE_VERSION} or newer.`, + 'usage-unavailable', + now() + ) + } + let result: Awaited> try { result = await run({ @@ -131,11 +171,7 @@ export async function fetchAntigravityRateLimits( signal: options.signal }) } catch (error) { - return failed( - `Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.`, - 'cli-unavailable', - now() - ) + return failed(spawnFailureMessage(error), 'cli-unavailable', now()) } if (result.timedOut) { @@ -146,15 +182,6 @@ export async function fetchAntigravityRateLimits( ) } - const output = `${result.stdout}\n${result.stderr}` - if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) { - return unavailable( - 'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.', - 'missing-credentials', - now() - ) - } - const reading = parseAntigravityUsageStdout(result.stdout) // Why the successful read is checked first: a real reading can never be evidence of a prompt, so // ordering it ahead of the turn check makes a false latch impossible. @@ -163,8 +190,26 @@ export async function fetchAntigravityRateLimits( return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now()) } if (!reading) { - // Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy - // exits 0 for a signed-out read, so the code only adds detail once the payload is missing. + const failure = classifyAntigravityUsageFailure(`${result.stdout}\n${result.stderr}`) + if (failure?.signedOut) { + return unavailable( + 'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.', + failure.failureKind, + now() + ) + } + if (failure) { + return failed( + FAILURE_REASONS[failure.failureKind] ?? + 'Antigravity usage is not available. The CLI could not read this account’s quota.', + failure.failureKind, + now() + ) + } + } + if (!reading) { + // Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and a + // signed-out read is classified above, so the code only adds detail once the payload is missing. const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})` return failed( `Antigravity usage is not available. The Antigravity CLI did not report a quota${exitDetail}.`, diff --git a/src/main/rate-limits/antigravity-usage-real-cli.test.ts b/src/main/rate-limits/antigravity-usage-real-cli.test.ts index e4976b2274d2..6ea1c4e621d3 100644 --- a/src/main/rate-limits/antigravity-usage-real-cli.test.ts +++ b/src/main/rate-limits/antigravity-usage-real-cli.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { stdoutShowsModelTurn } from './antigravity-usage-response' import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' /** @@ -14,7 +16,23 @@ const enabled = process.env.ORCA_REAL_AGY_CLI_TEST === '1' describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => { it('reports quota with at least one named pool', async () => { - const result = await fetchAntigravityRateLimits() + const calls: string[] = [] + const result = await fetchAntigravityRateLimits({ + runCommand: async (spec) => { + const output = await runProcess(spec) + calls.push(spec.args?.[0] ?? '') + if (spec.args?.[0] === '-p') { + expect(stdoutShowsModelTurn(output.stdout)).toBe(false) + const envelope: unknown = JSON.parse(output.stdout) + expect(envelope).toMatchObject({ + conversation_id: '', + num_turns: 0 + }) + } + return output + } + }) + expect(calls[0]).toBe('--version') if (result.status !== 'ok') { // A machine with no agy or no sign-in still proves the classification, not a crash. @@ -23,6 +41,7 @@ describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => { return } + expect(calls).toEqual(['--version', '-p']) expect(result.provider).toBe('antigravity') expect(result.error).toBeNull() expect(result.buckets?.length).toBeGreaterThan(0) diff --git a/src/main/rate-limits/antigravity-usage-snapshot.ts b/src/main/rate-limits/antigravity-usage-snapshot.ts new file mode 100644 index 000000000000..879993f9d1ae --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-snapshot.ts @@ -0,0 +1,12 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +// A null slot means loading to readers; a disabled meter must settle instead. +export function antigravityUsageDisabledSnapshot(now: number = Date.now()): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: now, + error: null, + status: 'idle' + } +} diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 1ffe7cabc34d..8e65e53740be 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -133,3 +133,66 @@ describe('RateLimitService Antigravity usage', () => { expect(state.gemini?.status).toBe('ok') }) }) + +describe('Antigravity usage gating', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + }) + + it('never spawns agy when the usage meter is hidden', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + service.setAntigravityUsageEnabledResolver(() => false) + + await service.refresh() + + // Why: the probe starts the agy language server for ~2.5 s. A user not showing the meter + // should not pay that every cycle. + expect(fetchAntigravityRateLimits).not.toHaveBeenCalled() + expect(service.getState().antigravity?.status).toBe('idle') + // Why the rest must be untouched: gating one provider cannot gate the cycle. + expect(service.getState().gemini?.status).toBe('ok') + }) + + it('spawns agy when the meter is shown', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + service.setAntigravityUsageEnabledResolver(() => true) + + await service.refresh() + + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().antigravity?.status).toBe('ok') + }) + + it('fetches when no resolver is configured', async () => { + // Why default-on: the status bar shows Antigravity by default, so an unconfigured service + // must still fill the meter rather than silently reporting nothing. + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + + await service.refresh() + + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + }) + + it('keeps the last reading when the meter is hidden mid-session', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 44)) + const service = new RateLimitService() + let enabled = true + service.setAntigravityUsageEnabledResolver(() => enabled) + await service.refresh() + expect(service.getState().antigravity?.session?.usedPercent).toBe(44) + + enabled = false + await service.refresh() + + // Why kept: hiding the meter is not evidence the quota changed, and re-showing it should not + // flash an empty segment while the next poll runs. + expect(service.getState().antigravity?.session?.usedPercent).toBe(44) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 2a788b1cf935..7db5d24a4a75 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -10,6 +10,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type AntigravityUsageEnabledResolver, type GeminiCliOAuthEnabledResolver, type InactiveCodexAccountInfo, type InactiveClaudeAccountInfo, @@ -52,6 +53,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.geminiCliOAuthEnabledResolver = resolver } + setAntigravityUsageEnabledResolver(resolver: AntigravityUsageEnabledResolver): void { + this.antigravityUsageEnabledResolver = resolver + } + setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { this.networkProxySettingsResolver = resolver } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 5395ace5fbff..4ecdbb8237ba 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -7,6 +7,7 @@ import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' +import { antigravityUsageDisabledSnapshot } from '../antigravity-usage-snapshot' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -116,6 +117,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ } const miniMaxGeneration = this.minimaxFetchGeneration + const antigravityUsageEnabled = this.antigravityUsageEnabledResolver?.() ?? true + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). this.updateState({ ...previousState, @@ -129,7 +132,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), + antigravity: antigravityUsageEnabled + ? this.withFetchingStatus(previousState.antigravity, 'antigravity') + : (previousState.antigravity ?? antigravityUsageDisabledSnapshot()), minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), @@ -155,10 +160,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) - // Why its own promise: the Antigravity read spawns `agy` and waits ~2.5 s for the CLI to start - // its language server and refresh the quota. Inside the awaited tuple that latency would be - // added to every other provider's cycle. - const antigravityResultPromise = fetchAntigravityRateLimits({ signal }).then( + // Hidden meters avoid the CLI spawn; the separate promise keeps other providers responsive. + const antigravityResultPromise = ( + antigravityUsageEnabled + ? fetchAntigravityRateLimits({ signal }) + : Promise.resolve(previousState.antigravity ?? antigravityUsageDisabledSnapshot()) + ).then( (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 7af82c6c3135..eb810f1e21bc 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -13,6 +13,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type AntigravityUsageEnabledResolver, type GeminiCliOAuthEnabledResolver, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, @@ -101,6 +102,7 @@ export abstract class RateLimitServiceState { protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected antigravityUsageEnabledResolver: AntigravityUsageEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index a3998c13abe4..c80f6bb68f14 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -62,6 +62,9 @@ export type MiniMaxResolvedConfig = { } export type GeminiCliOAuthEnabledResolver = () => boolean + +/** Whether the user is actually showing Antigravity usage, so the `agy` probe is worth spawning. */ +export type AntigravityUsageEnabledResolver = () => boolean export type ActiveRateLimitProvider = ProviderRateLimits['provider'] export type ActiveProviderState = { provider: ActiveRateLimitProvider diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 6abf73d66106..b32c54e41546 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -130,6 +130,10 @@ export function initializeMainProcessAccountServices(): void { } }) state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + // Reuse the meter switch so hidden Antigravity usage does not spawn agy. + state.rateLimits.setAntigravityUsageEnabledResolver(() => + store.getUI().statusBarItems.includes('antigravity') + ) state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) state.keybindings = new KeybindingService({ homePath: app.getPath('home'), diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 987bb91c040e..8506bae6779c 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -125,11 +125,9 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the - // checked item alone must not suppress the usage setup CTA. - expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( - false - ) + // Why: the checked item + PATH detection is durable on its own — the + // snapshot comes from the `agy` CLI probe and needs no Gemini OAuth. + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) // Why: an OPENCODE_API_KEY or a key OpenCode saved on /connect is invisible // to the renderer, so main's presence flag is the only durable signal. expect(hasUsageProviderSettings(usageSettings({ opencodeGoApiKeyConfigured: true }))).toBe(true) @@ -175,21 +173,15 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { - expect( - hasUsageProviderSettingsForProvider( - 'antigravity', - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) - ) - ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. + it('treats the checked Antigravity item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) + // Why no Gemini OAuth term: the snapshot comes from the `agy` CLI probe, so + // the OAuth opt-in says nothing about Antigravity. expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -357,11 +349,11 @@ describe('getVisibleUsageProvider', () => { ).toBe(null) }) - it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + it('keeps Antigravity visible from the checked item alone while the snapshot is pending', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -371,24 +363,15 @@ describe('getVisibleUsageProvider', () => { }) }) - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. + it('hides Antigravity when its status item is unchecked', () => { + // Why: without the checked item there is no durable signal, so an unavailable + // snapshot (e.g. a signed-out agy) must not pin a bar the user never asked for. + expect(getVisibleUsageProvider('antigravity', null, usageSettings())).toBe(null) expect( getVisibleUsageProvider( 'antigravity', - null, - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) - expect( - getVisibleUsageProvider( - 'antigravity', - provider('unavailable', { - provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' - }), - usageSettings({ antigravityUsageConfigured: true }) + provider('unavailable', { provider: 'antigravity' }), + usageSettings() ) ).toBe(null) }) @@ -535,14 +518,14 @@ describe('isUsageEmptyState', () => { cursor: provider('unavailable', { provider: 'cursor' }), zcode: provider('unavailable', { provider: 'zcode' }) }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) ).toBe(false) }) - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('still shows the setup CTA when no agent is detected, even with the Antigravity item checked', () => { + // Why: `antigravityUsageConfigured` is checked item + PATH detection, so a + // profile with nothing installed never counts Antigravity as set up. expect( isUsageEmptyState( { @@ -557,7 +540,7 @@ describe('isUsageEmptyState', () => { cursor: provider('unavailable', { provider: 'cursor' }), zcode: provider('unavailable', { provider: 'zcode' }) }, - usageSettings({ antigravityUsageConfigured: true }) + usageSettings() ) ).toBe(true) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index a49a9fe3a59b..98c54eb14b0f 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -10,9 +10,8 @@ export type UsageProviderSettings = Pick< > & { // Why: Antigravity has no separate persisted usage credential in Orca. The // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. + // this after PATH detection says the agent is available. No Gemini OAuth + // gate — the snapshot comes from the `agy` CLI probe, not the Gemini fetch. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -82,8 +81,7 @@ export function hasUsageProviderSettings( settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || settings?.opencodeGoApiKeyConfigured === true || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true || @@ -114,10 +112,9 @@ export function hasUsageProviderSettingsForProvider( ) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + // Why no Gemini OAuth gate: the snapshot comes from the `agy` CLI probe, so + // the checked item (PATH-gated above) is the whole durable signal. + return settings.antigravityUsageConfigured === true } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true || settings.minimaxApiKeyConfigured === true diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index cbcfcda04929..6977319fa152 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -6,13 +6,14 @@ describe('getUsageProviderAccountsSectionId', () => { expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') - expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') }) - it('does not invent an Accounts section for CLI-owned Kimi credentials', () => { + it('does not invent an Accounts section for CLI-owned credentials', () => { + expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() + expect(getUsageProviderAccountsSectionId('zcode')).toBeNull() }) }) diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index b531bffd161e..5a86e98ec350 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -9,8 +9,6 @@ export function getUsageProviderAccountsSectionId( case 'codex': return 'accounts-codex' case 'gemini': - case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. return 'accounts-gemini' case 'opencode-go': return 'accounts-opencode-go' @@ -20,10 +18,12 @@ export function getUsageProviderAccountsSectionId( return 'accounts-grok' case 'cursor': return 'accounts-cursor' + case 'antigravity': case 'kimi': case 'zcode': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json. + // Antigravity credentials live in the agy CLI; quota is fetched directly via agy. return null } } diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 8ec0bcfce437..8c94bc0ccc33 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -96,7 +96,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. - // Why: Antigravity visibility also requires geminiCliOAuthEnabled because its usage snapshot mirrors the Gemini fetch. const antigravityUsageConfigured = statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) diff --git a/src/shared/app-version.test.ts b/src/shared/app-version.test.ts index df3bff84c4cf..7190d6e3c80c 100644 --- a/src/shared/app-version.test.ts +++ b/src/shared/app-version.test.ts @@ -3,7 +3,8 @@ import { compareAppVersions, isPerfPrereleaseAppVersion, isPrereleaseAppVersion, - isValidAppVersion + isValidAppVersion, + parseCliVersion } from './app-version' describe('app version comparison', () => { @@ -23,3 +24,28 @@ describe('app version comparison', () => { expect(isPerfPrereleaseAppVersion('1.5.0-rc.1')).toBe(false) }) }) + +describe('parseCliVersion', () => { + it.each([ + ['2.1.261 (Claude Code)', '2.1.261'], + ['agy version 1.1.11\n', '1.1.11'], + ['v1.1.10 (darwin arm64)', '1.1.10'], + ['1.1.11-rc.1', '1.1.11-rc.1'], + ['agy 1.2.4+build.7', '1.2.4+build.7'] + ])('reads %j as %s', (output, expected) => { + expect(parseCliVersion(output)).toBe(expected) + }) + + it.each([ + '', + 'agy', + 'version unknown', + 'agy version 1.1.11.0', + 'agy version 1.1.11-', + 'agy version 1.1.11+', + null, + undefined + ])('returns null for %j', (output) => { + expect(parseCliVersion(output)).toBeNull() + }) +}) diff --git a/src/shared/app-version.ts b/src/shared/app-version.ts index e3c537f4534a..4ad35cf8da26 100644 --- a/src/shared/app-version.ts +++ b/src/shared/app-version.ts @@ -22,6 +22,14 @@ export function isValidAppVersion(value: string): boolean { return parseVersion(value) !== null } +/** Extracts the first valid semver (with prerelease/build) from CLI `--version` output. */ +export function parseCliVersion(output: string | null | undefined): string | null { + const version = output?.match( + /(? 0