Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
Expand All @@ -14,6 +18,7 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

Expand All @@ -37,6 +42,7 @@ jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
Expand Down Expand Up @@ -66,6 +72,7 @@ jobs:
build-windows:
name: Build + Unit Tests (windows-latest)
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

Expand All @@ -87,6 +94,7 @@ jobs:
coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aisw"
version = "0.3.7"
version = "0.3.8"
edition = "2021"
rust-version = "1.80"
description = "Manage multiple accounts for Claude Code, Codex CLI, and Gemini CLI"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,10 @@ aisw context use acme

- 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 <name> --from-live` is bootstrap-only. The durable path is direct per-profile login with `aisw add codex <name>`.
- Codex: personal access token sessions imported from live state are treated separately from ChatGPT-managed refresh-token auth, so the shared-mode ChatGPT block does not apply to them.
- 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.
- Gemini: Google-account login is currently documented upstream as the recommended interactive local path. Some account types still require `GOOGLE_CLOUD_PROJECT`, and headless automation should prefer `GEMINI_API_KEY` or Vertex AI.
- Gemini: `aisw init` and `aisw add gemini ...` can still capture the currently live Gemini state for supported upstream auth modes, but you should start a fresh process after switching.

</details>

Expand Down
6 changes: 5 additions & 1 deletion docs/adding-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,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.
- Gemini: sets `GEMINI_CLI_HOME` to a scratch directory, spawns `gemini`, then copies the resulting auth/state 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:
Expand All @@ -58,6 +58,8 @@ Claude OAuth support depends on how the installed Claude build scopes auth:

Interactive OAuth requires a terminal and browser access. It is not available in `--non-interactive` mode.

Important Gemini note: current upstream Gemini CLI docs again recommend `Login with Google` for interactive local use. Some account types still require `GOOGLE_CLOUD_PROJECT`, including Workspace / Code Assist-style setups and certain region-limited cases. For headless or automation use, prefer `GEMINI_API_KEY` or Vertex AI.

## Capture current live credentials

Import what the tool is currently using, without launching a browser:
Expand All @@ -73,6 +75,8 @@ This is the fastest path if you are already logged in. The captured profile is a

For Codex ChatGPT-managed auth, `--from-live` is compatibility/bootstrap only. It captures the current live session, but the durable setup is to re-login directly into the profile with interactive `aisw add codex <name>` so future upstream refreshes stay tied to that profile's own `CODEX_HOME`.

For Codex personal access token sessions, `--from-live` is the current `aisw` path: authenticate upstream with `codex login --with-access-token`, then import that live session. `aisw` treats those profiles separately from ChatGPT-managed refresh-token auth, so the shared-mode ChatGPT block does not apply to them.

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.
Expand Down
2 changes: 2 additions & 0 deletions docs/common-situations.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ This is especially useful when:

For Codex ChatGPT-managed auth, `--from-live` is bootstrap-only. After import, re-login directly inside the isolated profile if you want a durable profile that survives future upstream refreshes cleanly.

For Gemini, this pattern applies to whichever auth mode is currently live on the machine. Upstream Gemini docs currently recommend Google-account login for interactive local use, but some account types still require `GOOGLE_CLOUD_PROJECT`, and non-interactive/headless use should rely on `GEMINI_API_KEY` or Vertex AI.

## GUI-safe and automation-safe secret entry

If another application is driving `aisw`, passing API keys in process arguments is the wrong shape. `aisw` supports stdin-based secret entry for that path:
Expand Down
20 changes: 10 additions & 10 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,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.
- `--from-live`: copies the live Gemini regular-file tree under `~/.gemini/` into the profile directory.
- Interactive OAuth: sets `GEMINI_CLI_HOME` to a temporary scratch directory, spawns `gemini` so it writes its OAuth cache there, then copies all resulting regular files from `<scratch>/.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.

**How `aisw use` applies credentials:**
- Restores the managed Gemini regular-file tree into `~/.gemini/` and removes stale live files from the previously active Gemini profile.
- There is no configurable shared mode because Gemini's auth and broader local state are tightly coupled under `~/.gemini/`. Separating them would risk corrupting the tool's session state.

**State mode:** Gemini is always `isolated`. Each profile carries its own complete `~/.gemini/` state.

### Antigravity CLI

Expand All @@ -123,15 +132,6 @@ On Linux, if the Secret Service daemon is not available at runtime (e.g. headles
- `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 `<scratch>/.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.

**How `aisw use` applies credentials:**
- Copies all profile files into `~/.gemini/`, replacing whatever is currently there.
- There is no configurable shared mode because Gemini's auth and broader local state are tightly coupled under `~/.gemini/`. Separating them would risk corrupting the tool's session state.

**State mode:** Gemini is always `isolated`. Each profile carries its own complete `~/.gemini/` state.

## Automatic Synchronization

Expand Down
4 changes: 4 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ From install to switching accounts in five minutes.

```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

Expand Down Expand Up @@ -74,6 +76,8 @@ 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`.

Upstream Gemini CLI docs currently recommend Google-account login for interactive local use. Some account types still require `GOOGLE_CLOUD_PROJECT`, especially Workspace / Code Assist-style setups and certain region-limited cases. For headless or automation use, prefer `GEMINI_API_KEY` or Vertex AI.

For Antigravity, `aisw add antigravity <name>` 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:
Expand Down
15 changes: 11 additions & 4 deletions docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ description: Claude Code, Codex CLI, Gemini CLI, and Antigravity CLI support mat
|---|---|---|---|---|---|
| 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 |
| Gemini CLI | `gemini` | Google-account auth, Vertex AI, API key | Full | Full | Full |
| Antigravity CLI | `agy` | OAuth | Full | Full | Full |

## Binary detection
Expand Down Expand Up @@ -71,8 +71,9 @@ Codex uses `CODEX_HOME` to override its root directory. `aisw` sets this variabl
Supported Codex auth models in `aisw`:
- Durable: API-key profiles.
- Durable: ChatGPT-managed profiles authenticated directly inside their own isolated `CODEX_HOME`.
- Durable when already live upstream: personal access token sessions imported with `aisw add codex <name> --from-live` after `codex login --with-access-token`.
- Bootstrap only: ChatGPT-managed profiles imported with `aisw add codex <name> --from-live`.
- Unsupported: shared-mode ChatGPT auth switching.
- Unsupported: shared-mode ChatGPT auth switching for ChatGPT-managed refresh-token auth.

Codex's keyring account identifier is an opaque string, not the system username. `aisw` discovers the identifier from the live keyring entry during import and stores it so subsequent switches write to the correct account. `aisw` will not fabricate a keyring account name if it cannot read the live identifier.

Expand All @@ -84,9 +85,15 @@ Codex's keyring account identifier is an opaque string, not the system username.
| Linux | `~/.gemini/` | Not supported |
| Windows | `~/.gemini/` | Not supported |

Gemini stores all auth and local state under `~/.gemini/`. `aisw` captures and restores the complete directory contents. This includes OAuth tokens, settings, and any MCP OAuth token files.
Gemini stores all auth and local state under `~/.gemini/`. `aisw` captures and restores the complete regular-file tree for that directory and removes stale live files from the previously active Gemini profile. This includes OAuth tokens, settings, and any MCP OAuth token files stored as regular files under the Gemini state root.

For interactive OAuth, `aisw` uses `GEMINI_CLI_HOME` to redirect Gemini's config root to a scratch directory during the login flow, then copies the resulting files into the profile. This was introduced in Gemini CLI as the clean way to redirect config storage without overriding `HOME`.
Upstream Gemini CLI docs currently recommend Google-account login for interactive local use. Some account types still require `GOOGLE_CLOUD_PROJECT`. `aisw` can manage:

- API-key-backed Gemini profiles (`GEMINI_API_KEY`)
- Vertex AI-backed Gemini profiles
- Google-account Gemini logins, including the standard local browser-login flow and Workspace / Code Assist-style flows that may require `GOOGLE_CLOUD_PROJECT`

For interactive Google-account / OAuth-style capture, `aisw` uses `GEMINI_CLI_HOME` to redirect Gemini's config root to a scratch directory during the login flow, then copies the resulting files into the profile. This was introduced in Gemini CLI as the clean way to redirect config storage without overriding `HOME`.

API key profiles store a `.env` file containing `GEMINI_API_KEY=<key>`. This is the format Gemini reads natively from `~/.gemini/.env`.

Expand Down
89 changes: 89 additions & 0 deletions src/auth/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,4 +920,93 @@ mod tests {
.unwrap()
);
}

#[test]
fn capture_live_snapshot_preserves_nested_relative_paths() {
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(live_app_dir(&user_home).join("profiles/work")).unwrap();
fs::create_dir_all(live_shared_dir(&user_home).join("repos/work")).unwrap();

fs::write(
live_app_dir(&user_home).join("profiles/work/settings.json"),
br#"{"theme":"dark"}"#,
)
.unwrap();
fs::write(
live_shared_dir(&user_home).join("repos/work/settings.json"),
br#"{"mode":"plan"}"#,
)
.unwrap();
super::super::system_keyring::upsert_generic_password(
KEYRING_SERVICE,
KEYRING_ACCOUNT,
br#"{"email":"work@example.com"}"#,
)
.unwrap();

let snapshot = capture_live_snapshot(&user_home).unwrap();
let app_files = snapshot
.app_files
.iter()
.map(|(path, bytes)| (path.replace('\\', "/"), bytes.as_slice()))
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
app_files.get("profiles/work/settings.json").copied(),
Some(br#"{"theme":"dark"}"#.as_slice())
);
let shared_files = snapshot
.shared_files
.iter()
.map(|(path, bytes)| (path.replace('\\', "/"), bytes.as_slice()))
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
shared_files.get("repos/work/settings.json").copied(),
Some(br#"{"mode":"plan"}"#.as_slice())
);
}

#[test]
fn profile_tree_map_preserves_nested_duplicate_basenames() {
let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let temp = tempdir().unwrap();
let home = temp.path().join("home");
fs::create_dir_all(&home).unwrap();
let profile_store = ProfileStore::new(&home);
profile_store.create(Tool::Antigravity, "work").unwrap();

profile_store
.write_file(
Tool::Antigravity,
"work",
"app/profiles/work/settings.json",
br#"{"theme":"dark"}"#,
)
.unwrap();
profile_store
.write_file(
Tool::Antigravity,
"work",
"app/profiles/personal/settings.json",
br#"{"theme":"light"}"#,
)
.unwrap();

let stored = profile_tree_map(&profile_store, "work", APP_PREFIX).unwrap();
assert_eq!(stored.len(), 2);
let stored = stored
.iter()
.map(|(path, bytes)| (path.replace('\\', "/"), bytes.as_slice()))
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
stored.get("profiles/work/settings.json").copied(),
Some(br#"{"theme":"dark"}"#.as_slice())
);
assert_eq!(
stored.get("profiles/personal/settings.json").copied(),
Some(br#"{"theme":"light"}"#.as_slice())
);
}
}
Loading
Loading