From e049f924652a00b24ba0cd57dc6c562d72bb1e63 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 7 Sep 2026 22:25:17 +0200 Subject: [PATCH 1/2] feat: add Antigravity CLI harness with Google OAuth Wire agy as a fourth /orch backend using interactive Google sign-in, MCP merge into ~/.gemini/config, and print-mode stream-json turns. Co-authored-by: Cursor --- AGENTS.md | 5 +- CHANGELOG.md | 8 + CLAUDE.md | 4 +- CONTRIBUTING.md | 4 +- README.md | 6 +- SECURITY.md | 11 +- config_command.go | 10 +- config_command_test.go | 2 +- docs/COMMANDS.md | 9 +- docs/ORCHESTRATION.md | 69 ++- internal/agent/agy_agent.go | 526 ++++++++++++++++++++++ internal/agent/agy_agent_test.go | 138 ++++++ internal/api/config.go | 7 +- internal/api/context.go | 2 +- internal/repl/planwindow_turn_test.go | 1 + internal/repl/repl.go | 89 +++- internal/setup/agy.go | 56 +++ internal/setup/consent.go | 4 + internal/setup/consent_test.go | 2 +- internal/setup/orch.go | 40 +- internal/setup/orch_test.go | 23 + internal/skills/materialize.go | 10 +- internal/skills/materialize_test.go | 25 +- internal/tui/input.go | 1 + internal/tui/terminal.go | 5 + internal/tui/tui_backend.go | 51 ++- internal/tui/tui_backend_agy_test.go | 30 ++ internal/tui/tui_backend_cc_test.go | 8 +- internal/tui/tui_backend_cerebras_test.go | 12 +- main.go | 41 +- main_models.go | 11 + main_models_test.go | 3 + receipt.go | 2 + 33 files changed, 1153 insertions(+), 62 deletions(-) create mode 100644 internal/agent/agy_agent.go create mode 100644 internal/agent/agy_agent_test.go create mode 100644 internal/setup/agy.go create mode 100644 internal/tui/tui_backend_agy_test.go diff --git a/AGENTS.md b/AGENTS.md index 61384cf..4612161 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Instructions for coding agents working on qmax-code. qmax-code is a terminal **QA** agent that can also host coding CLIs. It runs standalone on a local repository or connected to QualityMax. Through `/orch` -it can launch Claude Code, Codex, or OpenCode while keeping qmax tools and +it can launch Claude Code, Codex, Antigravity, or OpenCode while keeping qmax tools and terminal UX — or run the built-in loop on Anthropic, Cerebras, or Ollama. This repository is **Go 1.25.13+**, not TypeScript. The thing users install is one @@ -23,7 +23,8 @@ those other agents when the job is coding. - **Cerebras** = fast inference (~1000–2000+ tok/s). Models today: GPT-OSS 120B, GLM 4.7, Gemma 4. **Qwen 3.8 is coming soon** on Cerebras; do not wire it into `/orch` until the model ID is live on the Cerebras API. -- **Claude Code / Codex** = judgment for hard design and tricky refactors. +- **Claude Code / Codex / Antigravity** = judgment for hard design and tricky refactors. + Antigravity uses Google OAuth (run interactive `agy` to sign in), not a Gemini API key. - **OpenCode** = opt-in providers (Z.AI GLM, Groq, OpenRouter) via `/providers`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7fb7d..949160e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// ## [Unreleased] +### Added +- Antigravity CLI (`agy`) is a first-class `/orch` backend. It runs Google's + native agent harness as a subprocess (`agy -p --output-format stream-json`), + attaches qmax tools through `~/.gemini/config/mcp_config.json`, and authenticates + with **Google OAuth** (interactive `agy` browser sign-in) using the same Google account as + AI Studio. A Gemini API key is not required. Select it with `/agy`, + `/orch`, or `--backend agy`. + ## [1.32.0] - 2026-09-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2cd8afd..2538099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,11 +2,11 @@ qmax-code is a **Go** terminal **QA** agent (Go 1.25.13+). It ships as one compiled binary: `curl | bash` installs a file, not a Node or Python runtime. -Claude Code, Codex, and OpenCode are separate subprocesses that `/orch` can +Claude Code, Codex, Antigravity, and OpenCode are separate subprocesses that `/orch` can host; they are not this repository's runtime. **Positioning:** not an IDE, not a faster Claude. Cursor owns the editor. -Claude Code / Codex own hard coding. qmax-code owns QA (tests, crawls, review, +Claude Code / Codex / Antigravity own hard coding. qmax-code owns QA (tests, crawls, review, receipts) and switches inference in `/orch`. **Cerebras** is the fast path (~1000–2000+ tok/s: GPT-OSS 120B, GLM 4.7, Gemma 4; **Qwen 3.8 coming soon**). Do not add Qwen to the picker until Cerebras publishes the live model ID. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22f39fe..5072248 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,13 +27,13 @@ Thanks for your interest in improving `qmax-code`. This document covers everythi | Requirement | Notes | |---|---| | **Go 1.25.13+** | See `go.mod` for the exact version. `go version` to check. | -| **Inference backend** | The direct API path needs an Anthropic key. You can instead develop against a logged-in Claude Code/Codex CLI, an enabled OpenCode provider, Cerebras, or Ollama. | +| **Inference backend** | The direct API path needs an Anthropic key. You can instead develop against a logged-in Claude Code/Codex/Antigravity CLI, an enabled OpenCode provider, Cerebras, or Ollama. | | **QualityMax account** | Required only for connected cloud tools (test generation, crawl, repo review). Standalone `--local` development and most unit tests do not need one. | Optional but useful: - [golangci-lint](https://golangci-lint.run/usage/install/) — the CI linter; run it locally to catch issues before pushing -- [Claude Code](https://claude.ai/download), [Codex](https://github.com/openai/codex), or [OpenCode](https://opencode.ai) — only needed when working on that CLI orchestration backend +- [Claude Code](https://claude.ai/download), [Codex](https://github.com/openai/codex), [Antigravity](https://antigravity.google/docs/cli/install/), or [OpenCode](https://opencode.ai) — only needed when working on that CLI orchestration backend - [Ollama](https://ollama.com) or a Cerebras key — only needed when working on that inference adapter --- diff --git a/README.md b/README.md index 8f996a3..1d36c79 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ repositories, heal scripts, and prepare CI. It calls the QualityMax API directly, so the separate `qmax` CLI is optional. Use the built-in agent with Anthropic, Cerebras, or Ollama, or use -**orchestration mode** to run Claude Code, Codex, or OpenCode with the same qmax +**orchestration mode** to run Claude Code, Codex, Antigravity, or OpenCode with the same qmax QA tools through MCP. ![Five open QualityMax tools and where qmax-code sits among them](docs/img/family.svg) @@ -175,7 +175,7 @@ background-job health—remain experimental and are only exposed when multimodal input for Gemma 4, optional reasoning effort, and live speed metrics. - **27 managed QA skills:** the catalog is refreshed into Claude Code, Codex, - and OpenCode and can be inspected or reinstalled with `/skills`. + OpenCode, and Antigravity and can be inspected or reinstalled with `/skills`. - **Improved terminal sessions:** a stable input panel, prompt queue, session status and cost metrics, compact/verbose output toggle, ten themes, saved sessions, and optional cloud sync. @@ -336,6 +336,7 @@ troubleshooting. | Anthropic API | `/api` or `/orch` | `ANTHROPIC_API_KEY` or OS keychain | Built-in agent loop; tool set follows connected vs. standalone mode. | | Claude Code | `/cc` or `/orch` | Local Claude Code login | CLI subprocess; qmax tools arrive through MCP. Uses Claude Code authentication and plan/provider billing; see [subscription setup](docs/ORCHESTRATION.md#claude-code-subscription-billing). | | Codex | `/codex` or `/orch` | Local Codex login | CLI subprocess using the user's OpenAI access; qmax tools arrive through MCP. | +| Antigravity | `/agy` or `/orch` | Google OAuth (run `agy` once to sign in) | CLI subprocess using the same Google account as AI Studio. No Gemini API key. qmax tools arrive through MCP. | | Cerebras | `/gemma`, `/orch`, or `--backend cerebras` | `CEREBRAS_API_KEY` or OS keychain | Built-in native function calling. Fast inference (~1000–2000+ tok/s): GPT-OSS 120B, GLM 4.7, Gemma 4 (vision + effort). **Qwen 3.8 coming soon.** | | OpenCode | `/opencode` or `/orch` | Per-provider key in OS keychain | CLI subprocess for opt-in Z.AI, Groq, and OpenRouter providers. | | Ollama | `/ollama` or `/orch` | Configured Ollama endpoint | Self-hosted inference; configure the URL and model first. | @@ -373,6 +374,7 @@ agent. qmax-code declares that dependency in the Codex skill metadata. ```text /orch Pick backend, model, and effort +/agy Switch to Antigravity (Google OAuth) /providers List opt-in OpenCode providers /providers enable groq Store a provider key and enable its models /skills Show managed QA skills and install status diff --git a/SECURITY.md b/SECURITY.md index 8abb301..1cf5c17 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,10 +51,13 @@ qmax tools supplied through MCP. Only use Unattended mode in a trusted, recoverable workspace. Neither mode creates a sandbox or worktree boundary. -Claude Code and Codex can optionally receive a user-level qmax MCP entry in -`~/.claude/settings.json` or `~/.codex/config.toml`. That makes qmax tools -available whenever the CLI is launched, not only inside qmax-code. OpenCode -uses a separate qmax-managed overlay at `~/.qmax-code/opencode.json`. +Claude Code, Codex, and Antigravity can optionally receive a user-level qmax MCP +entry in `~/.claude/settings.json`, `~/.codex/config.toml`, or +`~/.gemini/config/mcp_config.json`. That makes qmax tools available whenever +the CLI is launched, not only inside qmax-code. OpenCode uses a separate +qmax-managed overlay at `~/.qmax-code/opencode.json`. Antigravity authenticates +with Google OAuth via interactive `agy` (browser sign-in); qmax-code does not store a Gemini API +key for that backend. qmax-code also installs managed QA skills into the selected CLI's user-level skills directory. See [Orchestration mode](docs/ORCHESTRATION.md) for the diff --git a/config_command.go b/config_command.go index 9e0af81..1e30688 100644 --- a/config_command.go +++ b/config_command.go @@ -174,6 +174,12 @@ func printConfig() { } else { fmt.Print(" (WARNING: opencode binary not found in PATH)") } + case "agy": + if bin := agent.FindAgy(); bin != "" { + fmt.Printf(" (agy found: %s; Google OAuth)", bin) + } else { + fmt.Print(" (WARNING: agy binary not found in PATH)") + } } fmt.Println() if len(cfg.EnabledProviders) > 0 { @@ -276,10 +282,10 @@ func setConfigField(key, value string) error { case "backend": switch value { - case "", "api", "cc", "codex", "cerebras", "opencode": + case "", "api", "cc", "codex", "agy", "cerebras", "opencode": cfg.Backend = value default: - return fmt.Errorf("invalid backend %q; allowed: api, cc, codex, cerebras, opencode", value) + return fmt.Errorf("invalid backend %q; allowed: api, cc, codex, agy, cerebras, opencode", value) } case "cerebras_key": diff --git a/config_command_test.go b/config_command_test.go index 6a23bef..b49d9ac 100644 --- a/config_command_test.go +++ b/config_command_test.go @@ -45,7 +45,7 @@ func TestSetConfigField_DefaultFrameworkRejectsBadValues(t *testing.T) { func TestSetConfigField_BackendValidation(t *testing.T) { withTempHome(t) - for _, b := range []string{"api", "cc", "codex", "cerebras", ""} { + for _, b := range []string{"api", "cc", "codex", "agy", "cerebras", ""} { if err := setConfigField("backend", b); err != nil { t.Errorf("backend %q should be accepted: %v", b, err) } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 3e20db7..7b59c1a 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -34,7 +34,7 @@ qmax-code serve --mcp | `--verbose` | Show tool calls and raw responses. | | `--professional` | Disable the cat personality for this run. | | `-q` | Reserved for a future quiet/CI output mode; currently has no effect. | -| `--backend NAME` | Override the saved backend with `api`, `cc`, `codex`, `cerebras`, or `opencode`. | +| `--backend NAME` | Override the saved backend with `api`, `cc`, `codex`, `agy`, `cerebras`, or `opencode`. | | `--version` | Print the version and exit. | Non-interactive stdin must include either `-p` or a positional prompt. This @@ -87,7 +87,7 @@ qmax-code codex connect These attach the active local Claude Code or a fresh Codex login to the currently authenticated QualityMax user. They are separate from selecting a -backend with `/cc` or `/codex`. +backend with `/cc`, `/codex`, or `/agy`. ## Configuration subcommand @@ -151,6 +151,7 @@ prints the selected local manifest. `verify` checks its signature offline. | `/api` | Switch to the direct Anthropic API. | | `/cc` | Switch to the Claude Code CLI backend. | | `/codex` | Switch to the Codex CLI backend. | +| `/agy` | Switch to the Antigravity CLI backend (Google OAuth). | | `/opencode` | Switch to the OpenCode CLI backend. | | `/gemma [none\|low\|medium\|high]` | Activate Gemma 4 on Cerebras with the chosen reasoning level. | | `/gemma off` | Return to the direct Anthropic API. | @@ -174,7 +175,7 @@ behavior. | `/status` | Show connection, session, usage, and model information. | | `/cost` | Show token usage and estimated model cost. | | `/update` | Check for a newer qmax-code release and install it when found. | -| `/plan` | Show the subscription coding-plan usage window: elapsed, turns, and time until the rolling 5-hour limit resets (cc/codex/opencode backends). | +| `/plan` | Show the subscription coding-plan usage window: elapsed, turns, and time until the rolling 5-hour limit resets (cc/codex/agy/opencode backends). | In standalone mode, `/connect` and `/project` explain how to return to connected mode. `/status`, `/context`, and `/config` identify the active standalone @@ -200,7 +201,7 @@ after the current turn. | Command | Action | | --- | --- | | `/skills` | List all qmax QA skills and their install status by CLI backend. | -| `/skills install` | Refresh skills for Claude Code, Codex, and OpenCode. | +| `/skills install` | Refresh skills for Claude Code, Codex, OpenCode, and Antigravity. | | `/config` | Show selected session configuration. | | `/set KEY VALUE` | Change a supported setting from the REPL. | | `/keys` | Open the interactive API-key menu. | diff --git a/docs/ORCHESTRATION.md b/docs/ORCHESTRATION.md index cd5e20f..fe79761 100644 --- a/docs/ORCHESTRATION.md +++ b/docs/ORCHESTRATION.md @@ -26,6 +26,7 @@ qmax-code └─ CLI subprocess ├─ Claude Code ├─ Codex + ├─ Antigravity └─ OpenCode └─ qmax MCP server ├─ connected mode: QualityMax + approved local tools @@ -48,6 +49,7 @@ inference backend within one qmax-code process. | Anthropic API | Anthropic API key | Built in | Yes | qmax-code local/cloud sessions | | Claude Code (`cc`) | `claude` installed and logged in | Embedded MCP | No, text-only subprocess path | Claude Code native state plus qmax history | | Codex | `codex` installed and logged in | Embedded MCP | No, text-only subprocess path | Codex native state plus qmax history | +| Antigravity (`agy`) | `agy` installed and signed in with Google OAuth | Embedded MCP via `~/.gemini/config/mcp_config.json` | No, text-only subprocess path | Antigravity native conversation plus qmax history | | Cerebras | Cerebras API key | Built-in native function calling | Yes for vision-capable models such as Gemma 4 | qmax-code local/cloud sessions | | OpenCode | `opencode` installed and at least one enabled provider | Embedded MCP through a qmax-managed overlay | No, text-only subprocess path | OpenCode native state plus qmax history | | Ollama | Reachable HTTP(S) endpoint and configured model | Built in | Model/path dependent; do not assume vision | qmax-code local/cloud sessions | @@ -56,7 +58,7 @@ QualityMax authentication is separate from backend authentication. Use `--local` to skip QualityMax authentication entirely. QualityMax cloud tools require connected mode and `qmax-code login`. -## Go integration with Claude Code and Codex +## Go integration with Claude Code, Codex, and Antigravity qmax-code calls both native harnesses directly from Go. As of September 5, 2026, their official harness SDKs are available for Python and TypeScript; @@ -66,6 +68,7 @@ neither provider documents an official Go harness SDK. | --- | --- | --- | | Claude Code | `os/exec` with `claude --print --output-format stream-json` | `--resume` with the native session ID | | Codex | The Go `codexrunner` package with `codex exec --json` | `codex exec resume` with the native thread ID | +| Antigravity | `os/exec` with `agy -p --output-format stream-json` | `--conversation` with the native conversation ID | Anthropic explicitly recommends a CLI subprocess for other languages in its [Agent SDK overview](https://code.claude.com/docs/en/agent-sdk). OpenAI documents @@ -136,6 +139,43 @@ limit. Pro and standard seats use paid usage credits for Fable 5.1 from the firs request. A subscription login therefore does not guarantee included Fable usage; check your tier and extra-usage settings in [Anthropic's Fable plan guide](https://support.claude.com/en/articles/15424964-claude-fable-models-on-your-plan). +## Antigravity Google OAuth + +Yes: qmax-code can use the native Antigravity harness with a Google account +login, without a Gemini API key. The `agy` backend runs `agy -p` and connects +qmax tools through MCP. It inherits Antigravity authentication; it does not +require QualityMax login in local mode. + +Sign in once with Google OAuth (the same Google account you use for AI Studio). +Antigravity CLI 1.1.x has no `auth login` subcommand — run the interactive CLI +and complete the browser sign-in, then exit: + +```bash +agy +# After Google sign-in and exiting Antigravity: +qmax-code --local --backend agy +``` + +Inside qmax-code, `/agy` and `/orch` offer to launch interactive `agy` on first +activation so the browser OAuth flow can run. Headless print mode uses the +cached Google token (OS keyring, or +`~/.gemini/antigravity-cli/antigravity-oauth-token` when file storage is +forced). An unauthenticated run fails with `authentication required` rather +than hanging. + +A Gemini API key (`GEMINI_API_KEY` + `modelProvider: gemini` in Antigravity +settings) is a CI fallback that Antigravity itself supports. qmax-code does +not set that path; prefer Google OAuth for interactive use. + +```bash +qmax-code --local --backend agy --model gemini-3.7-flash-high +``` + +As of September 7, 2026, Antigravity CLI has no per-invocation `--mcp-config` +flag, so qmax-code merges the qmax MCP entry into +`~/.gemini/config/mcp_config.json`. Pass `--add-dir` for the workspace so +print-mode writes land in the repo rather than Antigravity's scratch directory. + ## Standalone local-only orchestration Every backend can be selected in standalone mode: @@ -145,6 +185,7 @@ qmax-code --local --backend codex qmax-code --local --backend cc qmax-code --local --backend cerebras qmax-code --local --backend opencode +qmax-code --local --backend agy ``` The direct API path also works with `--local` when Anthropic is configured. @@ -170,7 +211,7 @@ start cloud session/live-feed services. The qmax tool boundary is: | Surface | Standalone tools | | --- | --- | | Built-in agent | `update_plan`, `read_file`, `run_command`, `edit_file`, `write_file` | -| MCP for Claude Code, Codex, OpenCode | `read_file`, `run_command`, `edit_file`, `write_file` | +| MCP for Claude Code, Codex, Antigravity, OpenCode | `read_file`, `run_command`, `edit_file`, `write_file` | `run_local_test` is not in that list: it downloads a script from QualityMax and reports its result, despite executing the test process locally. CLI agents may @@ -198,6 +239,7 @@ Direct switches are also available: /api /cc /codex +/agy /opencode /gemma /ollama @@ -207,6 +249,7 @@ For non-interactive use: ```bash qmax-code --backend cc -p "review the current diff" +qmax-code --backend agy -p "review the current diff" qmax-code --backend codex -p "run the narrowest relevant tests" qmax-code --backend cerebras -p "inspect this repository for test gaps" qmax-code --backend opencode -p "review error handling" @@ -224,7 +267,7 @@ Ollama is selected from the REPL rather than the `backend` config field. ## Permission modes -The first activation of Claude Code, Codex, or OpenCode asks how much autonomy +The first activation of Claude Code, Codex, Antigravity, or OpenCode asks how much autonomy to grant. The answer is persisted in `~/.qmax-code/config.json`. ### Standard @@ -255,12 +298,13 @@ backend again. Valid values are `standard` and `unattended`. ## MCP installation scope -Claude Code and Codex ask whether qmax should be registered globally. +Claude Code, Codex, and Antigravity ask whether qmax should be registered globally. If accepted, qmax-code adds or updates only the `qmax` MCP entry in: - Claude Code: `~/.claude/settings.json` - Codex: `~/.codex/config.toml` +- Antigravity: `~/.gemini/config/mcp_config.json` Existing unrelated settings are preserved. The global entry runs: @@ -268,10 +312,14 @@ Existing unrelated settings are preserved. The global entry runs: qmax-code serve --mcp ``` -This makes qmax tools available in ordinary `claude` or `codex` sessions as +This makes qmax tools available in ordinary `claude`, `codex`, or `agy` sessions as well as sessions launched by qmax-code. -If global installation is declined, qmax-code uses session-scoped integration +Antigravity has no per-invocation `--mcp-config` flag, so qmax-code also writes +that MCP entry when the `agy` backend starts a turn (same class as Codex writing +`~/.codex/config.toml`). + +If global installation is declined, Claude Code uses session-scoped integration and does not add the user-level MCP entry. OpenCode is different: qmax-code writes a separate overlay at @@ -292,8 +340,9 @@ The same catalog is materialized in the native format of each CLI: - Claude Code: `~/.claude/skills/` - Codex: `~/.codex/skills/` - OpenCode: `~/.config/opencode/skills/` +- Antigravity: `~/.gemini/antigravity-cli/skills/` -Claude Code and Codex skill installation follows the global-install consent. +Claude Code, Codex, and Antigravity skill installation follows the global-install consent. OpenCode skills are refreshed whenever that backend is activated. Installation is idempotent, and upgrading qmax-code refreshes managed skill content. @@ -303,7 +352,7 @@ is idempotent, and upgrading qmax-code refreshes managed skill content. ``` `/skills` shows the install status for all 27 skills. `/skills install` -materializes the catalog for all three CLI backends. Codex receives additional +materializes the catalog for all four CLI backends. Codex receives additional `agents/openai.yaml` metadata; browser/runtime skills declare their Playwright MCP dependency there. @@ -396,9 +445,11 @@ Activating orchestration may create or update: | `~/.qmax-code/opencode.json` | qmax-managed OpenCode overlay | | `~/.claude/settings.json` | Optional global qmax MCP entry | | `~/.codex/config.toml` | Optional global qmax MCP entry | +| `~/.gemini/config/mcp_config.json` | Antigravity qmax MCP entry | | `~/.claude/skills/` | Managed Claude Code QA skills | | `~/.codex/skills/` | Managed Codex QA skills | | `~/.config/opencode/skills/` | Managed OpenCode QA skills | +| `~/.gemini/antigravity-cli/skills/` | Managed Antigravity QA skills | Provider secrets are not written to those files by qmax-code. They are loaded from the OS keychain or the provider's supported environment variable and @@ -412,7 +463,7 @@ The same config file stores the optional `local_only` default. A per-run ### The backend is missing from `/orch` -- Claude Code, Codex, and OpenCode only become selectable when their executable +- Claude Code, Codex, Antigravity, and OpenCode only become selectable when their executable is installed and discoverable. - OpenCode provider models only appear after the provider is enabled and has a usable key. diff --git a/internal/agent/agy_agent.go b/internal/agent/agy_agent.go new file mode 100644 index 0000000..0f3df7d --- /dev/null +++ b/internal/agent/agy_agent.go @@ -0,0 +1,526 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/sysutil" + "github.com/qualitymax/qmax-code/internal/tui" +) + +// AgyAgent orchestrates an Antigravity CLI (`agy`) subprocess for LLM +// inference. Authentication is Google OAuth: run interactive `agy` once so +// the CLI can open a browser sign-in (the same Google account as AI Studio). +// qmax-code does not ask for a Gemini API key and does not set modelProvider=gemini. +// +// Per-message flow: +// 1. qmax-code merges the qmax MCP server into ~/.gemini/config/mcp_config.json +// 2. qmax-code spawns: agy --output-format stream-json --add-dir +// [--conversation ] [-p prompt] +// 3. agy spawns qmax-code serve --mcp and runs the native agent harness +// 4. qmax-code parses NDJSON events and renders them +// 5. conversation_id is kept for --conversation on the next turn +type AgyAgent struct { + agyBin string + modelID string // "" = agy default; otherwise --model + effort string // "low" | "medium" | "high" + outputVerbose bool + permissionMode string // "standard" | "unattended" + conversationID string + sctx *api.SessionContext + lastTurnIn int + lastTurnOut int + lastTurnOK bool + lastLimitHit bool + lastLimitReset time.Time + lastStderr string + mu sync.Mutex + runMu sync.Mutex + runCancel context.CancelFunc +} + +const agyQASystemPrompt = `You are QMax, an elite QA engineer running inside Google Antigravity. +You inherit Antigravity's native tools (shell, file read/write, search) AND the +QualityMax platform via the "qmax" MCP server. Use both toolsets. + +Coverage axes: happy path, error/exception paths, boundary conditions, +auth boundaries, concurrent access, state transitions. + +Risk priority: HIGH (auth, payments, data integrity), MEDIUM (core flows, +integrations), LOW (UI polish, rarely-used features). + +Never guess project IDs, test names, or execution results — always use a tool. +End each response with the next highest-impact action. +` + +// FindAgy returns the path to the Antigravity CLI binary, or "" if not found. +func FindAgy() string { + if path, err := exec.LookPath("agy"); err == nil { + return path + } + home := os.Getenv("HOME") + for _, p := range []string{ + filepath.Join(home, ".local", "bin", "agy"), + "/usr/local/bin/agy", + "/opt/homebrew/bin/agy", + } { + if p == "" { + continue + } + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// AgyOAuthTokenPath is the file-storage fallback for Google OAuth tokens +// (~/.gemini/antigravity-cli/antigravity-oauth-token). On macOS the token +// usually lives in the OS keyring instead; this path is the Linux/CI fallback. +func AgyOAuthTokenPath(home string) string { + return filepath.Join(home, ".gemini", "antigravity-cli", "antigravity-oauth-token") +} + +// AgyFileTokenPresent reports whether the file-backed Google OAuth token +// exists. A false result does not mean the user is logged out: macOS Keychain +// credentials are invisible here. +func AgyFileTokenPresent() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + info, err := os.Stat(AgyOAuthTokenPath(home)) + return err == nil && info.Size() > 0 +} + +// NewAgyAgent creates an Antigravity subprocess orchestrator. +func NewAgyAgent(bin, modelID, effort, permissionMode string, outputVerbose bool, sctx *api.SessionContext) *AgyAgent { + if effort == "" { + effort = "high" + } + if permissionMode == "" { + permissionMode = "standard" + } + return &AgyAgent{ + agyBin: bin, + modelID: modelID, + effort: effort, + outputVerbose: outputVerbose, + permissionMode: permissionMode, + sctx: sctx, + } +} + +// agyMCPConfigPath is ~/.gemini/config/mcp_config.json. +func agyMCPConfigPath(home string) string { + return filepath.Join(home, ".gemini", "config", "mcp_config.json") +} + +// WriteMCPConfig merges the qmax MCP server into Antigravity's global MCP +// config. agy has no per-invocation --mcp-config flag, so this is the attach +// path (same class as Codex writing ~/.codex/config.toml). +func (a *AgyAgent) WriteMCPConfig() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + dir := filepath.Join(home, ".gemini", "config") + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + env := map[string]string{} + if a.sctx != nil && a.sctx.ProjectID > 0 { + env["QMAX_PROJECT_ID"] = fmt.Sprintf("%d", a.sctx.ProjectID) + } + if a.sctx != nil && a.sctx.LiveFeed { + env["QMAX_LIVE_FEED"] = "1" + } + if a.sctx != nil && a.sctx.LocalOnly { + env[api.LocalOnlyEnv] = "1" + } + if path := sysutil.LiveURLFilePath(); path != "" { + env["QMAX_LIVE_URL_FILE"] = path + } + if path := sysutil.ExecIDFilePath(); path != "" { + env["QMAX_EXEC_ID_FILE"] = path + } + _, err = WriteAgyMCPEntry(agyMCPConfigPath(home), env) + return err +} + +// WriteAgyMCPEntry merges the qmax MCP stdio server into an Antigravity +// mcp_config.json, preserving unrelated servers. +func WriteAgyMCPEntry(path string, env map[string]string) (alreadyHad bool, err error) { + cfg := map[string]any{} + if data, readErr := os.ReadFile(path); readErr == nil { + _ = json.Unmarshal(data, &cfg) + } + mcpServers, _ := cfg["mcpServers"].(map[string]any) + if mcpServers == nil { + mcpServers = map[string]any{} + } + if existing, ok := mcpServers["qmax"]; ok && existing != nil { + alreadyHad = true + } + entry := map[string]any{ + "command": QmaxMCPCommand, + "args": []string{"serve", "--mcp"}, + } + if len(env) > 0 { + entry["env"] = env + } + mcpServers["qmax"] = entry + cfg["mcpServers"] = mcpServers + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return alreadyHad, err + } + if err := os.WriteFile(path, append(data, '\n'), 0600); err != nil { + return alreadyHad, err + } + return alreadyHad, nil +} + +func validateAgyConversationID(id string) error { + if id == "" { + return fmt.Errorf("empty Antigravity conversation ID") + } + if len(id) != 36 { + return fmt.Errorf("invalid Antigravity conversation ID") + } + for i, r := range id { + switch i { + case 8, 13, 18, 23: + if r != '-' { + return fmt.Errorf("invalid Antigravity conversation ID") + } + default: + if !((r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') || (r >= '0' && r <= '9')) { + return fmt.Errorf("invalid Antigravity conversation ID") + } + } + } + return nil +} + +// buildAgyArgs constructs the print-mode argv. -p and the prompt must be last. +func (a *AgyAgent) buildAgyArgs(prompt, cwd, conversationID string) ([]string, error) { + safe, err := sanitizeCCUserPrompt(prompt) + if err != nil { + return nil, err + } + args := []string{ + "--output-format", "stream-json", + "--print-timeout", "30m", + } + if cwd != "" { + args = append(args, "--add-dir", cwd) + } + if a.modelID != "" && a.modelID != "auto" { + args = append(args, "--model", a.modelID) + } + if a.effort != "" { + args = append(args, "--effort", a.effort) + } + if a.permissionMode == "unattended" { + args = append(args, "--dangerously-skip-permissions") + } + if conversationID != "" { + if err := validateAgyConversationID(conversationID); err != nil { + return nil, err + } + args = append(args, "--conversation", conversationID) + } + args = append(args, "-p", safe) + return args, nil +} + +// Run executes one conversation turn through an agy subprocess. +func (a *AgyAgent) Run(userMsg string, term *tui.Terminal) (string, error) { + if err := a.WriteMCPConfig(); err != nil { + return "", fmt.Errorf("MCP config: %w", err) + } + + a.mu.Lock() + a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = 0, 0, false + a.lastLimitHit, a.lastLimitReset = false, time.Time{} + a.lastStderr = "" + conversationID := a.conversationID + a.mu.Unlock() + + cwd, err := os.Getwd() + if err != nil || cwd == "" { + cwd = "." + } + + message := userMsg + if conversationID == "" { + message = cliQASystemPrompt(a.sctx, agyQASystemPrompt) + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + userMsg + } + + args, err := a.buildAgyArgs(message, cwd, conversationID) + if err != nil { + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + a.runMu.Lock() + a.runCancel = cancel + a.runMu.Unlock() + defer func() { + a.runMu.Lock() + a.runCancel = nil + a.runMu.Unlock() + }() + + cmd := exec.CommandContext(ctx, a.agyBin, args...) + cmd.Dir = cwd + var stderrBuf bytes.Buffer + if term != nil { + cmd.Stderr = io.MultiWriter(term.Stderr(), &stderrBuf) + } else { + cmd.Stderr = &stderrBuf + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start agy: %w", err) + } + + result := a.parseStream(stdout, term) + waitErr := cmd.Wait() + a.mu.Lock() + a.lastStderr = stderrBuf.String() + a.mu.Unlock() + + if waitErr != nil { + if ctx.Err() != nil { + return result, nil + } + if result == "" { + return "", a.wrapAgyExit(waitErr) + } + } + return result, nil +} + +func (a *AgyAgent) wrapAgyExit(err error) error { + a.mu.Lock() + stderr := a.lastStderr + a.mu.Unlock() + lower := strings.ToLower(stderr + err.Error()) + if strings.Contains(lower, "authentication required") || strings.Contains(lower, "not authenticated") { + return fmt.Errorf("Antigravity is not signed in with Google. Run `agy`, sign in with Google in the browser, then exit and retry.\n Use the same Google account as AI Studio. An API key is not required.\n (%v)", err) + } + if strings.Contains(lower, "usage") && strings.Contains(lower, "limit") { + a.mu.Lock() + a.lastLimitHit = true + a.mu.Unlock() + } + tail := strings.TrimSpace(stderr) + if len(tail) > 400 { + tail = tail[len(tail)-400:] + } + if tail != "" { + return fmt.Errorf("agy exited with error: %w\n%s", err, tail) + } + return fmt.Errorf("agy exited with error: %w", err) +} + +func (a *AgyAgent) Cancel() { + a.runMu.Lock() + if a.runCancel != nil { + a.runCancel() + } + a.runMu.Unlock() +} + +func (a *AgyAgent) Cleanup() {} + +func (a *AgyAgent) SetOutputVerbose(verbose bool) { + a.mu.Lock() + a.outputVerbose = verbose + a.mu.Unlock() +} + +func (a *AgyAgent) ResetConversation() { + a.mu.Lock() + a.conversationID = "" + a.mu.Unlock() +} + +func (a *AgyAgent) LastTurnStats() (inputTokens, outputTokens int, ok bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastTurnIn, a.lastTurnOut, a.lastTurnOK +} + +func (a *AgyAgent) LastPlanLimit() (reset time.Time, hit bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastLimitReset, a.lastLimitHit +} + +// agyEvent is one NDJSON line from `agy --output-format stream-json`. +type agyEvent struct { + Event string `json:"event"` + ConversationID string `json:"conversation_id,omitempty"` + StepUpdate *agyStepUpdate `json:"step_update,omitempty"` + Result *agyResult `json:"result,omitempty"` +} + +type agyStepUpdate struct { + ConversationID string `json:"conversation_id"` + StepIndex int `json:"step_index"` + State string `json:"state"` + StepType string `json:"step_type"` + ToolName string `json:"tool_name,omitempty"` + TextDelta string `json:"text_delta,omitempty"` + Usage *agyUsage `json:"usage,omitempty"` + ToolInfo *agyToolInfo `json:"tool_info,omitempty"` +} + +type agyResult struct { + ConversationID string `json:"conversation_id"` + Status string `json:"status"` + Response string `json:"response"` + Error string `json:"error,omitempty"` + Usage *agyUsage `json:"usage,omitempty"` +} + +type agyUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type agyToolInfo struct { + Name string `json:"name"` + Parameters map[string]any `json:"parameters"` + Output string `json:"output"` + Error *agyToolError `json:"error,omitempty"` +} + +type agyToolError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +func (a *AgyAgent) parseStream(stdout io.Reader, term *tui.Terminal) string { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + + var finalResult string + seenTool := map[int]bool{} + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var event agyEvent + if err := json.Unmarshal(line, &event); err != nil { + continue + } + if event.ConversationID != "" && validateAgyConversationID(event.ConversationID) == nil { + a.mu.Lock() + a.conversationID = event.ConversationID + a.mu.Unlock() + } + + switch event.Event { + case "init": + // conversation_id is on the envelope. + case "step_update": + if event.StepUpdate == nil { + continue + } + su := event.StepUpdate + if su.ConversationID != "" && validateAgyConversationID(su.ConversationID) == nil { + a.mu.Lock() + a.conversationID = su.ConversationID + a.mu.Unlock() + } + switch su.StepType { + case "agent_response": + if su.TextDelta != "" && term != nil { + term.StreamText(su.TextDelta) + } + case "tool": + if su.State == "ACTIVE" && !seenTool[su.StepIndex] { + seenTool[su.StepIndex] = true + name := su.ToolName + if su.ToolInfo != nil && su.ToolInfo.Name != "" { + name = su.ToolInfo.Name + } + name = stripMCPPrefix(name) + if term != nil { + term.PrintToolIcon(name) + if a.outputVerbose && su.ToolInfo != nil { + term.PrintToolStart(name, su.ToolInfo.Parameters) + } else { + term.EndLine() + } + } + } + if su.State == "DONE" && term != nil { + if a.outputVerbose && su.ToolInfo != nil { + out := su.ToolInfo.Output + if su.ToolInfo.Error != nil && su.ToolInfo.Error.Message != "" { + out = su.ToolInfo.Error.Message + } + term.PrintToolResult(stripMCPPrefix(su.ToolName), tui.TruncateStr(out, 200)) + } else { + term.StartThinking() + } + } + } + if su.Usage != nil { + a.mu.Lock() + a.lastTurnIn = su.Usage.InputTokens + a.lastTurnOut = su.Usage.OutputTokens + a.lastTurnOK = su.Usage.InputTokens > 0 || su.Usage.OutputTokens > 0 + a.mu.Unlock() + } + case "result": + if event.Result == nil { + continue + } + finalResult = event.Result.Response + if event.Result.ConversationID != "" && validateAgyConversationID(event.Result.ConversationID) == nil { + a.mu.Lock() + a.conversationID = event.Result.ConversationID + a.mu.Unlock() + } + if event.Result.Usage != nil { + a.mu.Lock() + a.lastTurnIn = event.Result.Usage.InputTokens + a.lastTurnOut = event.Result.Usage.OutputTokens + a.lastTurnOK = event.Result.Usage.InputTokens > 0 || event.Result.Usage.OutputTokens > 0 + a.mu.Unlock() + } + if event.Result.Status != "" && !strings.EqualFold(event.Result.Status, "SUCCESS") && event.Result.Error != "" && term != nil { + term.PrintError("Antigravity: " + event.Result.Error) + } + } + } + if term != nil { + term.FinishMarkdown(finalResult) + } + return finalResult +} diff --git a/internal/agent/agy_agent_test.go b/internal/agent/agy_agent_test.go new file mode 100644 index 0000000..f8d9962 --- /dev/null +++ b/internal/agent/agy_agent_test.go @@ -0,0 +1,138 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteAgyMCPEntryMergesQmaxAndPreservesOthers(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcp_config.json") + existing := `{ + "mcpServers": { + "other": {"command": "echo", "args": ["hi"]} + } +} +` + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatal(err) + } + + had, err := WriteAgyMCPEntry(path, map[string]string{"QMAX_LOCAL_ONLY": "1"}) + if err != nil { + t.Fatalf("WriteAgyMCPEntry: %v", err) + } + if had { + t.Fatal("fresh qmax entry should not report alreadyHad") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + servers := cfg["mcpServers"].(map[string]any) + if _, ok := servers["other"]; !ok { + t.Fatal("unrelated MCP server was dropped") + } + qmax := servers["qmax"].(map[string]any) + if qmax["command"] != QmaxMCPCommand { + t.Fatalf("command = %v", qmax["command"]) + } + had, err = WriteAgyMCPEntry(path, nil) + if err != nil { + t.Fatal(err) + } + if !had { + t.Fatal("second write should report alreadyHad") + } +} + +func TestBuildAgyArgsPutsPrintLastAndResumesConversation(t *testing.T) { + a := NewAgyAgent("agy", "gemini-3.7-flash-high", "medium", "unattended", false, nil) + args, err := a.buildAgyArgs("review the diff", "/tmp/repo", "055a398f-db14-4c5f-abbb-1bf03f8120a7") + if err != nil { + t.Fatal(err) + } + if len(args) < 4 { + t.Fatalf("too few args: %v", args) + } + if args[len(args)-2] != "-p" || args[len(args)-1] != "review the diff" { + t.Fatalf("-p prompt must be last, got %v", args) + } + joined := strings.Join(args, " ") + for _, want := range []string{ + "--output-format stream-json", + "--add-dir /tmp/repo", + "--model gemini-3.7-flash-high", + "--effort medium", + "--dangerously-skip-permissions", + "--conversation 055a398f-db14-4c5f-abbb-1bf03f8120a7", + } { + if !strings.Contains(joined, want) { + t.Errorf("missing %q in %v", want, args) + } + } +} + +func TestBuildAgyArgsRejectsBadConversationID(t *testing.T) { + a := NewAgyAgent("agy", "", "high", "standard", false, nil) + if _, err := a.buildAgyArgs("hi", ".", "not-a-uuid"); err == nil { + t.Fatal("expected invalid conversation id to fail") + } +} + +func TestParseAgyStreamCapturesResultAndUsage(t *testing.T) { + ndjson := strings.Join([]string{ + `{"event":"init","conversation_id":"c3b66b04-872b-4fbe-a3a4-058a026ef20a","init":{"cwd":"/tmp"}}`, + `{"event":"step_update","step_update":{"conversation_id":"c3b66b04-872b-4fbe-a3a4-058a026ef20a","step_index":1,"state":"DONE","step_type":"agent_response","text_delta":"ok\n"}}`, + `{"event":"result","result":{"conversation_id":"c3b66b04-872b-4fbe-a3a4-058a026ef20a","status":"SUCCESS","response":"ok\n","usage":{"input_tokens":10,"output_tokens":2}}}`, + }, "\n") + a := NewAgyAgent("agy", "", "high", "standard", false, nil) + got := a.parseStream(strings.NewReader(ndjson), nil) + if got != "ok\n" { + t.Fatalf("response = %q", got) + } + in, out, ok := a.LastTurnStats() + if !ok || in != 10 || out != 2 { + t.Fatalf("usage = (%d,%d,ok=%v)", in, out, ok) + } + a.mu.Lock() + id := a.conversationID + a.mu.Unlock() + if id != "c3b66b04-872b-4fbe-a3a4-058a026ef20a" { + t.Fatalf("conversation_id = %q", id) + } +} + +func TestAgyResetConversationClearsResumeID(t *testing.T) { + a := NewAgyAgent("agy", "", "high", "standard", false, nil) + a.conversationID = "c3b66b04-872b-4fbe-a3a4-058a026ef20a" + a.ResetConversation() + if a.conversationID != "" { + t.Fatal("ResetConversation left a conversation id") + } +} + +func TestWrapAgyExitPointsAtGoogleOAuth(t *testing.T) { + a := NewAgyAgent("agy", "", "high", "standard", false, nil) + a.lastStderr = "authentication required" + err := a.wrapAgyExit(os.ErrPermission) + if err == nil || !strings.Contains(err.Error(), "Run `agy`") { + t.Fatalf("want Google OAuth hint, got %v", err) + } +} + +func TestAgyOAuthTokenPath(t *testing.T) { + got := AgyOAuthTokenPath("/Users/ada") + want := "/Users/ada/.gemini/antigravity-cli/antigravity-oauth-token" + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } +} diff --git a/internal/api/config.go b/internal/api/config.go index 611845d..529a0d4 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -55,6 +55,9 @@ type Config struct { // "cc" → Claude Code CLI subprocess (no QM API key; `claude --print` // uses Claude Code's configured subscription or provider billing) // "codex" → OpenAI Codex CLI subprocess (uses OpenAI subscription, no API key needed) + // "agy" → Google Antigravity CLI subprocess (Google OAuth via + // interactive `agy` Google sign-in; same Google account as AI Studio. + // No Gemini API key required.) // "cerebras" → Cerebras OpenAI-compatible API (requires CEREBRAS_API_KEY). // Drives the native qmax agent loop with the full tool set via // native function calling — fast and low-cost. @@ -62,7 +65,7 @@ type Config struct { // support (Z.AI, Groq, OpenRouter, …); the specific model is // ModelOverride in "provider/model" form. Providers are opted // into per-user via EnabledProviders + the /providers command. - // In the CLI modes (cc/codex/opencode) qmax tools are served via an embedded MCP server. + // In the CLI modes (cc/codex/agy/opencode) qmax tools are served via an embedded MCP server. Backend string `json:"backend,omitempty"` // EnabledProviders is the per-user opt-in set of opencode provider IDs @@ -85,7 +88,7 @@ type Config struct { Effort string `json:"effort,omitempty"` // OrchPermissionMode records the autonomy level the user consented to for - // CC and opencode backends. Codex uses its own approval and sandbox policy: + // CC, Antigravity, and opencode backends. Codex uses its own approval and sandbox policy: // "" = no consent yet; activation will prompt // "standard" = curated allowlist auto-approved (reads, test runners, // qmax MCP tools); edits and destructive shell still gated diff --git a/internal/api/context.go b/internal/api/context.go index 64ee4bb..952925b 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -22,7 +22,7 @@ type SessionContext struct { ProjectFile string // name of .qmax.yml file if detected API *APIClient // direct QualityMax API client (no legacy qmax CLI needed) Auth *AuthConfig // authentication credentials - Backend string // "" | "cc" | "codex" — active CLI inference backend + Backend string // "" | "cc" | "codex" | "agy" | "opencode" — active CLI inference backend // LiveFeed enables QM Cloud Sandbox execution for run_test / start_crawl // and turns on auto-launch of /browserfeed when a poll response surfaces diff --git a/internal/repl/planwindow_turn_test.go b/internal/repl/planwindow_turn_test.go index 4839e45..91e7b23 100644 --- a/internal/repl/planwindow_turn_test.go +++ b/internal/repl/planwindow_turn_test.go @@ -75,6 +75,7 @@ func TestPlanWindowKeySeparatesQuotas(t *testing.T) { }{ {"claude code", "cc", &api.Config{}, "cc"}, {"codex", "codex", &api.Config{}, "codex"}, + {"antigravity", "agy", &api.Config{}, "agy"}, {"opencode without a model override", "opencode", &api.Config{}, "opencode"}, {"opencode on zai", "opencode", &api.Config{ModelOverride: "zai/glm-4.6"}, "opencode/zai"}, {"opencode on groq", "opencode", &api.Config{ModelOverride: "groq/llama-3.3-70b"}, "opencode/groq"}, diff --git a/internal/repl/repl.go b/internal/repl/repl.go index bfeef31..a83bc78 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -540,6 +540,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin OllamaModel: cfg.OllamaModel, CCInstalled: agent.FindClaudeCode() != "", CodexInstalled: agent.FindCodex() != "", + AgyInstalled: agent.FindAgy() != "", CerebrasKeySet: cfg.CerebrasKey != "", OpenCodeInstalled: agent.FindOpenCode() != "", OpenCodeModels: ocModels, @@ -652,6 +653,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintSystem(" npm install -g @openai/codex") continue } + case "agy": + if agent.FindAgy() == "" { + term.PrintError("Antigravity CLI ('agy') not found.") + term.PrintSystem(" curl -fsSL https://antigravity.google/cli/install.sh | bash") + term.PrintSystem(" Then run `agy` and sign in with Google") + continue + } case "opencode": if agent.FindOpenCode() == "" { term.PrintError("opencode CLI ('opencode') not found.") @@ -682,6 +690,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin if consent.GlobalInstall || result.Backend == "opencode" { setup.InstallSkillsReport(result.Backend, term) } + if result.Backend == "agy" { + setup.PromptAgyGoogleLogin(agent.FindAgy()) + } } // Tear down current CLI agent and disable Ollama/Cerebras if switching away. @@ -703,6 +714,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } cliAgent = ca term.PrintSystem(fmt.Sprintf("Backend: Codex model: %s policy: Codex config prompt effort: %s", codexModelLabel(result.ModelID), result.Effort)) + case "agy": + aa := agent.NewAgyAgent(agent.FindAgy(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, ag.Cfg.Context) + if err := aa.WriteMCPConfig(); err != nil { + term.PrintSystem(fmt.Sprintf("Warning: Antigravity MCP config: %v", err)) + } + cliAgent = aa + term.PrintSystem(fmt.Sprintf("Backend: Antigravity model: %s effort: %s (Google OAuth)", agyModelLabel(result.ModelID), result.Effort)) case "opencode": oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { @@ -769,7 +787,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } continue - case input == "/cc", input == "/codex", input == "/api", input == "/opencode": + case input == "/cc", input == "/codex", input == "/api", input == "/opencode", input == "/agy": // Instant backend switching — no restart needed. cfg := ag.AppConfig if cfg == nil { @@ -783,6 +801,8 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin wantBackend = "cc" case "/codex": wantBackend = "codex" + case "/agy": + wantBackend = "agy" case "/opencode": wantBackend = "opencode" case "/api": @@ -826,6 +846,29 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin cfg.ModelOverride = model } + if wantBackend == "agy" { + if agent.FindAgy() == "" { + term.PrintError("'agy' CLI not found.") + term.PrintSystem(" curl -fsSL https://antigravity.google/cli/install.sh | bash") + term.PrintSystem(" Then run `agy` and sign in with Google") + continue + } + consent := setup.PromptOrchConsent(cfg, "agy") + if !consent.Proceed { + term.PrintSystem("Backend not changed.") + continue + } + cfg.OrchPermissionMode = consent.PermissionMode + cfg.OrchGlobalInstall = consent.GlobalInstall + if consent.GlobalInstall { + if !setup.IsOrchInstalled("agy") { + setup.RunOrch("agy", term) + } + setup.InstallSkillsReport("agy", term) + } + setup.PromptAgyGoogleLogin(agent.FindAgy()) + } + // Tear down current CLI agent. if cliAgent != nil { cliAgent.Cleanup() @@ -888,6 +931,17 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin _ = cfg.Save() term.PrintSystem(fmt.Sprintf("Backend → Codex (%s) · Codex config policy", bin)) + case "agy": + bin := agent.FindAgy() + aa := agent.NewAgyAgent(bin, cfg.ModelOverride, cfg.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, ag.Cfg.Context) + if err := aa.WriteMCPConfig(); err != nil { + term.PrintSystem(fmt.Sprintf("Warning: MCP config: %v", err)) + } + cliAgent = aa + cfg.Backend = "agy" + _ = cfg.Save() + term.PrintSystem(fmt.Sprintf("Backend → Antigravity (%s) · Google OAuth · %s mode", bin, cfg.OrchPermissionMode)) + case "opencode": // Pre-flight above already validated the CLI, providers, model, and // consent (and set cfg.ModelOverride / cfg.OrchPermissionMode). @@ -1561,7 +1615,7 @@ func handleKeys(ag *agent.Agent, term *tui.Terminal) { // per-token or run locally, so they have no such window. func isPlanBackend(backend string) bool { switch backend { - case "cc", "codex", "opencode": + case "cc", "codex", "opencode", "agy": return true default: return false @@ -1633,6 +1687,7 @@ Commands: /api Switch to direct Anthropic API /cc Switch to Claude Code (Agent SDK credit) /codex Switch to Codex CLI + /agy Switch to Antigravity CLI (Google OAuth) /opencode Switch to OpenCode (opt-in providers) /gemma [none|low|medium|high|off] Activate Gemma 4 on Cerebras, or return to API @@ -1642,7 +1697,7 @@ Commands: Manage Z.AI, Groq, or OpenRouter /reconnect Restore the active CC/Codex MCP transport /skills List 27 qmax QA skills + install status - /skills install Refresh skills for CC, Codex, and OpenCode + /skills install Refresh skills for CC, Codex, OpenCode, and Antigravity /connect Log in to QualityMax (opens browser) /disconnect Log out and clear saved credentials @@ -1710,7 +1765,7 @@ Models (--model flag): Flags: -p "prompt" Run once and exit --local Standalone mode: no QualityMax login or cloud tools - --backend NAME Override backend: api, cc, codex, cerebras, opencode + --backend NAME Override backend: api, cc, codex, agy, cerebras, opencode --resume ID Resume a saved session (or "last") --save-session Force saving this run (built-in backends) --professional Disable cat personality for this session @@ -1751,8 +1806,14 @@ func reconnectMCPTransport(cliAgent agent.CLIAgent, term *tui.Terminal) { return } term.PrintSystem("QMax MCP transport restored for Codex.") + case *agent.AgyAgent: + if err := a.WriteMCPConfig(); err != nil { + term.PrintError(fmt.Sprintf("Could not restore Antigravity MCP transport: %v", err)) + return + } + term.PrintSystem("QMax MCP transport restored for Antigravity.") default: - term.PrintSystem("No CC/Codex MCP transport is active. Use /cc or /codex first.") + term.PrintSystem("No CC/Codex/Antigravity MCP transport is active. Use /cc, /codex, or /agy first.") } } @@ -1779,6 +1840,13 @@ func codexModelLabel(model string) string { return model } +func agyModelLabel(model string) string { + if model == "" { + return "Antigravity config" + } + return model +} + func persistOrchModelSelection(cfg *api.Config, backend, modelID string) { if backend == "codex" { cfg.CodexModel = modelID @@ -2114,11 +2182,20 @@ func applySettingValue(key, value string, ag *agent.Agent, term *tui.Terminal) s } cfg.Backend = "codex" term.PrintSystem("Backend set to Codex. Use /codex to switch live, or restart to apply.") + case "agy": + if agent.FindAgy() == "" { + term.PrintError("'agy' CLI not found.") + term.PrintSystem(" curl -fsSL https://antigravity.google/cli/install.sh | bash") + term.PrintSystem(" Then run `agy` and sign in with Google") + return settingInvalid + } + cfg.Backend = "agy" + term.PrintSystem("Backend set to Antigravity. Use /agy to switch live, or restart to apply.") case "", "api": cfg.Backend = "" term.PrintSystem("Backend set to Anthropic API. Restart or use /api to switch live.") default: - term.PrintError("Valid backends: cc, codex, api (use /gemma for cerebras)") + term.PrintError("Valid backends: cc, codex, agy, api (use /gemma for cerebras)") return settingInvalid } diff --git a/internal/setup/agy.go b/internal/setup/agy.go new file mode 100644 index 0000000..fef0255 --- /dev/null +++ b/internal/setup/agy.go @@ -0,0 +1,56 @@ +package setup + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/qualitymax/qmax-code/internal/agent" + "golang.org/x/term" +) + +// PromptAgyGoogleLogin offers to launch Antigravity's Google OAuth flow. +// agy 1.1.x has no `auth login` subcommand: Google sign-in runs when the +// interactive CLI starts with no cached token. An AI Studio API key is not +// required. Skipped when stdin is not a TTY, or when a file-backed OAuth +// token is already present. +func PromptAgyGoogleLogin(agyBin string) { + if agyBin == "" { + return + } + if agent.AgyFileTokenPresent() { + return + } + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Println(" Sign in with Google first: run `agy`, complete the browser login, then exit.") + return + } + + fmt.Println() + fmt.Println(" Antigravity authenticates with Google OAuth (browser sign-in).") + fmt.Println(" Use the same Google account as Google AI Studio.") + fmt.Println(" An AI Studio API key is not required.") + fmt.Println() + fmt.Print(" Launch Google sign-in now? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + ans := strings.ToLower(strings.TrimSpace(line)) + if ans != "y" && ans != "yes" { + fmt.Println(" Skipping. If a later turn fails with authentication required,") + fmt.Println(" run `agy`, sign in with Google, then exit and retry /agy.") + return + } + + fmt.Println(" Opening Antigravity. Sign in with Google in the browser,") + fmt.Println(" then exit the CLI (/exit or Ctrl+C) to return here.") + cmd := exec.Command(agyBin) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf(" Interactive `agy` exited (%v).\n", err) + fmt.Println(" If you did not finish Google sign-in, run `agy` in a terminal and try again.") + } +} diff --git a/internal/setup/consent.go b/internal/setup/consent.go index 7d3460d..8a57e95 100644 --- a/internal/setup/consent.go +++ b/internal/setup/consent.go @@ -33,6 +33,10 @@ func PromptOrchConsent(cfg *api.Config, backend string) ConsentResult { cliName = "opencode" globalConfigPath = "~/.qmax-code/opencode.json" } + if backend == "agy" { + cliName = "Antigravity" + globalConfigPath = "~/.gemini/config/mcp_config.json" + } res := ConsentResult{ PermissionMode: cfg.OrchPermissionMode, diff --git a/internal/setup/consent_test.go b/internal/setup/consent_test.go index 465abbe..6c73633 100644 --- a/internal/setup/consent_test.go +++ b/internal/setup/consent_test.go @@ -6,7 +6,7 @@ func TestCodexDefersPermissionPolicyToCodexConfiguration(t *testing.T) { if qmaxSelectsPermissionMode("codex") { t.Fatal("Codex activation must not present a qmax permission selector") } - for _, backend := range []string{"cc", "opencode"} { + for _, backend := range []string{"cc", "opencode", "agy"} { if !qmaxSelectsPermissionMode(backend) { t.Fatalf("%s unexpectedly stopped using qmax permission selection", backend) } diff --git a/internal/setup/orch.go b/internal/setup/orch.go index 00d40af..99d23a8 100644 --- a/internal/setup/orch.go +++ b/internal/setup/orch.go @@ -59,6 +59,21 @@ func InstallCodex() (*InstallResult, error) { return writeCodexMCPEntry(cfgPath, nil) } +// InstallAgy writes the qmax MCP server into ~/.gemini/config/mcp_config.json +// so Antigravity CLI picks up qmax tools in ordinary `agy` sessions as well as +// sessions launched by qmax-code. agy has no per-invocation --mcp-config flag. +func InstallAgy() (*InstallResult, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + dir := filepath.Join(home, ".gemini", "config") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + return writeMCPEntry(filepath.Join(dir, "mcp_config.json")) +} + // writeMCPEntry merges the qmax MCP entry into a JSON config file. // Existing keys are preserved; only the "qmax" entry under "mcpServers" is // added or updated. @@ -147,6 +162,20 @@ func RunOrch(backend string, term *tui.Terminal) { term.PrintSystem(fmt.Sprintf("qmax MCP entry added to %s", res.MCPPath)) } term.PrintSystem("Codex now has qmax tools in every session.") + + case "agy": + term.PrintSystem("Setting up Antigravity integration…") + res, err := InstallAgy() + if err != nil { + term.PrintError(fmt.Sprintf("Antigravity setup failed: %v", err)) + return + } + if res.AlreadyHadMCP { + term.PrintSystem(fmt.Sprintf("qmax MCP entry updated in %s", res.MCPPath)) + } else { + term.PrintSystem(fmt.Sprintf("qmax MCP entry added to %s", res.MCPPath)) + } + term.PrintSystem("Antigravity now has qmax tools in every `agy` session.") } } @@ -185,6 +214,7 @@ func InstallSkillsAll(term *tui.Terminal) { InstallSkillsReport("cc", term) InstallSkillsReport("codex", term) InstallSkillsReport("opencode", term) + InstallSkillsReport("agy", term) } // PrintSkillsStatus lists the qmax QA skill catalog and shows, per skill, @@ -200,20 +230,22 @@ func PrintSkillsStatus(term *tui.Terminal) { ccDir, _ := skills.SkillsDir(skills.BackendCC, home) cxDir, _ := skills.SkillsDir(skills.BackendCodex, home) ocDir, _ := skills.SkillsDir(skills.BackendOpenCode, home) + agyDir, _ := skills.SkillsDir(skills.BackendAgy, home) catalog := skills.SortedCatalog() - term.PrintSystem(fmt.Sprintf("qmax QA skills (%d) — installed in: cc = ~/.claude/skills · codex = ~/.codex/skills · oc = ~/.config/opencode/skills", len(catalog))) + term.PrintSystem(fmt.Sprintf("qmax QA skills (%d) — installed in: cc = ~/.claude/skills · codex = ~/.codex/skills · oc = ~/.config/opencode/skills · agy = ~/.gemini/antigravity-cli/skills", len(catalog))) for _, sk := range catalog { cc := installMark(filepath.Join(ccDir, sk.Name, "SKILL.md")) cx := installMark(filepath.Join(cxDir, sk.Name, "SKILL.md")) oc := installMark(filepath.Join(ocDir, sk.Name, "SKILL.md")) + agy := installMark(filepath.Join(agyDir, sk.Name, "SKILL.md")) desc := sk.ShortDescription if desc == "" { desc = sk.Description } - fmt.Printf(" cc:%s codex:%s oc:%s %s%-22s%s %s\n", cc, cx, oc, tui.ColorBold, sk.Name, tui.ColorReset, desc) + fmt.Printf(" cc:%s codex:%s oc:%s agy:%s %s%-22s%s %s\n", cc, cx, oc, agy, tui.ColorBold, sk.Name, tui.ColorReset, desc) } - term.PrintSystem("Skills load inside Claude Code / Codex / opencode sessions — auto-invoked by description, or `$name` in Codex.") + term.PrintSystem("Skills load inside Claude Code / Codex / OpenCode / Antigravity sessions — auto-invoked by description, or `$name` in Codex.") term.PrintSystem("Run /skills install to (re)install them into all backends now.") } @@ -239,6 +271,8 @@ func IsOrchInstalled(backend string) bool { cfgPath = filepath.Join(home, ".claude", "settings.json") case "codex": cfgPath = filepath.Join(home, ".codex", "config.toml") + case "agy": + cfgPath = filepath.Join(home, ".gemini", "config", "mcp_config.json") default: return true } diff --git a/internal/setup/orch_test.go b/internal/setup/orch_test.go index 9d89396..afc873b 100644 --- a/internal/setup/orch_test.go +++ b/internal/setup/orch_test.go @@ -45,6 +45,29 @@ func TestInstallCodexWritesConfigTOML(t *testing.T) { } } +func TestInstallAgyWritesMCPConfigJSON(t *testing.T) { + home := withTempHome(t) + + res, err := InstallAgy() + if err != nil { + t.Fatalf("InstallAgy: %v", err) + } + want := filepath.Join(home, ".gemini", "config", "mcp_config.json") + if res.MCPPath != want { + t.Fatalf("MCPPath = %q, want %q", res.MCPPath, want) + } + data, err := os.ReadFile(res.MCPPath) + if err != nil { + t.Fatalf("read mcp_config.json: %v", err) + } + if !strings.Contains(string(data), `"qmax"`) { + t.Fatalf("missing qmax MCP entry:\n%s", data) + } + if !IsOrchInstalled("agy") { + t.Fatal("IsOrchInstalled(agy) should detect mcp_config.json qmax entry") + } +} + func TestWriteCodexMCPEntryReportsAlreadyHad(t *testing.T) { home := withTempHome(t) path := filepath.Join(home, ".codex", "config.toml") diff --git a/internal/skills/materialize.go b/internal/skills/materialize.go index 3b1606f..377d4e2 100644 --- a/internal/skills/materialize.go +++ b/internal/skills/materialize.go @@ -21,6 +21,7 @@ const ( BackendCC Backend = "cc" BackendCodex Backend = "codex" BackendOpenCode Backend = "opencode" + BackendAgy Backend = "agy" ) // MaterializeResult reports what Materialize wrote, for display to the user. @@ -41,6 +42,8 @@ func SkillsDir(backend Backend, home string) (string, error) { return filepath.Join(home, ".codex", "skills"), nil case BackendOpenCode: return filepath.Join(home, ".config", "opencode", "skills"), nil + case BackendAgy: + return filepath.Join(home, ".gemini", "antigravity-cli", "skills"), nil default: return "", fmt.Errorf("skills: unknown backend %q", backend) } @@ -172,10 +175,9 @@ func renderSkillMD(backend Backend, sk Skill) string { b.WriteString("metadata:\n") b.WriteString(" short-description: " + yamlScalar(sk.ShortDescription) + "\n") } - case BackendOpenCode: - // opencode recognizes only name + description in frontmatter (plus - // optional license/compatibility/metadata); it ignores allowed-tools, so - // we emit nothing extra. The body is identical across backends. + case BackendOpenCode, BackendAgy: + // opencode and Antigravity recognize name + description in frontmatter; + // extra keys such as allowed-tools are ignored, so we emit nothing extra. default: // Materialize validates the backend via SkillsDir before reaching here, // so an unknown value is a programming error, not a runtime condition. diff --git a/internal/skills/materialize_test.go b/internal/skills/materialize_test.go index 4a055ef..435bf0a 100644 --- a/internal/skills/materialize_test.go +++ b/internal/skills/materialize_test.go @@ -85,6 +85,29 @@ func TestMaterializeCodexEmitsOpenAIYAML(t *testing.T) { } } +func TestMaterializeAgy(t *testing.T) { + home := t.TempDir() + res, err := Materialize(BackendAgy, home) + if err != nil { + t.Fatalf("Materialize(agy): %v", err) + } + if len(res.Written) != len(Catalog) { + t.Fatalf("wrote %d skills, want %d", len(res.Written), len(Catalog)) + } + md := filepath.Join(home, ".gemini", "antigravity-cli", "skills", "qa-triage", "SKILL.md") + data, err := os.ReadFile(md) + if err != nil { + t.Fatalf("read %s: %v", md, err) + } + text := string(data) + if !strings.Contains(text, "name: qa-triage") { + t.Errorf("missing name frontmatter:\n%s", text) + } + if strings.Contains(text, "allowed-tools:") { + t.Errorf("agy SKILL.md should not carry allowed-tools") + } +} + func TestMaterializeOpenCode(t *testing.T) { home := t.TempDir() res, err := Materialize(BackendOpenCode, home) @@ -163,7 +186,7 @@ func TestSkillsDirRejectsUnknownBackend(t *testing.T) { t.Errorf("SkillsDir(%q): expected error, got nil", b) } } - for _, b := range []Backend{BackendCC, BackendCodex, BackendOpenCode} { + for _, b := range []Backend{BackendCC, BackendCodex, BackendOpenCode, BackendAgy} { if _, err := SkillsDir(b, home); err != nil { t.Errorf("SkillsDir(%q): unexpected error: %v", b, err) } diff --git a/internal/tui/input.go b/internal/tui/input.go index d9090e6..0a09b33 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -55,6 +55,7 @@ var slashMenuItems = []SlashMenuItem{ {"/feed", "Open the most recent live browser feed (after a test/crawl with /live on)"}, {"/cc", "Switch to Claude Code backend"}, {"/codex", "Switch to Codex CLI backend"}, + {"/agy", "Switch to Antigravity CLI (Google OAuth)"}, {"/opencode", "Switch to opencode backend (Z.AI / Groq / OpenRouter)"}, {"/api", "Switch to direct Anthropic API"}, {"/providers", "Pick and enable an opencode provider (arrows + Enter)"}, diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index 89d7c29..f9b9092 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -416,6 +416,11 @@ func (t *Terminal) PrintBanner(version string, ctx *api.SessionContext) { if ctx.Auth != nil && ctx.Auth.Email != "" { fmt.Printf(" %s▸ QualityMax: %s%s\n", themeStatusColor, ctx.Auth.Email, ColorReset) } + case "agy": + fmt.Printf(" %s▸ Backend: Antigravity CLI (Google OAuth — no Gemini API key)%s\n", themeStatusColor, ColorReset) + if ctx.Auth != nil && ctx.Auth.Email != "" { + fmt.Printf(" %s▸ QualityMax: %s%s\n", themeStatusColor, ctx.Auth.Email, ColorReset) + } default: // API mode — show direct API or qmax CLI connection status. if ctx.LocalOnly { diff --git a/internal/tui/tui_backend.go b/internal/tui/tui_backend.go index 25988a1..0ea8b79 100644 --- a/internal/tui/tui_backend.go +++ b/internal/tui/tui_backend.go @@ -50,6 +50,9 @@ var ( pickerIconOpenCode = lipgloss.NewStyle(). Foreground(lipgloss.Color("170")) // magenta — opencode ◈ + pickerIconAgy = lipgloss.NewStyle(). + Foreground(lipgloss.Color("33")) // google-blue — Antigravity ✧ + pickerDotGreen = lipgloss.NewStyle().Foreground(lipgloss.Color("82")) pickerDotRed = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) @@ -115,6 +118,11 @@ var ( Foreground(lipgloss.Color("107")). Background(lipgloss.Color("236")). Bold(true) + + pickerStatusIconAgy = lipgloss.NewStyle(). + Foreground(lipgloss.Color("33")). + Background(lipgloss.Color("236")). + Bold(true) ) // ─── Model catalogue ────────────────────────────────────────────────────────── @@ -153,6 +161,13 @@ var codexModels = []pickerEntry{ {backend: "codex", modelID: "gpt-5.3-codex-spark", label: "GPT 5.3 Codex Spark", external: true}, } +var agyModels = []pickerEntry{ + {backend: "agy", modelID: "", label: "Antigravity default", subLabel: "Google OAuth", external: true, isFav: true}, + {backend: "agy", modelID: "gemini-3.7-flash-high", label: "Gemini 3.7 Flash", subLabel: "high", external: true, isNew: true}, + {backend: "agy", modelID: "gemini-3.7-flash-medium", label: "Gemini 3.7 Flash", subLabel: "medium", external: true}, + {backend: "agy", modelID: "gemini-3.1-pro-high", label: "Gemini 3.1 Pro", subLabel: "high", external: true}, +} + var apiModels = []pickerEntry{ {backend: "", modelID: api.ModelFable51, label: "Fable 5.1", subLabel: "1M ctx", isNew: true}, {backend: "", modelID: "auto", label: "auto", subLabel: "haiku→sonnet routing", isFav: true}, @@ -230,6 +245,7 @@ type modelPickerModel struct { // the View doesn't shell out to LookPath on every frame). ccInstalled bool codexInstalled bool + agyInstalled bool openCodeInstalled bool // hasOpenCode is true when at least one enabled provider contributed a model. @@ -246,10 +262,11 @@ type modelPickerModel struct { chosen *pickerEntry } -func newModelPickerModel(currentBackend, currentModelID, effort, ollamaURL, ollamaModel string, ccInstalled, codexInstalled, cerebrasKeySet, openCodeInstalled bool, openCodeModels []OpenCodeModelEntry) modelPickerModel { - entries := make([]pickerEntry, 0, len(ccModels)+len(codexModels)+len(apiModels)+len(cerebrasModels)+len(openCodeModels)+1) +func newModelPickerModel(currentBackend, currentModelID, effort, ollamaURL, ollamaModel string, ccInstalled, codexInstalled, cerebrasKeySet, openCodeInstalled, agyInstalled bool, openCodeModels []OpenCodeModelEntry) modelPickerModel { + entries := make([]pickerEntry, 0, len(ccModels)+len(codexModels)+len(agyModels)+len(apiModels)+len(cerebrasModels)+len(openCodeModels)+1) entries = append(entries, ccModels...) entries = append(entries, codexModels...) + entries = append(entries, agyModels...) entries = append(entries, apiModels...) entries = append(entries, cerebrasModels...) @@ -306,6 +323,7 @@ func newModelPickerModel(currentBackend, currentModelID, effort, ollamaURL, olla ollamaURL: ollamaURL, ccInstalled: ccInstalled, codexInstalled: codexInstalled, + agyInstalled: agyInstalled, cerebrasKeySet: cerebrasKeySet, openCodeInstalled: openCodeInstalled, hasOpenCode: len(openCodeModels) > 0, @@ -399,6 +417,7 @@ func (m modelPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m modelPickerModel) View() string { ccInstalled := m.ccInstalled codexInstalled := m.codexInstalled + agyInstalled := m.agyInstalled var b strings.Builder @@ -444,6 +463,27 @@ func (m modelPickerModel) View() string { b.WriteString(pickerDivider.Render(strings.Repeat("─", 52))) b.WriteByte('\n') + // ── Antigravity section ────────────────────────────────────────── + sectionIconAgy := pickerIconAgy.Render("✧") + sectionLabelAgy := "Antigravity" + if !agyInstalled { + sectionLabelAgy += pickerBadgeExt.Render(" not installed") + } + b.WriteString(pickerSectionHeader.Render(fmt.Sprintf("%s %s", sectionIconAgy, sectionLabelAgy))) + b.WriteByte('\n') + + for i, e := range m.allEntries { + if e.backend != "agy" { + continue + } + b.WriteString(m.renderRow(i, e, "agy")) + b.WriteByte('\n') + } + + // ── Divider ───────────────────────────────────────────────────── + b.WriteString(pickerDivider.Render(strings.Repeat("─", 52))) + b.WriteByte('\n') + // ── Direct API section ─────────────────────────────────────────── sectionIcon3 := pickerIconAPI.Render("○") b.WriteString(pickerSectionHeader.Render(fmt.Sprintf("%s Direct API", sectionIcon3))) @@ -575,6 +615,8 @@ func (m modelPickerModel) renderRow(idx int, e pickerEntry, backend string) stri icon = pickerIcon.Render("✦") case "codex": icon = pickerIconCodex.Render("⊗") + case "agy": + icon = pickerIconAgy.Render("✧") case "ollama": icon = pickerIconOllama.Render("⬡") case "cerebras": @@ -662,6 +704,8 @@ func (m modelPickerModel) renderStatusBar() string { icon = pickerStatusIcon.Render("✦") case "codex": icon = pickerStatusIconCodex.Render("⊗") + case "agy": + icon = pickerStatusIconAgy.Render("✧") case "ollama": icon = pickerStatusBar.Render("⬡") case "cerebras": @@ -824,6 +868,7 @@ type ModelPickerOpts struct { OllamaModel string // currently configured Ollama model; "" hides the section CCInstalled bool // pre-resolved (don't shell out from picker.View per frame) CodexInstalled bool + AgyInstalled bool CerebrasKeySet bool // true when a Cerebras API key is configured (drives the section status dot) OpenCodeInstalled bool // true when the opencode CLI is present @@ -833,7 +878,7 @@ type ModelPickerOpts struct { // ShowModelPicker opens the unified model + effort TUI. // Returns the result; Confirmed=false means the user cancelled. func ShowModelPicker(opts ModelPickerOpts) ModelPickerResult { - m := newModelPickerModel(opts.CurrentBackend, opts.CurrentModelID, opts.Effort, opts.OllamaURL, opts.OllamaModel, opts.CCInstalled, opts.CodexInstalled, opts.CerebrasKeySet, opts.OpenCodeInstalled, opts.OpenCodeModels) + m := newModelPickerModel(opts.CurrentBackend, opts.CurrentModelID, opts.Effort, opts.OllamaURL, opts.OllamaModel, opts.CCInstalled, opts.CodexInstalled, opts.CerebrasKeySet, opts.OpenCodeInstalled, opts.AgyInstalled, opts.OpenCodeModels) p := tea.NewProgram(m) result, err := p.Run() if err != nil { diff --git a/internal/tui/tui_backend_agy_test.go b/internal/tui/tui_backend_agy_test.go new file mode 100644 index 0000000..c42ee5b --- /dev/null +++ b/internal/tui/tui_backend_agy_test.go @@ -0,0 +1,30 @@ +package tui + +import "testing" + +func TestPickerIncludesAntigravityModels(t *testing.T) { + m := newModelPickerModel("agy", "", "high", "", "", true, true, false, false, true, nil) + found := map[string]bool{} + for _, e := range m.allEntries { + if e.backend == "agy" { + found[e.modelID] = true + } + } + if !found[""] { + t.Fatal("picker missing Antigravity default row") + } + if !found["gemini-3.7-flash-high"] { + t.Fatal("picker missing Gemini 3.7 Flash high") + } + if !found["gemini-3.1-pro-high"] { + t.Fatal("picker missing Gemini 3.1 Pro high") + } +} + +func TestPickerAntigravityDefaultCursor(t *testing.T) { + m := newModelPickerModel("agy", "", "high", "", "", true, true, false, false, true, nil) + cur := m.allEntries[m.cursor] + if cur.backend != "agy" || cur.modelID != "" { + t.Errorf("cursor on %s/%s, want agy default", cur.backend, cur.modelID) + } +} diff --git a/internal/tui/tui_backend_cc_test.go b/internal/tui/tui_backend_cc_test.go index e950700..3cf35de 100644 --- a/internal/tui/tui_backend_cc_test.go +++ b/internal/tui/tui_backend_cc_test.go @@ -8,7 +8,7 @@ import ( ) func TestPickerIncludesClaudeCodeFableAndSonnet5(t *testing.T) { - m := newModelPickerModel("cc", "", "high", "", "", true, true, false, false, nil) + m := newModelPickerModel("cc", "", "high", "", "", true, true, false, false, false, nil) seen := map[string]pickerEntry{} for _, e := range m.allEntries { @@ -41,7 +41,7 @@ func TestPickerIncludesClaudeCodeFableAndSonnet5(t *testing.T) { } func TestPickerClaudeCodeDefaultCursorOnSonnet5(t *testing.T) { - m := newModelPickerModel("cc", "", "high", "", "", true, true, false, false, nil) + m := newModelPickerModel("cc", "", "high", "", "", true, true, false, false, false, nil) cur := m.allEntries[m.cursor] if cur.backend != "cc" || cur.modelID != api.ModelSonnet5 { t.Errorf("cursor on %s/%s, want cc/%s", cur.backend, cur.modelID, api.ModelSonnet5) @@ -50,7 +50,7 @@ func TestPickerClaudeCodeDefaultCursorOnSonnet5(t *testing.T) { func TestPickerCodexDefaultAndExplicitModel(t *testing.T) { for _, model := range []string{"", "legacy-model", "gpt-6-astra"} { - m := newModelPickerModel("codex", model, "high", "", "", true, true, false, false, nil) + m := newModelPickerModel("codex", model, "high", "", "", true, true, false, false, false, nil) want := model if model == "legacy-model" { want = "" @@ -82,7 +82,7 @@ func TestPickerOffersEverySupportedCodexModel(t *testing.T) { } func TestPickerIncludesFable51ForDirectAPI(t *testing.T) { - m := newModelPickerModel("", api.ModelFable51, "high", "", "", false, false, false, false, nil) + m := newModelPickerModel("", api.ModelFable51, "high", "", "", false, false, false, false, false, nil) cur := m.allEntries[m.cursor] if cur.backend != "" || cur.modelID != api.ModelFable51 { t.Fatal("direct API Fable 5.1 selection is missing") diff --git a/internal/tui/tui_backend_cerebras_test.go b/internal/tui/tui_backend_cerebras_test.go index c81958f..c27be74 100644 --- a/internal/tui/tui_backend_cerebras_test.go +++ b/internal/tui/tui_backend_cerebras_test.go @@ -8,7 +8,7 @@ import ( ) func TestPickerIncludesCerebrasEntries(t *testing.T) { - m := newModelPickerModel("", "", "high", "", "", true, true, false, false, nil) + m := newModelPickerModel("", "", "high", "", "", true, true, false, false, false, nil) var got []string for _, e := range m.allEntries { if e.backend == "cerebras" { @@ -25,7 +25,7 @@ func TestPickerIncludesCerebrasEntries(t *testing.T) { func TestPickerCerebrasSectionRenders(t *testing.T) { // No key configured → status should advertise the inline prompt. - m := newModelPickerModel("", "", "high", "", "", true, true, false, false, nil) + m := newModelPickerModel("", "", "high", "", "", true, true, false, false, false, nil) view := m.View() if !strings.Contains(view, "Cerebras") { t.Error("picker view missing Cerebras section header") @@ -35,7 +35,7 @@ func TestPickerCerebrasSectionRenders(t *testing.T) { } // Key configured → status should say so. - m2 := newModelPickerModel("", "", "high", "", "", true, true, true, false, nil) + m2 := newModelPickerModel("", "", "high", "", "", true, true, true, false, false, nil) if !strings.Contains(m2.View(), "key set") { t.Error("picker should show 'key set' status when CerebrasKeySet is true") } @@ -44,7 +44,7 @@ func TestPickerCerebrasSectionRenders(t *testing.T) { func TestPickerCerebrasCursorOnCurrent(t *testing.T) { // When cerebras is the active backend, the cursor should land on the // matching model entry. - m := newModelPickerModel("cerebras", "zai-glm-4.7", "high", "", "", true, true, true, false, nil) + m := newModelPickerModel("cerebras", "zai-glm-4.7", "high", "", "", true, true, true, false, false, nil) cur := m.allEntries[m.cursor] if cur.backend != "cerebras" || cur.modelID != "zai-glm-4.7" { t.Errorf("cursor on %s/%s, want cerebras/zai-glm-4.7", cur.backend, cur.modelID) @@ -52,7 +52,7 @@ func TestPickerCerebrasCursorOnCurrent(t *testing.T) { } func TestPickerEnterConfirmsWhileEffortFocused(t *testing.T) { - m := newModelPickerModel("cerebras", "gemma-4-31b", "high", "", "", true, true, true, false, nil) + m := newModelPickerModel("cerebras", "gemma-4-31b", "high", "", "", true, true, true, false, false, nil) m.effortFocus = true updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) next, ok := updated.(modelPickerModel) @@ -77,7 +77,7 @@ func TestPickerOpenCodeSetupRowsAreEnableActions(t *testing.T) { models := []OpenCodeModelEntry{ {ProviderID: "zai-coding-plan", ProviderName: "Enter to enable", ModelID: "enable:zai-coding-plan", Label: "Z.AI Coding Plan"}, } - m := newModelPickerModel("", "", "high", "", "", true, true, false, true, models) + m := newModelPickerModel("", "", "high", "", "", true, true, false, true, false, models) view := m.View() if !strings.Contains(view, "opencode") { t.Fatal("picker missing opencode section for setup rows") diff --git a/main.go b/main.go index 06fdaf9..1b34783 100644 --- a/main.go +++ b/main.go @@ -42,7 +42,7 @@ func main() { professional := flag.Bool("professional", false, "Disable cat personality, be direct and professional") quiet := flag.Bool("q", false, "Reserved for future quiet/CI mode") showVersion := flag.Bool("version", false, "Show version") - backendFlag := flag.String("backend", "", "Orchestration backend: cc, codex, cerebras, opencode, or api (overrides saved config)") + backendFlag := flag.String("backend", "", "Orchestration backend: cc, codex, agy, cerebras, opencode, or api (overrides saved config)") localFlag := flag.Bool("local", false, "Standalone local-only mode: skip QualityMax login and expose only local workspace tools") flag.Parse() _ = quiet // reserved for future CI mode @@ -216,14 +216,14 @@ func main() { // --backend flag overrides saved config for this session only. if *backendFlag != "" { switch *backendFlag { - case "cc", "codex", "cerebras", "opencode", "api", "": + case "cc", "codex", "agy", "cerebras", "opencode", "api", "": if *backendFlag == "api" { appConfig.Backend = "" } else { appConfig.Backend = *backendFlag } default: - fmt.Fprintf(os.Stderr, "Error: --backend must be cc, codex, cerebras, opencode, or api\n") + fmt.Fprintf(os.Stderr, "Error: --backend must be cc, codex, agy, cerebras, opencode, or api\n") exitWithReceipt(2) } } @@ -358,6 +358,26 @@ func main() { appConfig.OrchGlobalInstall = consent.GlobalInstall _ = appConfig.Save() } + } else if cliBackend == "agy" { + agyBin := agent.FindAgy() + if agyBin == "" { + fmt.Fprintln(os.Stderr, "\nError: backend=agy but 'agy' CLI was not found.") + fmt.Fprintln(os.Stderr, " Install Antigravity CLI: curl -fsSL https://antigravity.google/cli/install.sh | bash") + fmt.Fprintln(os.Stderr, " Then sign in with Google: run `agy` and complete the browser login") + fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api") + exitWithReceipt(1) + } + consent := setup.PromptOrchConsent(appConfig, "agy") + if !consent.Proceed { + fmt.Fprintln(os.Stderr, " Antigravity backend not activated. Falling back to direct API.") + cliBackend = "" + appConfig.Backend = "" + } else { + appConfig.OrchPermissionMode = consent.PermissionMode + appConfig.OrchGlobalInstall = consent.GlobalInstall + _ = appConfig.Save() + setup.PromptAgyGoogleLogin(agyBin) + } } else if cliBackend == "cerebras" { // Cerebras drives the native qmax agent loop (mode-filtered tool set, native // function calling) via its OpenAI-compatible API. No Anthropic key, @@ -431,6 +451,7 @@ func main() { fmt.Fprintln(os.Stderr, " Or use a CLI backend (no API key needed):") fmt.Fprintln(os.Stderr, " qmax-code config set backend cc # Claude Code login") fmt.Fprintln(os.Stderr, " qmax-code config set backend codex # OpenAI/Codex subscription") + fmt.Fprintln(os.Stderr, " qmax-code config set backend agy # Google Antigravity (Google OAuth)") if localOnly { fmt.Fprintln(os.Stderr, " qmax-code config set ollama_url http://127.0.0.1:11434") fmt.Fprintln(os.Stderr, " qmax-code config set ollama_model ") @@ -527,6 +548,20 @@ func main() { fmt.Fprintf(os.Stderr, "Warning: could not write Codex MCP config: %v\n", err) } cliAgent = ca + case "agy": + if appConfig.OrchGlobalInstall && !setup.IsOrchInstalled("agy") { + if res, err := setup.InstallAgy(); err == nil && !res.AlreadyHadMCP { + fmt.Printf(" qmax MCP entry added to %s\n", res.MCPPath) + } + } + if appConfig.OrchGlobalInstall { + _, _ = setup.InstallSkills("agy") + } + aa := agent.NewAgyAgent(agent.FindAgy(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, ctx) + if err := aa.WriteMCPConfig(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write Antigravity MCP config: %v\n", err) + } + cliAgent = aa case "opencode": oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx) if _, err := agent.WriteOpenCodeConfig(appConfig, ctx, appConfig.OrchPermissionMode); err != nil { diff --git a/main_models.go b/main_models.go index 48ef74b..1bf9d76 100644 --- a/main_models.go +++ b/main_models.go @@ -24,6 +24,17 @@ func resolveSessionModel(cfg *api.Config, requested string) (string, error) { cfg.CodexModel = model return "auto", nil } + if cfg.Backend == "agy" { + model := cfg.ModelOverride + if requested != "" { + model = requested + if model == "auto" { + model = "" + } + } + cfg.ModelOverride = model + return "auto", nil + } model := requested if model == "" { diff --git a/main_models_test.go b/main_models_test.go index cf1aec5..fadc047 100644 --- a/main_models_test.go +++ b/main_models_test.go @@ -21,6 +21,9 @@ func TestResolveSessionModel(t *testing.T) { {name: "Fable harness", backend: "cc", requested: "fable", wantModel: api.ModelFable51, wantClaude: api.ModelFable51}, {name: "Fable API", requested: api.ModelFable51, wantModel: api.ModelFable51, wantClaude: "saved-claude"}, {name: "Claude default", backend: "cc", requested: "auto", wantModel: "auto"}, + {name: "Agy default", backend: "agy", wantModel: "auto", wantClaude: "saved-claude"}, + {name: "Agy flash", backend: "agy", requested: "gemini-3.7-flash-high", wantModel: "auto", wantClaude: "gemini-3.7-flash-high"}, + {name: "Agy auto", backend: "agy", requested: "auto", wantModel: "auto", wantClaude: ""}, } { t.Run(tc.name, func(t *testing.T) { cfg := &api.Config{Backend: tc.backend, CodexModel: tc.savedCodex, ModelOverride: "saved-claude"} diff --git a/receipt.go b/receipt.go index b615584..6663e4d 100644 --- a/receipt.go +++ b/receipt.go @@ -43,6 +43,8 @@ func receiptKind(args []string) string { return "cc" case "codex": return "codex" + case "agy": + return "agy" case "config": return "config" } From 37a560734b77ec42c05298e22f9370b29975e4e7 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 7 Sep 2026 23:43:47 +0200 Subject: [PATCH 2/2] fix: address agy review notes for OAuth, tools, and consent Skip the Google login prompt after the first offer in a process, keep ACTIVE/DONE tool labels aligned, and show `agy` in consent copy. Co-authored-by: Cursor --- internal/agent/agy_agent.go | 21 +++++++++++++++------ internal/agent/agy_agent_test.go | 17 +++++++++++++++++ internal/setup/agy.go | 22 ++++++++++++++++++++-- internal/setup/consent.go | 13 ++++++++++++- internal/setup/consent_test.go | 15 +++++++++++++++ internal/setup/orch_test.go | 16 ++++++++++++++++ 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/internal/agent/agy_agent.go b/internal/agent/agy_agent.go index 0f3df7d..f56e522 100644 --- a/internal/agent/agy_agent.go +++ b/internal/agent/agy_agent.go @@ -421,6 +421,19 @@ type agyToolError struct { Message string `json:"message"` } +// agyToolName is the label used for both ACTIVE and DONE tool events so +// verbose start/result lines stay aligned when ToolName and ToolInfo.Name differ. +func agyToolName(su *agyStepUpdate) string { + if su == nil { + return "" + } + name := su.ToolName + if su.ToolInfo != nil && su.ToolInfo.Name != "" { + name = su.ToolInfo.Name + } + return stripMCPPrefix(name) +} + func (a *AgyAgent) parseStream(stdout io.Reader, term *tui.Terminal) string { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 1<<20), 1<<20) @@ -462,13 +475,9 @@ func (a *AgyAgent) parseStream(stdout io.Reader, term *tui.Terminal) string { term.StreamText(su.TextDelta) } case "tool": + name := agyToolName(su) if su.State == "ACTIVE" && !seenTool[su.StepIndex] { seenTool[su.StepIndex] = true - name := su.ToolName - if su.ToolInfo != nil && su.ToolInfo.Name != "" { - name = su.ToolInfo.Name - } - name = stripMCPPrefix(name) if term != nil { term.PrintToolIcon(name) if a.outputVerbose && su.ToolInfo != nil { @@ -484,7 +493,7 @@ func (a *AgyAgent) parseStream(stdout io.Reader, term *tui.Terminal) string { if su.ToolInfo.Error != nil && su.ToolInfo.Error.Message != "" { out = su.ToolInfo.Error.Message } - term.PrintToolResult(stripMCPPrefix(su.ToolName), tui.TruncateStr(out, 200)) + term.PrintToolResult(name, tui.TruncateStr(out, 200)) } else { term.StartThinking() } diff --git a/internal/agent/agy_agent_test.go b/internal/agent/agy_agent_test.go index f8d9962..fa26e11 100644 --- a/internal/agent/agy_agent_test.go +++ b/internal/agent/agy_agent_test.go @@ -136,3 +136,20 @@ func TestAgyOAuthTokenPath(t *testing.T) { t.Fatalf("path = %q, want %q", got, want) } } + +func TestAgyToolNameAlignsActiveAndDoneLabels(t *testing.T) { + su := &agyStepUpdate{ + ToolName: "mcp__qmax__run_tests", + ToolInfo: &agyToolInfo{Name: "run_tests"}, + } + if got := agyToolName(su); got != "run_tests" { + t.Fatalf("prefer ToolInfo.Name = %q", got) + } + su.ToolInfo.Name = "" + if got := agyToolName(su); got != "run_tests" { + t.Fatalf("fallback strip ToolName = %q", got) + } + if got := agyToolName(nil); got != "" { + t.Fatalf("nil update = %q", got) + } +} diff --git a/internal/setup/agy.go b/internal/setup/agy.go index fef0255..7d94220 100644 --- a/internal/setup/agy.go +++ b/internal/setup/agy.go @@ -6,16 +6,31 @@ import ( "os" "os/exec" "strings" + "sync/atomic" "github.com/qualitymax/qmax-code/internal/agent" "golang.org/x/term" ) +// agyGoogleLoginOffered is set after the first Google sign-in prompt in this +// process. AgyFileTokenPresent is a file-path probe only: macOS Keychain +// tokens are invisible, so a missing file must not keep re-asking on every +// /agy or /orch switch. +var agyGoogleLoginOffered atomic.Bool + +func claimAgyGoogleLoginPrompt() bool { + return agyGoogleLoginOffered.CompareAndSwap(false, true) +} + +func resetAgyGoogleLoginPrompt() { + agyGoogleLoginOffered.Store(false) +} + // PromptAgyGoogleLogin offers to launch Antigravity's Google OAuth flow. // agy 1.1.x has no `auth login` subcommand: Google sign-in runs when the // interactive CLI starts with no cached token. An AI Studio API key is not -// required. Skipped when stdin is not a TTY, or when a file-backed OAuth -// token is already present. +// required. Skipped when stdin is not a TTY, when a file-backed OAuth token +// is already present, or when this process already offered the prompt. func PromptAgyGoogleLogin(agyBin string) { if agyBin == "" { return @@ -23,6 +38,9 @@ func PromptAgyGoogleLogin(agyBin string) { if agent.AgyFileTokenPresent() { return } + if !claimAgyGoogleLoginPrompt() { + return + } if !term.IsTerminal(int(os.Stdin.Fd())) { fmt.Println(" Sign in with Google first: run `agy`, complete the browser login, then exit.") return diff --git a/internal/setup/consent.go b/internal/setup/consent.go index 8a57e95..db1a406 100644 --- a/internal/setup/consent.go +++ b/internal/setup/consent.go @@ -37,6 +37,7 @@ func PromptOrchConsent(cfg *api.Config, backend string) ConsentResult { cliName = "Antigravity" globalConfigPath = "~/.gemini/config/mcp_config.json" } + cliCmd := orchConsentCLICommand(backend, cliName) res := ConsentResult{ PermissionMode: cfg.OrchPermissionMode, @@ -93,7 +94,7 @@ func PromptOrchConsent(cfg *api.Config, backend string) ConsentResult { if backend != "opencode" && !res.GlobalInstall && !IsOrchInstalled(backend) { fmt.Println() fmt.Printf(" Optional: register qmax tools globally in %s\n", globalConfigPath) - fmt.Printf(" so they appear in every `%s` session, not just qmax-code.\n", strings.ToLower(cliName)) + fmt.Printf(" so they appear in every `%s` session, not just qmax-code.\n", cliCmd) fmt.Println() fmt.Print(" Install globally? [y/N]: ") line, _ := reader.ReadString('\n') @@ -108,3 +109,13 @@ func PromptOrchConsent(cfg *api.Config, backend string) ConsentResult { func qmaxSelectsPermissionMode(backend string) bool { return backend != "codex" } + +// orchConsentCLICommand is the executable name shown in backticks during +// consent. Display names like "Antigravity" must not be lowercased into a +// fake command; the binary is `agy`. +func orchConsentCLICommand(backend, cliName string) string { + if backend == "agy" { + return "agy" + } + return strings.ToLower(cliName) +} diff --git a/internal/setup/consent_test.go b/internal/setup/consent_test.go index 6c73633..7ffe5d8 100644 --- a/internal/setup/consent_test.go +++ b/internal/setup/consent_test.go @@ -12,3 +12,18 @@ func TestCodexDefersPermissionPolicyToCodexConfiguration(t *testing.T) { } } } + +func TestOrchConsentCLICommandUsesAgyBinary(t *testing.T) { + if got := orchConsentCLICommand("agy", "Antigravity"); got != "agy" { + t.Fatalf("agy command = %q, want agy", got) + } + if got := orchConsentCLICommand("cc", "Claude Code"); got != "claude code" { + t.Fatalf("cc command = %q, want claude code", got) + } + if got := orchConsentCLICommand("codex", "Codex"); got != "codex" { + t.Fatalf("codex command = %q, want codex", got) + } + if got := orchConsentCLICommand("opencode", "opencode"); got != "opencode" { + t.Fatalf("opencode command = %q, want opencode", got) + } +} diff --git a/internal/setup/orch_test.go b/internal/setup/orch_test.go index afc873b..8cc2529 100644 --- a/internal/setup/orch_test.go +++ b/internal/setup/orch_test.go @@ -68,6 +68,22 @@ func TestInstallAgyWritesMCPConfigJSON(t *testing.T) { } } +func TestClaimAgyGoogleLoginPromptOncePerProcess(t *testing.T) { + resetAgyGoogleLoginPrompt() + t.Cleanup(resetAgyGoogleLoginPrompt) + + PromptAgyGoogleLogin("") + if agyGoogleLoginOffered.Load() { + t.Fatal("empty binary must not consume the prompt") + } + if !claimAgyGoogleLoginPrompt() { + t.Fatal("first claim should offer the Google login prompt") + } + if claimAgyGoogleLoginPrompt() { + t.Fatal("second claim in the same process should not re-prompt") + } +} + func TestWriteCodexMCPEntryReportsAlreadyHad(t *testing.T) { home := withTempHome(t) path := filepath.Join(home, ".codex", "config.toml")