feat: implement auto-update checker for Antigravity plugin with installation instructions and model updates

This commit is contained in:
Noe
2025-12-21 03:09:16 +01:00
parent 684076eaba
commit a5f2ff440c
9 changed files with 768 additions and 97 deletions

265
README.md
View File

@@ -15,132 +15,225 @@ Enable Opencode to authenticate against **Antigravity** (Google's IDE) via OAuth
- **Debug logging** for requests and responses
- **Drop-in setup** Opencode auto-installs the plugin from config
## Quick start
## Installation
### Step 1: Create your config file
### For Humans
If this is your first time using Opencode, create the config directory first:
**Option A: Let an LLM do it for you**
Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.):
```
Install the opencode-antigravity-auth plugin by following: https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/README.md
```
**Option B: Do it yourself**
1. **Add the plugin to your config:**
```bash
mkdir -p ~/.config/opencode
```
Create or edit `~/.config/opencode/opencode.json`:
```json
{
"plugin": ["opencode-antigravity-auth@1.2.0"]
}
```
2. **Authenticate:**
```bash
opencode auth login
```
Select **Google** → **OAuth with Google (Antigravity)** → Press Enter to skip Project ID → Sign in via browser.
3. **Add models to your config:**
Add the models you want to use to the same config file:
```json
{
"plugin": ["opencode-antigravity-auth@1.2.0"],
"provider": {
"google": {
"models": {
"gemini-3-pro-high": {
"name": "Gemini 3 Pro High (Antigravity)",
"limit": { "context": 1048576, "output": 65535 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"claude-sonnet-4-5": {
"name": "Claude Sonnet 4.5 (Antigravity)",
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
}
}
}
}
}
```
> See [Available models](#available-models) for the full list.
4. **Use it:**
```bash
opencode run "Hello" --model=google/gemini-3-pro-high
```
---
### For LLMs
<details>
<summary><b>🤖 Detailed Installation Guide for LLM Agents</b> (click to expand)</summary>
If you're an LLM agent helping a user set up this plugin, follow these steps:
#### Step 1: Verify OpenCode is installed
```bash
command -v opencode && opencode --version || echo "OpenCode not installed - see https://opencode.ai/docs"
```
#### Step 2: Create or update the config
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["opencode-antigravity-auth@1.2.0"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "Add \"opencode-antigravity-auth@1.2.0\" to the plugin array manually"
fi
else
echo '{"plugin":["opencode-antigravity-auth@1.2.0"]}' > ~/.config/opencode/opencode.json
fi
```
Then create or edit the config file at `~/.config/opencode/opencode.json`:
#### Step 3: Ask user about multi-account setup
```json
{
"plugin": ["opencode-antigravity-auth@1.2.0"]
}
```
Before running `opencode auth login`, ask the user:
> **Note:** You can also use a project-local `.opencode.json` file in your project root instead. The global config at `~/.config/opencode/opencode.json` applies to all projects.
> **Do you want to add multiple Google accounts for load balancing?**
> - Adding 2-10 accounts maximizes rate limits and provides automatic failover
> - You can always add more accounts later
### Step 2: Authenticate
Based on their answer:
- **Yes, multiple accounts** → After first login, prompt: "Sign in complete. Add another account? (up to 10 supported)"
- **No, just one** → Single account is fine, they can add more later with `opencode auth login`
Run the authentication command:
#### Step 4: Run authentication
Tell the user to run:
```bash
opencode auth login
```
Guide them through the prompts:
1. Select **Google** as the provider
2. Select **OAuth with Google (Antigravity)**
3. **Project ID prompt:** You'll see this prompt:
```
Project ID (leave blank to use your default project):
```
**Just press Enter to skip this** — it's optional and only needed if you want to use a specific Google Cloud project. Most users can leave it blank.
4. Sign in via the browser and return to Opencode. If the browser doesn't open, copy the displayed URL manually.
5. After signing in, you can add more Google accounts (up to 10) for load balancing, or press Enter to finish.
3. **Project ID prompt** → Tell user: "Press Enter to skip (most users don't need this)"
4. Browser opens for Google sign-in
5. If multi-account: repeat for additional accounts, or press Enter to finish
> **Alternative:** For a quick single-account setup without project ID options, open `opencode` and use the `/connect` command instead.
#### Step 5: Add models to config
### Step 3: Add the models you want to use
Merge model definitions into the user's config file (`~/.config/opencode/opencode.json`):
Open the **same config file** you created in Step 1 (`~/.config/opencode/opencode.json`) and add the models under `provider.google.models`:
```bash
# If jq is available, merge models programmatically
if command -v jq &> /dev/null; then
jq '.provider.google.models = {
"gemini-3-pro-high": {"name": "Gemini 3 Pro High (Antigravity)", "limit": {"context": 1048576, "output": 65535}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
"gemini-3-pro-low": {"name": "Gemini 3 Pro Low (Antigravity)", "limit": {"context": 1048576, "output": 65535}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
"gemini-3-flash": {"name": "Gemini 3 Flash (Antigravity)", "limit": {"context": 1048576, "output": 65536}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
"claude-sonnet-4-5": {"name": "Claude Sonnet 4.5 (Antigravity)", "limit": {"context": 200000, "output": 64000}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
"claude-sonnet-4-5-thinking": {"name": "Claude Sonnet 4.5 Thinking (Antigravity)", "limit": {"context": 200000, "output": 64000}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
"claude-opus-4-5-thinking": {"name": "Claude Opus 4.5 Thinking (Antigravity)", "limit": {"context": 200000, "output": 64000}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
"gpt-oss-120b-medium": {"name": "GPT-OSS 120B Medium (Antigravity)", "limit": {"context": 131072, "output": 32768}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}}
} + (.provider.google.models // {})' ~/.config/opencode/opencode.json > /tmp/oc.json && mv /tmp/oc.json ~/.config/opencode/opencode.json
fi
```
Or edit the file manually and add the models from the [Available models](#available-models) section.
#### Step 6: Verify setup
```bash
opencode run "Hello" --model=google/gemini-3-pro-high
```
If successful, tell the user: **"Setup complete! You can now use Antigravity models in OpenCode."**
</details>
---
## Available models
Add these models to your `~/.config/opencode/opencode.json` under `provider.google.models`:
| Model ID | Description |
|----------|-------------|
| `gemini-3-pro-high` | Gemini 3 Pro High |
| `gemini-3-pro-low` | Gemini 3 Pro Low |
| `gemini-3-flash` | Gemini 3 Flash |
| `claude-sonnet-4-5` | Claude Sonnet 4.5 |
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with thinking |
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with thinking |
| `gpt-oss-120b-medium` | GPT-OSS 120B Medium |
<details>
<summary><b>Full model configuration</b> (click to expand)</summary>
```json
{
"plugin": ["opencode-antigravity-auth@1.2.0"],
"provider": {
"google": {
"models": {
"gemini-3-pro-high": {
"name": "Gemini 3 Pro High (Antigravity)",
"limit": {
"context": 1048576,
"output": 65535
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 1048576, "output": 65535 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"gemini-3-pro-low": {
"name": "Gemini 3 Pro Low (Antigravity)",
"limit": {
"context": 1048576,
"output": 65535
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 1048576, "output": 65535 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"gemini-3-flash": {
"name": "Gemini 3 Flash (Antigravity)",
"limit": {
"context": 1048576,
"output": 65536
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 1048576, "output": 65536 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"claude-sonnet-4-5": {
"name": "Claude Sonnet 4.5 (Antigravity)",
"limit": {
"context": 200000,
"output": 64000
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"claude-sonnet-4-5-thinking": {
"name": "Claude Sonnet 4.5 Thinking (Antigravity)",
"limit": {
"context": 200000,
"output": 64000
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"claude-opus-4-5-thinking": {
"name": "Claude Opus 4.5 Thinking (Antigravity)",
"limit": {
"context": 200000,
"output": 64000
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"gpt-oss-120b-medium": {
"name": "GPT-OSS 120B Medium (Antigravity)",
"limit": {
"context": 131072,
"output": 32768
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
}
"limit": { "context": 131072, "output": 32768 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
}
}
}
@@ -148,19 +241,7 @@ Open the **same config file** you created in Step 1 (`~/.config/opencode/opencod
}
```
> **Tip:** You only need to add the models you plan to use. The example above includes all available models, but you can remove any you don't need. The `modalities` field enables image and PDF support in the TUI.
### Step 4: Use a model
```bash
opencode run "Hello world" --model=google/gemini-3-pro-high
```
Or start the interactive TUI and select a model from the model picker:
```bash
opencode
```
</details>
## Multi-account load balancing

View File

@@ -0,0 +1,91 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { CACHE_DIR, PACKAGE_NAME } from "./constants";
interface BunLockfile {
workspaces?: {
""?: {
dependencies?: Record<string, string>;
};
};
packages?: Record<string, unknown>;
}
function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1");
}
function removeFromBunLock(packageName: string): boolean {
const lockPath = path.join(CACHE_DIR, "bun.lock");
if (!fs.existsSync(lockPath)) return false;
try {
const content = fs.readFileSync(lockPath, "utf-8");
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile;
let modified = false;
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
delete lock.workspaces[""].dependencies[packageName];
modified = true;
}
if (lock.packages?.[packageName]) {
delete lock.packages[packageName];
modified = true;
}
if (modified) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));
console.log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);
}
return modified;
} catch {
return false;
}
}
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try {
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName);
const pkgJsonPath = path.join(CACHE_DIR, "package.json");
let packageRemoved = false;
let dependencyRemoved = false;
let lockRemoved = false;
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true });
console.log(`[auto-update-checker] Package removed: ${pkgDir}`);
packageRemoved = true;
}
if (fs.existsSync(pkgJsonPath)) {
const content = fs.readFileSync(pkgJsonPath, "utf-8");
const pkgJson = JSON.parse(content);
if (pkgJson.dependencies?.[packageName]) {
delete pkgJson.dependencies[packageName];
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
console.log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`);
dependencyRemoved = true;
}
}
lockRemoved = removeFromBunLock(packageName);
if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
console.log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`);
return false;
}
return true;
} catch (err) {
console.error("[auto-update-checker] Failed to invalidate package:", err);
return false;
}
}
export function invalidateCache(): boolean {
console.warn("[auto-update-checker] WARNING: invalidateCache is deprecated, use invalidatePackage");
return invalidatePackage();
}

View File

@@ -0,0 +1,257 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types";
import {
PACKAGE_NAME,
NPM_REGISTRY_URL,
NPM_FETCH_TIMEOUT,
INSTALLED_PACKAGE_JSON,
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
} from "./constants";
export function isLocalDevMode(directory: string): boolean {
return getLocalDevPath(directory) !== null;
}
function stripJsonComments(json: string): string {
return json
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m: string, g: string | undefined) => (g ? "" : m))
.replace(/,(\s*[}\]])/g, "$1");
}
function getConfigPaths(directory: string): string[] {
return [
path.join(directory, ".opencode", "opencode.json"),
path.join(directory, ".opencode", "opencode.jsonc"),
path.join(directory, ".opencode.json"),
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
];
}
export function getLocalDevPath(directory: string): string | null {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue;
const content = fs.readFileSync(configPath, "utf-8");
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
const plugins = config.plugin ?? [];
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
try {
return fileURLToPath(entry);
} catch {
return entry.replace("file://", "");
}
}
}
} catch {
continue;
}
}
return null;
}
function findPackageJsonUp(startPath: string): string | null {
try {
const stat = fs.statSync(startPath);
let dir = stat.isDirectory() ? startPath : path.dirname(startPath);
for (let i = 0; i < 10; i++) {
const pkgPath = path.join(dir, "package.json");
if (fs.existsSync(pkgPath)) {
try {
const content = fs.readFileSync(pkgPath, "utf-8");
const pkg = JSON.parse(content) as PackageJson;
if (pkg.name === PACKAGE_NAME) return pkgPath;
} catch {
continue;
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
} catch {
return null;
}
return null;
}
export function getLocalDevVersion(directory: string): string | null {
const localPath = getLocalDevPath(directory);
if (!localPath) return null;
try {
const pkgPath = findPackageJsonUp(localPath);
if (!pkgPath) return null;
const content = fs.readFileSync(pkgPath, "utf-8");
const pkg = JSON.parse(content) as PackageJson;
return pkg.version ?? null;
} catch {
return null;
}
}
export interface PluginEntryInfo {
entry: string;
isPinned: boolean;
pinnedVersion: string | null;
configPath: string;
}
export function findPluginEntry(directory: string): PluginEntryInfo | null {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue;
const content = fs.readFileSync(configPath, "utf-8");
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
const plugins = config.plugin ?? [];
for (const entry of plugins) {
if (entry === PACKAGE_NAME) {
return { entry, isPinned: false, pinnedVersion: null, configPath };
}
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
const isPinned = pinnedVersion !== "latest";
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath };
}
}
} catch {
continue;
}
}
return null;
}
export function getCachedVersion(): string | null {
try {
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8");
const pkg = JSON.parse(content) as PackageJson;
if (pkg.version) return pkg.version;
}
} catch {
return null;
}
try {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pkgPath = findPackageJsonUp(currentDir);
if (pkgPath) {
const content = fs.readFileSync(pkgPath, "utf-8");
const pkg = JSON.parse(content) as PackageJson;
if (pkg.version) return pkg.version;
}
} catch (err) {
console.log("[auto-update-checker] Failed to resolve version from current directory:", err);
}
return null;
}
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
try {
const content = fs.readFileSync(configPath, "utf-8");
const newEntry = `${PACKAGE_NAME}@${newVersion}`;
const pluginMatch = content.match(/"plugin"\s*:\s*\[/);
if (!pluginMatch || pluginMatch.index === undefined) {
console.log(`[auto-update-checker] No "plugin" array found in ${configPath}`);
return false;
}
const startIdx = pluginMatch.index + pluginMatch[0].length;
let bracketCount = 1;
let endIdx = startIdx;
for (let i = startIdx; i < content.length && bracketCount > 0; i++) {
if (content[i] === "[") bracketCount++;
else if (content[i] === "]") bracketCount--;
endIdx = i;
}
const before = content.slice(0, startIdx);
const pluginArrayContent = content.slice(startIdx, endIdx);
const after = content.slice(endIdx);
const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`["']${escapedOldEntry}["']`);
if (!regex.test(pluginArrayContent)) {
console.log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`);
return false;
}
const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`);
const updatedContent = before + updatedPluginArray + after;
if (updatedContent === content) {
console.log(`[auto-update-checker] No changes made to ${configPath}`);
return false;
}
fs.writeFileSync(configPath, updatedContent, "utf-8");
console.log(`[auto-update-checker] Updated ${configPath}: ${oldEntry}${newEntry}`);
return true;
} catch (err) {
console.error(`[auto-update-checker] Failed to update config file ${configPath}:`, err);
return false;
}
}
export async function getLatestVersion(): Promise<string | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
try {
const response = await fetch(NPM_REGISTRY_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
if (!response.ok) return null;
const data = (await response.json()) as NpmDistTags;
return data.latest ?? null;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
if (isLocalDevMode(directory)) {
console.log("[auto-update-checker] Local dev mode detected, skipping update check");
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false };
}
const pluginInfo = findPluginEntry(directory);
if (!pluginInfo) {
console.log("[auto-update-checker] Plugin not found in config");
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false };
}
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion;
if (!currentVersion) {
console.log("[auto-update-checker] No version found (cached or pinned)");
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned };
}
const latestVersion = await getLatestVersion();
if (!latestVersion) {
console.log("[auto-update-checker] Failed to fetch latest version");
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned };
}
const needsUpdate = currentVersion !== latestVersion;
console.log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`);
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned };
}

View File

@@ -0,0 +1,32 @@
import * as path from "node:path";
import * as os from "node:os";
export const PACKAGE_NAME = "opencode-antigravity-auth";
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
export const NPM_FETCH_TIMEOUT = 5000;
function getCacheDir(): string {
if (process.platform === "win32") {
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode");
}
return path.join(os.homedir(), ".cache", "opencode");
}
export const CACHE_DIR = getCacheDir();
export const INSTALLED_PACKAGE_JSON = path.join(
CACHE_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
);
function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
}
export const USER_CONFIG_DIR = getUserConfigDir();
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json");
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc");

View File

@@ -0,0 +1,164 @@
import type { AutoUpdateCheckerOptions } from "./types";
import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker";
import { invalidatePackage } from "./cache";
import { PACKAGE_NAME } from "./constants";
interface PluginClient {
tui: {
showToast(options: {
body: {
title?: string;
message: string;
variant: "info" | "warning" | "success" | "error";
duration?: number;
};
}): Promise<unknown>;
};
}
interface SessionCreatedEvent {
type: "session.created";
properties?: {
info?: {
parentID?: string;
};
};
}
type PluginEvent = SessionCreatedEvent | { type: string; properties?: unknown };
export function createAutoUpdateCheckerHook(
client: PluginClient,
directory: string,
options: AutoUpdateCheckerOptions = {}
) {
const { showStartupToast = true, autoUpdate = true } = options;
let hasChecked = false;
return {
event: ({ event }: { event: PluginEvent }) => {
if (event.type !== "session.created") return;
if (hasChecked) return;
const props = event.properties as { info?: { parentID?: string } } | undefined;
if (props?.info?.parentID) return;
hasChecked = true;
setTimeout(() => {
const localDevVersion = getLocalDevVersion(directory);
if (localDevVersion) {
if (showStartupToast) {
showLocalDevToast(client, localDevVersion).catch(() => {});
}
console.log("[auto-update-checker] Local development mode");
return;
}
runBackgroundUpdateCheck(client, directory, autoUpdate).catch((err) => {
console.log("[auto-update-checker] Background update check failed:", err);
});
}, 0);
},
};
}
async function runBackgroundUpdateCheck(
client: PluginClient,
directory: string,
autoUpdate: boolean
): Promise<void> {
const pluginInfo = findPluginEntry(directory);
if (!pluginInfo) {
console.log("[auto-update-checker] Plugin not found in config");
return;
}
const cachedVersion = getCachedVersion();
const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
if (!currentVersion) {
console.log("[auto-update-checker] No version found (cached or pinned)");
return;
}
const latestVersion = await getLatestVersion();
if (!latestVersion) {
console.log("[auto-update-checker] Failed to fetch latest version");
return;
}
if (currentVersion === latestVersion) {
console.log("[auto-update-checker] Already on latest version");
return;
}
console.log(`[auto-update-checker] Update available: ${currentVersion}${latestVersion}`);
if (!autoUpdate) {
await showUpdateAvailableToast(client, latestVersion);
console.log("[auto-update-checker] Auto-update disabled, notification only");
return;
}
if (pluginInfo.isPinned) {
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion);
if (updated) {
invalidatePackage(PACKAGE_NAME);
await showAutoUpdatedToast(client, currentVersion, latestVersion);
console.log(`[auto-update-checker] Config updated: ${pluginInfo.entry}${PACKAGE_NAME}@${latestVersion}`);
} else {
await showUpdateAvailableToast(client, latestVersion);
}
} else {
invalidatePackage(PACKAGE_NAME);
await showUpdateAvailableToast(client, latestVersion);
}
}
async function showUpdateAvailableToast(client: PluginClient, latestVersion: string): Promise<void> {
await client.tui
.showToast({
body: {
title: `Antigravity Auth Update`,
message: `v${latestVersion} available. Restart OpenCode to apply.`,
variant: "info" as const,
duration: 8000,
},
})
.catch(() => {});
console.log(`[auto-update-checker] Update available toast shown: v${latestVersion}`);
}
async function showAutoUpdatedToast(client: PluginClient, oldVersion: string, newVersion: string): Promise<void> {
await client.tui
.showToast({
body: {
title: `Antigravity Auth Updated!`,
message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`,
variant: "success" as const,
duration: 8000,
},
})
.catch(() => {});
console.log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`);
}
async function showLocalDevToast(client: PluginClient, version: string): Promise<void> {
await client.tui
.showToast({
body: {
title: `Antigravity Auth ${version} (dev)`,
message: "Running in local development mode.",
variant: "warning" as const,
duration: 5000,
},
})
.catch(() => {});
console.log(`[auto-update-checker] Local dev toast shown: v${version}`);
}
export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types";
export { checkForUpdate, getCachedVersion, getLatestVersion } from "./checker";
export { invalidatePackage, invalidateCache } from "./cache";

View File

@@ -0,0 +1,28 @@
export interface NpmDistTags {
latest: string;
[key: string]: string;
}
export interface OpencodeConfig {
plugin?: string[];
[key: string]: unknown;
}
export interface PackageJson {
version: string;
name?: string;
[key: string]: unknown;
}
export interface UpdateCheckResult {
needsUpdate: boolean;
currentVersion: string | null;
latestVersion: string | null;
isLocalDev: boolean;
isPinned: boolean;
}
export interface AutoUpdateCheckerOptions {
showStartupToast?: boolean;
autoUpdate?: boolean;
}

View File

@@ -26,6 +26,7 @@ import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token
import { startOAuthListener, type OAuthListener } from "./plugin/server";
import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage";
import { AccountManager, type ModelFamily } from "./plugin/accounts";
import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
import type {
GetAuth,
LoaderResult,
@@ -387,9 +388,16 @@ function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
* Creates an Antigravity OAuth plugin for a specific provider ID.
*/
export const createAntigravityPlugin = (providerId: string) => async (
{ client }: PluginContext,
): Promise<PluginResult> => ({
auth: {
{ client, directory }: PluginContext,
): Promise<PluginResult> => {
const updateChecker = createAutoUpdateCheckerHook(client, directory, {
showStartupToast: true,
autoUpdate: true,
});
return {
event: updateChecker.event,
auth: {
provider: providerId,
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | Record<string, unknown>> => {
const auth = await getAuth();
@@ -1273,7 +1281,8 @@ export const createAntigravityPlugin = (providerId: string) => async (
},
],
},
});
};
};
export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);
export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;

View File

@@ -606,7 +606,7 @@ export function prepareAntigravityRequest(
let upstreamModel = rawModel;
if (upstreamModel === "gemini-2.5-flash-image") {
upstreamModel = "gemini-3-flash";
upstreamModel = "gemini-2.5-flash";
}
const effectiveModel = upstreamModel;

View File

@@ -43,6 +43,7 @@ export type PluginClient = PluginInput["client"];
export interface PluginContext {
client: PluginClient;
directory: string;
}
export type AuthPrompt =
@@ -81,12 +82,20 @@ export interface AuthMethod {
authorize?: (inputs?: Record<string, string>) => Promise<OAuthAuthorizationResult>;
}
export interface PluginEventPayload {
event: {
type: string;
properties?: unknown;
};
}
export interface PluginResult {
auth: {
provider: string;
loader: (getAuth: GetAuth, provider: Provider) => Promise<LoaderResult | Record<string, unknown>>;
methods: AuthMethod[];
};
event?: (payload: PluginEventPayload) => void;
}
export interface RefreshParts {