From a5f2ff440ca41b0866dfbc942d767d42e2aded50 Mon Sep 17 00:00:00 2001 From: Noe Date: Sun, 21 Dec 2025 03:09:16 +0100 Subject: [PATCH] feat: implement auto-update checker for Antigravity plugin with installation instructions and model updates --- README.md | 265 ++++++++++++++------- src/hooks/auto-update-checker/cache.ts | 91 +++++++ src/hooks/auto-update-checker/checker.ts | 257 ++++++++++++++++++++ src/hooks/auto-update-checker/constants.ts | 32 +++ src/hooks/auto-update-checker/index.ts | 164 +++++++++++++ src/hooks/auto-update-checker/types.ts | 28 +++ src/plugin.ts | 17 +- src/plugin/request.ts | 2 +- src/plugin/types.ts | 9 + 9 files changed, 768 insertions(+), 97 deletions(-) create mode 100644 src/hooks/auto-update-checker/cache.ts create mode 100644 src/hooks/auto-update-checker/checker.ts create mode 100644 src/hooks/auto-update-checker/constants.ts create mode 100644 src/hooks/auto-update-checker/index.ts create mode 100644 src/hooks/auto-update-checker/types.ts diff --git a/README.md b/README.md index 631fffe..752e2f2 100644 --- a/README.md +++ b/README.md @@ -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 + +
+🤖 Detailed Installation Guide for LLM Agents (click to expand) + +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."** + +
+ +--- + +## 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 | + +
+Full model configuration (click to expand) ```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 -``` +
## Multi-account load balancing diff --git a/src/hooks/auto-update-checker/cache.ts b/src/hooks/auto-update-checker/cache.ts new file mode 100644 index 0000000..f97cd06 --- /dev/null +++ b/src/hooks/auto-update-checker/cache.ts @@ -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; + }; + }; + packages?: Record; +} + +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(); +} diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts new file mode 100644 index 0000000..8fd921a --- /dev/null +++ b/src/hooks/auto-update-checker/checker.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts new file mode 100644 index 0000000..48e30cd --- /dev/null +++ b/src/hooks/auto-update-checker/constants.ts @@ -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"); diff --git a/src/hooks/auto-update-checker/index.ts b/src/hooks/auto-update-checker/index.ts new file mode 100644 index 0000000..e67aae6 --- /dev/null +++ b/src/hooks/auto-update-checker/index.ts @@ -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; + }; +} + +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 { + 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 { + 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 { + 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 { + 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"; diff --git a/src/hooks/auto-update-checker/types.ts b/src/hooks/auto-update-checker/types.ts new file mode 100644 index 0000000..369c026 --- /dev/null +++ b/src/hooks/auto-update-checker/types.ts @@ -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; +} diff --git a/src/plugin.ts b/src/plugin.ts index 8719284..0ce8427 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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 { * Creates an Antigravity OAuth plugin for a specific provider ID. */ export const createAntigravityPlugin = (providerId: string) => async ( - { client }: PluginContext, -): Promise => ({ - auth: { + { client, directory }: PluginContext, +): Promise => { + const updateChecker = createAutoUpdateCheckerHook(client, directory, { + showStartupToast: true, + autoUpdate: true, + }); + + return { + event: updateChecker.event, + auth: { provider: providerId, loader: async (getAuth: GetAuth, provider: Provider): Promise> => { 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; diff --git a/src/plugin/request.ts b/src/plugin/request.ts index b35fee9..1bc2700 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -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; diff --git a/src/plugin/types.ts b/src/plugin/types.ts index ad6b9c2..f4ff690 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -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) => Promise; } +export interface PluginEventPayload { + event: { + type: string; + properties?: unknown; + }; +} + export interface PluginResult { auth: { provider: string; loader: (getAuth: GetAuth, provider: Provider) => Promise>; methods: AuthMethod[]; }; + event?: (payload: PluginEventPayload) => void; } export interface RefreshParts {