diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e65b06c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[env] +# The unit/integration suites still rely on process-global auth override state +# and mock executable setup. Running the Rust test harness single-threaded keeps +# those tests deterministic in CI and local verification. +RUST_TEST_THREADS = "1" diff --git a/README.md b/README.md index c829386..b74b99f 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@

- aisw + aisw

-

Named profile and context manager for Claude Code, Codex CLI, and Gemini CLI.

+

AI Switcher (`aisw`) for Claude Code, Codex CLI, and Gemini CLI.

Switch between work, personal, and client accounts without copying auth files, editing hidden config, or logging in again every time.

@@ -30,9 +30,9 @@ --- -## Why people use aisw +## Why people use AI Switcher -`aisw` exists for a very specific kind of mess: +AI Switcher (`aisw`) exists for a very specific kind of mess: - You use one Claude Code account for work and another for personal projects. - Codex CLI should use one OpenAI account for client A and a different one for client B, without relying on copied shared ChatGPT session files. @@ -48,7 +48,7 @@ The underlying problem is not just "multiple accounts." It is that each upstream - Switch in one command with rollback if something fails. - Bind repos to expected contexts so the wrong account does not silently launch in the wrong workspace. -If you have ever searched for "Claude Code account switcher", "multiple Codex CLI accounts", "Gemini CLI work and personal profiles", or "coding agent profile switch per repo", this is the tool that addresses that workflow directly. +If you have ever searched for "AI account switcher for Claude Code", "multiple Codex CLI accounts", "Gemini CLI work and personal profiles", or "coding agent profile switch per repo", AI Switcher addresses that workflow directly. ## Common situations @@ -104,6 +104,8 @@ Context workflow: ```sh # Homebrew (macOS and Linux) +# If Homebrew asks you to trust the tap first +brew trust burakdede/tap brew tap burakdede/tap brew install aisw @@ -116,6 +118,127 @@ cargo install aisw ## Quick start +The easiest mental model is: + +1. `aisw init` captures whatever Claude, Codex, and Gemini accounts are live right now. +2. `aisw add ...` introduces another account directly under `aisw`. +3. `aisw use ...` restores the saved credentials for the selected profile. +4. After switching, start a fresh Claude/Codex/Gemini process. + +If you are setting up work and personal accounts, follow this order instead of logging out and back in repeatedly at the upstream CLI first. + +The top-level commands most people need are: + +- `aisw init` +- `aisw add ` +- `aisw use ` +- `aisw use --all --profile ` +- `aisw list` +- `aisw status` + +### First account: import what is already live + +```sh +# Bootstrap ~/.aisw/, install shell integration, and import +# the accounts that are already live right now +aisw init + +# Verify what got imported and marked active +aisw list +aisw status +``` + +If Claude, Codex, or Gemini were already logged in when you ran `aisw init`, you do not need to re-add that same first account with `--from-live`. + +### Second account: add it directly + +```sh +# Preferred path for a second Claude account +aisw add claude account-2 --label account-2@gmail.com + +# Preferred path for a second Codex account +aisw add codex account-2 --label account-2@gmail.com + +# If you also use Gemini +aisw add gemini account-2 --label account-2@gmail.com +``` + +This is the recommended path because AI Switcher drives the login flow itself instead of depending on whatever account happens to be live upstream at that moment. + +For Codex specifically, this distinction matters: + +- Durable: `aisw add codex ` for ChatGPT-managed auth, because login happens inside that profile's isolated `CODEX_HOME`. +- Bootstrap only: `aisw add codex --from-live` when you intentionally want to import the currently live ChatGPT-managed session. + +### Switch between saved accounts + +```sh +aisw use claude account-1 +aisw use claude account-2 + +# If profile names line up across tools +aisw use --all --profile account-1 +aisw use --all --profile account-2 +``` + +After `aisw use ...`, start a fresh Claude/Codex/Gemini process. You should not need to log out and log back in manually after every switch, but you should not rely on reusing an already running or resumable session from the previous account. + +### When to use `--from-live` + +Use `--from-live` when you intentionally want to capture whichever account is currently live in the upstream CLI. + +```sh +# Example: import the account that is currently live upstream +aisw add claude account-2 --from-live --label account-2@gmail.com +``` + +That workflow is: + +1. Exit the upstream CLI. +2. Log out upstream. +3. Log back in upstream as the account you want to capture. +4. Run `aisw add ... --from-live`. + +That is a valid import path, but it is not the best default recommendation when AI Switcher can run the login flow directly. + +
+Common variants + +Use these when your setup is a little different from the normal two-account OAuth flow above. + +```sh +# API-key-backed profiles +aisw add claude work --api-key "$ANTHROPIC_API_KEY" +aisw add codex work --api-key "$OPENAI_API_KEY" +aisw add gemini work --api-key "$GEMINI_API_KEY" + +# Switch every tool when the profile names line up +aisw use --all --profile work + +# Create one mixed-tool context when names do not line up +aisw context create acme \ + --claude acme-claude \ + --codex acme-codex \ + --gemini acme-gemini + +aisw context use acme +``` +
+ +
+Important tool-specific limits + +- Claude: after switching, start a fresh Claude process instead of relying on a resumed session from the previous account. +- Codex: ChatGPT-managed `aisw add codex --from-live` is bootstrap-only. The durable path is direct per-profile login with `aisw add codex `. +- Codex: shared-mode ChatGPT auth switching is explicitly unsupported. +- Gemini: `aisw init` and `aisw add gemini ...` can capture the currently live Gemini state, but you should still start a fresh process after switching. + +
+ +### Generic command examples + +Once the two-account flow above makes sense, these are the shorter building blocks: + ```sh # Bootstrap: creates ~/.aisw/, offers shell-hook setup, # and can import already logged-in accounts @@ -187,7 +310,7 @@ What you do not get from a context: The practical value is simple: `aisw use --all --profile personal` works when names line up, and `aisw context use acme` works when the real world does not. -## Why aisw works better than manual switching +## Why AI Switcher works better than manual switching - It writes the native upstream credential locations that Claude Code, Codex CLI, and Gemini CLI already use. - It snapshots live state before switching and rolls back on failure instead of leaving you mid-edit. @@ -254,10 +377,12 @@ Credentials never leave the local machine. There is no remote service, no teleme ## Documentation +Most users should be able to get productive from this README. For deeper workflows, edge cases, and tool-specific behavior, use the docs below. + - [Common switching situations](https://burakdede.github.io/aisw/common-situations/) - [Quickstart](https://burakdede.github.io/aisw/quickstart/) - [Commands](https://burakdede.github.io/aisw/commands/) -- [Why aisw](https://burakdede.github.io/aisw/why-aisw/) +- [Why AI Switcher](https://burakdede.github.io/aisw/why-aisw/) - [Workspace guardrails](https://burakdede.github.io/aisw/workspace/) - [How it works](https://burakdede.github.io/aisw/how-it-works/) - [Security](https://burakdede.github.io/aisw/security/) diff --git a/docs/acceptance-matrix.md b/docs/acceptance-matrix.md index ef1642c..dc8c54e 100644 --- a/docs/acceptance-matrix.md +++ b/docs/acceptance-matrix.md @@ -16,6 +16,7 @@ This matrix records the current end-to-end acceptance status for supported `aisw | Codex CLI | Local state configured for keyring without importable `auth.json` | Supported diagnostic | Not applicable | `init` reports that Codex appears keyring-backed but no importable credential file is available | `tests/init_cmd.rs`, `src/auth/codex.rs` unit coverage, full `cargo test` | | Gemini CLI | File-managed auth and local state | Supported | Supported | Imports managed Gemini files, preserves required local state files, reapplies live state under `~/.gemini` on switch | `tests/init_cmd.rs`, `tests/use_cmd.rs`, full `cargo test` | | Gemini CLI | System keyring | Not supported | Not supported | Gemini remains file-managed in `aisw` because upstream behavior is file-centric | Product policy; see [supported-tools.md](./supported-tools.md) | +| Antigravity CLI | Shared live OAuth keyring auth plus documented `~/.gemini` config roots | Supported via `add` / `--from-live` | Supported | Restores the shared live OS keyring session plus `~/.gemini/antigravity-cli/` and `~/.gemini/config/`; upstream does not currently document an isolated per-profile auth root | `src/auth/antigravity.rs`, `src/commands/add.rs`, `src/commands/use_.rs`, full `cargo test` | ## Notes diff --git a/docs/adding-profiles.md b/docs/adding-profiles.md index a9061ee..cfe6fb4 100644 --- a/docs/adding-profiles.md +++ b/docs/adding-profiles.md @@ -49,6 +49,7 @@ aisw add gemini personal - Claude: spawns `claude auth login`. When the installed Claude build supports profile-scoped auth, `aisw` runs login inside the profile-owned `CLAUDE_CONFIG_DIR`; otherwise it monitors the live credential file and Keychain for changes and captures the result there. - Codex: sets `CODEX_HOME` to the profile directory and spawns `codex`. The device-auth flow writes credentials directly into that profile-owned isolated state. This is the durable ChatGPT-managed Codex path. - Gemini: sets `GEMINI_CLI_HOME` to a scratch directory, spawns `gemini`, then copies the resulting OAuth cache files into the profile. The scratch directory is removed after the flow regardless of outcome. +- Antigravity: spawns `agy`, captures the resulting live keyring-backed OAuth session plus the documented `~/.gemini/antigravity-cli/` and `~/.gemini/config/` state, then restores the prior live state unless `--set-active` is requested. Claude OAuth support depends on how the installed Claude build scopes auth: - File-backed or profile-scoped keychain auth: the interactive login is a durable isolated profile path. @@ -65,6 +66,7 @@ Import what the tool is currently using, without launching a browser: aisw add claude work --from-live aisw add codex work --from-live aisw add gemini work --from-live +aisw add antigravity work --from-live ``` This is the fastest path if you are already logged in. The captured profile is automatically set as active because those credentials are already live. @@ -73,6 +75,8 @@ For Codex ChatGPT-managed auth, `--from-live` is compatibility/bootstrap only. I For Claude OAuth, `--from-live` captures whatever Claude is currently using, but it does not upgrade a shared live session into an independently isolated auth owner. If the install still uses Claude's legacy shared Keychain credential, treat the imported profile as a captured shared-live session rather than as a durable isolated OAuth bundle. +For Antigravity OAuth, both interactive add and `--from-live` operate on the same shared live upstream model: `aisw` stores the current keyring-backed session and documented Antigravity config roots, then restores them on switch. Upstream does not currently document an isolated per-profile auth root or profile selector. + If a profile with that name already exists, use `--yes` to overwrite it: ```sh @@ -106,6 +110,7 @@ All credential files are written with `0600` permissions. The profile name is re - `file`: portable and backup-friendly - `system-keyring`: stronger local secret storage for Claude and Codex where the OS keyring is usable. Stored config and status output use `system_keyring`. - Gemini remains file-managed because its auth is coupled to broader `~/.gemini/` state +- Antigravity supports `file` and `system-keyring` for the managed profile, but live auth is always restored into Antigravity's shared OS keyring entry. ## Duplicate account detection diff --git a/docs/commands.md b/docs/commands.md index 272e9b1..74847ae 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -123,14 +123,16 @@ Notes: - `--from-live` always activates the profile because those credentials are already live. - `--from-live --yes` overwrites an existing profile in place; the existing entry is not removed until capture succeeds. - For Codex ChatGPT-managed auth, `--from-live` is a bootstrap import, not a durable interchangeable account bundle. +- For Antigravity, `--from-live` captures the current shared live keyring-backed session plus the documented Antigravity config roots. - When OAuth identity can be resolved, `add` blocks creating a duplicate profile for an already-stored account. - `--credential-backend` affects the managed `aisw` profile only. It does not force the upstream CLI's live auth backend. -- Gemini supports only `file`. Claude and Codex support `file` and `system-keyring`. Stored config and status output use `system_keyring`. +- Gemini supports only `file`. Claude, Codex, and Antigravity support `file` and `system-keyring`. Stored config and status output use `system_keyring`. Live credential locations by tool: - Claude: `~/.claude/.credentials.json` or the macOS Keychain - Codex: `~/.codex/auth.json` or the OS keyring - Gemini: `~/.gemini/.env` (API key) or OAuth files in `~/.gemini/` +- Antigravity: live OS keyring auth plus config/state under `~/.gemini/antigravity-cli/` and `~/.gemini/config/` ```sh aisw add claude work --api-key "$ANTHROPIC_API_KEY" @@ -162,7 +164,7 @@ Activate a stored profile as the live account. | `--emit-env` | Print shell export/unset lines to stdout instead of writing them to the session | Notes: -- `--state-mode` applies to Claude Code and Codex CLI only. Gemini does not support it. +- `--state-mode` applies to Claude Code and Codex CLI only. Gemini and Antigravity do not support it. - Switching is atomic: the previous live state is snapshotted before any write. A failed write triggers a full rollback. - With shell hook active, `aisw use` also emits the environment variable exports into the current shell session. - `--emit-env` is used internally by the shell hook. You can use it directly to apply exports in a subshell: `eval "$(aisw use claude work --emit-env)"`. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index ef35e99..26ed008 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -1,11 +1,11 @@ --- title: How aisw works -description: Architecture, design decisions, credential storage model, OS keyring integration, and per-tool implementation details for Claude Code, Codex CLI, and Gemini CLI. +description: Architecture, design decisions, credential storage model, OS keyring integration, and per-tool implementation details for Claude Code, Codex CLI, Gemini CLI, and Antigravity CLI. --- # How aisw works -This page explains the design decisions behind `aisw`, how credentials are stored and applied, and the per-tool implementation details for Claude Code, Codex CLI, and Gemini CLI. +This page explains the design decisions behind `aisw`, how credentials are stored and applied, and the per-tool implementation details for Claude Code, Codex CLI, Gemini CLI, and Antigravity CLI. ## Profile and context model @@ -113,6 +113,16 @@ On Linux, if the Secret Service daemon is not available at runtime (e.g. headles **How `aisw` captures credentials:** - `--api-key` / `--from-env`: stores the key in a profile `.env` file. - `--from-live`: copies everything under `~/.gemini/` into the profile directory. + +### Antigravity CLI + +- Live auth: shared OS-native keyring entry documented by upstream behavior +- Live state: `~/.gemini/antigravity-cli/` and `~/.gemini/config/` +- `--from-live`: captures the current live keyring-backed session plus both documented config roots. +- Interactive OAuth: launches `agy`, captures the resulting live keyring/config state, and restores the prior live state unless `--set-active` is requested. +- `use`: restores the managed keyring secret into Antigravity's live keyring entry, then transactionally syncs the documented config roots. + +**Important Antigravity limitation:** upstream does not currently document an isolated per-profile auth/data root or profile selector. `aisw` therefore supports Antigravity through shared live switching rather than profile-owned isolated auth. This is a product limitation upstream, not `aisw` corruption. - Interactive OAuth: sets `GEMINI_CLI_HOME` to a temporary scratch directory, spawns `gemini` so it writes its OAuth cache there, then copies all resulting files from `/.gemini/` into the profile directory. The scratch directory is always cleaned up, regardless of success or failure. `GEMINI_CLI_HOME` was introduced in Gemini CLI to override the home directory used for config storage. It is cleaner than overriding `HOME` because it does not affect other processes or macOS Keychain lookups that depend on the real home directory. diff --git a/docs/quickstart.md b/docs/quickstart.md index f34cb9b..b3f6e30 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,6 +1,6 @@ --- title: Quickstart -description: Install aisw, store your first profiles, and switch between Claude Code, Codex CLI, and Gemini CLI accounts in under five minutes. +description: Install aisw, store your first profiles, and switch between Claude Code, Codex CLI, Gemini CLI, and Antigravity CLI accounts in under five minutes. --- # Quickstart @@ -33,7 +33,7 @@ aisw --version aisw init ``` -This creates `~/.aisw/`, offers to install the optional shell hook (recommended), and detects any accounts you are already logged into. If you are already signed into Claude Code, Codex, or Gemini, `init` will offer to import those credentials as named profiles so you start without re-authenticating. +This creates `~/.aisw/`, offers to install the optional shell hook (recommended), and detects any accounts you are already logged into. If you are already signed into Claude Code, Codex, or Gemini, `init` will offer to import those credentials as named profiles so you start without re-authenticating. Antigravity support currently starts with explicit `add` flows rather than `init` auto-import. For GUI or other machine-driven onboarding, use the non-prompting bootstrap path instead: @@ -69,10 +69,13 @@ aisw add codex ci --from-env aisw add claude personal aisw add codex personal aisw add gemini personal +aisw add antigravity work --from-live ``` For Codex ChatGPT-managed auth, this interactive path is the durable setup because login happens inside the profile-owned isolated `CODEX_HOME`. +For Antigravity, `aisw add antigravity ` captures the shared live keyring-backed OAuth session that `agy` creates and stores the documented Antigravity config roots alongside it. Upstream does not currently document an isolated per-profile auth root. + If you want machine-readable OAuth progress for a GUI: ```sh diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 563b2f9..5f61b71 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -1,6 +1,6 @@ --- title: Supported tools -description: Claude Code, Codex CLI, and Gemini CLI support matrix - auth methods, credential locations, OS keyring support, and state mode behavior per platform. +description: Claude Code, Codex CLI, Gemini CLI, and Antigravity CLI support matrix - auth methods, credential locations, OS keyring support, and state mode behavior per platform. --- # Supported tools @@ -12,6 +12,7 @@ description: Claude Code, Codex CLI, and Gemini CLI support matrix - auth meth | Claude Code | `claude` | OAuth, API key | Full | Full | Full | | Codex CLI | `codex` | OAuth, API key | Full | Full | Full | | Gemini CLI | `gemini` | OAuth, API key | Full | Full | Full | +| Antigravity CLI | `agy` | OAuth | Full | Full | Full | ## Binary detection @@ -24,6 +25,7 @@ description: Claude Code, Codex CLI, and Gemini CLI support matrix - auth meth | Claude Code | `CLAUDE_CONFIG_DIR` set to profile directory when the install supports profile-owned auth | `CLAUDE_CONFIG_DIR` unset | | Codex CLI | `CODEX_HOME` set to profile directory | `CODEX_HOME` unset for API-key profiles only | | Gemini CLI | Profile files applied to `~/.gemini/` | Not supported | +| Antigravity CLI | Not supported | Shared live keyring-backed auth and `~/.gemini` config roots restored transactionally | In `isolated` mode, the tool reads config, history, and extensions from the profile-specific directory. In `shared` mode, the tool reads its standard config directory. Credentials are applied to the live location in both modes; state mode only controls which config directory the tool reads. @@ -33,6 +35,8 @@ For Claude OAuth, isolated mode is intentionally blocked only when Claude is usi Gemini does not support `shared` mode because its auth state and broader local state (settings, session history, MCP configs) are tightly coupled under `~/.gemini/`. Separating them is not safely possible without risking session corruption. +Antigravity does not currently expose a documented per-profile auth/data root like `CODEX_HOME` or `CLAUDE_CONFIG_DIR`. `aisw` therefore supports Antigravity through shared live switching: it restores the live OS keyring credential plus the documented `~/.gemini/antigravity-cli/` and `~/.gemini/config/` trees for the selected profile. + ## Credential storage by tool and platform ### Claude Code @@ -86,6 +90,15 @@ For interactive OAuth, `aisw` uses `GEMINI_CLI_HOME` to redirect Gemini's config API key profiles store a `.env` file containing `GEMINI_API_KEY=`. This is the format Gemini reads natively from `~/.gemini/.env`. +### Antigravity CLI + +- Live auth: OS-native keyring entry (`service=gemini`, `account=antigravity`) as observed in upstream issue reports and docs-aligned behavior. +- Live config/state: `~/.gemini/antigravity-cli/` and `~/.gemini/config/` +- `--from-live`: captures the current live keyring-backed session plus both documented config roots. +- Interactive OAuth: launches `agy`, captures the resulting live keyring/config state, and restores the prior live state unless `--set-active` is requested. +- No API-key path in `aisw` because upstream Antigravity docs currently describe OAuth/keyring auth, not API-key profile auth. +- No `--state-mode` support because upstream does not currently document an isolated per-profile auth or data root. + ## Auth backend support matrix | Tool | Backend | `aisw init` import | `aisw use` | Notes | @@ -97,6 +110,8 @@ API key profiles store a `.env` file containing `GEMINI_API_KEY=`. This is | Codex CLI | System keyring (not discoverable) | Not supported | Fail-closed | `aisw` will not fabricate an account identifier | | Gemini CLI | File-backed `~/.gemini/` state | Supported | Supported | Full directory capture and restore | | Gemini CLI | System keyring | Not supported | Not supported | Gemini does not use keyring for credentials | +| Antigravity CLI | File-backed managed profile + live OS keyring apply | Supported | Supported | Stores captured secret in the profile, then restores it into the live keyring on switch | +| Antigravity CLI | System keyring-backed managed profile | Supported | Supported | Stores the captured live keyring secret in `aisw`'s managed keyring backend | **Fail-closed** means `aisw` refuses the operation rather than guessing. This applies specifically to Codex when the keyring account identifier cannot be read from the live credential store. diff --git a/src/auth/antigravity.rs b/src/auth/antigravity.rs new file mode 100644 index 0000000..744e326 --- /dev/null +++ b/src/auth/antigravity.rs @@ -0,0 +1,923 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use chrono::Utc; + +use super::files; +use super::identity; +use super::secure_store; +use crate::config::{AuthMethod, ConfigStore, CredentialBackend, ProfileMeta}; +use crate::live_apply::LiveFileChange; +use crate::profile::ProfileStore; +use crate::types::Tool; + +pub(crate) const KEYRING_METADATA_FILE: &str = "keyring.json"; +const SECRET_FILE: &str = "keyring-secret.json"; +const APP_PREFIX: &str = "app"; +const SHARED_PREFIX: &str = "shared"; +const OAUTH_TIMEOUT: Duration = Duration::from_secs(180); +const KEYRING_SERVICE: &str = "gemini"; +const KEYRING_ACCOUNT: &str = "antigravity"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AntigravityAuthClassification { + OauthSharedLiveKeyring, +} + +impl AntigravityAuthClassification { + pub fn as_str(self) -> &'static str { + match self { + Self::OauthSharedLiveKeyring => "oauth_shared_live_keyring", + } + } + + pub fn human_label(self) -> &'static str { + match self { + Self::OauthSharedLiveKeyring => "OAuth shared live keyring", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyringRef { + pub service: String, + pub account: String, +} + +#[derive(Debug, Clone)] +pub struct LiveSnapshot { + pub keyring_ref: KeyringRef, + pub keyring_secret: Option>, + pub app_files: BTreeMap>, + pub shared_files: BTreeMap>, +} + +pub fn live_app_dir(user_home: &Path) -> PathBuf { + user_home.join(".gemini").join("antigravity-cli") +} + +pub fn live_shared_dir(user_home: &Path) -> PathBuf { + user_home.join(".gemini").join("config") +} + +pub fn default_live_keyring_ref() -> KeyringRef { + KeyringRef { + service: KEYRING_SERVICE.to_owned(), + account: KEYRING_ACCOUNT.to_owned(), + } +} + +pub fn classify_profile( + _profile_store: &ProfileStore, + _name: &str, + auth_method: AuthMethod, + _credential_backend: CredentialBackend, +) -> Result { + if auth_method != AuthMethod::OAuth { + bail!("Antigravity currently supports OAuth profiles only"); + } + Ok(AntigravityAuthClassification::OauthSharedLiveKeyring) +} + +pub fn read_managed_secret( + profile_store: &ProfileStore, + profile_name: &str, + backend: CredentialBackend, +) -> Result>> { + match backend { + CredentialBackend::File => { + let path = profile_store + .profile_dir(Tool::Antigravity, profile_name) + .join(SECRET_FILE); + if !path.exists() { + return Ok(None); + } + profile_store + .read_file(Tool::Antigravity, profile_name, SECRET_FILE) + .map(Some) + } + CredentialBackend::SystemKeyring => { + secure_store::read_profile_secret(Tool::Antigravity, profile_name) + } + } +} + +pub fn persist_managed_secret( + profile_store: &ProfileStore, + profile_name: &str, + backend: CredentialBackend, + secret: &[u8], +) -> Result<()> { + match backend { + CredentialBackend::File => { + profile_store.write_file(Tool::Antigravity, profile_name, SECRET_FILE, secret) + } + CredentialBackend::SystemKeyring => { + secure_store::write_profile_secret(Tool::Antigravity, profile_name, secret) + } + } +} + +pub fn live_credentials_snapshot_for_import(user_home: &Path) -> Result> { + let snapshot = capture_live_snapshot(user_home)?; + if snapshot.keyring_secret.is_none() + && snapshot.app_files.is_empty() + && snapshot.shared_files.is_empty() + { + return Ok(None); + } + Ok(Some(snapshot)) +} + +pub fn capture_live_snapshot(user_home: &Path) -> Result { + let keyring_ref = default_live_keyring_ref(); + Ok(LiveSnapshot { + keyring_secret: super::system_keyring::read_generic_password( + &keyring_ref.service, + Some(&keyring_ref.account), + )?, + keyring_ref, + app_files: read_live_dir(&live_app_dir(user_home))?, + shared_files: read_live_dir(&live_shared_dir(user_home))?, + }) +} + +fn read_live_dir(dir: &Path) -> Result>> { + if !dir.exists() { + return Ok(BTreeMap::new()); + } + let mut files_map = BTreeMap::new(); + for file in files::list_regular_files_recursive(dir)? { + let relative = file.file_name.to_string_lossy().into_owned(); + let bytes = fs::read(&file.path) + .with_context(|| format!("could not read {}", file.path.display()))?; + files_map.insert(relative, bytes); + } + Ok(files_map) +} + +pub fn write_profile_snapshot( + profile_store: &ProfileStore, + config_store: &ConfigStore, + profile_name: &str, + label: Option, + backend: CredentialBackend, + snapshot: &LiveSnapshot, + overwrite_existing: bool, +) -> Result<()> { + let existing_secret = if overwrite_existing { + read_managed_secret(profile_store, profile_name, backend)? + } else { + None + }; + let result = write_profile_snapshot_inner( + profile_store, + config_store, + profile_name, + label, + backend, + snapshot, + overwrite_existing, + ); + if result.is_err() && overwrite_existing && backend == CredentialBackend::SystemKeyring { + match existing_secret { + Some(secret) => { + let _ = + secure_store::write_profile_secret(Tool::Antigravity, profile_name, &secret); + } + None => { + let _ = secure_store::delete_profile_secret(Tool::Antigravity, profile_name); + } + } + } + result +} + +fn write_profile_snapshot_inner( + profile_store: &ProfileStore, + config_store: &ConfigStore, + profile_name: &str, + label: Option, + backend: CredentialBackend, + snapshot: &LiveSnapshot, + overwrite_existing: bool, +) -> Result<()> { + if snapshot.keyring_secret.is_none() { + bail!("no Antigravity keyring credential found. Sign in with 'agy' first, then retry."); + } + + if let Some(existing) = identity::existing_antigravity_oauth_profile_for_live_secret( + profile_store, + config_store, + snapshot.keyring_secret.as_deref(), + )? { + if existing != profile_name { + bail!( + "An Antigravity OAuth profile for this account already exists as '{}'.\n \ + Use that profile or remove it before saving another alias.", + existing + ); + } + } + + persist_profile_keyring_ref(profile_store, profile_name, &snapshot.keyring_ref)?; + if let Some(secret) = snapshot.keyring_secret.as_deref() { + persist_managed_secret(profile_store, profile_name, backend, secret)?; + } + + clear_profile_subtree(profile_store, profile_name, APP_PREFIX)?; + clear_profile_subtree(profile_store, profile_name, SHARED_PREFIX)?; + persist_profile_tree(profile_store, profile_name, APP_PREFIX, &snapshot.app_files)?; + persist_profile_tree( + profile_store, + profile_name, + SHARED_PREFIX, + &snapshot.shared_files, + )?; + + identity::ensure_unique_oauth_identity( + profile_store, + config_store, + Tool::Antigravity, + profile_name, + backend, + )?; + + let meta = ProfileMeta { + added_at: Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: backend, + label, + }; + if overwrite_existing { + config_store.upsert_profile(Tool::Antigravity, profile_name, meta)?; + } else { + config_store.add_profile(Tool::Antigravity, profile_name, meta)?; + } + Ok(()) +} + +fn clear_profile_subtree( + profile_store: &ProfileStore, + profile_name: &str, + prefix: &str, +) -> Result<()> { + let dir = profile_store + .profile_dir(Tool::Antigravity, profile_name) + .join(prefix); + if dir.exists() { + fs::remove_dir_all(&dir).with_context(|| format!("could not delete {}", dir.display()))?; + } + Ok(()) +} + +fn persist_profile_tree( + profile_store: &ProfileStore, + profile_name: &str, + prefix: &str, + files_map: &BTreeMap>, +) -> Result<()> { + for (relative, bytes) in files_map { + let stored = format!("{prefix}/{relative}"); + profile_store.write_file(Tool::Antigravity, profile_name, &stored, bytes)?; + } + Ok(()) +} + +pub fn apply_live_credentials( + profile_store: &ProfileStore, + profile_name: &str, + backend: CredentialBackend, + user_home: &Path, +) -> Result<()> { + let keyring_ref = read_profile_keyring_ref(profile_store, profile_name)?; + let Some(secret) = read_managed_secret(profile_store, profile_name, backend)? else { + bail!( + "managed Antigravity credential is missing for profile '{}'", + profile_name + ); + }; + + let changes = build_apply_transaction(profile_store, profile_name, user_home)?; + crate::live_apply::apply_transaction(changes)?; + super::system_keyring::upsert_generic_password( + &keyring_ref.service, + &keyring_ref.account, + &secret, + ) +} + +fn build_apply_transaction( + profile_store: &ProfileStore, + profile_name: &str, + user_home: &Path, +) -> Result> { + let stored_app = profile_tree_map(profile_store, profile_name, APP_PREFIX)?; + let stored_shared = profile_tree_map(profile_store, profile_name, SHARED_PREFIX)?; + let mut changes = Vec::new(); + changes.extend(sync_dir_to_live( + &stored_app, + &live_app_dir(user_home), + &read_live_dir(&live_app_dir(user_home))?, + )); + changes.extend(sync_dir_to_live( + &stored_shared, + &live_shared_dir(user_home), + &read_live_dir(&live_shared_dir(user_home))?, + )); + Ok(changes) +} + +fn sync_dir_to_live( + stored: &BTreeMap>, + live_root: &Path, + live: &BTreeMap>, +) -> Vec { + let mut changes = Vec::new(); + for (relative, bytes) in stored { + let live_bytes = live.get(relative); + if live_bytes != Some(bytes) { + changes.push(LiveFileChange::write( + live_root.join(relative), + bytes.clone(), + )); + } + } + for relative in live.keys() { + if !stored.contains_key(relative) { + changes.push(LiveFileChange::delete(live_root.join(relative))); + } + } + changes +} + +fn profile_tree_map( + profile_store: &ProfileStore, + profile_name: &str, + prefix: &str, +) -> Result>> { + let root = profile_store + .profile_dir(Tool::Antigravity, profile_name) + .join(prefix); + if !root.exists() { + return Ok(BTreeMap::new()); + } + let mut files_map = BTreeMap::new(); + for file in files::list_regular_files_recursive(&root)? { + let relative = file.file_name.to_string_lossy().into_owned(); + let stored = format!("{prefix}/{relative}"); + let bytes = profile_store.read_file(Tool::Antigravity, profile_name, &stored)?; + files_map.insert(relative, bytes); + } + Ok(files_map) +} + +pub fn live_state_matches( + profile_store: &ProfileStore, + profile_name: &str, + backend: CredentialBackend, + user_home: &Path, +) -> Result { + let keyring_ref = read_profile_keyring_ref(profile_store, profile_name)?; + let managed_secret = read_managed_secret(profile_store, profile_name, backend)?; + let live_secret = super::system_keyring::read_generic_password( + &keyring_ref.service, + Some(&keyring_ref.account), + )?; + if managed_secret != live_secret { + return Ok(false); + } + Ok(profile_tree_map(profile_store, profile_name, APP_PREFIX)? + == read_live_dir(&live_app_dir(user_home))? + && profile_tree_map(profile_store, profile_name, SHARED_PREFIX)? + == read_live_dir(&live_shared_dir(user_home))?) +} + +pub fn sync_profile_from_live_if_same_identity( + profile_store: &ProfileStore, + profile_name: &str, + backend: CredentialBackend, + user_home: &Path, +) -> Result { + let Some(snapshot) = live_credentials_snapshot_for_import(user_home)? else { + return Ok(false); + }; + let Some(secret) = snapshot.keyring_secret.as_deref() else { + return Ok(false); + }; + let Some(managed_secret) = read_managed_secret(profile_store, profile_name, backend)? else { + return Ok(false); + }; + let managed_identity = identity::resolve_identity_from_json_bytes(&managed_secret)?; + let live_identity = identity::resolve_identity_from_json_bytes(secret)?; + if managed_identity.is_none() || managed_identity != live_identity { + return Ok(false); + } + persist_profile_keyring_ref(profile_store, profile_name, &snapshot.keyring_ref)?; + persist_managed_secret(profile_store, profile_name, backend, secret)?; + clear_profile_subtree(profile_store, profile_name, APP_PREFIX)?; + clear_profile_subtree(profile_store, profile_name, SHARED_PREFIX)?; + persist_profile_tree(profile_store, profile_name, APP_PREFIX, &snapshot.app_files)?; + persist_profile_tree( + profile_store, + profile_name, + SHARED_PREFIX, + &snapshot.shared_files, + )?; + Ok(true) +} + +pub fn add_oauth_with_backend( + profile_store: &ProfileStore, + config_store: &ConfigStore, + profile_name: &str, + label: Option, + agy_bin: &Path, + backend: CredentialBackend, +) -> Result<()> { + let user_home = dirs::home_dir().context("could not determine home directory")?; + let before = capture_live_snapshot(&user_home)?; + let mut child = Command::new(agy_bin) + .spawn() + .with_context(|| format!("could not launch {}", agy_bin.display()))?; + let status = child.wait_timeout(OAUTH_TIMEOUT)?.unwrap_or_else(|| { + let _ = child.kill(); + let _ = child.wait(); + std::process::ExitStatus::from_raw(1 << 8) + }); + if !status.success() { + bail!( + "Antigravity login did not complete successfully.\n \ + Complete login in the agy session, then retry 'aisw add antigravity {}'.", + profile_name + ); + } + let after = capture_live_snapshot(&user_home)?; + if before.keyring_secret == after.keyring_secret + && before.app_files == after.app_files + && before.shared_files == after.shared_files + { + bail!( + "Antigravity login did not produce any new managed state.\n \ + If agy already signed into the desired account, use 'aisw add antigravity {} --from-live' instead.", + profile_name + ); + } + + profile_store.create(Tool::Antigravity, profile_name)?; + let result = write_profile_snapshot( + profile_store, + config_store, + profile_name, + label, + backend, + &after, + false, + ); + if result.is_err() { + let _ = profile_store.delete(Tool::Antigravity, profile_name); + if backend == CredentialBackend::SystemKeyring { + let _ = secure_store::delete_profile_secret(Tool::Antigravity, profile_name); + } + } + result +} + +pub fn restore_live_state_after_oauth_add( + snapshot: Option, + user_home: &Path, +) -> Result<()> { + let Some(snapshot) = snapshot else { + return Ok(()); + }; + restore_snapshot_to_live(&snapshot, user_home) +} + +pub fn restore_snapshot_to_live(snapshot: &LiveSnapshot, user_home: &Path) -> Result<()> { + let changes = { + let mut changes = Vec::new(); + changes.extend(sync_dir_to_live( + &snapshot.app_files, + &live_app_dir(user_home), + &read_live_dir(&live_app_dir(user_home))?, + )); + changes.extend(sync_dir_to_live( + &snapshot.shared_files, + &live_shared_dir(user_home), + &read_live_dir(&live_shared_dir(user_home))?, + )); + changes + }; + crate::live_apply::apply_transaction(changes)?; + match snapshot.keyring_secret.as_deref() { + Some(secret) => super::system_keyring::upsert_generic_password( + &snapshot.keyring_ref.service, + &snapshot.keyring_ref.account, + secret, + ), + None => super::system_keyring::delete_generic_password( + &snapshot.keyring_ref.service, + &snapshot.keyring_ref.account, + ), + } +} + +pub fn emit_shell_env() {} + +fn persist_profile_keyring_ref( + profile_store: &ProfileStore, + profile_name: &str, + keyring_ref: &KeyringRef, +) -> Result<()> { + let bytes = serde_json::to_vec(keyring_ref).context("could not serialize keyring metadata")?; + profile_store.write_file( + Tool::Antigravity, + profile_name, + KEYRING_METADATA_FILE, + &bytes, + ) +} + +pub fn read_profile_keyring_ref( + profile_store: &ProfileStore, + profile_name: &str, +) -> Result { + let bytes = profile_store.read_file(Tool::Antigravity, profile_name, KEYRING_METADATA_FILE)?; + serde_json::from_slice(&bytes).context("could not parse Antigravity keyring metadata") +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableKeyringRef { + service: String, + account: String, +} + +impl serde::Serialize for KeyringRef { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + SerializableKeyringRef { + service: self.service.clone(), + account: self.account.clone(), + } + .serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for KeyringRef { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let value = SerializableKeyringRef::deserialize(deserializer)?; + Ok(Self { + service: value.service, + account: value.account, + }) + } +} + +trait WaitTimeoutExt { + fn wait_timeout(&mut self, timeout: Duration) -> Result>; +} + +impl WaitTimeoutExt for std::process::Child { + fn wait_timeout(&mut self, timeout: Duration) -> Result> { + let start = std::time::Instant::now(); + loop { + if let Some(status) = self.try_wait()? { + return Ok(Some(status)); + } + if start.elapsed() >= timeout { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; +#[cfg(windows)] +use std::os::windows::process::ExitStatusExt; + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use tempfile::tempdir; + + use super::*; + use crate::config::ConfigStore; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => unsafe { std::env::set_var(self.key, value) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + fn write_live_state(user_home: &Path, secret: &[u8]) { + fs::create_dir_all(live_app_dir(user_home).join("cache")).unwrap(); + fs::create_dir_all(live_shared_dir(user_home).join("projects")).unwrap(); + fs::write( + live_app_dir(user_home).join("settings.json"), + br#"{"theme":"terminal"}"#, + ) + .unwrap(); + fs::write( + live_app_dir(user_home).join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + fs::write( + live_shared_dir(user_home).join("hooks.json"), + br#"{"hooks":[]}"#, + ) + .unwrap(); + fs::write( + live_shared_dir(user_home) + .join("projects") + .join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + super::super::system_keyring::upsert_generic_password( + KEYRING_SERVICE, + KEYRING_ACCOUNT, + secret, + ) + .unwrap(); + } + + #[test] + fn capture_and_apply_round_trip_file_backend() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempdir().unwrap(); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", temp.path()); + let home = temp.path().join("home"); + let user_home = temp.path().join("user"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + let profile_store = ProfileStore::new(&home); + let config_store = ConfigStore::new(&home); + + write_live_state(&user_home, br#"{"email":"work@example.com"}"#); + let snapshot = capture_live_snapshot(&user_home).unwrap(); + profile_store.create(Tool::Antigravity, "work").unwrap(); + write_profile_snapshot( + &profile_store, + &config_store, + "work", + None, + CredentialBackend::File, + &snapshot, + false, + ) + .unwrap(); + + fs::write( + live_app_dir(&user_home).join("settings.json"), + br#"{"theme":"light"}"#, + ) + .unwrap(); + super::super::system_keyring::upsert_generic_password( + KEYRING_SERVICE, + KEYRING_ACCOUNT, + br#"{"email":"other@example.com"}"#, + ) + .unwrap(); + + apply_live_credentials(&profile_store, "work", CredentialBackend::File, &user_home) + .unwrap(); + assert!( + live_state_matches(&profile_store, "work", CredentialBackend::File, &user_home) + .unwrap() + ); + } + + #[test] + fn classify_profile_is_shared_live_oauth() { + let temp = tempdir().unwrap(); + let profile_store = ProfileStore::new(temp.path()); + let classification = classify_profile( + &profile_store, + "work", + AuthMethod::OAuth, + CredentialBackend::File, + ) + .unwrap(); + assert_eq!( + classification, + AntigravityAuthClassification::OauthSharedLiveKeyring + ); + } + + #[test] + fn live_credentials_snapshot_returns_none_when_live_state_is_empty() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempdir().unwrap(); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", temp.path()); + let user_home = temp.path().join("user"); + fs::create_dir_all(&user_home).unwrap(); + + let snapshot = live_credentials_snapshot_for_import(&user_home).unwrap(); + assert!(snapshot.is_none()); + } + + #[test] + fn write_profile_snapshot_system_keyring_backend_stores_secret_outside_profile_dir() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempdir().unwrap(); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", temp.path()); + let home = temp.path().join("home"); + let user_home = temp.path().join("user"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + + let profile_store = ProfileStore::new(&home); + let config_store = ConfigStore::new(&home); + write_live_state(&user_home, br#"{"email":"work@example.com"}"#); + let snapshot = capture_live_snapshot(&user_home).unwrap(); + + profile_store.create(Tool::Antigravity, "work").unwrap(); + write_profile_snapshot( + &profile_store, + &config_store, + "work", + None, + CredentialBackend::SystemKeyring, + &snapshot, + false, + ) + .unwrap(); + + assert!(!profile_store + .profile_dir(Tool::Antigravity, "work") + .join(SECRET_FILE) + .exists()); + assert_eq!( + read_managed_secret(&profile_store, "work", CredentialBackend::SystemKeyring) + .unwrap() + .unwrap(), + br#"{"email":"work@example.com"}"# + ); + } + + #[test] + fn sync_profile_from_live_if_same_identity_updates_managed_snapshot() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempdir().unwrap(); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", temp.path()); + let home = temp.path().join("home"); + let user_home = temp.path().join("user"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + + let profile_store = ProfileStore::new(&home); + let config_store = ConfigStore::new(&home); + write_live_state( + &user_home, + br#"{"email":"work@example.com","token":"live"}"#, + ); + let snapshot = capture_live_snapshot(&user_home).unwrap(); + + profile_store.create(Tool::Antigravity, "work").unwrap(); + write_profile_snapshot( + &profile_store, + &config_store, + "work", + None, + CredentialBackend::File, + &snapshot, + false, + ) + .unwrap(); + + fs::write( + live_app_dir(&user_home).join("settings.json"), + br#"{"theme":"light"}"#, + ) + .unwrap(); + super::super::system_keyring::upsert_generic_password( + KEYRING_SERVICE, + KEYRING_ACCOUNT, + br#"{"email":"work@example.com","token":"new-live"}"#, + ) + .unwrap(); + + let synced = sync_profile_from_live_if_same_identity( + &profile_store, + "work", + CredentialBackend::File, + &user_home, + ) + .unwrap(); + + assert!(synced); + assert_eq!( + read_managed_secret(&profile_store, "work", CredentialBackend::File) + .unwrap() + .unwrap(), + br#"{"email":"work@example.com","token":"new-live"}"# + ); + assert_eq!( + profile_store + .read_file(Tool::Antigravity, "work", "app/settings.json") + .unwrap(), + br#"{"theme":"light"}"# + ); + } + + #[test] + fn restore_snapshot_to_live_removes_stale_files_and_deletes_secret() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempdir().unwrap(); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", temp.path()); + let user_home = temp.path().join("user"); + fs::create_dir_all(&user_home).unwrap(); + write_live_state(&user_home, br#"{"email":"work@example.com"}"#); + + restore_snapshot_to_live( + &LiveSnapshot { + keyring_ref: default_live_keyring_ref(), + keyring_secret: None, + app_files: BTreeMap::new(), + shared_files: BTreeMap::new(), + }, + &user_home, + ) + .unwrap(); + + assert!(read_live_dir(&live_app_dir(&user_home)).unwrap().is_empty()); + assert!(read_live_dir(&live_shared_dir(&user_home)) + .unwrap() + .is_empty()); + assert!(super::super::system_keyring::read_generic_password( + KEYRING_SERVICE, + Some(KEYRING_ACCOUNT), + ) + .unwrap() + .is_none()); + } + + #[test] + fn live_state_matches_returns_false_when_live_secret_differs() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempdir().unwrap(); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", temp.path()); + let home = temp.path().join("home"); + let user_home = temp.path().join("user"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + + let profile_store = ProfileStore::new(&home); + let config_store = ConfigStore::new(&home); + write_live_state(&user_home, br#"{"email":"work@example.com"}"#); + let snapshot = capture_live_snapshot(&user_home).unwrap(); + + profile_store.create(Tool::Antigravity, "work").unwrap(); + write_profile_snapshot( + &profile_store, + &config_store, + "work", + None, + CredentialBackend::File, + &snapshot, + false, + ) + .unwrap(); + + super::super::system_keyring::upsert_generic_password( + KEYRING_SERVICE, + KEYRING_ACCOUNT, + br#"{"email":"other@example.com"}"#, + ) + .unwrap(); + + assert!( + !live_state_matches(&profile_store, "work", CredentialBackend::File, &user_home) + .unwrap() + ); + } +} diff --git a/src/auth/claude/oauth.rs b/src/auth/claude/oauth.rs index ccd7263..4e9d8f2 100644 --- a/src/auth/claude/oauth.rs +++ b/src/auth/claude/oauth.rs @@ -29,7 +29,9 @@ use super::keychain::{ read_keychain_credentials, read_keychain_credentials_for_service, watch_keychain_during_oauth, ClaudeAuthStorage, }; -use super::paths::{live_account_metadata_path, live_credentials_path, live_credentials_paths}; +use super::paths::{ + live_account_metadata_path, live_credentials_path, live_credentials_paths, live_local_state_dir, +}; use super::{read_stored_credentials, LiveCredentialSnapshot, LiveCredentialSource}; fn persist_oauth_storage( @@ -60,31 +62,31 @@ pub fn live_credentials_snapshot_for_import( source: LiveCredentialSource::Keychain, })); } - - if live_path.exists() { - let bytes = std::fs::read(&live_path) - .with_context(|| format!("could not read {}", live_path.display()))?; - return Ok(Some(LiveCredentialSnapshot { - bytes, - source: LiveCredentialSource::File(live_path), - })); - } - - Ok(None) } - ClaudeAuthStorage::File => { - if live_path.exists() { - let bytes = std::fs::read(&live_path) - .with_context(|| format!("could not read {}", live_path.display()))?; - return Ok(Some(LiveCredentialSnapshot { - bytes, - source: LiveCredentialSource::File(live_path), - })); - } + ClaudeAuthStorage::File => {} + } - Ok(None) - } + if live_path.exists() { + let bytes = std::fs::read(&live_path) + .with_context(|| format!("could not read {}", live_path.display()))?; + return Ok(Some(LiveCredentialSnapshot { + bytes, + source: LiveCredentialSource::File(live_path), + })); + } + + if live_local_state_dir(user_home).is_none() { + return Ok(None); } + + let Some(bytes) = read_live_keychain_credentials_for_import()? else { + return Ok(None); + }; + + Ok(Some(LiveCredentialSnapshot { + bytes, + source: LiveCredentialSource::Keychain, + })) } // ---- OAuth account metadata ---- @@ -441,11 +443,17 @@ If you need a different Claude account, fully sign out of claude.com first, then .then(|| fs::read(&credential_path)) .transpose() .with_context(|| format!("could not read {}", credential_path.display()))?; - let fallback_file_before = fallback_live_path + let fallback_before = fallback_live_path .as_ref() .filter(|path| path.exists()) - .map(|path| fs::read(path).with_context(|| format!("could not read {}", path.display()))) - .transpose()?; + .map(fs::read) + .transpose() + .with_context(|| { + fallback_live_path + .as_ref() + .map(|path| format!("could not read {}", path.display())) + .unwrap_or_else(|| "could not read fallback Claude credential path".to_owned()) + })?; let mut cmd = Command::new(claude_bin); cmd.arg("auth").arg("login"); @@ -485,11 +493,11 @@ If you need a different Claude account, fully sign out of claude.com first, then } } - if let Some(fallback_live_path) = fallback_live_path.as_ref() { - if fallback_live_path.exists() { - let current = fs::read(fallback_live_path) - .with_context(|| format!("could not read {}", fallback_live_path.display()))?; - let changed = fallback_file_before.as_deref() != Some(current.as_slice()); + if let Some(fallback_path) = fallback_live_path.as_ref() { + if fallback_path.exists() { + let current = fs::read(fallback_path) + .with_context(|| format!("could not read {}", fallback_path.display()))?; + let changed = fallback_before.as_deref() != Some(current.as_slice()); if changed { let _ = child.kill(); let _ = child.wait(); @@ -519,11 +527,10 @@ If you need a different Claude account, fully sign out of claude.com first, then .with_context(|| format!("could not read {}", credential_path.display())); } - if let Some(fallback_live_path) = fallback_live_path.as_ref() { - if fallback_live_path.exists() && status.success() { - return fs::read(fallback_live_path).with_context(|| { - format!("could not read {}", fallback_live_path.display()) - }); + if let Some(fallback_path) = fallback_live_path.as_ref() { + if fallback_path.exists() && status.success() { + return fs::read(fallback_path) + .with_context(|| format!("could not read {}", fallback_path.display())); } } diff --git a/src/auth/files.rs b/src/auth/files.rs index 60ff659..4b7ae55 100644 --- a/src/auth/files.rs +++ b/src/auth/files.rs @@ -70,6 +70,45 @@ pub fn list_regular_files(dir: &Path) -> Result> { Ok(files) } +pub fn list_regular_files_recursive(dir: &Path) -> Result> { + let mut files = Vec::new(); + list_regular_files_recursive_inner(dir, dir, &mut files)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) +} + +fn list_regular_files_recursive_inner( + root: &Path, + current: &Path, + files: &mut Vec, +) -> Result<()> { + for entry in std::fs::read_dir(current) + .with_context(|| format!("could not read {}", current.display()))? + { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + list_regular_files_recursive_inner(root, &path, files)?; + continue; + } + if !file_type.is_file() { + continue; + } + let relative = path + .strip_prefix(root) + .with_context(|| format!("could not relativize {}", path.display()))?; + files.push(RegularFile { + file_name: relative.as_os_str().to_owned(), + path, + }); + } + Ok(()) +} + pub fn json_equal(a: &[u8], b: &[u8]) -> Result { let va = serde_json::from_slice::(a) .context("could not parse JSON for comparison")?; @@ -191,6 +230,24 @@ mod tests { assert_eq!(files[0].path, file); } + #[cfg(unix)] + #[test] + fn list_regular_files_recursive_returns_relative_paths() { + let dir = tempdir().unwrap(); + let nested = dir.path().join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + let root_file = dir.path().join("root.txt"); + let nested_file = nested.join("child.txt"); + + std::fs::write(&root_file, "root").unwrap(); + std::fs::write(&nested_file, "child").unwrap(); + + let files = list_regular_files_recursive(dir.path()).unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].file_name, OsString::from("nested/child.txt")); + assert_eq!(files[1].file_name, OsString::from("root.txt")); + } + #[test] fn apply_and_match_profile_file_round_trip() { let dir = tempdir().unwrap(); diff --git a/src/auth/identity.rs b/src/auth/identity.rs index 43bf308..0fedb18 100644 --- a/src/auth/identity.rs +++ b/src/auth/identity.rs @@ -10,6 +10,7 @@ const CLAUDE_CREDENTIALS_FILE: &str = ".credentials.json"; const CLAUDE_OAUTH_ACCOUNT_FILE: &str = "oauth-account.json"; const CODEX_AUTH_FILE: &str = "auth.json"; const GEMINI_OAUTH_FILES: &[&str] = &["settings.json", "oauth_creds.json"]; +const ANTIGRAVITY_SECRET_FILE: &str = "keyring-secret.json"; #[derive(Clone, Debug, PartialEq, Eq)] enum OAuthIdentity { @@ -89,6 +90,18 @@ pub fn existing_claude_oauth_profile_for_live_state( existing_oauth_profile_for_identity(profile_store, config_store, Tool::Claude, identity) } +pub fn existing_antigravity_oauth_profile_for_live_secret( + profile_store: &ProfileStore, + config_store: &ConfigStore, + secret_bytes: Option<&[u8]>, +) -> Result> { + let identity = secret_bytes + .map(|bytes| resolve_identity_from_json_bytes_for_tool(Tool::Antigravity, bytes)) + .transpose()? + .flatten(); + existing_oauth_profile_for_identity(profile_store, config_store, Tool::Antigravity, identity) +} + fn existing_oauth_profile_for_identity( profile_store: &ProfileStore, config_store: &ConfigStore, @@ -177,6 +190,7 @@ fn read_api_key_for_profile( Tool::Claude => claude::read_api_key_with_backend(profile_store, profile_name, backend), Tool::Codex => codex::read_api_key_with_backend(profile_store, profile_name, backend), Tool::Gemini => gemini::read_api_key(profile_store, profile_name), + Tool::Antigravity => bail!("Antigravity CLI does not support API key profiles"), } } @@ -220,6 +234,13 @@ fn resolve_oauth_identity( backend, GEMINI_OAUTH_FILES, ), + Tool::Antigravity => resolve_identity_from_optional_profile_files( + profile_store, + tool, + profile_name, + backend, + &[ANTIGRAVITY_SECRET_FILE], + ), } } @@ -234,6 +255,7 @@ fn resolve_identity_from_optional_profile_files( let bytes = match tool { Tool::Claude | Tool::Codex => secure_store::read_profile_secret(tool, profile_name)?, Tool::Gemini => None, + Tool::Antigravity => secure_store::read_profile_secret(tool, profile_name)?, }; return match bytes { Some(bytes) => resolve_identity_from_json_bytes_for_tool(tool, &bytes), @@ -366,6 +388,10 @@ fn resolve_identity_from_value(tool: Tool, value: &Value) -> Option find_email(value) + .or_else(|| find_subject(value)) + .map(normalize_identity) + .map(OAuthIdentity::Generic), } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c211ec4..c24bf3e 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,3 +1,4 @@ +pub mod antigravity; pub mod claude; pub mod codex; pub(crate) mod files; diff --git a/src/backup.rs b/src/backup.rs index 11388c8..6a23535 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -65,21 +65,7 @@ impl BackupManager { secure_store::snapshot_profile_secret(tool, name, &backup_id)?; } - for entry in fs::read_dir(profile_dir) - .with_context(|| format!("could not read profile dir {}", profile_dir.display()))? - { - let entry = entry?; - let src = entry.path(); - if src.is_symlink() || !src.is_file() { - continue; - } - let filename = entry.file_name(); - let dst = dest.join(&filename); - fs::copy(&src, &dst).with_context(|| { - format!("could not copy {} to {}", src.display(), dst.display()) - })?; - set_permissions_600(&dst)?; - } + copy_profile_tree(profile_dir, &dest)?; write_metadata( &dest.join(METADATA_FILE), @@ -187,22 +173,7 @@ impl BackupManager { restored += 1; } - for file_entry in fs::read_dir(&profile_path)? { - let file_entry = file_entry?; - let src = file_entry.path(); - if src.is_symlink() || !src.is_file() { - continue; - } - if file_entry.file_name() == METADATA_FILE { - continue; - } - let dst = dest_dir.join(file_entry.file_name()); - fs::copy(&src, &dst).with_context(|| { - format!("could not restore {} to {}", src.display(), dst.display()) - })?; - set_permissions_600(&dst)?; - restored += 1; - } + restored += restore_profile_tree(&profile_path, &dest_dir)?; } } @@ -260,6 +231,51 @@ impl BackupManager { } } +fn copy_profile_tree(src_root: &Path, dest_root: &Path) -> Result<()> { + for file in crate::auth::files::list_regular_files_recursive(src_root)? { + let relative = file.file_name.to_string_lossy().into_owned(); + let dst = dest_root.join(&relative); + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } + fs::copy(&file.path, &dst).with_context(|| { + format!( + "could not copy {} to {}", + file.path.display(), + dst.display() + ) + })?; + set_permissions_600(&dst)?; + } + Ok(()) +} + +fn restore_profile_tree(src_root: &Path, dest_root: &Path) -> Result { + let mut restored = 0usize; + for file in crate::auth::files::list_regular_files_recursive(src_root)? { + if file.file_name == METADATA_FILE { + continue; + } + let relative = file.file_name.to_string_lossy().into_owned(); + let dst = dest_root.join(&relative); + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } + fs::copy(&file.path, &dst).with_context(|| { + format!( + "could not restore {} to {}", + file.path.display(), + dst.display() + ) + })?; + set_permissions_600(&dst)?; + restored += 1; + } + Ok(restored) +} + fn backup_id_now() -> String { // Filesystem-safe, lexicographically sortable, and unique even when multiple // snapshots are created within the same wall-clock tick in one process. @@ -344,6 +360,7 @@ fn infer_auth_method(tool: Tool, profile_path: &Path) -> Result { Ok(AuthMethod::OAuth) } } + Tool::Antigravity => Ok(AuthMethod::OAuth), } } diff --git a/src/cli.rs b/src/cli.rs index 2d00db5..60cc79d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,7 @@ use crate::types::{StateMode, Tool}; #[derive(Parser, Debug)] #[command( name = "aisw", - about = "Manage multiple accounts for Claude Code, Codex CLI, and Gemini CLI", + about = "Manage multiple accounts for Claude Code, Codex CLI, Gemini CLI, and Antigravity CLI", long_about = None, version, propagate_version = true, @@ -442,6 +442,10 @@ pub struct ContextCreateArgs { #[arg(long, value_name = "PROFILE")] pub gemini: Option, + /// Antigravity profile to include + #[arg(long, value_name = "PROFILE")] + pub antigravity: Option, + /// Output result as JSON #[arg(long)] pub json: bool, @@ -494,6 +498,10 @@ pub struct ContextSetArgs { #[arg(long, value_name = "PROFILE")] pub gemini: Option, + /// Antigravity profile to set + #[arg(long, value_name = "PROFILE")] + pub antigravity: Option, + /// Output result as JSON #[arg(long)] pub json: bool, @@ -516,6 +524,10 @@ pub struct ContextUnsetArgs { #[arg(long)] pub gemini: bool, + /// Remove the Antigravity mapping + #[arg(long)] + pub antigravity: bool, + /// Output result as JSON #[arg(long)] pub json: bool, diff --git a/src/commands/add.rs b/src/commands/add.rs index 3a06428..685c246 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -38,6 +38,7 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< let requested_backend = args.credential_backend.map(map_cli_backend); validate_requested_backend(args.tool, requested_backend)?; + validate_auth_source_support(&args)?; // --from-live captures live credentials without launching any login flow. // Tool detection is intentionally skipped: the tool is already installed @@ -89,6 +90,7 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< Tool::Claude => CLAUDE_ENV_VAR, Tool::Codex => CODEX_ENV_VAR, Tool::Gemini => GEMINI_ENV_VAR, + Tool::Antigravity => unreachable!("validated above"), }; let key = std::env::var(env_var).unwrap_or_default(); if key.is_empty() { @@ -119,6 +121,7 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< args.label.clone(), backend, )?, + Tool::Antigravity => unreachable!("validated above"), } if args.set_active { config_store.set_active(args.tool, &args.profile_name)?; @@ -159,6 +162,7 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< args.label.clone(), backend, )?, + Tool::Antigravity => unreachable!("validated above"), } (backend, AuthMethod::ApiKey, None) } else { @@ -247,6 +251,28 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< )?; (CredentialBackend::File, AuthMethod::OAuth, None) } + Tool::Antigravity => { + let backend = requested_backend.unwrap_or(CredentialBackend::File); + let user_home = dirs::home_dir().context("could not determine home directory")?; + let live_snapshot = (!args.set_active) + .then(|| auth::antigravity::capture_live_snapshot(&user_home)) + .transpose()?; + auth::antigravity::add_oauth_with_backend( + &profile_store, + &config_store, + &args.profile_name, + args.label.clone(), + &detected.binary_path, + backend, + )?; + if let Some(snapshot) = live_snapshot { + auth::antigravity::restore_live_state_after_oauth_add( + Some(snapshot), + &user_home, + )?; + } + (backend, AuthMethod::OAuth, None) + } } }; @@ -315,7 +341,7 @@ fn prepare_from_live_target( struct FromLiveOverwriteSnapshot { config: Config, - files: Vec<(OsString, Vec)>, + files: Vec<(String, Vec)>, secure_secret: Option>, secure_backend_was_tracked: bool, } @@ -328,14 +354,15 @@ impl FromLiveOverwriteSnapshot { name: &str, ) -> Result { let config = config_store.load()?; - let files = auth::files::list_regular_files(&profile_store.profile_dir(tool, name))? - .into_iter() - .map(|file| { - let bytes = fs::read(&file.path) - .with_context(|| format!("could not read {}", file.path.display()))?; - Ok((file.file_name, bytes)) - }) - .collect::>>()?; + let files = + auth::files::list_regular_files_recursive(&profile_store.profile_dir(tool, name))? + .into_iter() + .map(|file| { + let bytes = fs::read(&file.path) + .with_context(|| format!("could not read {}", file.path.display()))?; + Ok((file.file_name.to_string_lossy().into_owned(), bytes)) + }) + .collect::>>()?; let old_backend = config .profiles_for(tool) .get(name) @@ -370,6 +397,9 @@ impl FromLiveOverwriteSnapshot { if path.is_symlink() { continue; } + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } if fs::write(&path, bytes).is_ok() { let _ = auth::files::set_permissions_600(&path); } @@ -392,6 +422,7 @@ fn from_live(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> { Tool::Claude => from_live_claude(args, home, user_home), Tool::Codex => from_live_codex(args, home, user_home), Tool::Gemini => from_live_gemini(args, home, user_home), + Tool::Antigravity => from_live_antigravity(args, home, user_home), } } @@ -1014,6 +1045,105 @@ fn from_live_gemini(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> ) } +fn from_live_antigravity(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> { + let profile_store = ProfileStore::new(home); + let config_store = ConfigStore::new(home); + let backend = requested_backend(&args).unwrap_or(CredentialBackend::File); + + let snapshot = auth::antigravity::live_credentials_snapshot_for_import(user_home)? + .with_context(|| { + format!( + "no live Antigravity credentials found — run 'agy' and sign in first, \ + then retry 'aisw add antigravity {} --from-live'.", + args.profile_name, + ) + })?; + + let overwriting = prepare_from_live_target( + &profile_store, + Tool::Antigravity, + &args.profile_name, + args.yes, + )?; + let overwrite_snapshot = if overwriting { + Some(FromLiveOverwriteSnapshot::capture( + &profile_store, + &config_store, + Tool::Antigravity, + &args.profile_name, + )?) + } else { + None + }; + + if let Err(e) = auth::antigravity::write_profile_snapshot( + &profile_store, + &config_store, + &args.profile_name, + args.label.clone(), + backend, + &snapshot, + overwriting, + ) { + if let Some(snapshot) = overwrite_snapshot.as_ref() { + snapshot.restore( + &profile_store, + &config_store, + Tool::Antigravity, + &args.profile_name, + backend, + ); + } else { + let _ = profile_store.delete(Tool::Antigravity, &args.profile_name); + if backend == CredentialBackend::SystemKeyring { + let _ = crate::auth::secure_store::delete_profile_secret( + Tool::Antigravity, + &args.profile_name, + ); + } + } + return Err(e); + } + + if let Err(e) = auth::antigravity::apply_live_credentials( + &profile_store, + &args.profile_name, + backend, + user_home, + ) { + if let Some(snapshot) = overwrite_snapshot.as_ref() { + snapshot.restore( + &profile_store, + &config_store, + Tool::Antigravity, + &args.profile_name, + backend, + ); + } + return Err(e); + } + if let Err(e) = config_store.activate_profile(Tool::Antigravity, &args.profile_name, None) { + if let Some(snapshot) = overwrite_snapshot.as_ref() { + snapshot.restore( + &profile_store, + &config_store, + Tool::Antigravity, + &args.profile_name, + backend, + ); + } + return Err(e); + } + + finalize_from_live( + &args, + Tool::Antigravity, + backend, + AuthMethod::OAuth, + Some(user_home), + ) +} + fn finalize_from_live( args: &AddArgs, tool: Tool, @@ -1031,6 +1161,7 @@ fn finalize_from_live( "source": "from_live", "claude_auth_classification": claude_add_classification(tool, auth_method, user_home), "codex_auth_classification": codex_add_classification(tool, auth_method, true), + "antigravity_auth_classification": antigravity_add_classification(tool, auth_method), "warnings": warnings, }); if runtime::is_progress_json() { @@ -1059,6 +1190,9 @@ fn finalize_from_live( if let Some(classification) = codex_add_classification(tool, auth_method, true) { output::print_kv("Codex auth", classification); } + if let Some(classification) = antigravity_add_classification(tool, auth_method) { + output::print_kv("Antigravity auth", classification); + } output::print_kv("Activation", "active"); output::print_blank_line(); output::print_effects_header(); @@ -1073,6 +1207,11 @@ fn finalize_from_live( "Re-login directly inside this profile's isolated CODEX_HOME for the durable path.", ); } + if tool == Tool::Antigravity { + output::print_effect( + "Antigravity restores the shared live OS keyring credential and the documented ~/.gemini config roots when you switch profiles.", + ); + } if let Some(warning) = add_warnings(tool, auth_method, user_home).first() { output::print_effect(warning); } @@ -1098,6 +1237,25 @@ fn validate_requested_backend(tool: Tool, requested: Option) Ok(()) } +fn validate_auth_source_support(args: &AddArgs) -> Result<()> { + if args.tool != Tool::Antigravity { + return Ok(()); + } + if args.from_env { + bail!( + "Antigravity CLI does not document API-key or environment-variable authentication.\n \ + Use interactive OAuth or --from-live instead." + ); + } + if args.api_key.is_some() || args.api_key_stdin { + bail!( + "Antigravity CLI support in aisw is OAuth-only because upstream documents system-keyring-backed sign-in, not API-key profile auth.\n \ + Use 'aisw add antigravity ' or 'aisw add antigravity --from-live'." + ); + } + Ok(()) +} + fn resolved_api_key_backend(args: &AddArgs) -> CredentialBackend { requested_backend(args).unwrap_or(CredentialBackend::File) } @@ -1126,6 +1284,9 @@ fn print_add_summary( if let Some(classification) = codex_add_classification(args.tool, auth_method, false) { output::print_kv("Codex auth", classification); } + if let Some(classification) = antigravity_add_classification(args.tool, auth_method) { + output::print_kv("Antigravity auth", classification); + } if let Some(source) = source { output::print_kv("Source", source); } @@ -1145,6 +1306,14 @@ fn print_add_summary( ); output::print_effect("This is the durable ChatGPT-managed Codex path."); } + if args.tool == Tool::Antigravity { + output::print_effect( + "Antigravity OAuth is restored through the shared live OS keyring entry and the documented ~/.gemini config roots.", + ); + output::print_effect( + "Upstream does not currently document an isolated per-profile auth root or profile selector for Antigravity.", + ); + } for warning in add_warnings(args.tool, auth_method, user_home) { output::print_effect(warning); } @@ -1174,6 +1343,7 @@ fn emit_add_result( "source": source, "claude_auth_classification": claude_add_classification(args.tool, auth_method, user_home), "codex_auth_classification": codex_add_classification(args.tool, auth_method, args.from_live), + "antigravity_auth_classification": antigravity_add_classification(args.tool, auth_method), "warnings": warnings, }); if let Some(progress) = progress { @@ -1211,6 +1381,11 @@ fn codex_add_classification( }) } +fn antigravity_add_classification(tool: Tool, auth_method: AuthMethod) -> Option<&'static str> { + (tool == Tool::Antigravity && auth_method == AuthMethod::OAuth) + .then_some("oauth_shared_live_keyring") +} + fn claude_add_classification( tool: Tool, auth_method: AuthMethod, @@ -1241,6 +1416,11 @@ fn claude_add_classification( } fn add_warnings(tool: Tool, auth_method: AuthMethod, user_home: Option<&Path>) -> Vec { + if tool == Tool::Antigravity && auth_method == AuthMethod::OAuth { + return vec![ + "Antigravity currently documents shared live OS-keyring auth, not an isolated per-profile auth root. aisw switches the live keyring-backed session and Antigravity config roots transactionally.".to_owned(), + ]; + } match claude_add_classification(tool, auth_method, user_home) { Some("oauth_macos_keychain_shared_live") => { vec![ @@ -1670,6 +1850,40 @@ mod tests { }); } + #[test] + fn antigravity_from_env_is_rejected_before_tool_detection() { + with_env_lock(|| { + let tmp = tempdir().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + + let err = run_in( + from_env_args(Tool::Antigravity, "work"), + &home, + OsString::new(), + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("does not document API-key or environment-variable authentication"), + "unexpected: {err}" + ); + }); + } + + #[test] + fn antigravity_api_key_auth_is_rejected_before_tool_detection() { + with_env_lock(|| { + let tmp = tempdir().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + + let args = add_args_api_key(Tool::Antigravity, "work", "AIza-not-supported"); + let err = run_in(args, &home, OsString::new()).unwrap_err(); + assert!(err.to_string().contains("OAuth-only"), "unexpected: {err}"); + }); + } + #[test] fn claude_oauth_add_without_set_active_restores_live_state() { with_env_lock(|| { @@ -1839,6 +2053,31 @@ mod tests { fs::set_permissions(&env, fs::Permissions::from_mode(0o600)).unwrap(); } + fn write_antigravity_live_state( + user_home: &Path, + keyring_root: &Path, + secret: &str, + ) -> EnvVarGuard { + let app_dir = user_home.join(".gemini").join("antigravity-cli"); + let shared_dir = user_home.join(".gemini").join("config"); + fs::create_dir_all(app_dir.join("cache")).unwrap(); + fs::create_dir_all(shared_dir.join("projects")).unwrap(); + fs::write(app_dir.join("settings.json"), br#"{"theme":"terminal"}"#).unwrap(); + fs::write(app_dir.join("cache").join("projects.json"), br#"{}"#).unwrap(); + fs::write(shared_dir.join("hooks.json"), br#"{}"#).unwrap(); + fs::write(shared_dir.join("projects").join("repo.json"), br#"{}"#).unwrap(); + + let keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", keyring_root.to_str().unwrap()); + let keyring_ref = auth::antigravity::default_live_keyring_ref(); + crate::auth::system_keyring::upsert_generic_password( + &keyring_ref.service, + &keyring_ref.account, + secret.as_bytes(), + ) + .unwrap(); + keyring + } + fn profile_meta( auth_method: AuthMethod, credential_backend: CredentialBackend, @@ -2607,4 +2846,51 @@ mod tests { "unexpected: {err}" ); } + + #[test] + fn from_live_antigravity_creates_profile_and_activates() { + with_env_lock(|| { + let tmp = tempdir().unwrap(); + let aisw_home = tmp.path().join("aisw"); + let user_home = tmp.path().join("user"); + let keyring_dir = tmp.path().join("keyring"); + fs::create_dir_all(&aisw_home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + let _home = EnvVarGuard::set("HOME", user_home.to_str().unwrap()); + let _keyring = write_antigravity_live_state( + &user_home, + &keyring_dir, + "{\"session\":\"live-secret\"}", + ); + + run_in( + from_live_args(Tool::Antigravity, "work"), + &aisw_home, + OsString::new(), + ) + .unwrap(); + + let ps = ProfileStore::new(&aisw_home); + assert!(ps.exists(Tool::Antigravity, "work")); + assert_eq!( + String::from_utf8( + ps.read_file(Tool::Antigravity, "work", "keyring-secret.json") + .unwrap() + ) + .unwrap(), + "{\"session\":\"live-secret\"}" + ); + assert!(ps + .profile_dir(Tool::Antigravity, "work") + .join("app/settings.json") + .exists()); + assert!(ps + .profile_dir(Tool::Antigravity, "work") + .join("shared/hooks.json") + .exists()); + + let config = ConfigStore::new(&aisw_home).load().unwrap(); + assert_eq!(config.active_for(Tool::Antigravity), Some("work")); + }); + } } diff --git a/src/commands/capabilities.rs b/src/commands/capabilities.rs index b0f4fec..799788c 100644 --- a/src/commands/capabilities.rs +++ b/src/commands/capabilities.rs @@ -33,6 +33,7 @@ struct ToolCapabilitiesSet { claude: ToolCapabilities, codex: ToolCapabilities, gemini: ToolCapabilities, + antigravity: ToolCapabilities, } #[derive(Serialize)] @@ -66,6 +67,7 @@ pub fn run(args: CapabilitiesArgs) -> Result<()> { claude: tool_capabilities(Tool::Claude), codex: tool_capabilities(Tool::Codex), gemini: tool_capabilities(Tool::Gemini), + antigravity: tool_capabilities(Tool::Antigravity), }, }; @@ -101,5 +103,11 @@ fn tool_capabilities(tool: Tool) -> ToolCapabilities { credential_backends: vec!["file"], fail_closed_keyring_identity: None, }, + Tool::Antigravity => ToolCapabilities { + auth_methods: vec!["oauth", "from_live"], + state_modes: Vec::new(), + credential_backends: vec!["file", "system_keyring"], + fail_closed_keyring_identity: None, + }, } } diff --git a/src/commands/context.rs b/src/commands/context.rs index 1185e82..2c2a513 100644 --- a/src/commands/context.rs +++ b/src/commands/context.rs @@ -36,11 +36,11 @@ pub fn run(args: ContextArgs, home: &Path) -> Result<()> { fn create(args: ContextCreateArgs, home: &Path) -> Result<()> { validate_profile_name(&args.context_name)?; - let profiles = profile_map_from_options(args.claude, args.codex, args.gemini); + let profiles = profile_map_from_options(args.claude, args.codex, args.gemini, args.antigravity); if profiles.is_empty() { bail!( "context create requires at least one tool mapping.\n \ - Re-run with one or more of: --claude , --codex , --gemini " + Re-run with one or more of: --claude , --codex , --gemini , --antigravity " ); } @@ -147,11 +147,11 @@ fn set(args: ContextSetArgs, home: &Path) -> Result<()> { name: args.context_name.clone(), })?; - let updates = profile_map_from_options(args.claude, args.codex, args.gemini); + let updates = profile_map_from_options(args.claude, args.codex, args.gemini, args.antigravity); if updates.is_empty() { bail!( "context set requires at least one mapping.\n \ - Re-run with one or more of: --claude , --codex , --gemini " + Re-run with one or more of: --claude , --codex , --gemini , --antigravity " ); } ensure_profiles_exist(&config, &updates)?; @@ -207,6 +207,7 @@ fn unset(args: ContextUnsetArgs, home: &Path) -> Result<()> { (Tool::Claude, args.claude), (Tool::Codex, args.codex), (Tool::Gemini, args.gemini), + (Tool::Antigravity, args.antigravity), ] { if selected { changed = true; @@ -216,7 +217,7 @@ fn unset(args: ContextUnsetArgs, home: &Path) -> Result<()> { if !changed { bail!( "context unset requires at least one tool flag.\n \ - Re-run with one or more of: --claude, --codex, --gemini" + Re-run with one or more of: --claude, --codex, --gemini, --antigravity" ); } if profiles.is_empty() { @@ -422,6 +423,7 @@ fn profile_map_from_options( claude: Option, codex: Option, gemini: Option, + antigravity: Option, ) -> HashMap { let mut profiles = HashMap::new(); if let Some(profile) = claude { @@ -433,6 +435,9 @@ fn profile_map_from_options( if let Some(profile) = gemini { profiles.insert(Tool::Gemini, profile); } + if let Some(profile) = antigravity { + profiles.insert(Tool::Antigravity, profile); + } profiles } @@ -535,6 +540,9 @@ enum LiveStateSnapshot { dir: std::path::PathBuf, files: Vec, }, + Antigravity { + snapshot: auth::antigravity::LiveSnapshot, + }, } #[derive(Debug, Clone)] @@ -575,6 +583,9 @@ fn snapshot_live_state_for_context( dir, } } + Tool::Antigravity => LiveStateSnapshot::Antigravity { + snapshot: auth::antigravity::capture_live_snapshot(user_home)?, + }, }; snapshots.insert(switch.tool, snapshot); } @@ -600,6 +611,9 @@ fn restore_live_state_for_context( )?, LiveStateSnapshot::Codex { files } => restore_file_snapshots(files)?, LiveStateSnapshot::Gemini { dir, files } => restore_regular_files(dir, files)?, + LiveStateSnapshot::Antigravity { snapshot } => { + auth::antigravity::restore_snapshot_to_live(snapshot, user_home)? + } } } Ok(()) @@ -671,6 +685,7 @@ fn normalized_profiles_json(profiles: &ContextProfiles) -> serde_json::Value { "claude": profiles.get(Tool::Claude), "codex": profiles.get(Tool::Codex), "gemini": profiles.get(Tool::Gemini), + "antigravity": profiles.get(Tool::Antigravity), }) } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index c53fa82..482aad9 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -256,6 +256,7 @@ fn credentials_filename(tool: Tool) -> &'static str { Tool::Claude => ".credentials.json", Tool::Codex => "auth.json", Tool::Gemini => "oauth_credentials.json", + Tool::Antigravity => "keyring-secret.json", } } diff --git a/src/commands/list.rs b/src/commands/list.rs index 1c11a23..53332f8 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -171,6 +171,7 @@ fn print_table(rows: &[Row]) { "claude" => Tool::Claude, "codex" => Tool::Codex, "gemini" => Tool::Gemini, + "agy" => Tool::Antigravity, _ => unreachable!(), }; output::print_tool_section(tool); diff --git a/src/commands/status.rs b/src/commands/status.rs index dadc7bb..4bdab1c 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -26,6 +26,7 @@ pub(crate) struct ToolStatus { pub credential_backend: Option, pub claude_auth_classification: Option, pub codex_auth_classification: Option, + pub antigravity_auth_classification: Option, pub state_mode: Option, pub active_profile_added_at: Option>, pub active_profile_applied: Option, @@ -117,6 +118,12 @@ fn assess_live_state( &user_home.join(".gemini"), )?, }, + Tool::Antigravity => auth::antigravity::live_state_matches( + profile_store, + profile_name, + credential_backend, + user_home, + )?, }; if applied { @@ -160,6 +167,7 @@ pub(crate) fn collect_status( credential_backend, claude_auth_classification, codex_auth_classification, + antigravity_auth_classification, active_profile_added_at, active_profile_applied, credentials_present, @@ -206,6 +214,20 @@ pub(crate) fn collect_status( } else { None }; + let antigravity_auth_classification = if tool == Tool::Antigravity { + Some( + auth::antigravity::classify_profile( + &profile_store, + name, + profile_meta.auth_method, + profile_meta.credential_backend, + )? + .as_str() + .to_owned(), + ) + } else { + None + }; let added_at = profiles.get(name).map(|m| m.added_at); let applied = if creds { profiles @@ -242,13 +264,14 @@ pub(crate) fn collect_status( backend, claude_auth_classification, codex_auth_classification, + antigravity_auth_classification, added_at, applied, creds, perms, ) } else { - (None, None, None, None, None, None, None, false, true) + (None, None, None, None, None, None, None, None, false, true) }; statuses.push(ToolStatus { @@ -260,6 +283,7 @@ pub(crate) fn collect_status( credential_backend, claude_auth_classification, codex_auth_classification, + antigravity_auth_classification, state_mode, active_profile_added_at, active_profile_applied, @@ -477,6 +501,9 @@ fn print_text(statuses: &[ToolStatus], context_status: Option<&DerivedContextSta if let Some(classification) = s.codex_auth_classification.as_deref() { output::print_kv("Codex auth", classification); } + if let Some(classification) = s.antigravity_auth_classification.as_deref() { + output::print_kv("Antigravity auth", classification); + } if let Some(mode) = s.state_mode.as_deref() { output::print_kv("State mode", output::ellipsize(mode, STATE_MODE_WIDTH)); } @@ -504,6 +531,7 @@ fn print_json( "credential_backend": s.credential_backend, "claude_auth_classification": s.claude_auth_classification, "codex_auth_classification": s.codex_auth_classification, + "antigravity_auth_classification": s.antigravity_auth_classification, "state_mode": s.state_mode, "active_profile_applied": s.active_profile_applied, "credentials_present": s.credentials_present, @@ -776,7 +804,7 @@ mod tests { fn empty_config_no_path_all_not_found() { let tmp = tempdir().unwrap(); let statuses = collect_status(tmp.path(), tmp.path(), &empty_path()).unwrap(); - assert_eq!(statuses.len(), 3); + assert_eq!(statuses.len(), 4); assert!(statuses.iter().all(|s| !s.binary_found)); assert!(statuses.iter().all(|s| s.active_profile.is_none())); } @@ -1155,6 +1183,7 @@ mod tests { credential_backend: Some("file".to_owned()), claude_auth_classification: Some("api_key".to_owned()), codex_auth_classification: None, + antigravity_auth_classification: None, state_mode: Some("isolated".to_owned()), active_profile_added_at: Some(chrono::Utc::now()), active_profile_applied: Some(true), @@ -1170,6 +1199,7 @@ mod tests { credential_backend: None, claude_auth_classification: None, codex_auth_classification: None, + antigravity_auth_classification: None, state_mode: Some("isolated".to_owned()), active_profile_added_at: None, active_profile_applied: None, @@ -1210,6 +1240,7 @@ mod tests { credential_backend: Some("file".to_owned()), claude_auth_classification: Some("api_key".to_owned()), codex_auth_classification: None, + antigravity_auth_classification: None, state_mode: Some("isolated".to_owned()), active_profile_added_at: Some(older), active_profile_applied: Some(true), @@ -1225,6 +1256,7 @@ mod tests { credential_backend: Some("file".to_owned()), claude_auth_classification: None, codex_auth_classification: None, + antigravity_auth_classification: None, state_mode: Some("isolated".to_owned()), active_profile_added_at: Some(newer), active_profile_applied: Some(true), diff --git a/src/commands/use_.rs b/src/commands/use_.rs index ff764b5..c6b6697 100644 --- a/src/commands/use_.rs +++ b/src/commands/use_.rs @@ -190,7 +190,8 @@ pub(crate) fn resolve_profile_switch_request( "--state-mode is currently supported only for claude and codex.\n \ Gemini remains isolated-only because its native ~/.gemini directory mixes \ credentials with broader local state such as history, trusted folders, \ - project mappings, settings, and MCP config." + project mappings, settings, and MCP config.\n \ + Antigravity does not currently expose a documented isolated auth/data root, so aisw switches its shared live keyring-backed session without a state-mode selector." ); } (_, None) => None, @@ -445,6 +446,18 @@ pub(crate) fn apply_resolved_profile_switch( } } } + Tool::Antigravity => { + if emit_env { + auth::antigravity::emit_shell_env(); + } else { + auth::antigravity::apply_live_credentials( + &profile_store, + &resolved.profile_name, + resolved.profile_meta.credential_backend, + user_home, + )?; + } + } } Ok(()) @@ -494,6 +507,14 @@ fn maybe_sync_active_profile_before_switch( user_home, )?; } + Tool::Antigravity => { + let _ = auth::antigravity::sync_profile_from_live_if_same_identity( + profile_store, + active_name, + active_profile.credential_backend, + user_home, + )?; + } } Ok(()) } @@ -516,6 +537,15 @@ fn print_switch_summary(resolved: &ResolvedProfileSwitch, home: &Path, user_home ) { output::print_kv("Codex auth", classification.human_label()); } + } else if resolved.tool == Tool::Antigravity { + if let Ok(classification) = auth::antigravity::classify_profile( + &profile_store, + &resolved.profile_name, + resolved.profile_meta.auth_method, + resolved.profile_meta.credential_backend, + ) { + output::print_kv("Antigravity auth", classification.human_label()); + } } output::print_kv( "Backend", @@ -548,6 +578,7 @@ fn print_switch_summary(resolved: &ResolvedProfileSwitch, home: &Path, user_home "Codex will keep shared local state and only switch account credentials." } (Tool::Gemini, _) => unreachable!(), + (Tool::Antigravity, _) => unreachable!(), }); } if resolved.backup_on_switch { @@ -605,6 +636,13 @@ fn print_switch_summary(resolved: &ResolvedProfileSwitch, home: &Path, user_home ); } } + } else if resolved.tool == Tool::Antigravity { + output::print_effect( + "Antigravity switching restores the shared live OS keyring credential and the documented ~/.gemini config roots for this profile.", + ); + output::print_effect( + "Upstream does not currently document an isolated per-profile auth root or profile selector for Antigravity.", + ); } output::print_blank_line(); output::print_next_step(output::next_step_after_use()); @@ -845,9 +883,19 @@ fn extract_switch_identity(profile_store: &ProfileStore, tool: Tool, name: &str) Tool::Claude => ".credentials.json", Tool::Codex => "auth.json", Tool::Gemini => "oauth_creds.json", + Tool::Antigravity => "keyring-secret.json", }; - let bytes = profile_store.read_file(tool, name, cred_file).ok()?; + let bytes = if tool == Tool::Antigravity { + profile_store + .read_file(tool, name, cred_file) + .or_else(|_| { + auth::secure_store::read_profile_secret(tool, name).map(|v| v.unwrap_or_default()) + }) + .ok()? + } else { + profile_store.read_file(tool, name, cred_file).ok()? + }; let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?; // Try common email/identity fields in order of specificity. @@ -867,7 +915,7 @@ fn extract_switch_identity(profile_store: &ProfileStore, tool: Tool, name: &str) } // For Codex API-key profiles the "token" field may be a JWT. - if tool == Tool::Codex { + if matches!(tool, Tool::Codex | Tool::Antigravity) { if let Some(jwt) = v.get("token").and_then(|t| t.as_str()) { if let Some(email) = decode_jwt_email(jwt) { return Some(email); diff --git a/src/commands/verify.rs b/src/commands/verify.rs index e1e514e..a476f42 100644 --- a/src/commands/verify.rs +++ b/src/commands/verify.rs @@ -248,6 +248,7 @@ mod tests { credential_backend: Some("file".to_owned()), claude_auth_classification: None, codex_auth_classification: None, + antigravity_auth_classification: None, state_mode: Some("isolated".to_owned()), active_profile_added_at: None, active_profile_applied: Some(true), diff --git a/src/config.rs b/src/config.rs index d34fa8d..733912b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -86,7 +86,9 @@ impl CredentialBackend { pub fn validate_for_tool(self, tool: Tool) -> Result<()> { match (self, tool) { (CredentialBackend::File, _) => Ok(()), - (CredentialBackend::SystemKeyring, Tool::Claude | Tool::Codex) => Ok(()), + (CredentialBackend::SystemKeyring, Tool::Claude | Tool::Codex | Tool::Antigravity) => { + Ok(()) + } (CredentialBackend::SystemKeyring, Tool::Gemini) => bail!( "credential backend '{}' is not supported for {}.\n \ Gemini CLI auth remains file-managed because its local ~/.gemini state mixes \ @@ -651,6 +653,8 @@ struct LegacyActiveProfiles { pub claude: Option, pub codex: Option, pub gemini: Option, + #[serde(default)] + pub antigravity: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -658,6 +662,8 @@ struct LegacyAllProfiles { pub claude: HashMap, pub codex: HashMap, pub gemini: HashMap, + #[serde(default)] + pub antigravity: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -683,6 +689,8 @@ struct LegacyContextProfiles { pub claude: Option, pub codex: Option, pub gemini: Option, + #[serde(default)] + pub antigravity: Option, } impl Serialize for ActiveProfiles { @@ -694,6 +702,7 @@ impl Serialize for ActiveProfiles { claude: self.get(Tool::Claude).cloned().flatten(), codex: self.get(Tool::Codex).cloned().flatten(), gemini: self.get(Tool::Gemini).cloned().flatten(), + antigravity: self.get(Tool::Antigravity).cloned().flatten(), } .serialize(serializer) } @@ -709,6 +718,7 @@ impl<'de> Deserialize<'de> for ActiveProfiles { inner.insert(Tool::Claude, legacy.claude); inner.insert(Tool::Codex, legacy.codex); inner.insert(Tool::Gemini, legacy.gemini); + inner.insert(Tool::Antigravity, legacy.antigravity); Ok(Self(inner)) } } @@ -722,6 +732,7 @@ impl Serialize for AllProfiles { claude: self.get(Tool::Claude).cloned().unwrap_or_default(), codex: self.get(Tool::Codex).cloned().unwrap_or_default(), gemini: self.get(Tool::Gemini).cloned().unwrap_or_default(), + antigravity: self.get(Tool::Antigravity).cloned().unwrap_or_default(), } .serialize(serializer) } @@ -737,6 +748,7 @@ impl<'de> Deserialize<'de> for AllProfiles { inner.insert(Tool::Claude, legacy.claude); inner.insert(Tool::Codex, legacy.codex); inner.insert(Tool::Gemini, legacy.gemini); + inner.insert(Tool::Antigravity, legacy.antigravity); Ok(Self(inner)) } } @@ -750,6 +762,7 @@ impl Serialize for ContextProfiles { claude: self.get(Tool::Claude).map(str::to_owned), codex: self.get(Tool::Codex).map(str::to_owned), gemini: self.get(Tool::Gemini).map(str::to_owned), + antigravity: self.get(Tool::Antigravity).map(str::to_owned), } .serialize(serializer) } @@ -771,6 +784,9 @@ impl<'de> Deserialize<'de> for ContextProfiles { if let Some(profile) = legacy.gemini { inner.insert(Tool::Gemini, profile); } + if let Some(profile) = legacy.antigravity { + inner.insert(Tool::Antigravity, profile); + } Ok(Self(inner)) } } @@ -829,6 +845,7 @@ impl From for LegacyContextProfiles { claude: value.get(Tool::Claude).map(str::to_owned), codex: value.get(Tool::Codex).map(str::to_owned), gemini: value.get(Tool::Gemini).map(str::to_owned), + antigravity: value.get(Tool::Antigravity).map(str::to_owned), } } } @@ -845,6 +862,9 @@ impl From for ContextProfiles { if let Some(profile) = value.gemini { profiles.insert(Tool::Gemini, profile); } + if let Some(profile) = value.antigravity { + profiles.insert(Tool::Antigravity, profile); + } profiles } } @@ -1089,6 +1109,64 @@ mod tests { assert_eq!(work.profiles.get(Tool::Gemini), None); } + #[test] + fn round_trip_preserves_antigravity_profiles() { + let dir = tempdir().unwrap(); + let store = store(dir.path()); + + store + .add_profile(Tool::Antigravity, "work", meta(AuthMethod::OAuth)) + .unwrap(); + + let config = store.load().unwrap(); + assert!(config.profiles_for(Tool::Antigravity).contains_key("work")); + assert_eq!( + config.profiles_for(Tool::Antigravity)["work"].auth_method, + AuthMethod::OAuth + ); + } + + #[test] + fn round_trip_preserves_active_antigravity_profile() { + let dir = tempdir().unwrap(); + let store = store(dir.path()); + + store + .add_profile(Tool::Antigravity, "work", meta(AuthMethod::OAuth)) + .unwrap(); + store.set_active(Tool::Antigravity, "work").unwrap(); + + let config = store.load().unwrap(); + assert_eq!(config.active_for(Tool::Antigravity), Some("work")); + } + + #[test] + fn round_trip_preserves_antigravity_context_mapping() { + let dir = tempdir().unwrap(); + let store = store(dir.path()); + let now = Utc::now(); + let mut profiles = ContextProfiles::default(); + profiles.insert(Tool::Antigravity, "ag-work"); + store + .create_context( + "work", + ContextEntry { + profiles, + created_at: now, + updated_at: now, + }, + ) + .unwrap(); + + let config = store.load().unwrap(); + assert_eq!( + config + .context("work") + .and_then(|context| context.profiles.get(Tool::Antigravity)), + Some("ag-work") + ); + } + #[test] fn unrelated_profile_mutation_preserves_existing_contexts() { let dir = tempdir().unwrap(); diff --git a/src/profile.rs b/src/profile.rs index 3bfb9b1..763e092 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -129,6 +129,10 @@ impl ProfileStore { let dir = self.profile_dir(tool, name); let dest = dir.join(filename); reject_symlink(&dest)?; + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } let tmp = dest.with_extension("tmp"); fs::write(&tmp, contents).with_context(|| format!("could not write {}", tmp.display()))?; set_permissions_600(&tmp)?; @@ -147,6 +151,10 @@ impl ProfileStore { let dir = self.profile_dir(tool, name); let dest = dir.join(dest_filename); reject_symlink(&dest)?; + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } fs::copy(src, &dest) .with_context(|| format!("could not copy {} to {}", src.display(), dest.display()))?; set_permissions_600(&dest) @@ -422,6 +430,39 @@ mod tests { assert_eq!(contents, b"auth-data"); } + #[test] + fn write_file_creates_nested_parent_directories() { + let dir = tempdir().unwrap(); + let s = store(dir.path()); + + s.create(Tool::Gemini, "work").unwrap(); + s.write_file(Tool::Gemini, "work", "nested/deeper/state.json", b"{}") + .unwrap(); + + let contents = s + .read_file(Tool::Gemini, "work", "nested/deeper/state.json") + .unwrap(); + assert_eq!(contents, b"{}"); + } + + #[test] + fn copy_file_into_creates_nested_parent_directories() { + let dir = tempdir().unwrap(); + let s = store(dir.path()); + s.create(Tool::Codex, "work").unwrap(); + + let src_dir = tempdir().unwrap(); + let src = src_dir.path().join("auth.json"); + fs::write(&src, b"auth-data").unwrap(); + + s.copy_file_into(Tool::Codex, "work", &src, "nested/auth.json") + .unwrap(); + let contents = s + .read_file(Tool::Codex, "work", "nested/auth.json") + .unwrap(); + assert_eq!(contents, b"auth-data"); + } + #[test] #[cfg(unix)] fn check_permissions_detects_broad_mode() { diff --git a/src/types.rs b/src/types.rs index 7d5ea96..647a29e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -6,16 +6,18 @@ pub enum Tool { Claude, Codex, Gemini, + Antigravity, } impl Tool { - pub const ALL: [Tool; 3] = [Tool::Claude, Tool::Codex, Tool::Gemini]; + pub const ALL: [Tool; 4] = [Tool::Claude, Tool::Codex, Tool::Gemini, Tool::Antigravity]; pub fn binary_name(&self) -> &'static str { match self { Tool::Claude => "claude", Tool::Codex => "codex", Tool::Gemini => "gemini", + Tool::Antigravity => "agy", } } @@ -24,6 +26,7 @@ impl Tool { Tool::Claude => "claude", Tool::Codex => "codex", Tool::Gemini => "gemini", + Tool::Antigravity => "antigravity", } } @@ -32,6 +35,7 @@ impl Tool { Tool::Claude => "Claude Code", Tool::Codex => "Codex CLI", Tool::Gemini => "Gemini CLI", + Tool::Antigravity => "Antigravity CLI", } } diff --git a/tests/add_cmd.rs b/tests/add_cmd.rs index c0d9d7a..6ac8cb4 100644 --- a/tests/add_cmd.rs +++ b/tests/add_cmd.rs @@ -14,6 +14,39 @@ const VALID_CODEX_KEY: &str = "sk-codex-test-key-12345"; const VALID_CODEX_KEY_ALT: &str = "sk-codex-test-key-67890"; const VALID_GEMINI_KEY: &str = "AIzatest1234567890ABCDEF"; const VALID_GEMINI_KEY_ALT: &str = "AIzaalt0987654321FEDCBA"; +const ANTIGRAVITY_SECRET: &str = r#"{"email":"work@example.com","token":"live"}"#; + +fn antigravity_live_keyring_secret_path(env: &TestEnv) -> PathBuf { + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret") +} + +fn write_antigravity_live_state(env: &TestEnv, secret: &str) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + fs::create_dir_all(app_dir.join("cache")).unwrap(); + fs::create_dir_all(shared_dir.join("projects")).unwrap(); + fs::write(app_dir.join("settings.json"), br#"{"theme":"terminal"}"#).unwrap(); + fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + fs::write(shared_dir.join("hooks.json"), br#"{"hooks":[]}"#).unwrap(); + fs::write( + shared_dir.join("projects").join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + + let secret_path = antigravity_live_keyring_secret_path(env); + fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + fs::write(secret_path, secret).unwrap(); +} fn write_config_only_profile(env: &TestEnv, tool: &str, profile: &str, backend: &str) { let mut config = serde_json::json!({ @@ -732,6 +765,138 @@ fn add_gemini_oauth_succeeds_with_mocked_binary() { env.assert_home_file_exists("profiles/gemini/work/settings.json"); } +#[test] +fn add_antigravity_oauth_succeeds_with_mocked_binary() { + let env = TestEnv::new(); + env.add_script_tool( + "agy", + &format!( + "#!/bin/sh\n\ + if [ \"$1\" = \"--version\" ]; then\n\ + echo 'agy 1.0.0'\n\ + exit 0\n\ + fi\n\ + root=\"${{AISW_KEYRING_TEST_DIR:-$HOME/keychain}}/gemini/antigravity\"\n\ + /bin/mkdir -p \"$root\" \"$HOME/.gemini/antigravity-cli/cache\" \"$HOME/.gemini/config/projects\"\n\ + printf '%s' 'antigravity' > \"$root/account\"\n\ + printf '%s' '{{}}' > \"$HOME/.gemini/antigravity-cli/cache/projects.json\"\n\ + printf '%s' '{{}}' > \"$HOME/.gemini/config/hooks.json\"\n\ + printf '%s' '{{}}' > \"$HOME/.gemini/config/projects/repo.json\"\n\ + printf '%s' '{{\"theme\":\"terminal\"}}' > \"$HOME/.gemini/antigravity-cli/settings.json\"\n\ + printf '%s' '{ANTIGRAVITY_SECRET}' > \"$root/secret\"\n" + ), + ); + + env.cmd() + .args(["add", "antigravity", "work"]) + .assert() + .success() + .stdout(contains("Added profile")) + .stdout(contains("Antigravity CLI")) + .stdout(contains("oauth_shared_live_keyring")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!( + config["profiles"]["antigravity"]["work"]["auth_method"], + "o_auth" + ); + assert_eq!( + config["profiles"]["antigravity"]["work"]["credential_backend"], + "file" + ); + env.assert_home_file_exists("profiles/antigravity/work/keyring-secret.json"); + env.assert_home_file_exists("profiles/antigravity/work/keyring.json"); + env.assert_home_file_exists("profiles/antigravity/work/app/settings.json"); + env.assert_home_file_exists("profiles/antigravity/work/shared/hooks.json"); +} + +#[test] +fn add_antigravity_from_live_succeeds_and_activates_profile() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET); + + env.cmd() + .args(["add", "antigravity", "work", "--from-live"]) + .assert() + .success() + .stdout(contains("Added profile")) + .stdout(contains("Activation")) + .stdout(contains("active")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!(config["active"]["antigravity"], "work"); + assert_eq!( + fs::read_to_string(env.home_file("profiles/antigravity/work/keyring-secret.json")).unwrap(), + ANTIGRAVITY_SECRET + ); +} + +#[test] +fn add_antigravity_from_live_supports_explicit_system_keyring_backend() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET); + + env.cmd() + .args([ + "add", + "antigravity", + "work", + "--from-live", + "--credential-backend", + "system-keyring", + ]) + .assert() + .success() + .stdout(contains("system_keyring")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!( + config["profiles"]["antigravity"]["work"]["credential_backend"], + "system_keyring" + ); + assert!(!env + .home_file("profiles/antigravity/work/keyring-secret.json") + .exists()); +} + +#[test] +fn add_antigravity_from_live_fails_without_live_credentials() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + + env.cmd() + .args(["add", "antigravity", "work", "--from-live"]) + .assert() + .failure() + .stderr(contains("no live Antigravity credentials found")); +} + +#[test] +fn add_antigravity_rejects_api_key_auth_paths() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + + env.cmd() + .args(["add", "antigravity", "work", "--api-key", VALID_GEMINI_KEY]) + .assert() + .failure() + .stderr(contains("OAuth-only")) + .stderr(contains("Use 'aisw add antigravity '")); + + env.cmd() + .args(["add", "antigravity", "work", "--from-env"]) + .assert() + .failure() + .stderr(contains( + "does not document API-key or environment-variable authentication", + )); +} + #[test] fn add_oauth_in_non_interactive_mode_fails_clearly() { let env = TestEnv::new(); diff --git a/tests/backup_cmd.rs b/tests/backup_cmd.rs index 7974fcc..35e2f42 100644 --- a/tests/backup_cmd.rs +++ b/tests/backup_cmd.rs @@ -4,6 +4,9 @@ use common::TestEnv; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; +const ANTIGRAVITY_SECRET_WORK: &str = r#"{"email":"work@example.com","token":"work-live"}"#; +const ANTIGRAVITY_SECRET_ALT: &str = r#"{"email":"alt@example.com","token":"alt-live"}"#; + fn strip_ansi(input: &str) -> String { let mut stripped = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); @@ -43,6 +46,42 @@ fn first_backup_id(list_output: &str) -> String { .expect("expected at least one backup entry") } +fn write_antigravity_live_state(env: &TestEnv, secret: &str, theme: &str, mode: &str) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write( + app_dir.join("settings.json"), + format!(r#"{{"theme":"{theme}"}}"#), + ) + .unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + std::fs::write( + shared_dir.join("hooks.json"), + format!(r#"{{"hooks":["{mode}"]}}"#), + ) + .unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + format!(r#"{{"mode":"{mode}"}}"#), + ) + .unwrap(); + let secret_path = env + .fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, secret).unwrap(); +} + // ── help / parse tests ──────────────────────────────────────────────────────── #[test] @@ -267,6 +306,84 @@ fn backup_restore_yes_restores_credentials() { .stdout(contains("work")); } +#[test] +fn backup_restore_yes_restores_antigravity_live_state() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET_WORK, "terminal", "plan"); + env.cmd() + .args(["add", "antigravity", "work", "--from-live"]) + .assert() + .success(); + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET_ALT, "light", "chat"); + env.cmd() + .args(["add", "antigravity", "alt", "--from-live"]) + .assert() + .success(); + + env.cmd() + .args(["use", "antigravity", "work"]) + .assert() + .success(); + env.cmd() + .args(["use", "antigravity", "alt"]) + .assert() + .success(); + + let list_out = env + .cmd() + .args(["backup", "list", "--json"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let entries: serde_json::Value = serde_json::from_slice(&list_out).unwrap(); + let backup_id = entries + .as_array() + .unwrap() + .iter() + .find(|entry| entry["tool"] == "agy" && entry["profile"] == "work") + .and_then(|entry| entry["backup_id"].as_str()) + .expect("expected Antigravity work backup entry") + .to_owned(); + + env.cmd() + .args(["backup", "restore", "--yes", &backup_id]) + .assert() + .success() + .stdout(contains("Restored")) + .stdout(contains("work")); + + env.cmd() + .args(["use", "antigravity", "work"]) + .assert() + .success(); + + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret") + ) + .unwrap(), + ANTIGRAVITY_SECRET_WORK + ); + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json") + ) + .unwrap(), + r#"{"theme":"terminal"}"# + ); +} + #[test] fn backup_restore_prints_use_hint() { let env = TestEnv::new(); diff --git a/tests/context_cmd.rs b/tests/context_cmd.rs index 9b5a78f..95227de 100644 --- a/tests/context_cmd.rs +++ b/tests/context_cmd.rs @@ -5,6 +5,7 @@ use predicates::str::contains; const VALID_CLAUDE_KEY: &str = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const VALID_CLAUDE_KEY_ALT: &str = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const ANTIGRAVITY_SECRET: &str = r#"{"email":"work@example.com","token":"work-live"}"#; fn setup_profiles(env: &TestEnv) { env.add_fake_tool("claude", "claude 2.3.0"); @@ -43,6 +44,47 @@ fn setup_profiles(env: &TestEnv) { .success(); } +fn write_antigravity_live_state(env: &TestEnv, secret: &str, theme: &str) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write( + app_dir.join("settings.json"), + format!(r#"{{"theme":"{theme}"}}"#), + ) + .unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + std::fs::write(shared_dir.join("hooks.json"), br#"{"hooks":["plan"]}"#).unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + let secret_path = env + .fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, secret).unwrap(); +} + +fn add_antigravity_profile(env: &TestEnv, name: &str) { + env.add_fake_tool("agy", "agy 1.0.0"); + write_antigravity_live_state(env, ANTIGRAVITY_SECRET, "terminal"); + env.cmd() + .args(["add", "antigravity", name, "--from-live"]) + .assert() + .success(); +} + #[test] fn context_create_and_list_json_work() { let env = TestEnv::new(); @@ -312,6 +354,64 @@ fn context_use_json_reports_machine_activation_state() { assert_eq!(json["result"]["active"]["gemini"], serde_json::Value::Null); } +#[test] +fn context_use_with_antigravity_updates_live_state_and_active_profile() { + let env = TestEnv::new(); + setup_profiles(&env); + add_antigravity_profile(&env, "acme-antigravity"); + + env.cmd() + .args([ + "context", + "create", + "work", + "--claude", + "acme-claude", + "--antigravity", + "acme-antigravity", + ]) + .assert() + .success(); + + write_antigravity_live_state( + &env, + r#"{"email":"other@example.com","token":"other"}"#, + "light", + ); + + env.cmd() + .args(["context", "use", "work"]) + .assert() + .success(); + + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json") + ) + .unwrap(), + r#"{"theme":"terminal"}"# + ); + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret") + ) + .unwrap(), + ANTIGRAVITY_SECRET + ); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!(config["active"]["claude"], "acme-claude"); + assert_eq!(config["active"]["antigravity"], "acme-antigravity"); +} + #[test] fn context_rename_json_reports_new_context_state() { let env = TestEnv::new(); diff --git a/tests/json_output_contract.rs b/tests/json_output_contract.rs index 7adf3bd..0b06f65 100644 --- a/tests/json_output_contract.rs +++ b/tests/json_output_contract.rs @@ -57,6 +57,10 @@ fn list_json_contract_snapshot() { let json = run_json(&env, &["list", "--json"]); let expected = serde_json::json!({ + "agy": { + "active": null, + "profiles": [], + }, "claude": { "active": "work", "profiles": [{"name": "work", "auth": "api_key", "claude_auth_classification": "api_key", "codex_auth_classification": null, "label": null}], @@ -96,6 +100,7 @@ fn status_json_contract_snapshot() { "credential_backend": "file", "claude_auth_classification": "api_key", "codex_auth_classification": null, + "antigravity_auth_classification": null, "state_mode": "isolated", "active_profile_applied": expected_claude_active_applied, "credentials_present": true, @@ -110,6 +115,7 @@ fn status_json_contract_snapshot() { "credential_backend": "file", "claude_auth_classification": null, "codex_auth_classification": "api_key", + "antigravity_auth_classification": null, "state_mode": "isolated", "active_profile_applied": true, "credentials_present": true, @@ -124,11 +130,27 @@ fn status_json_contract_snapshot() { "credential_backend": "file", "claude_auth_classification": null, "codex_auth_classification": null, + "antigravity_auth_classification": null, "state_mode": null, "active_profile_applied": true, "credentials_present": true, "permissions_ok": true, - } + }, + { + "tool": "agy", + "binary_found": false, + "stored_profiles": 0, + "active_profile": null, + "auth_method": null, + "credential_backend": null, + "claude_auth_classification": null, + "codex_auth_classification": null, + "antigravity_auth_classification": null, + "state_mode": null, + "active_profile_applied": null, + "credentials_present": false, + "permissions_ok": true, + }, ]); assert_eq!(json, expected); @@ -197,8 +219,8 @@ fn list_json_contract_preserved_with_filter_and_sort_flags() { ); let root = json.as_object().expect("list json should be object"); - assert_eq!(root.len(), 3); - for tool in ["codex", "gemini"] { + assert_eq!(root.len(), 4); + for tool in ["agy", "codex", "gemini"] { let entry = root.get(tool).expect("tool key should exist"); assert_eq!(entry["active"], serde_json::Value::Null); assert_eq!( @@ -259,6 +281,7 @@ fn status_json_contract_preserved_with_filter_and_sort_flags() { "credential_backend", "claude_auth_classification", "codex_auth_classification", + "antigravity_auth_classification", "state_mode", "active_profile_applied", "credentials_present", @@ -270,7 +293,7 @@ fn status_json_contract_preserved_with_filter_and_sort_flags() { "missing key `{key}` in status entry" ); } - assert_eq!(entry.len(), 12); + assert_eq!(entry.len(), 13); } #[test] diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index d467f6f..dc226db 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -14,6 +14,8 @@ const CLAUDE_KEY_ALT: &str = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" const CODEX_KEY: &str = "sk-codex-test-key-12345"; const CODEX_KEY_ALT: &str = "sk-codex-test-key-67890"; const GEMINI_KEY: &str = "AIzatest1234567890ABCDEF"; +const ANTIGRAVITY_SECRET_WORK: &str = r#"{"email":"work@example.com","token":"work-live"}"#; +const ANTIGRAVITY_SECRET_ALT: &str = r#"{"email":"alt@example.com","token":"alt-live"}"#; // --------------------------------------------------------------------------- // Helpers @@ -31,6 +33,10 @@ fn setup_gemini(env: &TestEnv) { env.add_fake_tool("gemini", "gemini 0.9.0"); } +fn setup_antigravity(env: &TestEnv) { + env.add_fake_tool("agy", "agy 1.0.0"); +} + fn add_claude(env: &TestEnv, name: &str) { let key = if name == "work" { CLAUDE_KEY @@ -62,6 +68,55 @@ fn add_gemini(env: &TestEnv, name: &str) { .success(); } +fn write_antigravity_live_state(env: &TestEnv, secret: &str, theme: &str, mode: &str) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write( + app_dir.join("settings.json"), + format!(r#"{{"theme":"{theme}"}}"#), + ) + .unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + std::fs::write( + shared_dir.join("hooks.json"), + format!(r#"{{"hooks":["{mode}"]}}"#), + ) + .unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + format!(r#"{{"mode":"{mode}"}}"#), + ) + .unwrap(); + let secret_path = env + .fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, secret).unwrap(); +} + +fn add_antigravity(env: &TestEnv, name: &str) { + let (secret, theme, mode) = if name == "work" { + (ANTIGRAVITY_SECRET_WORK, "terminal", "plan") + } else { + (ANTIGRAVITY_SECRET_ALT, "light", "chat") + }; + write_antigravity_live_state(env, secret, theme, mode); + env.cmd() + .args(["add", "antigravity", name, "--from-live"]) + .assert() + .success(); +} + fn list_json(env: &TestEnv) -> serde_json::Value { let out = env .cmd() @@ -101,7 +156,7 @@ fn profiles_for<'a>(j: &'a serde_json::Value, tool: &str) -> Vec<&'a str> { /// Count total profiles across all tools. fn total_profiles(j: &serde_json::Value) -> usize { - ["claude", "codex", "gemini"] + ["claude", "codex", "gemini", "agy"] .iter() .map(|t| profiles_for(j, t).len()) .sum() @@ -245,6 +300,44 @@ fn gemini_full_lifecycle_add_use_list_remove() { assert_eq!(total_profiles(&j), 0); } +#[test] +fn antigravity_full_lifecycle_add_use_list_remove() { + let env = TestEnv::new(); + setup_antigravity(&env); + + add_antigravity(&env, "work"); + + let j = list_json(&env); + assert_eq!(profiles_for(&j, "agy"), vec!["work"]); + assert_eq!(active_profile(&j, "agy"), Some("work")); + + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET_ALT, "light", "chat"); + env.cmd() + .args(["use", "antigravity", "work"]) + .assert() + .success(); + + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret") + ) + .unwrap(), + ANTIGRAVITY_SECRET_WORK + ); + + env.cmd() + .args(["remove", "antigravity", "work", "--yes", "--force"]) + .assert() + .success(); + + let j = list_json(&env); + assert_eq!(total_profiles(&j), 0); +} + // --------------------------------------------------------------------------- // Two-profile switching — env var output changes on each switch // --------------------------------------------------------------------------- @@ -358,6 +451,58 @@ fn restore_after_remove_recreates_profile_in_config_and_can_be_used() { .stdout(contains("work")); } +#[test] +fn antigravity_backup_restore_recovers_live_state_after_switch() { + let env = TestEnv::new(); + setup_antigravity(&env); + + add_antigravity(&env, "work"); + add_antigravity(&env, "alt"); + + env.cmd() + .args(["use", "antigravity", "work"]) + .assert() + .success(); + env.cmd() + .args(["use", "antigravity", "alt"]) + .assert() + .success(); + + let backup_list = env.cmd().args(["backup", "list"]).output().unwrap().stdout; + let backup_id = first_backup_id(&String::from_utf8_lossy(&backup_list)); + + env.cmd() + .args(["backup", "restore", "--yes", &backup_id]) + .assert() + .success(); + env.cmd() + .args(["use", "antigravity", "work"]) + .assert() + .success(); + + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret") + ) + .unwrap(), + ANTIGRAVITY_SECRET_WORK + ); + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json") + ) + .unwrap(), + r#"{"theme":"terminal"}"# + ); +} + // --------------------------------------------------------------------------- // --set-active on add — reflected immediately in list and status // --------------------------------------------------------------------------- diff --git a/tests/remove_cmd.rs b/tests/remove_cmd.rs index f218107..22da4b8 100644 --- a/tests/remove_cmd.rs +++ b/tests/remove_cmd.rs @@ -5,6 +5,7 @@ use common::TestEnv; use predicates::str::contains; const VALID_CLAUDE_KEY: &str = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const ANTIGRAVITY_SECRET: &str = r#"{"email":"work@example.com","token":"work-live"}"#; fn add_claude(env: &TestEnv, name: &str) { env.add_fake_tool("claude", "claude 2.3.0"); @@ -18,6 +19,40 @@ fn activate_claude(env: &TestEnv, name: &str) { env.cmd().args(["use", "claude", name]).assert().success(); } +fn add_antigravity(env: &TestEnv, name: &str) { + env.add_fake_tool("agy", "agy 1.0.0"); + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write(app_dir.join("settings.json"), br#"{"theme":"terminal"}"#).unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + std::fs::write(shared_dir.join("hooks.json"), br#"{"hooks":["plan"]}"#).unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + let secret_path = env + .fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, ANTIGRAVITY_SECRET).unwrap(); + + env.cmd() + .args(["add", "antigravity", name, "--from-live"]) + .assert() + .success(); +} + #[test] fn remove_profile_exits_zero_and_deletes_dir() { let env = TestEnv::new(); @@ -152,3 +187,27 @@ fn remove_without_profile_in_non_tty_fails_clearly() { .stderr(contains("requires an interactive TTY")) .stderr(contains("aisw remove claude ")); } + +#[test] +fn remove_active_antigravity_profile_with_force_clears_active_and_deletes_dir() { + let env = TestEnv::new(); + add_antigravity(&env, "work"); + + env.cmd() + .args(["remove", "antigravity", "work", "--yes", "--force"]) + .assert() + .success() + .stdout(contains("Removed")) + .stdout(contains("Antigravity CLI")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert!(config["active"]["antigravity"].is_null()); + assert!(config["profiles"]["antigravity"]["work"].is_null()); + assert!(!env + .aisw_home + .join("profiles") + .join("antigravity") + .join("work") + .exists()); +} diff --git a/tests/rename_cmd.rs b/tests/rename_cmd.rs index 2b95dc6..aa0246d 100644 --- a/tests/rename_cmd.rs +++ b/tests/rename_cmd.rs @@ -6,6 +6,7 @@ use predicates::str::contains; const VALID_CLAUDE_KEY: &str = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const VALID_CLAUDE_KEY_ALT: &str = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const ANTIGRAVITY_SECRET: &str = r#"{"email":"work@example.com","token":"work-live"}"#; fn add_claude_profile(env: &TestEnv, name: &str, key: &str) { env.add_fake_tool("claude", "claude 2.3.0"); @@ -15,6 +16,43 @@ fn add_claude_profile(env: &TestEnv, name: &str, key: &str) { .success(); } +fn write_antigravity_live_state(env: &TestEnv, secret: &str) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write(app_dir.join("settings.json"), br#"{"theme":"terminal"}"#).unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + std::fs::write(shared_dir.join("hooks.json"), br#"{"hooks":["plan"]}"#).unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + let secret_path = env + .fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, secret).unwrap(); +} + +fn add_antigravity_profile(env: &TestEnv, name: &str) { + env.add_fake_tool("agy", "agy 1.0.0"); + write_antigravity_live_state(env, ANTIGRAVITY_SECRET); + env.cmd() + .args(["add", "antigravity", name, "--from-live"]) + .assert() + .success(); +} + #[test] fn rename_profile_updates_list_output() { let env = TestEnv::new(); @@ -88,3 +126,29 @@ fn rename_without_old_name_in_non_tty_fails_clearly() { .stderr(contains("requires an interactive TTY")) .stderr(contains("aisw rename claude ")); } + +#[test] +fn rename_antigravity_profile_preserves_active_state_and_updates_list() { + let env = TestEnv::new(); + add_antigravity_profile(&env, "default"); + + env.cmd() + .args(["rename", "antigravity", "default", "work"]) + .assert() + .success() + .stdout(contains("Renamed profile")) + .stdout(contains("Antigravity CLI")) + .stdout(contains("default")) + .stdout(contains("work")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!(config["active"]["antigravity"], "work"); + + env.cmd() + .args(["list"]) + .assert() + .success() + .stdout(contains("work")) + .stdout(predicates::str::contains("default").not()); +} diff --git a/tests/secure_backend_cmd.rs b/tests/secure_backend_cmd.rs index 1a6eb93..371675c 100644 --- a/tests/secure_backend_cmd.rs +++ b/tests/secure_backend_cmd.rs @@ -9,6 +9,8 @@ mod common; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use assert_cmd::Command; @@ -219,6 +221,75 @@ fn seed_system_keyring_codex_profile(env: &TestEnv, name: &str, secret: &str) -> keychain_secret_path(env, "aisw", &account) } +fn seed_system_keyring_antigravity_profile(env: &TestEnv, name: &str, secret: &str) -> PathBuf { + let profile_dir = env + .aisw_home + .join("profiles") + .join("antigravity") + .join(name); + fs::create_dir_all(profile_dir.join("app")).unwrap(); + fs::create_dir_all(profile_dir.join("shared").join("projects")).unwrap(); + fs::write( + profile_dir.join("keyring.json"), + br#"{"service":"gemini","account":"antigravity"}"#, + ) + .unwrap(); + fs::write( + profile_dir.join("app").join("settings.json"), + br#"{"theme":"terminal"}"#, + ) + .unwrap(); + fs::write( + profile_dir.join("shared").join("hooks.json"), + br#"{"hooks":["plan"]}"#, + ) + .unwrap(); + fs::write( + profile_dir + .join("shared") + .join("projects") + .join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + #[cfg(unix)] + for path in [ + profile_dir.join("keyring.json"), + profile_dir.join("app").join("settings.json"), + profile_dir.join("shared").join("hooks.json"), + profile_dir + .join("shared") + .join("projects") + .join("repo.json"), + ] { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + + let account = format!("profile:agy:{}", name); + seed_keychain_item(env, "aisw", &account, secret); + + let config_path = env.aisw_home.join("config.json"); + let mut config: serde_json::Value = if config_path.exists() { + serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap() + } else { + serde_json::json!({ + "version": 2, + "active": {"claude": null, "codex": null, "gemini": null, "antigravity": null}, + "profiles": {"claude": {}, "codex": {}, "gemini": {}, "antigravity": {}}, + "settings": {"backup_on_switch": true, "max_backups": 10} + }) + }; + config["profiles"]["antigravity"][name] = serde_json::json!({ + "added_at": "2026-01-01T00:00:00Z", + "auth_method": "o_auth", + "credential_backend": "system_keyring", + "label": null + }); + fs::write(&config_path, serde_json::to_string_pretty(&config).unwrap()).unwrap(); + + keychain_secret_path(env, "aisw", &account) +} + fn cmd_with_secure_env(env: &TestEnv) -> Command { let mut cmd = env.cmd(); cmd.env("AISW_SECURITY_BIN", env.bin_dir.join("security")) @@ -235,6 +306,7 @@ fn secure_cmd_for_tool(env: &TestEnv, tool: &str) -> Command { "codex" => { cmd.env("AISW_CODEX_AUTH_STORAGE", "keychain"); } + "antigravity" => {} _ => unreachable!(), } cmd @@ -432,6 +504,49 @@ fn use_codex_system_keyring_profile_applies_to_file_live_storage() { ); } +#[test] +fn use_antigravity_system_keyring_profile_applies_to_live_keyring_and_files() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + + seed_system_keyring_antigravity_profile( + &env, + "work", + r#"{"email":"dev@example.com","token":"oauth-token"}"#, + ); + + secure_cmd_for_tool(&env, "antigravity") + .args(["use", "antigravity", "work"]) + .assert() + .success(); + + assert_eq!( + fs::read(keychain_secret_path(&env, "gemini", "antigravity")).unwrap(), + br#"{"email":"dev@example.com","token":"oauth-token"}"# + ); + assert_eq!( + fs::read( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json") + ) + .unwrap(), + br#"{"theme":"terminal"}"# + ); + assert_eq!( + fs::read( + env.fake_home + .join(".gemini") + .join("config") + .join("projects") + .join("repo.json") + ) + .unwrap(), + br#"{"mode":"plan"}"# + ); +} + // --------------------------------------------------------------------------- // `aisw status` with keychain backend // --------------------------------------------------------------------------- @@ -497,6 +612,35 @@ fn status_detects_keychain_mismatch_when_secret_changed_externally() { .stdout(contains("does not match")); } +#[test] +fn status_detects_antigravity_system_keyring_mismatch_when_secret_changed_externally() { + let env = TestEnv::new(); + env.add_fake_tool("agy", "agy 1.0.0"); + + seed_system_keyring_antigravity_profile( + &env, + "work", + r#"{"email":"dev@example.com","token":"oauth-token"}"#, + ); + + secure_cmd_for_tool(&env, "antigravity") + .args(["use", "antigravity", "work"]) + .assert() + .success(); + + fs::write( + keychain_secret_path(&env, "gemini", "antigravity"), + br#"{"email":"tampered@example.com","token":"tampered"}"#, + ) + .unwrap(); + + secure_cmd_for_tool(&env, "antigravity") + .args(["status"]) + .assert() + .success() + .stdout(contains("does not match")); +} + // --------------------------------------------------------------------------- // `aisw init` — importing existing Keychain credentials // --------------------------------------------------------------------------- diff --git a/tests/status_cmd.rs b/tests/status_cmd.rs index 7516139..e2cefb5 100644 --- a/tests/status_cmd.rs +++ b/tests/status_cmd.rs @@ -10,6 +10,7 @@ use predicates::str::contains; const VALID_CLAUDE_KEY: &str = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const VALID_CODEX_KEY: &str = "sk-codex-test-key-12345"; const VALID_GEMINI_KEY: &str = "AIzatest1234567890ABCDEF"; +const ANTIGRAVITY_SECRET: &str = r#"{"email":"work@example.com","token":"work-live"}"#; fn add_and_activate_claude(env: &TestEnv, name: &str) { env.add_fake_tool("claude", "claude 2.3.0"); @@ -54,6 +55,47 @@ fn add_codex_profile(env: &TestEnv, name: &str, key: &str) { .success(); } +fn write_antigravity_live_state(env: &TestEnv, secret: &str) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write(app_dir.join("settings.json"), br#"{"theme":"terminal"}"#).unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + br#"{"current":"repo"}"#, + ) + .unwrap(); + std::fs::write(shared_dir.join("hooks.json"), br#"{"hooks":["plan"]}"#).unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + br#"{"mode":"plan"}"#, + ) + .unwrap(); + let secret_path = env + .fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, secret).unwrap(); +} + +fn add_and_activate_antigravity(env: &TestEnv, name: &str) { + env.add_fake_tool("agy", "agy 1.0.0"); + write_antigravity_live_state(env, ANTIGRAVITY_SECRET); + env.cmd() + .args(["add", "antigravity", name, "--from-live"]) + .assert() + .success(); + env.cmd() + .args(["use", "antigravity", name]) + .assert() + .success(); +} + #[test] fn status_no_profiles_no_tools_exits_zero() { // Empty PATH → no tools found. @@ -138,7 +180,7 @@ fn status_json_has_expected_keys() { let json: serde_json::Value = serde_json::from_slice(&output).expect("invalid JSON"); assert!(json.is_array()); let arr = json.as_array().unwrap(); - assert_eq!(arr.len(), 3); // one entry per tool + assert_eq!(arr.len(), 4); // one entry per tool let claude = arr.iter().find(|e| e["tool"] == "claude").unwrap(); assert_eq!(claude["binary_found"], true); @@ -155,6 +197,38 @@ fn status_json_has_expected_keys() { assert_eq!(claude["permissions_ok"], true); } +#[test] +fn status_json_reports_antigravity_classification_and_live_state() { + let env = TestEnv::new(); + add_and_activate_antigravity(&env, "work"); + + let output = env + .cmd() + .args(["status", "--json"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json: serde_json::Value = serde_json::from_slice(&output).expect("invalid JSON"); + let arr = json.as_array().unwrap(); + let antigravity = arr.iter().find(|e| e["tool"] == "agy").unwrap(); + assert_eq!(antigravity["binary_found"], true); + assert_eq!(antigravity["stored_profiles"], 1); + assert_eq!(antigravity["active_profile"], "work"); + assert_eq!(antigravity["auth_method"], "oauth"); + assert_eq!(antigravity["credential_backend"], "file"); + assert_eq!( + antigravity["antigravity_auth_classification"], + "oauth_shared_live_keyring" + ); + assert!(antigravity["state_mode"].is_null()); + assert_eq!(antigravity["active_profile_applied"], true); + assert_eq!(antigravity["credentials_present"], true); + assert_eq!(antigravity["permissions_ok"], true); +} + #[test] fn status_context_json_wraps_tools_and_context_summary() { let env = TestEnv::new(); @@ -322,6 +396,30 @@ fn status_reports_live_tool_config_mismatch_for_active_codex_profile() { )); } +#[test] +fn status_reports_live_tool_config_mismatch_for_active_antigravity_profile() { + let env = TestEnv::new(); + add_and_activate_antigravity(&env, "work"); + + std::fs::write( + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret"), + br#"{"email":"other@example.com","token":"other"}"#, + ) + .unwrap(); + + env.cmd() + .args(["status"]) + .assert() + .success() + .stdout(contains( + "live tool config does not match the active profile", + )); +} + #[test] fn status_shows_claude_state_mode() { let env = TestEnv::new(); diff --git a/tests/use_cmd.rs b/tests/use_cmd.rs index ef3a81f..17ed512 100644 --- a/tests/use_cmd.rs +++ b/tests/use_cmd.rs @@ -11,6 +11,9 @@ const VALID_CLAUDE_KEY_ALT: &str = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB const VALID_CODEX_KEY: &str = "sk-codex-test-key-12345"; const VALID_CODEX_KEY_ALT: &str = "sk-codex-test-key-67890"; const VALID_GEMINI_KEY: &str = "AIzatest1234567890ABCDEF"; +const ANTIGRAVITY_SECRET_WORK: &str = r#"{"email":"work@example.com","token":"work-live"}"#; +const ANTIGRAVITY_SECRET_PERSONAL: &str = + r#"{"email":"personal@example.com","token":"personal-live"}"#; fn add_claude_profile(env: &TestEnv, name: &str) { env.add_fake_tool("claude", "claude 2.3.0"); @@ -36,6 +39,68 @@ fn add_codex_profile(env: &TestEnv, name: &str) { .success(); } +fn antigravity_live_keyring_secret_path(env: &TestEnv) -> std::path::PathBuf { + env.fake_home + .join("keychain") + .join("gemini") + .join("antigravity") + .join("secret") +} + +fn write_antigravity_live_state( + env: &TestEnv, + secret: &str, + theme: &str, + project_mode: &str, + recent_project: &str, +) { + let app_dir = env.fake_home.join(".gemini").join("antigravity-cli"); + let shared_dir = env.fake_home.join(".gemini").join("config"); + std::fs::create_dir_all(app_dir.join("cache")).unwrap(); + std::fs::create_dir_all(shared_dir.join("projects")).unwrap(); + std::fs::write( + app_dir.join("settings.json"), + format!(r#"{{"theme":"{theme}"}}"#), + ) + .unwrap(); + std::fs::write( + app_dir.join("cache").join("projects.json"), + format!(r#"{{"current":"{recent_project}"}}"#), + ) + .unwrap(); + std::fs::write( + shared_dir.join("hooks.json"), + format!(r#"{{"hooks":["{project_mode}"]}}"#), + ) + .unwrap(); + std::fs::write( + shared_dir.join("projects").join("repo.json"), + format!(r#"{{"mode":"{project_mode}"}}"#), + ) + .unwrap(); + + let secret_path = antigravity_live_keyring_secret_path(env); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(secret_path.parent().unwrap().join("account"), "antigravity").unwrap(); + std::fs::write(secret_path, secret).unwrap(); +} + +fn add_antigravity_profile_from_live( + env: &TestEnv, + name: &str, + secret: &str, + theme: &str, + project_mode: &str, + recent_project: &str, +) { + env.add_fake_tool("agy", "agy 1.0.0"); + write_antigravity_live_state(env, secret, theme, project_mode, recent_project); + env.cmd() + .args(["add", "antigravity", name, "--from-live"]) + .assert() + .success(); +} + fn write_config_json(env: &TestEnv, json: serde_json::Value) { std::fs::write( env.aisw_home.join("config.json"), @@ -647,6 +712,26 @@ fn use_state_mode_is_rejected_for_unsupported_tools() { .assert() .failure() .stderr(contains("currently supported only for claude and codex")); + + add_antigravity_profile_from_live( + &env, + "antigravity-work", + ANTIGRAVITY_SECRET_WORK, + "terminal", + "plan", + "repo", + ); + env.cmd() + .args([ + "use", + "antigravity", + "antigravity-work", + "--state-mode", + "shared", + ]) + .assert() + .failure() + .stderr(contains("currently supported only for claude and codex")); } #[test] @@ -812,6 +897,126 @@ fn failed_gemini_oauth_switch_rolls_back_partial_live_writes() { ); } +#[test] +fn use_antigravity_restores_live_keyring_and_config_roots() { + let env = TestEnv::new(); + add_antigravity_profile_from_live( + &env, + "work", + ANTIGRAVITY_SECRET_WORK, + "terminal", + "plan", + "repo", + ); + + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET_PERSONAL, "light", "chat", "other"); + std::fs::write( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("stale.json"), + br#"{"stale":true}"#, + ) + .unwrap(); + + env.cmd() + .args(["use", "antigravity", "work"]) + .assert() + .success() + .stdout(contains("Antigravity CLI")) + .stdout(contains("Active profile updated")); + + assert_eq!( + std::fs::read_to_string(antigravity_live_keyring_secret_path(&env)).unwrap(), + ANTIGRAVITY_SECRET_WORK + ); + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json") + ) + .unwrap(), + r#"{"theme":"terminal"}"# + ); + assert_eq!( + std::fs::read_to_string( + env.fake_home + .join(".gemini") + .join("config") + .join("projects") + .join("repo.json") + ) + .unwrap(), + r#"{"mode":"plan"}"# + ); + assert!(!env + .fake_home + .join(".gemini") + .join("antigravity-cli") + .join("stale.json") + .exists()); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!(config["active"]["antigravity"], "work"); +} + +#[test] +fn failed_antigravity_switch_rolls_back_partial_live_writes() { + let env = TestEnv::new(); + add_antigravity_profile_from_live( + &env, + "old", + ANTIGRAVITY_SECRET_WORK, + "terminal", + "plan", + "repo", + ); + add_antigravity_profile_from_live( + &env, + "new", + ANTIGRAVITY_SECRET_PERSONAL, + "light", + "chat", + "other", + ); + env.cmd() + .args(["use", "antigravity", "old"]) + .assert() + .success(); + + let settings_path = env + .fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json"); + let hooks_path = env + .fake_home + .join(".gemini") + .join("config") + .join("hooks.json"); + let keyring_path = antigravity_live_keyring_secret_path(&env); + let settings_before = std::fs::read(&settings_path).unwrap(); + let hooks_before = std::fs::read(&hooks_path).unwrap(); + let keyring_before = std::fs::read(&keyring_path).unwrap(); + + env.cmd() + .env("AISW_FAULT_INJECTION", "live_apply.commit_write:2") + .args(["use", "antigravity", "new"]) + .assert() + .failure() + .stderr(contains("injected live-apply failure")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!(config["active"]["antigravity"], "old"); + assert_eq!(std::fs::read(&settings_path).unwrap(), settings_before); + assert_eq!(std::fs::read(&hooks_path).unwrap(), hooks_before); + assert_eq!(std::fs::read(&keyring_path).unwrap(), keyring_before); +} + #[test] fn use_quiet_suppresses_human_summary_output() { let env = TestEnv::new(); diff --git a/website/public/aisw-192.png b/website/public/aisw-192.png index bcb8e40..c053d49 100644 Binary files a/website/public/aisw-192.png and b/website/public/aisw-192.png differ diff --git a/website/public/aisw-512.png b/website/public/aisw-512.png index 86a2922..b68e25b 100644 Binary files a/website/public/aisw-512.png and b/website/public/aisw-512.png differ diff --git a/website/public/aisw-icon.svg b/website/public/aisw-icon.svg deleted file mode 100644 index 5ff9f3a..0000000 --- a/website/public/aisw-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/website/public/aisw-logo.png b/website/public/aisw-logo.png index 9504a65..38f3327 100644 Binary files a/website/public/aisw-logo.png and b/website/public/aisw-logo.png differ diff --git a/website/public/aisw-mark-dark.svg b/website/public/aisw-mark-dark.svg deleted file mode 100644 index 3677b60..0000000 --- a/website/public/aisw-mark-dark.svg +++ /dev/null @@ -1,9 +0,0 @@ - - aisw logo dark - The aisw brand mark for dark backgrounds. - - - diff --git a/website/public/aisw-mark-light.svg b/website/public/aisw-mark-light.svg deleted file mode 100644 index 48cea3f..0000000 --- a/website/public/aisw-mark-light.svg +++ /dev/null @@ -1,9 +0,0 @@ - - aisw logo light - The aisw brand mark for light backgrounds. - - - diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png index 7a100be..91fc724 100644 Binary files a/website/public/apple-touch-icon.png and b/website/public/apple-touch-icon.png differ diff --git a/website/public/favicon-16.png b/website/public/favicon-16.png index a082c2e..49dca41 100644 Binary files a/website/public/favicon-16.png and b/website/public/favicon-16.png differ diff --git a/website/public/favicon-32.png b/website/public/favicon-32.png index 17e0f60..ca39120 100644 Binary files a/website/public/favicon-32.png and b/website/public/favicon-32.png differ diff --git a/website/public/favicon.ico b/website/public/favicon.ico index c5e9064..0243402 100644 Binary files a/website/public/favicon.ico and b/website/public/favicon.ico differ