diff --git a/README.md b/README.md
index a380165f..7a4cd177 100644
--- a/README.md
+++ b/README.md
@@ -187,6 +187,14 @@ ccs sync
Re-creates symlinks for shared commands, skills, and settings.
+### Antigravity Quota Management
+
+```bash
+ccs cliproxy doctor # Check quota status for all agy accounts
+```
+
+**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota).
+
## Configuration
diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts
index 0de849d6..6a031402 100644
--- a/src/cliproxy/cliproxy-executor.ts
+++ b/src/cliproxy/cliproxy-executor.ts
@@ -58,6 +58,7 @@ import {
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
+import { fetchAccountQuota, findAvailableAccount } from './quota-fetcher';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -463,6 +464,40 @@ export async function execClaudeWithCLIProxy(
}
}
+ // 3b. Preflight quota check - auto-switch to account with quota before launch
+ // Only for agy (Antigravity) which has quota tracking
+ if (provider === 'agy') {
+ const defaultAccount = getDefaultAccount(provider);
+ if (defaultAccount) {
+ log(`Checking quota for ${defaultAccount.email || defaultAccount.id}`);
+ const quota = await fetchAccountQuota(provider, defaultAccount.id);
+
+ // Check if current account is exhausted (no model with >5% quota)
+ const hasQuota = quota.success && quota.models.some((m) => m.percentage > 5);
+
+ if (!hasQuota && quota.success) {
+ // Current account exhausted, try to find alternative
+ log('Current account quota exhausted, searching for alternatives...');
+ const alternative = await findAvailableAccount(provider, defaultAccount.id);
+
+ if (alternative) {
+ // Auto-switch to account with remaining quota
+ setDefaultAccount(provider, alternative.account.id);
+ touchAccount(provider, alternative.account.id);
+ console.log(
+ info(
+ `Auto-switched to ${alternative.account.email || alternative.account.id} (current account quota exhausted)`
+ )
+ );
+ } else {
+ // No alternatives available - warn but continue
+ console.log(warn('All accounts appear quota-exhausted'));
+ console.log(` Run: ccs cliproxy doctor`);
+ }
+ }
+ }
+ }
+
// 4. First-run model configuration (interactive)
// For supported providers, prompt user to select model on first run
// Pass customSettingsPath for CLIProxy variants
diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts
index 749d9393..d2daa236 100644
--- a/src/cliproxy/quota-fetcher.ts
+++ b/src/cliproxy/quota-fetcher.ts
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { CLIProxyProvider } from './types';
+import { getProviderAccounts, type AccountInfo } from './account-manager';
/** Individual model quota info */
export interface ModelQuota {
@@ -40,6 +41,10 @@ export interface QuotaResult {
expiresAt?: string;
/** True if account hasn't been activated in official Antigravity app */
isUnprovisioned?: boolean;
+ /** Account ID (email) this quota belongs to */
+ accountId?: string;
+ /** GCP project ID for this account */
+ projectId?: string;
}
/** Google Cloud Code API endpoints */
@@ -523,3 +528,129 @@ export async function fetchAccountQuota(
return result;
}
+
+/**
+ * Read project ID directly from auth file without making API call
+ * Used for quick project ID comparison in doctor command
+ */
+export function readProjectIdFromAuthFile(
+ provider: CLIProxyProvider,
+ accountId: string
+): string | null {
+ const authData = readAuthData(provider, accountId);
+ return authData?.projectId || null;
+}
+
+/** Result for all accounts of a provider */
+export interface AllAccountsQuotaResult {
+ /** Provider name */
+ provider: CLIProxyProvider;
+ /** Results per account */
+ accounts: Array<{
+ account: AccountInfo;
+ quota: QuotaResult;
+ }>;
+ /** Accounts grouped by project ID (for detecting shared projects) */
+ projectGroups: Record;
+ /** Timestamp of fetch */
+ lastUpdated: number;
+}
+
+/**
+ * Fetch quota for all accounts of a provider
+ * Also detects accounts sharing same GCP project (failover won't help)
+ *
+ * @param provider - Provider name (only 'agy' supported for quota)
+ * @returns Results for all accounts with project grouping
+ */
+export async function fetchAllProviderQuotas(
+ provider: CLIProxyProvider
+): Promise {
+ const accounts = getProviderAccounts(provider);
+ const results: AllAccountsQuotaResult = {
+ provider,
+ accounts: [],
+ projectGroups: {},
+ lastUpdated: Date.now(),
+ };
+
+ if (accounts.length === 0) {
+ return results;
+ }
+
+ // Fetch quota for each account in parallel
+ const quotaPromises = accounts.map(async (account) => {
+ const quota = await fetchAccountQuota(provider, account.id);
+
+ // Read project ID from auth file if not in quota result
+ let projectId = quota.projectId;
+ if (!projectId) {
+ projectId = readProjectIdFromAuthFile(provider, account.id) || undefined;
+ }
+
+ return {
+ account,
+ quota: { ...quota, accountId: account.id, projectId },
+ };
+ });
+
+ const quotaResults = await Promise.all(quotaPromises);
+
+ // Build project groups for detecting shared projects
+ for (const { account, quota } of quotaResults) {
+ results.accounts.push({ account, quota });
+
+ if (quota.projectId) {
+ if (!results.projectGroups[quota.projectId]) {
+ results.projectGroups[quota.projectId] = [];
+ }
+ results.projectGroups[quota.projectId].push(account.id);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Find available account with remaining quota
+ * Used by preflight check for auto-switching
+ *
+ * @param provider - Provider name
+ * @param excludeAccountId - Account to exclude (current exhausted account)
+ * @returns Account with available quota, or null if none available
+ */
+export async function findAvailableAccount(
+ provider: CLIProxyProvider,
+ excludeAccountId?: string
+): Promise<{ account: AccountInfo; quota: QuotaResult } | null> {
+ const allQuotas = await fetchAllProviderQuotas(provider);
+
+ // Get excluded account's project ID to avoid switching to same-project accounts
+ const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota
+ .projectId;
+
+ for (const { account, quota } of allQuotas.accounts) {
+ // Skip excluded account
+ if (excludeAccountId && account.id === excludeAccountId) {
+ continue;
+ }
+
+ // Skip failed quota fetches
+ if (!quota.success) {
+ continue;
+ }
+
+ // Skip accounts sharing same GCP project (quota is pooled)
+ if (excludedProjectId && quota.projectId === excludedProjectId) {
+ continue;
+ }
+
+ // Check if any model has remaining quota (> 5% to avoid edge cases)
+ const hasQuota = quota.models.some((m) => m.percentage > 5);
+ if (hasQuota) {
+ return { account, quota };
+ }
+ }
+
+ return null;
+}
diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts
index bce8a3b9..080d271b 100644
--- a/src/commands/cliproxy-command.ts
+++ b/src/commands/cliproxy-command.ts
@@ -21,6 +21,7 @@
import * as path from 'path';
import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler';
import { getProviderAccounts } from '../cliproxy/account-manager';
+import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher';
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog';
@@ -548,6 +549,7 @@ async function showHelp(): Promise {
[
['status', 'Show running CLIProxy status'],
['stop', 'Stop running CLIProxy instance'],
+ ['doctor', 'Quota diagnostics and shared project detection'],
],
],
[
@@ -579,6 +581,116 @@ async function showHelp(): Promise {
console.log('');
}
+// ============================================================================
+// DOCTOR COMMAND - Quota diagnostics and shared project detection
+// ============================================================================
+
+async function handleDoctor(): Promise {
+ await initUI();
+ console.log(header('CLIProxy Quota Diagnostics'));
+ console.log('');
+
+ // Check each OAuth provider (agy is the only one with quota)
+ const provider: CLIProxyProvider = 'agy';
+ const accounts = getProviderAccounts(provider);
+
+ if (accounts.length === 0) {
+ console.log(info('No Antigravity accounts configured'));
+ console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
+ return;
+ }
+
+ console.log(subheader(`Antigravity Accounts (${accounts.length})`));
+ console.log('');
+
+ // Fetch quota for all accounts
+ console.log(dim('Fetching quotas...'));
+ const quotaResult = await fetchAllProviderQuotas(provider);
+
+ // Display per-account quota status
+ for (const { account, quota } of quotaResult.accounts) {
+ const accountLabel = account.email || account.id || 'Unknown Account';
+ const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
+
+ if (!quota.success) {
+ console.log(` ${fail(accountLabel)}${defaultBadge}`);
+ console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
+ if (quota.isUnprovisioned) {
+ console.log(
+ ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}`
+ );
+ }
+ console.log('');
+ continue;
+ }
+
+ // Calculate overall quota health (guard against empty models array)
+ const avgQuota =
+ quota.models.length > 0
+ ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
+ : 0;
+ const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
+
+ console.log(` ${statusIcon}${accountLabel}${defaultBadge}`);
+ if (quota.projectId) {
+ console.log(` Project: ${dim(quota.projectId)}`);
+ }
+
+ // Show model quotas
+ for (const model of quota.models) {
+ const bar = formatQuotaBar(model.percentage);
+ console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`);
+ }
+ console.log('');
+ }
+
+ // Check for shared GCP projects (critical warning)
+ const sharedProjects = Object.entries(quotaResult.projectGroups).filter(
+ ([, accountIds]) => accountIds.length > 1
+ );
+
+ if (sharedProjects.length > 0) {
+ console.log('');
+ console.log(subheader('Shared Project Warning'));
+ console.log('');
+ for (const [projectId, accountIds] of sharedProjects) {
+ console.log(
+ fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`)
+ );
+ for (const accountId of accountIds) {
+ console.log(` - ${accountId}`);
+ }
+ console.log('');
+ console.log(warn('These accounts share the same quota pool!'));
+ console.log(warn('Failover between them will NOT help when quota is exhausted.'));
+ console.log(info('Solution: Use accounts from different GCP projects.'));
+ }
+ }
+
+ // Summary
+ console.log('');
+ console.log(subheader('Summary'));
+ const healthyAccounts = quotaResult.accounts.filter(
+ ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5)
+ );
+ console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`);
+ if (sharedProjects.length > 0) {
+ console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`);
+ } else if (accounts.length > 1) {
+ console.log(` ${ok('No shared projects (failover fully operational)')}`);
+ }
+ console.log('');
+}
+
+function formatQuotaBar(percentage: number): string {
+ const width = 20;
+ const clampedPct = Math.max(0, Math.min(100, percentage));
+ const filled = Math.round((clampedPct / 100) * width);
+ const empty = width - filled;
+ const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░';
+ return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`;
+}
+
// ============================================================================
// MAIN ROUTER
// ============================================================================
@@ -617,6 +729,11 @@ export async function handleCliproxyCommand(args: string[]): Promise {
return;
}
+ if (command === 'doctor' || command === 'diag') {
+ await handleDoctor();
+ return;
+ }
+
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {
let version = args[installIdx + 1];
diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts
index ddfa96ef..840ad3d5 100644
--- a/src/commands/help-command.ts
+++ b/src/commands/help-command.ts
@@ -256,6 +256,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
printSubSection('CLI Proxy Plus Management', [
['ccs cliproxy', 'Show CLIProxy Plus status and version'],
['ccs cliproxy --help', 'Full CLIProxy Plus management help'],
+ ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'],
['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
]);