Compare commits

...

7 Commits

Author SHA1 Message Date
Peter Steinberger
430d1d32fb docs: update changelog for antigravity oauth 2026-01-03 04:58:10 +01:00
Peter Steinberger
da4e4af038 chore: refresh pi-ai patch hash 2026-01-03 04:58:07 +01:00
Peter Steinberger
411d727b2e chore: note lossy google schema scrub 2026-01-03 04:57:04 +01:00
mukhtharcm
fe5367eba6 fix: reject antigravity auth in non-interactive onboarding mode 2026-01-03 04:57:04 +01:00
mukhtharcm
7e91c28f59 feat: add VPS-aware Antigravity OAuth with manual URL paste fallback
Detects SSH/VPS/headless environments and prompts user to paste
the OAuth callback URL instead of relying on localhost server.

- Add antigravity-oauth.ts with VPS detection and manual OAuth flow
- Update onboard-interactive.ts to use VPS-aware flow
- Update configure.ts to use VPS-aware flow
2026-01-03 04:57:04 +01:00
mukhtharcm
ee88f6ae3f fix: use claude-opus-4-5-thinking as default antigravity model 2026-01-03 04:57:04 +01:00
mukhtharcm
8633f4b9b2 feat: add Google Antigravity authentication support
- Add 'antigravity' as new auth choice in onboard and configure wizards
- Implement Google Antigravity OAuth flow using loginAntigravity from pi-ai
- Update writeOAuthCredentials to accept any OAuthProvider (not just 'anthropic')
- Add schema sanitization for Google Cloud Code Assist API to fix tool call errors
- Default model set to google-antigravity/claude-opus-4-5 after successful auth

The schema sanitization removes unsupported JSON Schema keywords (patternProperties,
const, anyOf, etc.) that Google's Cloud Code Assist API doesn't understand.
2026-01-03 04:56:38 +01:00
9 changed files with 625 additions and 14 deletions

View File

@ -28,6 +28,7 @@
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.

View File

@ -1,5 +1,91 @@
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7025b283b 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -140,6 +140,71 @@ export function convertMessages(model, context) {
}
return contents;
}
+/**
+ * Sanitize JSON Schema for Google Cloud Code Assist API.
+ * Removes unsupported keywords like patternProperties, const, anyOf, etc.
+ * and converts to a format compatible with Google's function declarations.
+ */
+function sanitizeSchemaForGoogle(schema) {
+ if (!schema || typeof schema !== 'object') {
+ return schema;
+ }
+ // If it's an array, sanitize each element
+ if (Array.isArray(schema)) {
+ return schema.map(item => sanitizeSchemaForGoogle(item));
+ }
+ const sanitized = {};
+ // List of unsupported JSON Schema keywords that Google's API doesn't understand
+ const unsupportedKeywords = [
+ 'patternProperties',
+ 'const',
+ 'anyOf',
+ 'oneOf',
+ 'allOf',
+ 'not',
+ '$schema',
+ '$id',
+ '$ref',
+ '$defs',
+ 'definitions',
+ 'if',
+ 'then',
+ 'else',
+ 'dependentSchemas',
+ 'dependentRequired',
+ 'unevaluatedProperties',
+ 'unevaluatedItems',
+ 'contentEncoding',
+ 'contentMediaType',
+ 'contentSchema',
+ 'deprecated',
+ 'readOnly',
+ 'writeOnly',
+ 'examples',
+ '$comment',
+ 'additionalProperties',
+ ];
+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords.
+ for (const [key, value] of Object.entries(schema)) {
+ // Skip unsupported keywords
+ if (unsupportedKeywords.includes(key)) {
+ continue;
+ }
+ // Recursively sanitize nested objects
+ if (key === 'properties' && typeof value === 'object' && value !== null) {
+ sanitized[key] = {};
+ for (const [propKey, propValue] of Object.entries(value)) {
+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue);
+ }
+ } else if (key === 'items' && typeof value === 'object') {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ } else {
+ sanitized[key] = value;
+ }
+ }
+ return sanitized;
+}
/**
* Convert tools to Gemini function declarations format.
*/
@@ -151,7 +216,7 @@ export function convertTools(tools) {
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
- parameters: tool.parameters,
+ parameters: sanitizeSchemaForGoogle(tool.parameters),
})),
},
];
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..c2bc63f483f3285b00755901ba97db810221cea6 100644
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..31bae0aface1319487ce62d35f1f3b6ed334863e 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -486,7 +486,6 @@ function convertTools(tools) {

10
pnpm-lock.yaml generated
View File

@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@mariozechner/pi-ai':
hash: bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832
hash: 717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent@0.31.1':
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
@ -33,7 +33,7 @@ importers:
version: 0.31.1(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai':
specifier: ^0.31.1
version: 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
version: 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-coding-agent':
specifier: ^0.31.1
version: 0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
@ -3350,7 +3350,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.31.1(ws@8.18.3)(zod@4.3.4)':
dependencies:
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.31.1
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -3360,7 +3360,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-ai@0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
'@google/genai': 1.34.0
@ -3383,7 +3383,7 @@ snapshots:
'@mariozechner/pi-coding-agent@0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)':
dependencies:
'@mariozechner/pi-agent-core': 0.31.1(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.31.1
chalk: 5.6.2
cli-highlight: 2.1.11

View File

@ -0,0 +1,394 @@
/**
* VPS-aware Antigravity OAuth flow.
*
* On local machines: Uses the standard pi-ai loginAntigravity with local server callback.
* On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually.
*/
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { readFileSync } from "node:fs";
import { randomBytes, createHash } from "node:crypto";
import { loginAntigravity, type OAuthCredentials } from "@mariozechner/pi-ai";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==");
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
// Antigravity requires these additional scopes
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
// Fallback project ID when discovery fails (same as pi-ai)
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
/**
* Detect if running in WSL (Windows Subsystem for Linux).
*/
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
/**
* Detect if running in WSL2 specifically.
*/
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
/**
* Detect if running in a remote/headless environment where localhost callback won't work.
*/
export function isRemoteEnvironment(): boolean {
// SSH session indicators
if (
process.env.SSH_CLIENT ||
process.env.SSH_TTY ||
process.env.SSH_CONNECTION
) {
return true;
}
// Container/cloud environments
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
return true;
}
// Linux without display (and not WSL which can use wslview)
if (
process.platform === "linux" &&
!process.env.DISPLAY &&
!process.env.WAYLAND_DISPLAY &&
!isWSL()
) {
return true;
}
return false;
}
/**
* Whether to skip the local OAuth callback server.
*/
export function shouldUseManualOAuthFlow(): boolean {
return isWSL2() || isRemoteEnvironment();
}
/**
* Generate PKCE verifier and challenge using Node.js crypto.
*/
function generatePKCESync(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
/**
* Build the Antigravity OAuth authorization URL.
*/
function buildAuthUrl(challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
/**
* Parse the OAuth callback URL or code input.
*/
function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) {
return { error: "No input provided" };
}
try {
// Try parsing as full URL
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." };
}
return { code, state };
} catch {
// Not a URL - treat as raw code (need state from original request)
if (!expectedState) {
return { error: "Paste the full redirect URL, not just the code." };
}
return { code: trimmed, state: expectedState };
}
}
/**
* Exchange authorization code for tokens.
*/
async function exchangeCodeForTokens(
code: string,
verifier: string,
): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
// Fetch user email
const email = await getUserEmail(data.access_token);
// Fetch project ID
const projectId = await fetchProjectId(data.access_token);
// Calculate expiry time (same as pi-ai: current time + expires_in - 5 min buffer)
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
/**
* Get user email from access token.
*/
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// Ignore errors, email is optional
}
return undefined;
}
/**
* Fetch the Antigravity project ID using the access token.
*/
async function fetchProjectId(accessToken: string): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
// Try endpoints in order: prod first, then sandbox
const endpoints = [
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
];
for (const endpoint of endpoints) {
try {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (!response.ok) continue;
const data = (await response.json()) as {
cloudaicompanionProject?: string | { id?: string };
};
if (typeof data.cloudaicompanionProject === "string") {
return data.cloudaicompanionProject;
}
if (
data.cloudaicompanionProject &&
typeof data.cloudaicompanionProject === "object" &&
data.cloudaicompanionProject.id
) {
return data.cloudaicompanionProject.id;
}
} catch {
continue;
}
}
// Use fallback project ID
return DEFAULT_PROJECT_ID;
}
/**
* Prompt user for input via readline.
*/
async function promptInput(message: string): Promise<string> {
const rl = createInterface({ input: stdin, output: stdout });
try {
return (await rl.question(message)).trim();
} finally {
rl.close();
}
}
/**
* VPS-aware Antigravity OAuth login.
*
* On local machines: Uses the standard pi-ai flow with automatic localhost callback.
* On VPS/SSH: Shows URL and prompts user to paste the callback URL manually.
*/
export async function loginAntigravityVpsAware(
onUrl: (url: string) => void | Promise<void>,
onProgress?: (message: string) => void,
): Promise<OAuthCredentials | null> {
// Check if we're in a remote environment
if (shouldUseManualOAuthFlow()) {
return loginAntigravityManual(onUrl, onProgress);
}
// Use the standard pi-ai flow for local environments
try {
return await loginAntigravity(
async ({ url, instructions }) => {
await onUrl(url);
onProgress?.(instructions ?? "Complete sign-in in browser...");
},
(msg) => onProgress?.(msg),
);
} catch (err) {
// If the local server fails (e.g., port in use), fall back to manual
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
onProgress?.("Local callback server failed. Switching to manual mode...");
return loginAntigravityManual(onUrl, onProgress);
}
throw err;
}
}
/**
* Manual Antigravity OAuth login for VPS/headless environments.
*
* Shows the OAuth URL and prompts user to paste the callback URL.
*/
export async function loginAntigravityManual(
onUrl: (url: string) => void | Promise<void>,
onProgress?: (message: string) => void,
): Promise<OAuthCredentials | null> {
const { verifier, challenge } = generatePKCESync();
const authUrl = buildAuthUrl(challenge, verifier);
// Show the URL to the user
await onUrl(authUrl);
onProgress?.("Waiting for you to paste the callback URL...");
console.log("\n");
console.log("=".repeat(60));
console.log("VPS/Remote Mode - Manual OAuth");
console.log("=".repeat(60));
console.log("\n1. Open the URL above in your LOCAL browser");
console.log("2. Complete the Google sign-in");
console.log("3. Your browser will redirect to a localhost URL that won't load");
console.log("4. Copy the ENTIRE URL from your browser's address bar");
console.log("5. Paste it below\n");
console.log("The URL will look like:");
console.log("http://localhost:51121/oauth-callback?code=xxx&state=yyy\n");
const callbackInput = await promptInput("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) {
throw new Error(parsed.error);
}
// Verify state matches
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
onProgress?.("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}

View File

@ -10,7 +10,15 @@ import {
spinner,
text,
} from "@clack/prompts";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import {
loginAnthropic,
type OAuthCredentials,
} from "@mariozechner/pi-ai";
import {
loginAntigravityVpsAware,
isRemoteEnvironment,
} from "./antigravity-oauth.js";
import type { ClawdisConfig } from "../config/config.js";
import {
@ -223,13 +231,17 @@ async function promptAuthConfig(
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
],
}),
runtime,
) as "oauth" | "apiKey" | "minimax" | "skip";
) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
let next = cfg;
@ -266,6 +278,59 @@ async function promptAuthConfig(
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = spinner();
spin.start("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.message("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.message(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
// Set default model to Claude Opus 4.5 via Antigravity
next = {
...next,
agent: {
...next.agent,
model: "google-antigravity/claude-opus-4-5-thinking",
},
};
note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdisAgentDir } from "../agents/agent-paths.js";
@ -9,7 +9,7 @@ import type { ClawdisConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js";
export async function writeOAuthCredentials(
provider: "anthropic",
provider: OAuthProvider,
creds: OAuthCredentials,
): Promise<void> {
const dir = path.join(CONFIG_DIR, "credentials");

View File

@ -9,7 +9,15 @@ import {
spinner,
text,
} from "@clack/prompts";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import {
loginAnthropic,
type OAuthCredentials,
} from "@mariozechner/pi-ai";
import {
loginAntigravityVpsAware,
isRemoteEnvironment,
} from "./antigravity-oauth.js";
import type { ClawdisConfig } from "../config/config.js";
import {
@ -198,6 +206,10 @@ export async function runInteractiveOnboarding(
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
@ -239,6 +251,59 @@ export async function runInteractiveOnboarding(
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = spinner();
spin.start("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.message("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.message(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
// Set default model to Claude Opus 4.5 via Antigravity
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: "google-antigravity/claude-opus-4-5-thinking",
},
};
note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({

View File

@ -98,8 +98,8 @@ export async function runNonInteractiveOnboarding(
await setAnthropicApiKey(key);
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (authChoice === "oauth") {
runtime.error("OAuth requires interactive mode.");
} else if (authChoice === "oauth" || authChoice === "antigravity") {
runtime.error(`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`);
runtime.exit(1);
return;
}

View File

@ -1,5 +1,5 @@
export type OnboardMode = "local" | "remote";
export type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip";
export type AuthChoice = "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";