diff --git a/.gitignore b/.gitignore index e63d3cc6..0d4c133e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .clawpatch/ +.antigravitycli/ dist dist-bun *.bun-build diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b98a50e..b7e66a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add Antigravity CLI (`agy`) as a supported CLI provider. (#231, thanks @yetmike) + ### Fixes - Chrome extension: abort stale side-panel summary streams on tab changes so delayed output from a closed or replaced tab cannot render under the new page title. diff --git a/README.md b/README.md index 8c93b770..f666b1fd 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Use `summarize --help` or `summarize help` for the full help text. - `--length short|medium|long|xl|xxl|s|m|l|` - `--language, --lang `: output language (`auto` = match source) - `--max-output-tokens `: hard cap for LLM output tokens -- `--cli [provider]`: use a CLI provider (`--model cli/`). Supports `claude`, `gemini`, `codex`, `agent`, `openclaw`, `opencode`. If omitted, uses auto selection with CLI enabled. +- `--cli [provider]`: use a CLI provider (`--model cli/`). Supports `claude`, `gemini`, `codex`, `agent`, `openclaw`, `opencode`, `agy`. If omitted, uses auto selection with CLI enabled. - `--stream auto|on|off`: stream LLM output (`auto` = TTY only; disabled in `--json` mode) - `--plain`: keep raw output (no ANSI/OSC Markdown rendering) - `--no-color`: disable ANSI colors @@ -353,7 +353,7 @@ Use `summarize --help` or `summarize help` for the full help text. - `--verbose`: debug/diagnostics on stderr - `--metrics off|on|detailed`: metrics output (default `on`) -### Coding CLIs (Codex, Claude, Gemini, Agent, OpenClaw, OpenCode) +### Coding CLIs (Codex, Claude, Gemini, Agent, OpenClaw, OpenCode, Antigravity) Summarize can use common coding CLIs as local model backends: @@ -363,6 +363,7 @@ Summarize can use common coding CLIs as local model backends: - `agent` (Cursor Agent CLI) -> `--cli agent` / `--model cli/agent/` - `openclaw` -> `--cli openclaw` / `--model cli/openclaw/` or `--model openclaw/` - `opencode` -> `--cli opencode` / `--model cli/opencode/` (`--model cli/opencode` uses the OpenCode runtime default) +- `agy` (Antigravity CLI) -> `--cli agy` / `--model cli/agy` (uses agy's active session model; per-call model selection is not supported by agy print mode) Built-in preset: @@ -370,8 +371,8 @@ Built-in preset: Requirements: -- Binary installed and on `PATH` (or set `CODEX_PATH`, `CLAUDE_PATH`, `GEMINI_PATH`, `AGENT_PATH`, `OPENCLAW_PATH`, `OPENCODE_PATH`) -- Provider authenticated (`codex login`, `claude auth`, `gemini` login flow, `agent login` or `CURSOR_API_KEY`, `opencode auth login`) +- Binary installed and on `PATH` (or set `CODEX_PATH`, `CLAUDE_PATH`, `GEMINI_PATH`, `AGENT_PATH`, `OPENCLAW_PATH`, `OPENCODE_PATH`, `AGY_PATH`) +- Provider authenticated (`codex login`, `claude auth`, `gemini` login flow, `agent login` or `CURSOR_API_KEY`, `opencode auth login`, `agy` login flow or `ANTIGRAVITY_API_KEY`) Quick smoke test: @@ -384,13 +385,14 @@ summarize --cli gemini --plain --timeout 2m /tmp/summarize-cli-smoke.txt summarize --cli agent --plain --timeout 2m /tmp/summarize-cli-smoke.txt summarize --cli openclaw --plain --timeout 2m /tmp/summarize-cli-smoke.txt summarize --cli opencode --plain --timeout 2m /tmp/summarize-cli-smoke.txt +summarize --cli agy --plain --timeout 2m /tmp/summarize-cli-smoke.txt ``` Set explicit CLI allowlist/order: ```json { - "cli": { "enabled": ["codex", "claude", "gemini", "agent", "openclaw", "opencode"] } + "cli": { "enabled": ["codex", "claude", "gemini", "agent", "openclaw", "opencode", "agy"] } } ``` diff --git a/apps/chrome-extension/src/lib/settings.ts b/apps/chrome-extension/src/lib/settings.ts index 88f0b50e..d463f747 100644 --- a/apps/chrome-extension/src/lib/settings.ts +++ b/apps/chrome-extension/src/lib/settings.ts @@ -151,7 +151,8 @@ function normalizeAutoCliOrder(value: unknown): string { item !== "agent" && item !== "openclaw" && item !== "opencode" && - item !== "copilot" + item !== "copilot" && + item !== "agy" ) { continue; } diff --git a/docs/README.md b/docs/README.md index 1c0a5d73..c3098773 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ summary: "Docs index for summarize behaviors and modes." - `docs/chrome-extension.md` — Chrome side panel extension + daemon setup/troubleshooting - `docs/cache.md` — cache design + config (SQLite) -- `docs/cli.md` — CLI models (Claude/Codex/Gemini/Agent/OpenClaw/OpenCode/Copilot) +- `docs/cli.md` — CLI models (Claude/Codex/Gemini/Agent/OpenClaw/OpenCode/Copilot/Antigravity) - `docs/config.md` — config file location, precedence, and schema - `docs/extract-only.md` — extract mode (no summary LLM call) - `docs/firecrawl.md` — Firecrawl mode + API key diff --git a/docs/cli.md b/docs/cli.md index 4b80997c..cbc2e287 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,14 +1,14 @@ --- title: "CLI providers" kicker: "models" -summary: "CLI model providers and config for Claude, Codex, Gemini, Cursor Agent, OpenClaw, OpenCode, and GitHub Copilot." +summary: "CLI model providers and config for Claude, Codex, Gemini, Cursor Agent, OpenClaw, OpenCode, GitHub Copilot, and Antigravity." read_when: - "When changing CLI model integration." --- # CLI models -Summarize can use installed CLIs (Claude, Codex, Gemini, Cursor Agent, OpenClaw, OpenCode, GitHub Copilot) as local model backends. +Summarize can use installed CLIs (Claude, Codex, Gemini, Cursor Agent, OpenClaw, OpenCode, GitHub Copilot, Antigravity) as local model backends. ## Model ids @@ -22,8 +22,10 @@ Summarize can use installed CLIs (Claude, Codex, Gemini, Cursor Agent, OpenClaw, - `cli/opencode` (use the OpenCode runtime default model) - `cli/copilot/` (e.g. `cli/copilot/gpt-5.2`) - `cli/copilot` (use the Copilot CLI runtime default model) +- `cli/agy` (use the Antigravity CLI active session model) Use `--cli [provider]` (case-insensitive) for the provider default, or `--model cli//` to pin a model. +Antigravity does not support per-call model selection in print mode, so use `cli/agy` without a model suffix. If `--cli` is provided without a provider, auto selection is used with CLI enabled. Codex GPT Fast: @@ -100,13 +102,14 @@ path-based prompt and enables the required tool flags: - Agent: uses built-in file tools in `agent --print` mode (no extra flags) - OpenCode: `opencode run --format json ... --file ` when a file/image path is required - Copilot: `copilot -p `; passes `--model ` when one is configured +- Antigravity: `agy --print`; does not auto-approve tools for attachment prompts ## Config ```json { "cli": { - "enabled": ["claude", "gemini", "codex", "agent", "openclaw", "opencode", "copilot"], + "enabled": ["claude", "gemini", "codex", "agent", "openclaw", "opencode", "copilot", "agy"], "autoFallback": { "enabled": true, "onlyWhenNoApiKeys": true, @@ -132,6 +135,9 @@ path-based prompt and enables the required tool flags: }, "copilot": { "binary": "/usr/local/bin/copilot" + }, + "agy": { + "binary": "/usr/local/bin/agy" } } } @@ -142,6 +148,8 @@ Notes: - CLI output is treated as text only (no token accounting). - If a CLI call fails, auto mode falls back to the next candidate. - Cursor Agent CLI uses the `agent` binary and relies on Cursor CLI auth (login or `CURSOR_API_KEY`). +- Antigravity CLI uses the active agy session model; `cli.agy.model` is ignored by runtime selection. +- Antigravity normal text summaries run `agy --print` with no prompt argument in a temporary cwd with `--sandbox`, streaming the prompt over stdin so extracted content is not exposed in argv. Attachment prompts keep the caller cwd so agy can inspect the requested path but do not auto-approve tools. - Codex CLI normal text summaries run isolated by default: `codex exec --ephemeral --ignore-user-config --ignore-rules -C ...` with a sanitized temporary `CODEX_HOME` that carries auth only. Set `cli.codex.isolated` to `false` only when you intentionally need Codex to inherit local config/rules. - Gemini CLI is invoked in headless mode with `--prompt` for compatibility with current Gemini CLI releases. - OpenClaw uses `openclaw agent --agent --message --json` because current OpenClaw requires `-m/--message`; very large extracted inputs are rejected before launch to avoid argv limits. diff --git a/docs/config.md b/docs/config.md index 92b66bb4..ed938b19 100644 --- a/docs/config.md +++ b/docs/config.md @@ -361,7 +361,7 @@ Examples: ```json { "cli": { - "enabled": ["gemini", "agent", "openclaw", "opencode", "copilot"], + "enabled": ["gemini", "agent", "openclaw", "opencode", "copilot", "agy"], "autoFallback": { "enabled": true, "onlyWhenNoApiKeys": true, @@ -372,7 +372,8 @@ Examples: "agent": { "binary": "/usr/local/bin/agent", "model": "gpt-5.2" }, "openclaw": { "binary": "/usr/local/bin/openclaw", "model": "main" }, "opencode": { "binary": "/usr/local/bin/opencode", "model": "openai/gpt-5.4" }, - "copilot": { "binary": "/usr/local/bin/copilot", "model": "gpt-5.2" } + "copilot": { "binary": "/usr/local/bin/copilot", "model": "gpt-5.2" }, + "agy": { "binary": "/usr/local/bin/agy" } } } ``` @@ -385,6 +386,7 @@ Notes: - Auto fallback stores the last successful provider in `~/.summarize/cli-state.json` and prioritizes it on the next run. - `cli..binary` overrides CLI binary discovery. - `cli..extraArgs` appends extra CLI args. +- Antigravity CLI uses the active agy session model; `cli.agy.model` is ignored by runtime selection. - `cli.codex.isolated` defaults to `true` for normal summaries, adding Codex ephemeral/no-user-config/no-rules flags, a temporary cwd, and a sanitized temporary `CODEX_HOME` that carries auth only. Set it to `false` only when local Codex config/rules are intentional. ## OpenAI config diff --git a/docs/llm.md b/docs/llm.md index 176f1af1..4d1cb876 100644 --- a/docs/llm.md +++ b/docs/llm.md @@ -38,7 +38,7 @@ installed, auto mode can use local CLI models via `cli.enabled` or implicit auto - `ANTHROPIC_API_KEY` (required for `anthropic/...` models) - `ANTHROPIC_BASE_URL` (optional; override Anthropic API endpoint) - `SUMMARIZE_MODEL` (optional; overrides default model selection) -- `CLAUDE_PATH` / `CODEX_PATH` / `GEMINI_PATH` / `AGENT_PATH` / `OPENCLAW_PATH` / `OPENCODE_PATH` / `COPILOT_PATH` (optional; override CLI binary paths) +- `CLAUDE_PATH` / `CODEX_PATH` / `GEMINI_PATH` / `AGENT_PATH` / `OPENCLAW_PATH` / `OPENCODE_PATH` / `COPILOT_PATH` / `AGY_PATH` (optional; override CLI binary paths) ## Flags @@ -53,6 +53,7 @@ installed, auto mode can use local CLI models via `cli.enabled` or implicit auto - `cli/openclaw/main` - `cli/opencode/openai/gpt-5.4` - `cli/copilot/gpt-5.2` + - `cli/agy` - `openai/gpt-5.4` - `openai/gpt-5.4-mini` - `openai/gpt-5.4-nano` @@ -69,7 +70,7 @@ installed, auto mode can use local CLI models via `cli.enabled` or implicit auto - `anthropic/claude-sonnet-4-5` - `openrouter/meta-llama/llama-3.3-70b-instruct:free` (force OpenRouter) - `--cli [provider]` - - Examples: `--cli claude`, `--cli Gemini`, `--cli codex`, `--cli agent`, `--cli openclaw`, `--cli opencode`, `--cli copilot` (equivalent to `--model cli/`); `--cli` alone uses auto selection with CLI enabled. + - Examples: `--cli claude`, `--cli Gemini`, `--cli codex`, `--cli agent`, `--cli openclaw`, `--cli opencode`, `--cli copilot`, `--cli agy` (equivalent to `--model cli/`); `--cli` alone uses auto selection with CLI enabled. - `--model auto` - See `docs/model-auto.md` - `--model ` diff --git a/src/config/parse-helpers.ts b/src/config/parse-helpers.ts index 21c56563..00caa745 100644 --- a/src/config/parse-helpers.ts +++ b/src/config/parse-helpers.ts @@ -17,7 +17,8 @@ export function parseCliProvider(value: unknown, path: string): CliProvider { trimmed === "agent" || trimmed === "openclaw" || trimmed === "opencode" || - trimmed === "copilot" + trimmed === "copilot" || + trimmed === "agy" ) { return trimmed as CliProvider; } diff --git a/src/config/sections.ts b/src/config/sections.ts index 37f84648..e681e372 100644 --- a/src/config/sections.ts +++ b/src/config/sections.ts @@ -325,6 +325,7 @@ export function parseCliConfig(root: Record, path: string): Cli const copilot = value.copilot ? parseCliProviderConfig(value.copilot, path, "copilot") : undefined; + const agy = value.agy ? parseCliProviderConfig(value.agy, path, "agy") : undefined; if (typeof value.autoFallback !== "undefined" && typeof value.magicAuto !== "undefined") { throw new Error( `Invalid config file ${path}: use only one of "cli.autoFallback" or legacy "cli.magicAuto".`, @@ -359,6 +360,7 @@ export function parseCliConfig(root: Record, path: string): Cli openclaw || opencode || copilot || + agy || autoFallback || promptOverride || typeof allowTools === "boolean" || @@ -373,6 +375,7 @@ export function parseCliConfig(root: Record, path: string): Cli ...(openclaw ? { openclaw } : {}), ...(opencode ? { opencode } : {}), ...(copilot ? { copilot } : {}), + ...(agy ? { agy } : {}), ...(autoFallback ? { autoFallback } : {}), ...(promptOverride ? { promptOverride } : {}), ...(typeof allowTools === "boolean" ? { allowTools } : {}), diff --git a/src/config/types.ts b/src/config/types.ts index 036adf2e..bc563eed 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -7,7 +7,8 @@ export type CliProvider = | "agent" | "openclaw" | "opencode" - | "copilot"; + | "copilot" + | "agy"; export type OpenAiReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh"; export type OpenAiTextVerbosity = "low" | "medium" | "high"; export type ModelRequestOptions = { @@ -36,6 +37,7 @@ export type CliConfig = { openclaw?: CliProviderConfig; opencode?: CliProviderConfig; copilot?: CliProviderConfig; + agy?: CliProviderConfig; autoFallback?: CliAutoFallbackConfig; magicAuto?: CliAutoFallbackConfig; promptOverride?: string; diff --git a/src/daemon/agent-model.ts b/src/daemon/agent-model.ts index 163b7d89..037d451c 100644 --- a/src/daemon/agent-model.ts +++ b/src/daemon/agent-model.ts @@ -206,8 +206,10 @@ function buildNoAgentModelAvailableError({ if (attempt.requiredEnv === "CLI_GEMINI") return "gemini"; if (attempt.requiredEnv === "CLI_AGENT") return "agent"; if (attempt.requiredEnv === "CLI_OPENCLAW") return "openclaw"; + if (attempt.requiredEnv === "CLI_OPENCODE") return "opencode"; if (attempt.requiredEnv === "CLI_COPILOT") return "copilot"; - return "opencode"; + if (attempt.requiredEnv === "CLI_AGY") return "agy"; + return "unknown"; }) .filter((provider) => !cliAvailability[provider as keyof typeof cliAvailability]), ), diff --git a/src/daemon/chat.ts b/src/daemon/chat.ts index 4d901511..601d04c7 100644 --- a/src/daemon/chat.ts +++ b/src/daemon/chat.ts @@ -42,7 +42,11 @@ function resolveConfiguredCliModel( ? cli?.agent?.model : provider === "openclaw" ? cli?.openclaw?.model - : cli?.opencode?.model; + : provider === "opencode" + ? cli?.opencode?.model + : provider === "agy" + ? null + : cli?.copilot?.model; return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; } diff --git a/src/daemon/env-snapshot.ts b/src/daemon/env-snapshot.ts index dd2bb81e..57d7922c 100644 --- a/src/daemon/env-snapshot.ts +++ b/src/daemon/env-snapshot.ts @@ -41,6 +41,8 @@ const ENV_KEYS = [ "OPENCLAW_PATH", "OPENCODE_PATH", "COPILOT_PATH", + "AGY_PATH", + "ANTIGRAVITY_API_KEY", "UVX_PATH", ] as const; diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 8ffa943d..9e441b76 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -142,6 +142,7 @@ export async function buildModelPickerOptions({ cliOpenclaw: boolean; cliOpencode: boolean; cliCopilot: boolean; + cliAgy: boolean; }; openaiBaseUrl: string | null; localModelsSource: { kind: "openai-compatible"; baseUrlHost: string } | null; @@ -164,6 +165,7 @@ export async function buildModelPickerOptions({ cliOpenclaw: false, cliOpencode: false, cliCopilot: false, + cliAgy: false, }; const cliAvailability = resolveCliAvailability({ env: envForRun, config: configForCli }); providers.cliClaude = Boolean(cliAvailability.claude); @@ -173,6 +175,7 @@ export async function buildModelPickerOptions({ providers.cliOpenclaw = Boolean(cliAvailability.openclaw); providers.cliOpencode = Boolean(cliAvailability.opencode); providers.cliCopilot = Boolean(cliAvailability.copilot); + providers.cliAgy = Boolean(cliAvailability.agy); const options: ModelPickerOption[] = [ { id: "auto", label: "Auto" }, @@ -201,6 +204,9 @@ export async function buildModelPickerOptions({ if (providers.cliCopilot) { options.push({ id: "cli/copilot", label: "CLI: GitHub Copilot" }); } + if (providers.cliAgy) { + options.push({ id: "cli/agy", label: "CLI: Antigravity (agy)" }); + } if (providers.openrouter) { options.push({ id: "free", label: "Free (OpenRouter)" }); diff --git a/src/llm/cli-provider-output.ts b/src/llm/cli-provider-output.ts index 369aa703..72c5c1a4 100644 --- a/src/llm/cli-provider-output.ts +++ b/src/llm/cli-provider-output.ts @@ -1,7 +1,10 @@ import type { CliProvider } from "../config.js"; import type { LlmTokenUsage } from "./generate-text.js"; -export type JsonCliProvider = Exclude; +export type JsonCliProvider = Exclude< + CliProvider, + "codex" | "openclaw" | "opencode" | "copilot" | "agy" +>; const JSON_RESULT_FIELDS = ["result", "response", "output", "message", "text"] as const; @@ -10,7 +13,8 @@ export function isJsonCliProvider(provider: CliProvider): provider is JsonCliPro provider !== "codex" && provider !== "openclaw" && provider !== "opencode" && - provider !== "copilot" + provider !== "copilot" && + provider !== "agy" ); } diff --git a/src/llm/cli.ts b/src/llm/cli.ts index 000ab78a..deee9647 100644 --- a/src/llm/cli.ts +++ b/src/llm/cli.ts @@ -23,9 +23,10 @@ const DEFAULT_BINARIES: Record = { openclaw: "openclaw", opencode: "opencode", copilot: "copilot", + agy: "agy", }; -const OPENCLAW_MAX_MESSAGE_ARG_BYTES = 120 * 1024; +const CLI_MAX_MESSAGE_ARG_BYTES = 120 * 1024; const CODEX_GPT_FAST_MODEL = "gpt-5.5"; const CODEX_GPT_FAST_ALIASES = new Set(["gpt-fast", "gpt-5.5-fast"]); @@ -37,6 +38,7 @@ const PROVIDER_PATH_ENV: Record = { openclaw: "OPENCLAW_PATH", opencode: "OPENCODE_PATH", copilot: "COPILOT_PATH", + agy: "AGY_PATH", }; type RunCliModelOptions = { @@ -72,6 +74,7 @@ function getCliProviderConfig( if (provider === "agent") return config.agent; if (provider === "openclaw") return config.openclaw; if (provider === "opencode") return config.opencode; + if (provider === "agy") return config.agy; return config.copilot; } @@ -123,6 +126,14 @@ function resolveCodexModelAndArgs( return { model: CODEX_GPT_FAST_MODEL, extraArgs }; } +function hasAnyFlag(args: string[], flags: string[]): boolean { + return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`))); +} + +function goDurationFromMs(timeoutMs: number): string { + return `${Math.max(1, Math.ceil(timeoutMs / 1000))}s`; +} + async function copyCodexAuthFiles(sourceDir: string | undefined, targetDir: string): Promise { const codexHome = sourceDir?.trim() || path.join(homedir(), ".codex"); const authPath = path.join(codexHome, "auth.json"); @@ -207,7 +218,7 @@ export async function runCliModel({ } if (provider === "openclaw") { const promptBytes = Buffer.byteLength(prompt, "utf8"); - if (promptBytes > OPENCLAW_MAX_MESSAGE_ARG_BYTES) { + if (promptBytes > CLI_MAX_MESSAGE_ARG_BYTES) { throw new Error( `OpenClaw CLI requires --message and cannot safely receive large prompts over argv (${promptBytes} bytes). ` + "Use a different CLI provider for this input, reduce extracted content, or update OpenClaw to support stdin/file input.", @@ -371,6 +382,43 @@ export async function runCliModel({ return { text, usage: null, costUsd: null }; } + if (provider === "agy") { + const isolatedCwd = !allowTools + ? await fs.mkdtemp(path.join(tmpdir(), "summarize-agy-")) + : null; + try { + const agyArgs: string[] = [...providerExtraArgs]; + if (!allowTools && !hasAnyFlag(providerExtraArgs, ["--sandbox"])) { + agyArgs.push("--sandbox"); + } + // With no prompt argument, agy print mode reads the prompt from stdin. + agyArgs.push("--print"); + if ( + Number.isFinite(timeoutMs) && + timeoutMs > 0 && + !hasAnyFlag(providerExtraArgs, ["--print-timeout", "-print-timeout"]) + ) { + agyArgs.push("--print-timeout", goDurationFromMs(timeoutMs)); + } + const { stdout } = await execCliWithInput({ + execFileImpl: execFileFn, + cmd: binary, + args: agyArgs, + input: prompt, + timeoutMs, + env: effectiveEnv, + cwd: isolatedCwd ?? cwd, + }); + const text = stdout.trim(); + if (!text) throw new Error("CLI returned empty output"); + return { text, usage: null, costUsd: null }; + } finally { + if (isolatedCwd) { + await fs.rm(isolatedCwd, { recursive: true, force: true }).catch(() => {}); + } + } + } + if (!isJsonCliProvider(provider)) { throw new Error(`Unsupported CLI provider "${provider}".`); } diff --git a/src/llm/provider-profile.ts b/src/llm/provider-profile.ts index 78abaa95..d727b817 100644 --- a/src/llm/provider-profile.ts +++ b/src/llm/provider-profile.ts @@ -35,7 +35,8 @@ export type RequiredModelEnv = | "CLI_AGENT" | "CLI_OPENCLAW" | "CLI_OPENCODE" - | "CLI_COPILOT"; + | "CLI_COPILOT" + | "CLI_AGY"; type GatewayProviderProfile = { requiredEnv: RequiredModelEnv; @@ -105,6 +106,7 @@ export const DEFAULT_CLI_MODELS: Record = { openclaw: "main", opencode: null, copilot: null, + agy: null, }; export const DEFAULT_AUTO_CLI_ORDER: CliProvider[] = [ @@ -115,6 +117,8 @@ export const DEFAULT_AUTO_CLI_ORDER: CliProvider[] = [ "openclaw", "opencode", "copilot", + // agy is intentionally excluded from the default auto-fallback order. + // Use --cli agy or --model cli/agy to opt in explicitly. ]; export function parseCliProviderName(raw: string): CliProvider | null { @@ -126,6 +130,7 @@ export function parseCliProviderName(raw: string): CliProvider | null { if (normalized === "openclaw") return "openclaw"; if (normalized === "opencode") return "opencode"; if (normalized === "copilot") return "copilot"; + if (normalized === "agy") return "agy"; return null; } @@ -142,7 +147,9 @@ export function requiredEnvForCliProvider(provider: CliProvider): RequiredModelE ? "CLI_OPENCODE" : provider === "copilot" ? "CLI_COPILOT" - : "CLI_CLAUDE"; + : provider === "agy" + ? "CLI_AGY" + : "CLI_CLAUDE"; } export function getGatewayProviderProfile(provider: GatewayProvider): GatewayProviderProfile { diff --git a/src/model-auto-cli.ts b/src/model-auto-cli.ts index 8e252b1c..d7a761b2 100644 --- a/src/model-auto-cli.ts +++ b/src/model-auto-cli.ts @@ -114,7 +114,9 @@ export function prependCliCandidates({ ? cli?.opencode?.model : provider === "copilot" ? cli?.copilot?.model - : cli?.claude?.model; + : provider === "agy" + ? undefined + : cli?.claude?.model; add(provider, modelOverride); } diff --git a/src/model-spec.ts b/src/model-spec.ts index 8a70314c..a951ee16 100644 --- a/src/model-spec.ts +++ b/src/model-spec.ts @@ -54,7 +54,8 @@ export type FixedModelSpec = | "CLI_AGENT" | "CLI_OPENCLAW" | "CLI_OPENCODE" - | "CLI_COPILOT"; + | "CLI_COPILOT" + | "CLI_AGY"; cliProvider: CliProvider; cliModel: string | null; }; @@ -191,12 +192,18 @@ export function parseRequestedModelId(raw: string): RequestedModel { providerRaw !== "agent" && providerRaw !== "openclaw" && providerRaw !== "opencode" && - providerRaw !== "copilot" + providerRaw !== "copilot" && + providerRaw !== "agy" ) { throw new Error(`Invalid CLI model id "${trimmed}". Expected cli//.`); } const cliProvider = providerRaw as CliProvider; const requestedModel = parts.slice(2).join("/").trim(); + if (cliProvider === "agy" && requestedModel.length > 0) { + throw new Error( + `Invalid CLI model id "${trimmed}". Antigravity CLI uses cli/agy without a model suffix.`, + ); + } const cliModel = requestedModel.length > 0 ? requestedModel : DEFAULT_CLI_MODELS[cliProvider]; const requiredEnv = requiredEnvForCliProvider(cliProvider) as Extract< RequiredModelEnv, @@ -207,6 +214,7 @@ export function parseRequestedModelId(raw: string): RequestedModel { | "CLI_OPENCLAW" | "CLI_OPENCODE" | "CLI_COPILOT" + | "CLI_AGY" >; const userModelId = cliModel ? `cli/${cliProvider}/${cliModel}` : `cli/${cliProvider}`; return { diff --git a/src/run/cli-fallback-state.ts b/src/run/cli-fallback-state.ts index fcc5f3fc..ac4dc722 100644 --- a/src/run/cli-fallback-state.ts +++ b/src/run/cli-fallback-state.ts @@ -18,7 +18,8 @@ function parseCliProvider(value: unknown): CliProvider | null { value === "agent" || value === "openclaw" || value === "opencode" || - value === "copilot" + value === "copilot" || + value === "agy" ) { return value; } diff --git a/src/run/env.ts b/src/run/env.ts index f1a79667..394e826c 100644 --- a/src/run/env.ts +++ b/src/run/env.ts @@ -84,6 +84,7 @@ export function resolveCliAvailability({ "openclaw", "opencode", "copilot", + "agy", ]; const availability: Partial> = {}; for (const provider of providers) { @@ -113,7 +114,8 @@ export function parseCliUserModelId(modelId: string): { provider !== "agent" && provider !== "openclaw" && provider !== "opencode" && - provider !== "copilot" + provider !== "copilot" && + provider !== "agy" ) { throw new Error(`Invalid CLI model id "${modelId}". Expected cli//.`); } @@ -130,7 +132,8 @@ export function parseCliProviderArg(raw: string): CliProvider { normalized === "agent" || normalized === "openclaw" || normalized === "opencode" || - normalized === "copilot" + normalized === "copilot" || + normalized === "agy" ) { return normalized as CliProvider; } diff --git a/src/run/help.ts b/src/run/help.ts index 6bccd8c9..395d6c26 100644 --- a/src/run/help.ts +++ b/src/run/help.ts @@ -141,7 +141,7 @@ export function buildProgram() { .addOption( new Option( "--cli [provider]", - "Use a CLI provider: claude, gemini, codex, agent, openclaw, opencode, copilot (equivalent to --model cli/). If omitted, use auto selection with CLI enabled.", + "Use a CLI provider: claude, gemini, codex, agent, openclaw, opencode, copilot, agy (equivalent to --model cli/). If omitted, use auto selection with CLI enabled.", ), ) .option("--extract", "Print extracted content and exit (no LLM summary)", false) @@ -295,6 +295,7 @@ ${heading("Env Vars")} OPENCLAW_PATH optional (path to OpenClaw CLI binary) OPENCODE_PATH optional (path to OpenCode CLI binary) COPILOT_PATH optional (path to GitHub Copilot CLI binary) + AGY_PATH optional (path to Antigravity CLI binary) SUMMARIZE_MODEL optional (overrides default model selection) SUMMARIZE_THEME optional (${CLI_THEME_NAMES.join(", ")}) SUMMARIZE_TRUECOLOR optional (force 24-bit color) diff --git a/src/run/run-models.ts b/src/run/run-models.ts index 39551427..5b6c6cac 100644 --- a/src/run/run-models.ts +++ b/src/run/run-models.ts @@ -16,6 +16,7 @@ function resolveConfiguredCliModel( if (provider === "agent") return cli?.agent?.model; if (provider === "openclaw") return cli?.openclaw?.model; if (provider === "opencode") return cli?.opencode?.model; + if (provider === "agy") return null; return cli?.copilot?.model; })(); return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; diff --git a/src/run/run-settings-parse.ts b/src/run/run-settings-parse.ts index 21b6fc60..519ee971 100644 --- a/src/run/run-settings-parse.ts +++ b/src/run/run-settings-parse.ts @@ -44,6 +44,7 @@ export const parseCliProvider = (raw: string): CliProvider | null => { if (normalized === "openclaw") return "openclaw"; if (normalized === "opencode") return "opencode"; if (normalized === "copilot") return "copilot"; + if (normalized === "agy") return "agy"; return null; }; diff --git a/src/run/summary-engine.ts b/src/run/summary-engine.ts index 55501ec6..fb7b565e 100644 --- a/src/run/summary-engine.ts +++ b/src/run/summary-engine.ts @@ -163,6 +163,9 @@ export function createSummaryEngine(deps: SummaryEngineDeps) { if (requiredEnv === "CLI_COPILOT") { return Boolean(deps.cliAvailability.copilot); } + if (requiredEnv === "CLI_AGY") { + return Boolean(deps.cliAvailability.agy); + } if (requiredEnv === "GEMINI_API_KEY") { return deps.keyFlags.googleConfigured; } @@ -212,6 +215,9 @@ export function createSummaryEngine(deps: SummaryEngineDeps) { if (attempt.requiredEnv === "CLI_COPILOT") { return `GitHub Copilot CLI not found for model ${attempt.userModelId}. Install Copilot CLI or set COPILOT_PATH.`; } + if (attempt.requiredEnv === "CLI_AGY") { + return `Antigravity CLI not found for model ${attempt.userModelId}. Install agy or set AGY_PATH.`; + } return `Missing ${attempt.requiredEnv} for model ${attempt.userModelId}. Set the env var or choose a different --model.`; }; diff --git a/src/run/types.ts b/src/run/types.ts index 3a660ca6..7ceca12e 100644 --- a/src/run/types.ts +++ b/src/run/types.ts @@ -18,7 +18,8 @@ export type ModelAttemptRequiredEnv = | "CLI_AGENT" | "CLI_OPENCLAW" | "CLI_OPENCODE" - | "CLI_COPILOT"; + | "CLI_COPILOT" + | "CLI_AGY"; export type ModelAttempt = { transport: "native" | "openrouter" | "cli"; diff --git a/tests/config.test.ts b/tests/config.test.ts index 4a3bd2f0..b60d3e03 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -499,6 +499,21 @@ describe("config loading", () => { expect(() => loadSummarizeConfig({ env: { HOME: root } })).toThrow(/unknown CLI provider/); }); + it("parses agy cli config", () => { + const { root } = writeJsonConfig({ + cli: { + enabled: ["agy"], + agy: { binary: "/usr/local/bin/agy", extraArgs: ["--sandbox"] }, + }, + }); + expect(loadSummarizeConfig({ env: { HOME: root } }).config).toEqual({ + cli: { + enabled: ["agy"], + agy: { binary: "/usr/local/bin/agy", extraArgs: ["--sandbox"] }, + }, + }); + }); + it("parses openclaw cli config", () => { const { root } = writeJsonConfig({ cli: { diff --git a/tests/daemon.agent.test.ts b/tests/daemon.agent.test.ts index 2247ac23..b96c1ddf 100644 --- a/tests/daemon.agent.test.ts +++ b/tests/daemon.agent.test.ts @@ -432,4 +432,38 @@ describe("daemon/agent", () => { autoSpy.mockRestore(); } }); + + it("labels CLI_AGY as agy in unavailable-provider diagnostics", async () => { + const home = makeTempHome(); + const autoSpy = vi.spyOn(modelAuto, "buildAutoModelAttempts").mockReturnValue([ + { + transport: "cli", + userModelId: "cli/agy", + llmModelId: null, + openrouterProviders: null, + forceOpenRouter: false, + requiredEnv: "CLI_AGY", + cliProvider: "agy", + cliModel: null, + debug: "agy fallback", + }, + ]); + + try { + await expect( + completeAgentResponse({ + env: { HOME: home, PATH: "" }, + pageUrl: "https://example.com", + pageTitle: null, + pageContent: "Hello world", + messages: [{ role: "user", content: "Hi" }], + modelOverride: null, + tools: [], + automationEnabled: false, + }), + ).rejects.toThrow(/CLI unavailable: agy/i); + } finally { + autoSpy.mockRestore(); + } + }); }); diff --git a/tests/daemon.chat.test.ts b/tests/daemon.chat.test.ts index e830100e..05d4b3eb 100644 --- a/tests/daemon.chat.test.ts +++ b/tests/daemon.chat.test.ts @@ -206,6 +206,42 @@ describe("daemon/chat", () => { expect(meta[0]?.model).toBe("cli/opencode/openai/gpt-5.4"); }); + it("uses agy's active session model for chat metadata", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-daemon-chat-agy-fixed-")); + const meta: Array<{ model?: string | null }> = []; + + await streamChatResponse({ + env: { HOME: home }, + fetchImpl: fetch, + configForCli: { + cli: { + agy: { + model: "Gemini 3.5 Flash (Medium)", + }, + }, + }, + session: { + id: "s-agy-fixed", + lastMeta: { model: null, modelLabel: null, inputSummary: null, summaryFromCache: null }, + }, + pageUrl: "https://example.com", + pageTitle: "Example", + pageContent: "Hello world", + messages: [{ role: "user", content: "Hi" }], + modelOverride: "cli/agy", + pushToSession: () => {}, + emitMeta: (patch) => meta.push(patch), + }); + + expect(runCliModel).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "agy", + model: null, + }), + ); + expect(meta[0]?.model).toBe("cli/agy"); + }); + it("routes openrouter overrides through openrouter transport", async () => { const home = mkdtempSync(join(tmpdir(), "summarize-daemon-chat-openrouter-")); const meta: Array<{ model?: string | null }> = []; diff --git a/tests/daemon.config.test.ts b/tests/daemon.config.test.ts index de699309..d22e2855 100644 --- a/tests/daemon.config.test.ts +++ b/tests/daemon.config.test.ts @@ -148,6 +148,8 @@ describe("daemon config", () => { OPENAI_API_KEY: " k ", OPENAI_WHISPER_BASE_URL: " http://127.0.0.1:8080/v1 ", COPILOT_PATH: " /opt/copilot ", + AGY_PATH: " /opt/agy ", + ANTIGRAVITY_API_KEY: " test-key-123 ", PATH: "", SUMMARIZE_TRANSCRIBER: " parakeet ", SUMMARIZE_ONNX_PARAKEET_CMD: " run-parakeet {input} ", @@ -169,6 +171,8 @@ describe("daemon config", () => { OPENAI_API_KEY: "k", OPENAI_WHISPER_BASE_URL: "http://127.0.0.1:8080/v1", COPILOT_PATH: "/opt/copilot", + AGY_PATH: "/opt/agy", + ANTIGRAVITY_API_KEY: "test-key-123", SUMMARIZE_TRANSCRIBER: "parakeet", SUMMARIZE_ONNX_PARAKEET_CMD: "run-parakeet {input}", SUMMARIZE_ONNX_CANARY_CMD: "run-canary {input}", diff --git a/tests/llm.cli.agy.test.ts b/tests/llm.cli.agy.test.ts new file mode 100644 index 00000000..decee5ae --- /dev/null +++ b/tests/llm.cli.agy.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { resolveCliBinary, runCliModel } from "../src/llm/cli.js"; +import type { ExecFileFn } from "../src/markitdown.js"; + +const makeStub = ( + handler: (args: string[], input?: string) => { stdout?: string; stderr?: string }, +) => { + const execFileStub: ExecFileFn = ((_cmd, args, _options, cb) => { + const result = handler(args); + if (cb) cb(null, result.stdout ?? "", result.stderr ?? ""); + return { + stdin: { write: (_chunk: unknown) => {}, end: () => {} }, + } as unknown as ReturnType; + }) as ExecFileFn; + return execFileStub; +}; + +describe("runCliModel - agy provider", () => { + it("invokes agy with --print, passes prompt via stdin, returns plain text", async () => { + let seenCmd = ""; + let seenCwd = ""; + let seenInput = ""; + const seen: string[][] = []; + const execFileImpl: ExecFileFn = ((cmd, args, options, cb) => { + seenCmd = String(cmd); + seen.push(args); + seenCwd = typeof options?.cwd === "string" ? options.cwd : ""; + cb?.(null, " Hello from agy. \n", ""); + return { + stdin: { + write: (chunk: unknown) => { + seenInput += String(chunk); + }, + end: () => {}, + }, + } as unknown as ReturnType; + }) as ExecFileFn; + + const result = await runCliModel({ + provider: "agy", + prompt: "Summarize this.", + model: null, + allowTools: false, + timeoutMs: 1000, + env: {}, + execFileImpl, + config: null, + cwd: "/tmp/agy-original-cwd", + }); + + expect(result.text).toBe("Hello from agy."); + expect(result.usage).toBeNull(); + expect(result.costUsd).toBeNull(); + expect(seenCmd).toBe("agy"); + expect(seen[0]).toContain("--print"); + expect(seen[0]).toContain("--sandbox"); + expect(seen[0]).toContain("--print-timeout"); + expect(seen[0]).toContain("1s"); + expect(seen[0]).not.toContain("--output-format"); + expect(seenCwd).toContain("summarize-agy-"); + expect(seenCwd).not.toBe("/tmp/agy-original-cwd"); + expect(seenInput).toContain("Summarize this."); + }); + + it("uses the active agy session model instead of passing --model", async () => { + const seen: string[][] = []; + const execFileImpl = makeStub((args) => { + seen.push(args); + return { stdout: "answer text" }; + }); + + const result = await runCliModel({ + provider: "agy", + prompt: "Q?", + model: "Gemini 3.5 Flash (Medium)", + allowTools: false, + timeoutMs: 1000, + env: {}, + execFileImpl, + config: null, + }); + + expect(result.text).toBe("answer text"); + expect(seen[0]).toContain("--print"); + expect(seen[0]).not.toContain("--model"); + expect(seen[0]).not.toContain("Gemini 3.5 Flash (Medium)"); + }); + + it("does not auto-approve agy tools when allowTools is true", async () => { + const seen: string[][] = []; + let seenCwd = ""; + const execFileImpl = makeStub((args) => { + seen.push(args); + return { stdout: "ok" }; + }); + const wrappedExecFileImpl: ExecFileFn = ((cmd, args, options, cb) => { + seenCwd = typeof options?.cwd === "string" ? options.cwd : ""; + return execFileImpl(cmd, args, options, cb); + }) as ExecFileFn; + + await runCliModel({ + provider: "agy", + prompt: "Q", + model: null, + allowTools: true, + timeoutMs: 1000, + env: {}, + execFileImpl: wrappedExecFileImpl, + config: null, + cwd: "/tmp/agy-tools-cwd", + }); + + expect(seen[0]).not.toContain("--dangerously-skip-permissions"); + expect(seen[0]).not.toContain("--sandbox"); + expect(seenCwd).toBe("/tmp/agy-tools-cwd"); + }); + + it("passes summarize timeout to agy unless extra args override it", async () => { + const seen: string[][] = []; + const execFileImpl = makeStub((args) => { + seen.push(args); + return { stdout: "ok" }; + }); + + await runCliModel({ + provider: "agy", + prompt: "Q", + model: null, + allowTools: false, + timeoutMs: 125_000, + env: {}, + execFileImpl, + config: null, + }); + expect(seen[0]).toContain("--print-timeout"); + expect(seen[0]).toContain("125s"); + + await runCliModel({ + provider: "agy", + prompt: "Q", + model: null, + allowTools: false, + timeoutMs: 125_000, + env: {}, + execFileImpl, + config: { agy: { extraArgs: ["--print-timeout=10m"] } }, + }); + expect(seen[1]?.filter((arg) => arg.startsWith("--print-timeout"))).toEqual([ + "--print-timeout=10m", + ]); + + await runCliModel({ + provider: "agy", + prompt: "Q", + model: null, + allowTools: false, + timeoutMs: 125_000, + env: {}, + execFileImpl, + config: { agy: { extraArgs: ["-print-timeout=10m"] } }, + }); + expect(seen[2]?.filter((arg) => arg.includes("print-timeout"))).toEqual(["-print-timeout=10m"]); + }); + + it("throws when agy returns empty output", async () => { + const execFileImpl = makeStub(() => ({ stdout: " \n" })); + await expect( + runCliModel({ + provider: "agy", + prompt: "Q", + model: null, + allowTools: false, + timeoutMs: 1000, + env: {}, + execFileImpl, + config: null, + }), + ).rejects.toThrow(/empty output/); + }); + + it("respects AGY_PATH and config-provided binary/extraArgs", async () => { + expect(resolveCliBinary("agy", null, { AGY_PATH: "/custom/agy" })).toBe("/custom/agy"); + expect(resolveCliBinary("agy", { agy: { binary: "/cfg/agy" } }, {})).toBe("/cfg/agy"); + expect(resolveCliBinary("agy", null, {})).toBe("agy"); + + const seen: string[][] = []; + const execFileImpl = makeStub((args) => { + seen.push(args); + return { stdout: "ok" }; + }); + await runCliModel({ + provider: "agy", + prompt: "Q", + model: null, + allowTools: false, + timeoutMs: 1000, + env: {}, + execFileImpl, + config: { agy: { extraArgs: ["--no-color"] } }, + }); + expect(seen[0]?.[0]).toBe("--no-color"); + }); +}); diff --git a/tests/model-spec.test.ts b/tests/model-spec.test.ts index 9dba98b3..006a61d6 100644 --- a/tests/model-spec.test.ts +++ b/tests/model-spec.test.ts @@ -74,6 +74,19 @@ describe("model spec parsing", () => { expect(parsed.requiredEnv).toBe("CLI_OPENCODE"); }); + it("uses agy's active session model and rejects model suffixes", () => { + const parsed = parseRequestedModelId("cli/agy"); + expect(parsed.kind).toBe("fixed"); + expect(parsed.transport).toBe("cli"); + expect(parsed.userModelId).toBe("cli/agy"); + expect(parsed.cliProvider).toBe("agy"); + expect(parsed.cliModel).toBeNull(); + expect(parsed.requiredEnv).toBe("CLI_AGY"); + expect(() => parseRequestedModelId("cli/agy/Gemini 3.5 Flash (Medium)")).toThrow( + /without a model suffix/, + ); + }); + it("rejects invalid cli providers", () => { expect(() => parseRequestedModelId("cli/unknown/model")).toThrow(/Invalid CLI model id/); });