diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd00418..c32dc0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -14,6 +18,7 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -37,6 +42,7 @@ jobs: test: name: Test runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: matrix: os: [ubuntu-latest, macos-latest] @@ -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 @@ -87,6 +94,7 @@ jobs: coverage: name: Coverage runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 88dbf86..436893e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aisw" -version = "0.3.7" +version = "0.3.8" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index fafa392..acfaf09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index b74b99f..4068601 100644 --- a/README.md +++ b/README.md @@ -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 --from-live` is bootstrap-only. The durable path is direct per-profile login with `aisw add codex `. +- 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. diff --git a/docs/adding-profiles.md b/docs/adding-profiles.md index cfe6fb4..56aad26 100644 --- a/docs/adding-profiles.md +++ b/docs/adding-profiles.md @@ -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: @@ -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: @@ -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 ` 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. diff --git a/docs/common-situations.md b/docs/common-situations.md index e09cab5..f9d8dc1 100644 --- a/docs/common-situations.md +++ b/docs/common-situations.md @@ -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: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 26ed008..1dca5be 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -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 `/.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 @@ -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 `/.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 diff --git a/docs/quickstart.md b/docs/quickstart.md index b3f6e30..761d001 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 @@ -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 ` 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: diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 5f61b71..0061c14 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -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 @@ -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 --from-live` after `codex login --with-access-token`. - Bootstrap only: ChatGPT-managed profiles imported with `aisw add codex --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. @@ -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=`. This is the format Gemini reads natively from `~/.gemini/.env`. diff --git a/src/auth/antigravity.rs b/src/auth/antigravity.rs index 744e326..394205a 100644 --- a/src/auth/antigravity.rs +++ b/src/auth/antigravity.rs @@ -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::>(); + 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::>(); + 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::>(); + 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()) + ); + } } diff --git a/src/auth/claude/mod.rs b/src/auth/claude/mod.rs index 0a293a0..a0e6b0b 100644 --- a/src/auth/claude/mod.rs +++ b/src/auth/claude/mod.rs @@ -24,7 +24,8 @@ use crate::profile::ProfileStore; use crate::types::{StateMode, Tool}; use keychain::{ - auth_storage, current_keychain_scheme, keychain_service_for_config_dir, ClaudeAuthStorage, + auth_storage, current_keychain_scheme, + keychain_service_for_config_dir as scoped_keychain_service_for_config_dir, ClaudeAuthStorage, ClaudeKeychainScheme as KeychainScheme, }; use paths::live_credentials_path; @@ -104,13 +105,15 @@ pub use keychain::ClaudeKeychainScheme; pub use keychain::{ current_keychain_scheme as current_claude_keychain_scheme, detected_keychain_scheme as detected_claude_keychain_scheme, imported_profile_backend, - keychain_import_supported, oauth_stored_backend, preferred_import_backend, - read_live_keychain_credentials_for_import, storage_fallback_note, uses_live_keychain, + keychain_import_supported, keychain_service_for_config_dir, oauth_stored_backend, + preferred_import_backend, read_live_keychain_credentials_for_import, storage_fallback_note, + uses_live_keychain, }; pub use oauth::{ add_oauth, add_oauth_with_backend, capture_live_oauth_account_metadata, live_credentials_snapshot_for_import, read_live_oauth_account_metadata_for_import, - restore_live_state_after_oauth_add, sync_profile_from_live_if_same_identity, + restore_live_state_after_oauth_add, sync_profile_from_active_state_if_same_identity, + sync_profile_from_live_if_same_identity, }; pub use paths::live_local_state_dir; @@ -131,7 +134,7 @@ pub fn classify_profile( } KeychainScheme::ScopedByConfigDir => { let profile_dir = profile_store.profile_dir(Tool::Claude, name); - let service = keychain_service_for_config_dir( + let service = scoped_keychain_service_for_config_dir( &profile_dir, user_home, KeychainScheme::ScopedByConfigDir, @@ -177,7 +180,7 @@ pub fn apply_live_credentials( ClaudeAuthStorage::Keychain => { let service = match state_mode { StateMode::Shared => KEYCHAIN_SERVICE.to_owned(), - StateMode::Isolated => keychain_service_for_config_dir( + StateMode::Isolated => scoped_keychain_service_for_config_dir( &profile_store.profile_dir(Tool::Claude, name), user_home, current_keychain_scheme(), @@ -227,7 +230,7 @@ pub fn live_credentials_match( ClaudeAuthStorage::Keychain => { let service = match state_mode { StateMode::Shared => KEYCHAIN_SERVICE.to_owned(), - StateMode::Isolated => keychain_service_for_config_dir( + StateMode::Isolated => scoped_keychain_service_for_config_dir( &profile_store.profile_dir(Tool::Claude, name), user_home, current_keychain_scheme(), @@ -1134,6 +1137,70 @@ mod tests { assert_eq!(metadata_json["organizationUuid"], "org-live"); } + #[test] + fn sync_profile_from_active_state_updates_scoped_keychain_credentials_for_isolated_mode() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempdir().unwrap(); + let user_home = dir.path().join("home"); + fs::create_dir_all(&user_home).unwrap(); + fs::write( + user_home.join(".claude.json"), + r#"{"oauthAccount":{"emailAddress":"work@example.com","organizationUuid":"org-live"}}"#, + ) + .unwrap(); + + let _storage = EnvVarGuard::set("AISW_CLAUDE_AUTH_STORAGE", "keychain"); + let _platform = EnvVarGuard::set("AISW_TEST_CLAUDE_PLATFORM", "macos"); + let _scheme = EnvVarGuard::set("AISW_CLAUDE_KEYCHAIN_SCHEME", "scoped"); + let _keyring = EnvVarGuard::set("AISW_KEYRING_TEST_DIR", dir.path().join("keychain")); + + let (ps, _cs) = stores(dir.path()); + ps.create(Tool::Claude, "work").unwrap(); + ps.write_file( + Tool::Claude, + "work", + CREDENTIALS_FILE, + br#"{"oauthToken":"stored-token","account":{"email":"work@example.com"}}"#, + ) + .unwrap(); + + let service = keychain::keychain_service_for_config_dir( + &ps.profile_dir(Tool::Claude, "work"), + &user_home, + keychain::ClaudeKeychainScheme::ScopedByConfigDir, + ); + secure_backend::upsert_generic_password( + KEYCHAIN_BACKEND, + &service, + "tester", + br#"{"oauthToken":"live-token","account":{"email":"work@example.com"}}"#, + ) + .unwrap(); + + let synced = sync_profile_from_active_state_if_same_identity( + &ps, + "work", + CredentialBackend::File, + &user_home, + StateMode::Isolated, + ) + .unwrap(); + + assert!(synced); + let stored = ps + .read_file(Tool::Claude, "work", CREDENTIALS_FILE) + .unwrap(); + let stored_json: serde_json::Value = serde_json::from_slice(&stored).unwrap(); + assert_eq!(stored_json["oauthToken"], "live-token"); + + let metadata = ps + .read_file(Tool::Claude, "work", OAUTH_ACCOUNT_FILE) + .unwrap(); + let metadata_json: serde_json::Value = serde_json::from_slice(&metadata).unwrap(); + assert_eq!(metadata_json["emailAddress"], "work@example.com"); + assert_eq!(metadata_json["organizationUuid"], "org-live"); + } + #[test] fn apply_live_oauth_account_metadata_creates_live_metadata_file_when_missing() { let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/src/auth/claude/oauth.rs b/src/auth/claude/oauth.rs index 4e9d8f2..9e3e729 100644 --- a/src/auth/claude/oauth.rs +++ b/src/auth/claude/oauth.rs @@ -568,7 +568,25 @@ pub fn sync_profile_from_live_if_same_identity( backend: CredentialBackend, user_home: &Path, ) -> Result { - let Some(snapshot) = live_credentials_snapshot_for_import(user_home)? else { + sync_profile_from_active_state_if_same_identity( + profile_store, + name, + backend, + user_home, + crate::types::StateMode::Shared, + ) +} + +pub fn sync_profile_from_active_state_if_same_identity( + profile_store: &ProfileStore, + name: &str, + backend: CredentialBackend, + user_home: &Path, + state_mode: crate::types::StateMode, +) -> Result { + let Some(snapshot) = + active_credentials_snapshot_for_sync(profile_store, name, user_home, state_mode)? + else { return Ok(false); }; @@ -589,6 +607,38 @@ pub fn sync_profile_from_live_if_same_identity( Ok(true) } +fn active_credentials_snapshot_for_sync( + profile_store: &ProfileStore, + name: &str, + user_home: &Path, + state_mode: crate::types::StateMode, +) -> Result> { + match state_mode { + crate::types::StateMode::Shared => live_credentials_snapshot_for_import(user_home), + crate::types::StateMode::Isolated => { + if !super::uses_live_keychain(user_home) { + return Ok(None); + } + let scheme = super::current_claude_keychain_scheme(); + if !matches!(scheme, super::ClaudeKeychainScheme::ScopedByConfigDir) { + return Ok(None); + } + let service = keychain_service_for_config_dir( + &profile_store.profile_dir(Tool::Claude, name), + user_home, + scheme, + ); + let Some(bytes) = read_keychain_credentials_for_service(&service)? else { + return Ok(None); + }; + Ok(Some(LiveCredentialSnapshot { + bytes, + source: LiveCredentialSource::Keychain, + })) + } + } +} + fn resolve_profile_oauth_identity( profile_store: &ProfileStore, name: &str, diff --git a/src/auth/codex.rs b/src/auth/codex.rs index 837b6c8..cfa7137 100644 --- a/src/auth/codex.rs +++ b/src/auth/codex.rs @@ -66,6 +66,7 @@ pub struct LiveCredentialSnapshot { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CodexAuthClassification { ApiKey, + PersonalAccessToken, ChatgptManagedIsolated, ChatgptManagedImportedBootstrap, } @@ -74,6 +75,7 @@ impl CodexAuthClassification { pub fn as_str(self) -> &'static str { match self { CodexAuthClassification::ApiKey => "api_key", + CodexAuthClassification::PersonalAccessToken => "personal_access_token", CodexAuthClassification::ChatgptManagedIsolated => "chatgpt_managed_isolated", CodexAuthClassification::ChatgptManagedImportedBootstrap => { "chatgpt_managed_imported_bootstrap" @@ -84,6 +86,7 @@ impl CodexAuthClassification { pub fn human_label(self) -> &'static str { match self { CodexAuthClassification::ApiKey => "API key", + CodexAuthClassification::PersonalAccessToken => "Personal access token", CodexAuthClassification::ChatgptManagedIsolated => "ChatGPT-managed isolated", CodexAuthClassification::ChatgptManagedImportedBootstrap => { "ChatGPT-managed bootstrap import" @@ -92,7 +95,11 @@ impl CodexAuthClassification { } pub fn is_chatgpt_managed(self) -> bool { - !matches!(self, CodexAuthClassification::ApiKey) + matches!( + self, + CodexAuthClassification::ChatgptManagedIsolated + | CodexAuthClassification::ChatgptManagedImportedBootstrap + ) } pub fn is_imported_bootstrap(self) -> bool { @@ -482,13 +489,22 @@ pub fn classify_profile( } let bytes = read_stored_credentials(profile_store, name, backend)?; - let _shape_hint = auth_bytes_look_chatgpt_managed(&bytes); + let imported_bootstrap = is_imported_bootstrap(profile_store, name); + Ok(classify_profile_bytes(&bytes, imported_bootstrap)) +} - Ok(if is_imported_bootstrap(profile_store, name) { +pub fn classify_profile_bytes_for_import(bytes: &[u8]) -> CodexAuthClassification { + classify_profile_bytes(bytes, true) +} + +fn classify_profile_bytes(bytes: &[u8], imported_bootstrap: bool) -> CodexAuthClassification { + if !auth_bytes_look_chatgpt_managed(bytes) { + CodexAuthClassification::PersonalAccessToken + } else if imported_bootstrap { CodexAuthClassification::ChatgptManagedImportedBootstrap } else { CodexAuthClassification::ChatgptManagedIsolated - }) + } } fn auth_bytes_look_chatgpt_managed(bytes: &[u8]) -> bool { @@ -984,6 +1000,37 @@ mod tests { .is_none()); } + #[test] + fn classify_profile_treats_non_chatgpt_oauth_payload_as_personal_access_token() { + let dir = tempdir().unwrap(); + let (ps, cs) = stores(dir.path()); + ps.create(Tool::Codex, "pat").unwrap(); + write_file_store_config(&ps, "pat").unwrap(); + ps.write_file( + Tool::Codex, + "pat", + AUTH_FILE, + br#"{"agentIdentity":{"id":"agent-123"},"issuedAt":"2026-07-17T00:00:00Z"}"#, + ) + .unwrap(); + cs.add_profile( + Tool::Codex, + "pat", + ProfileMeta { + added_at: Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: CredentialBackend::File, + label: None, + }, + ) + .unwrap(); + + let classification = + classify_profile(&ps, "pat", AuthMethod::OAuth, CredentialBackend::File).unwrap(); + assert_eq!(classification, CodexAuthClassification::PersonalAccessToken); + assert!(!classification.is_chatgpt_managed()); + } + #[test] fn apply_live_files_preserves_existing_config_settings() { let dir = tempdir().unwrap(); diff --git a/src/auth/gemini.rs b/src/auth/gemini.rs index 3627d67..a4c3a83 100644 --- a/src/auth/gemini.rs +++ b/src/auth/gemini.rs @@ -77,7 +77,7 @@ pub fn live_oauth_files_for_import(user_home: &Path) -> Result>(); @@ -88,11 +88,17 @@ pub fn live_oauth_files_for_import(user_home: &Path) -> Result Option<&files::RegularFile> { files .iter() - .find(|file| file.file_name == OsStr::new("settings.json")) + .find(|file| { + Path::new(&file.file_name) + .file_name() + .is_some_and(|name| name == OsStr::new("settings.json")) + }) .or_else(|| { - files - .iter() - .find(|file| file.file_name == OsStr::new("oauth_creds.json")) + files.iter().find(|file| { + Path::new(&file.file_name) + .file_name() + .is_some_and(|name| name == OsStr::new("oauth_creds.json")) + }) }) .or_else(|| files.first()) } @@ -597,7 +603,7 @@ fn capture_oauth_cache_into_profile(cache_dir: &Path, profile_dir: &Path) -> Res return Ok(0); } let mut count = 0; - for file in files::list_regular_files(cache_dir)? { + for file in files::list_regular_files_recursive(cache_dir)? { let dst = profile_dir.join(&file.file_name); std::fs::copy(&file.path, &dst).with_context(|| { format!( @@ -613,8 +619,11 @@ fn capture_oauth_cache_into_profile(cache_dir: &Path, profile_dir: &Path) -> Res } fn has_oauth_credentials(cache_dir: &Path) -> Result { - for file in files::list_regular_files(cache_dir)? { - let name = file.file_name.to_string_lossy(); + for file in files::list_regular_files_recursive(cache_dir)? { + let Some(name) = Path::new(&file.file_name).file_name() else { + continue; + }; + let name = name.to_string_lossy(); if OAUTH_PRIMARY_FILES .iter() .any(|candidate| *candidate == name) @@ -635,18 +644,29 @@ pub fn apply_token_cache( .with_context(|| format!("could not create {}", gemini_dir.display()))?; let profile_dir = profile_store.profile_dir(Tool::Gemini, name); + let mut expected_files = std::collections::BTreeSet::new(); let mut changes = Vec::new(); - for file in files::list_regular_files(&profile_dir)? { + for file in files::list_regular_files_recursive(&profile_dir)? { // Skip the .env file — that's for API key profiles. if file.file_name == std::ffi::OsStr::new(ENV_FILE) { continue; } + expected_files.insert(file.file_name.clone()); let dst = gemini_dir.join(&file.file_name); let contents = std::fs::read(&file.path) .with_context(|| format!("could not read {}", file.path.display()))?; changes.push(LiveFileChange::write(dst, contents)); } + for live_file in files::list_regular_files_recursive(gemini_dir)? { + if live_file.file_name == std::ffi::OsStr::new(ENV_FILE) { + continue; + } + if !expected_files.contains(&live_file.file_name) { + changes.push(LiveFileChange::delete(live_file.path)); + } + } + let env_file = gemini_dir.join(ENV_FILE); changes.push(LiveFileChange::delete(env_file)); @@ -667,7 +687,7 @@ pub fn live_token_cache_matches( } let mut saw_file = false; - for file in files::list_regular_files(&profile_dir)? { + for file in files::list_regular_files_recursive(&profile_dir)? { saw_file = true; let live = gemini_dir.join(&file.file_name); if !live.exists() { @@ -687,6 +707,20 @@ pub fn live_token_cache_matches( } } + let expected_names = files::list_regular_files_recursive(&profile_dir)? + .into_iter() + .filter(|file| file.file_name != OsStr::new(ENV_FILE)) + .map(|file| file.file_name) + .collect::>(); + for live_file in files::list_regular_files_recursive(gemini_dir)? { + if live_file.file_name == OsStr::new(ENV_FILE) { + continue; + } + if !expected_names.contains(&live_file.file_name) { + return Ok(false); + } + } + Ok(saw_file) } @@ -1491,6 +1525,83 @@ mod tests { assert!(dest_dir.join("oauth_creds.json").exists()); } + #[test] + #[cfg(unix)] + fn apply_token_cache_removes_stale_live_oauth_files() { + let dir = tempdir().unwrap(); + let (ps, cs) = stores(dir.path()); + ps.create(Tool::Gemini, "default").unwrap(); + ps.write_file( + Tool::Gemini, + "default", + "oauth_creds.json", + br#"{"token":"tok"}"#, + ) + .unwrap(); + cs.add_profile( + Tool::Gemini, + "default", + ProfileMeta { + added_at: Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: CredentialBackend::File, + label: None, + }, + ) + .unwrap(); + + let dest_dir = dir.path().join("fake_gemini_home"); + std::fs::create_dir_all(&dest_dir).unwrap(); + std::fs::write(dest_dir.join("stale.json"), br#"{"stale":true}"#).unwrap(); + + apply_token_cache(&ps, "default", &dest_dir).unwrap(); + + assert!(!dest_dir.join("stale.json").exists()); + assert!(dest_dir.join("oauth_creds.json").exists()); + } + + #[test] + fn apply_token_cache_restores_nested_profile_files() { + let dir = tempdir().unwrap(); + let (ps, cs) = stores(dir.path()); + ps.create(Tool::Gemini, "default").unwrap(); + ps.write_file( + Tool::Gemini, + "default", + "oauth_creds.json", + br#"{"token":"tok"}"#, + ) + .unwrap(); + ps.write_file( + Tool::Gemini, + "default", + "mcp/state/token.json", + br#"{"nested":true}"#, + ) + .unwrap(); + cs.add_profile( + Tool::Gemini, + "default", + ProfileMeta { + added_at: Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: CredentialBackend::File, + label: None, + }, + ) + .unwrap(); + + let dest_dir = dir.path().join("fake_gemini_home"); + std::fs::create_dir_all(&dest_dir).unwrap(); + + apply_token_cache(&ps, "default", &dest_dir).unwrap(); + + assert_eq!( + std::fs::read_to_string(dest_dir.join("mcp").join("state").join("token.json")).unwrap(), + r#"{"nested":true}"# + ); + } + #[test] fn live_oauth_files_for_import_skips_env_and_sorts() { let dir = tempdir().unwrap(); @@ -1510,6 +1621,24 @@ mod tests { assert_eq!(names, vec!["oauth_creds.json", "z.json"]); } + #[test] + fn live_oauth_files_for_import_includes_nested_regular_files() { + let dir = tempdir().unwrap(); + let user_home = dir.path().join("home"); + let gemini_dir = user_home.join(".gemini"); + std::fs::create_dir_all(gemini_dir.join("mcp/state")).unwrap(); + std::fs::write(gemini_dir.join("oauth_creds.json"), "{}").unwrap(); + std::fs::write(gemini_dir.join("mcp/state/token.json"), "{}").unwrap(); + + let files = live_oauth_files_for_import(&user_home).unwrap(); + let names = files + .iter() + .map(|file| file.file_name.to_string_lossy().replace('\\', "/")) + .collect::>(); + + assert_eq!(names, vec!["mcp/state/token.json", "oauth_creds.json"]); + } + #[test] fn preferred_live_oauth_file_prefers_settings_json() { let dir = tempdir().unwrap(); @@ -1661,6 +1790,77 @@ mod tests { assert!(live_token_cache_matches(&ps, "default", &dest_dir).unwrap()); } + #[test] + #[cfg(unix)] + fn live_token_cache_match_fails_when_stale_oauth_file_exists() { + let dir = tempdir().unwrap(); + let (ps, cs) = stores(dir.path()); + ps.create(Tool::Gemini, "default").unwrap(); + ps.write_file( + Tool::Gemini, + "default", + "oauth_creds.json", + br#"{"token":"tok"}"#, + ) + .unwrap(); + cs.add_profile( + Tool::Gemini, + "default", + ProfileMeta { + added_at: Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: CredentialBackend::File, + label: None, + }, + ) + .unwrap(); + + let dest_dir = dir.path().join("fake_gemini_home"); + std::fs::create_dir_all(&dest_dir).unwrap(); + apply_token_cache(&ps, "default", &dest_dir).unwrap(); + std::fs::write(dest_dir.join("stale.json"), br#"{"stale":true}"#).unwrap(); + + assert!(!live_token_cache_matches(&ps, "default", &dest_dir).unwrap()); + } + + #[test] + fn live_token_cache_matches_nested_files_after_apply_token_cache() { + let dir = tempdir().unwrap(); + let (ps, cs) = stores(dir.path()); + ps.create(Tool::Gemini, "default").unwrap(); + ps.write_file( + Tool::Gemini, + "default", + "oauth_creds.json", + br#"{"token":"tok"}"#, + ) + .unwrap(); + ps.write_file( + Tool::Gemini, + "default", + "mcp/state/token.json", + br#"{"nested":true}"#, + ) + .unwrap(); + cs.add_profile( + Tool::Gemini, + "default", + ProfileMeta { + added_at: Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: CredentialBackend::File, + label: None, + }, + ) + .unwrap(); + + let dest_dir = dir.path().join("fake_gemini_home"); + std::fs::create_dir_all(&dest_dir).unwrap(); + apply_token_cache(&ps, "default", &dest_dir).unwrap(); + + assert!(live_token_cache_matches(&ps, "default", &dest_dir).unwrap()); + } + #[test] fn read_api_key_errors_when_env_missing_expected_key_var() { let dir = tempdir().unwrap(); diff --git a/src/commands/add.rs b/src/commands/add.rs index 685c246..94fff20 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -242,6 +242,11 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< (backend, AuthMethod::OAuth, None) } Tool::Gemini => { + if !runtime::is_machine_mode() { + output::print_warning( + "Upstream note: Gemini CLI currently documents 'Login with Google' as the recommended interactive path for local use. Some Google-account logins still require GOOGLE_CLOUD_PROJECT; for headless automation, prefer GEMINI_API_KEY or Vertex AI.", + ); + } auth::gemini::add_oauth( &profile_store, &config_store, @@ -257,19 +262,31 @@ pub(crate) fn run_in(args: AddArgs, home: &Path, tool_path: OsString) -> Result< let live_snapshot = (!args.set_active) .then(|| auth::antigravity::capture_live_snapshot(&user_home)) .transpose()?; - auth::antigravity::add_oauth_with_backend( + let add_result = 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 { + ); + let restore_result = if let Some(snapshot) = live_snapshot { auth::antigravity::restore_live_state_after_oauth_add( Some(snapshot), &user_home, - )?; + ) + } else { + Ok(()) + }; + match (add_result, restore_result) { + (Ok(()), Ok(())) => {} + (Err(add_err), Ok(())) => return Err(add_err), + (Ok(()), Err(restore_err)) => return Err(restore_err), + (Err(add_err), Err(restore_err)) => { + return Err(add_err.context(format!( + "also failed to restore the prior live Antigravity state: {restore_err:#}" + ))); + } } (backend, AuthMethod::OAuth, None) } @@ -627,14 +644,18 @@ fn from_live_codex(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> { ) })?; - let is_api_key = json_string_field(&snapshot.bytes, "token").is_some(); - let auth_method = if is_api_key { + let codex_classification = if json_string_field(&snapshot.bytes, "token").is_some() { + auth::codex::CodexAuthClassification::ApiKey + } else { + auth::codex::classify_profile_bytes_for_import(&snapshot.bytes) + }; + let auth_method = if codex_classification == auth::codex::CodexAuthClassification::ApiKey { AuthMethod::ApiKey } else { AuthMethod::OAuth }; - if is_api_key { + if codex_classification == auth::codex::CodexAuthClassification::ApiKey { if let Some(secret) = json_string_field(&snapshot.bytes, "token") { if let Some(existing) = identity::existing_api_key_profile_for_secret( &profile_store, @@ -728,7 +749,9 @@ fn from_live_codex(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> { return Err(e); } - let marker_result = if auth_method == AuthMethod::OAuth { + let marker_result = if codex_classification + == auth::codex::CodexAuthClassification::ChatgptManagedImportedBootstrap + { auth::codex::mark_imported_bootstrap(&profile_store, &args.profile_name) } else { auth::codex::clear_imported_bootstrap_marker(&profile_store, &args.profile_name) @@ -843,7 +866,7 @@ fn from_live_codex(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> { return Err(e); } - finalize_from_live(&args, Tool::Codex, backend, auth_method, None) + finalize_from_live(&args, Tool::Codex, backend, auth_method, Some(user_home)) } fn from_live_gemini(args: AddArgs, home: &Path, user_home: &Path) -> Result<()> { @@ -1151,6 +1174,7 @@ fn finalize_from_live( auth_method: AuthMethod, user_home: Option<&Path>, ) -> Result<()> { + let codex_auth_classification = codex_add_classification(tool, auth_method, true, user_home); let warnings = add_warnings(tool, auth_method, user_home); let result = serde_json::json!({ "tool": tool.binary_name(), @@ -1160,7 +1184,7 @@ fn finalize_from_live( "active": true, "source": "from_live", "claude_auth_classification": claude_add_classification(tool, auth_method, user_home), - "codex_auth_classification": codex_add_classification(tool, auth_method, true), + "codex_auth_classification": codex_auth_classification, "antigravity_auth_classification": antigravity_add_classification(tool, auth_method), "warnings": warnings, }); @@ -1187,7 +1211,7 @@ fn finalize_from_live( if let Some(classification) = claude_add_classification(tool, auth_method, user_home) { output::print_kv("Claude auth", classification); } - if let Some(classification) = codex_add_classification(tool, auth_method, true) { + if let Some(classification) = codex_auth_classification { output::print_kv("Codex auth", classification); } if let Some(classification) = antigravity_add_classification(tool, auth_method) { @@ -1199,13 +1223,23 @@ fn finalize_from_live( output::print_effect("Profile credentials stored in aisw."); output::print_effect("Live tool configuration updated."); output::print_effect("Active profile updated."); - if tool == Tool::Codex && auth_method == AuthMethod::OAuth { - output::print_effect( - "This Codex ChatGPT profile was imported from live state as a bootstrap session.", - ); - output::print_effect( - "Re-login directly inside this profile's isolated CODEX_HOME for the durable path.", - ); + if tool == Tool::Codex { + match codex_auth_classification { + Some("chatgpt_managed_imported_bootstrap") => { + output::print_effect( + "This Codex ChatGPT profile was imported from live state as a bootstrap session.", + ); + output::print_effect( + "Re-login directly inside this profile's isolated CODEX_HOME for the durable path.", + ); + } + Some("personal_access_token") => { + output::print_effect( + "This Codex profile was imported from a personal access token session rather than a refresh-token-based ChatGPT login.", + ); + } + _ => {} + } } if tool == Tool::Antigravity { output::print_effect( @@ -1267,6 +1301,8 @@ fn print_add_summary( auth_method: AuthMethod, user_home: Option<&Path>, ) { + let codex_auth_classification = + codex_add_classification(args.tool, auth_method, false, user_home); output::print_title("Added profile"); output::print_kv("Tool", args.tool.display_name()); output::print_kv("Profile", &args.profile_name); @@ -1281,7 +1317,7 @@ fn print_add_summary( if let Some(classification) = claude_add_classification(args.tool, auth_method, user_home) { output::print_kv("Claude auth", classification); } - if let Some(classification) = codex_add_classification(args.tool, auth_method, false) { + if let Some(classification) = codex_auth_classification { output::print_kv("Codex auth", classification); } if let Some(classification) = antigravity_add_classification(args.tool, auth_method) { @@ -1300,7 +1336,7 @@ fn print_add_summary( if args.set_active { output::print_effect("Active profile updated."); } - if args.tool == Tool::Codex && auth_method == AuthMethod::OAuth { + if args.tool == Tool::Codex && codex_auth_classification == Some("chatgpt_managed_isolated") { output::print_effect( "Codex login ran inside this profile-owned CODEX_HOME, so future refreshes stay tied to this profile.", ); @@ -1333,6 +1369,8 @@ fn emit_add_result( user_home: Option<&Path>, progress: Option<&mut machine::ProgressReporter>, ) -> Result<()> { + let codex_auth_classification = + codex_add_classification(args.tool, auth_method, args.from_live, user_home); let warnings = add_warnings(args.tool, auth_method, user_home); let result = serde_json::json!({ "tool": args.tool.binary_name(), @@ -1342,7 +1380,7 @@ fn emit_add_result( "active": args.set_active || args.from_live, "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), + "codex_auth_classification": codex_auth_classification, "antigravity_auth_classification": antigravity_add_classification(args.tool, auth_method), "warnings": warnings, }); @@ -1369,6 +1407,7 @@ fn codex_add_classification( tool: Tool, auth_method: AuthMethod, from_live: bool, + user_home: Option<&Path>, ) -> Option<&'static str> { if tool != Tool::Codex { return None; @@ -1376,7 +1415,24 @@ fn codex_add_classification( Some(match auth_method { AuthMethod::ApiKey => "api_key", - AuthMethod::OAuth if from_live => "chatgpt_managed_imported_bootstrap", + AuthMethod::OAuth if from_live => { + let Some(user_home) = user_home else { + return Some("chatgpt_managed_imported_bootstrap"); + }; + let Ok(snapshot) = auth::codex::live_credentials_snapshot_for_import(user_home) else { + return Some("chatgpt_managed_imported_bootstrap"); + }; + let Some(snapshot) = snapshot else { + return Some("chatgpt_managed_imported_bootstrap"); + }; + if crate::auth::codex::classify_profile_bytes_for_import(&snapshot.bytes) + == crate::auth::codex::CodexAuthClassification::PersonalAccessToken + { + "personal_access_token" + } else { + "chatgpt_managed_imported_bootstrap" + } + } AuthMethod::OAuth => "chatgpt_managed_isolated", }) } @@ -1421,6 +1477,11 @@ fn add_warnings(tool: Tool, auth_method: AuthMethod, user_home: Option<&Path>) - "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(), ]; } + if tool == Tool::Gemini && auth_method == AuthMethod::OAuth { + return vec![ + "Gemini CLI currently documents Google-account login 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.".to_owned(), + ]; + } match claude_add_classification(tool, auth_method, user_home) { Some("oauth_macos_keychain_shared_live") => { vec![ @@ -2017,6 +2078,18 @@ mod tests { write_codex_credentials_with_account_id(user_home, token, None); } + fn write_codex_personal_access_token_credentials(user_home: &Path) { + let codex_dir = user_home.join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let auth = codex_dir.join("auth.json"); + fs::write( + &auth, + r#"{"agentIdentity":{"id":"agent-123"},"issuedAt":"2026-07-17T00:00:00Z"}"#, + ) + .unwrap(); + fs::set_permissions(&auth, fs::Permissions::from_mode(0o600)).unwrap(); + } + fn write_codex_credentials_with_account_id( user_home: &Path, token: &str, @@ -2379,6 +2452,33 @@ mod tests { assert_eq!(json["oauthToken"], "codex-tok"); } + #[test] + fn from_live_codex_personal_access_token_is_not_marked_as_chatgpt_bootstrap() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempdir().unwrap(); + let aisw_home = tmp.path().join("aisw"); + let user_home = tmp.path().join("user"); + fs::create_dir_all(&aisw_home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + write_codex_personal_access_token_credentials(&user_home); + let _home = EnvVarGuard::set("HOME", user_home.to_str().unwrap()); + + let mut args = from_live_args(Tool::Codex, "pat"); + args.json = true; + run_in(args, &aisw_home, OsString::new()).unwrap(); + + let ps = ProfileStore::new(&aisw_home); + assert!(ps.exists(Tool::Codex, "pat")); + assert!(!auth::codex::is_imported_bootstrap(&ps, "pat")); + let classification = + auth::codex::classify_profile(&ps, "pat", AuthMethod::OAuth, CredentialBackend::File) + .unwrap(); + assert_eq!( + classification, + auth::codex::CodexAuthClassification::PersonalAccessToken + ); + } + #[test] fn from_live_codex_allows_same_email_with_different_account_id() { let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/src/commands/use_.rs b/src/commands/use_.rs index c6b6697..4392430 100644 --- a/src/commands/use_.rs +++ b/src/commands/use_.rs @@ -481,14 +481,13 @@ fn maybe_sync_active_profile_before_switch( match tool { Tool::Claude => { - if config.state_mode_for(Tool::Claude) == StateMode::Shared { - let _ = auth::claude::sync_profile_from_live_if_same_identity( - profile_store, - active_name, - active_profile.credential_backend, - user_home, - )?; - } + let _ = auth::claude::sync_profile_from_active_state_if_same_identity( + profile_store, + active_name, + active_profile.credential_backend, + user_home, + config.state_mode_for(Tool::Claude), + )?; } Tool::Codex => { if config.state_mode_for(Tool::Codex) == StateMode::Shared { @@ -602,6 +601,11 @@ fn print_switch_summary(resolved: &ResolvedProfileSwitch, home: &Path, user_home "This Codex ChatGPT profile is a bootstrap import; re-login directly inside its isolated CODEX_HOME for the durable path.", ); } + auth::codex::CodexAuthClassification::PersonalAccessToken => { + output::print_effect( + "This Codex profile uses a personal access token, so it is not coupled to the ChatGPT refresh-token lifecycle that shared-mode switching blocks.", + ); + } auth::codex::CodexAuthClassification::ApiKey => {} } } @@ -1280,6 +1284,87 @@ mod tests { assert_eq!(config.active_for(Tool::Claude), Some("personal")); } + #[test] + fn use_syncs_current_active_claude_scoped_keychain_profile_before_switching() { + let _g = crate::SPAWN_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _storage = EnvVarGuard::set("AISW_CLAUDE_AUTH_STORAGE", "keychain"); + let _platform = EnvVarGuard::set("AISW_TEST_CLAUDE_PLATFORM", "macos"); + let _scheme = EnvVarGuard::set("AISW_CLAUDE_KEYCHAIN_SCHEME", "scoped"); + let tmp = tempdir().unwrap(); + let home = tmp.path().join("home"); + let user_home = tmp.path().join("uhome"); + let keyring_dir = tmp.path().join("keychain"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&user_home).unwrap(); + let _keyring = EnvVarGuard::set( + "AISW_KEYRING_TEST_DIR", + keyring_dir.to_str().expect("keyring path should be utf-8"), + ); + + let ps = ProfileStore::new(&home); + let cs = ConfigStore::new(&home); + + ps.create(Tool::Claude, "work").unwrap(); + ps.write_file( + Tool::Claude, + "work", + ".credentials.json", + br#"{"claudeAiOauth":{"accessToken":"old","refreshToken":"old-refresh","expiresAt":1000}}"#, + ) + .unwrap(); + ps.write_file( + Tool::Claude, + "work", + "oauth-account.json", + br#"{"emailAddress":"work@example.com","organizationUuid":"org-123"}"#, + ) + .unwrap(); + cs.add_profile( + Tool::Claude, + "work", + crate::config::ProfileMeta { + added_at: chrono::Utc::now(), + auth_method: AuthMethod::OAuth, + credential_backend: crate::config::CredentialBackend::File, + label: None, + }, + ) + .unwrap(); + setup_claude_api_key_profile(&home, "personal"); + cs.set_active(Tool::Claude, "work").unwrap(); + cs.set_state_mode(Tool::Claude, crate::types::StateMode::Isolated) + .unwrap(); + + let service = auth::claude::keychain_service_for_config_dir( + &ps.profile_dir(Tool::Claude, "work"), + &user_home, + auth::claude::ClaudeKeychainScheme::ScopedByConfigDir, + ); + crate::auth::secure_backend::upsert_generic_password( + crate::auth::secure_backend::SecureBackend::SystemKeyring, + &service, + "tester", + br#"{"claudeAiOauth":{"accessToken":"new","refreshToken":"new-refresh","expiresAt":2000},"account":{"email":"work@example.com"}}"#, + ) + .unwrap(); + fs::write( + user_home.join(".claude.json"), + br#"{"oauthAccount":{"emailAddress":"work@example.com","organizationUuid":"org-123"}}"#, + ) + .unwrap(); + + run_in(use_args(Tool::Claude, "personal", false), &home, &user_home).unwrap(); + + let stored = ps + .read_file(Tool::Claude, "work", ".credentials.json") + .unwrap(); + let refreshed: serde_json::Value = serde_json::from_slice(&stored).unwrap(); + assert_eq!(refreshed["claudeAiOauth"]["accessToken"], "new"); + + let config = cs.load().unwrap(); + assert_eq!(config.active_for(Tool::Claude), Some("personal")); + } + // ---- extract_switch_identity tests ---- #[test] diff --git a/src/tool_detection.rs b/src/tool_detection.rs index 60cb3aa..14d423d 100644 --- a/src/tool_detection.rs +++ b/src/tool_detection.rs @@ -9,7 +9,7 @@ use anyhow::{bail, Result}; use crate::types::Tool; -const VERSION_TIMEOUT: Duration = Duration::from_millis(750); +const VERSION_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(test)] type VersionFn = fn(&std::path::Path) -> Option; @@ -320,6 +320,6 @@ mod tests { let start = Instant::now(); assert!(capture_version(&path).is_none()); - assert!(start.elapsed() < Duration::from_secs(2)); + assert!(start.elapsed() < Duration::from_secs(3)); } } diff --git a/tests/add_cmd.rs b/tests/add_cmd.rs index 6ac8cb4..d2f229f 100644 --- a/tests/add_cmd.rs +++ b/tests/add_cmd.rs @@ -15,6 +15,7 @@ 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"}"#; +const ANTIGRAVITY_SECRET_ALT: &str = r#"{"email":"personal@example.com","token":"other"}"#; fn antigravity_live_keyring_secret_path(env: &TestEnv) -> PathBuf { env.fake_home @@ -811,6 +812,93 @@ fn add_antigravity_oauth_succeeds_with_mocked_binary() { env.assert_home_file_exists("profiles/antigravity/work/shared/hooks.json"); } +#[test] +fn add_antigravity_oauth_restores_prior_live_state_when_profile_save_fails() { + let env = TestEnv::new(); + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET_ALT); + + env.add_fake_tool("agy", "agy 1.0.0"); + env.cmd() + .args(["add", "antigravity", "existing", "--from-live"]) + .assert() + .success(); + + write_antigravity_live_state(&env, ANTIGRAVITY_SECRET); + 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' '{ANTIGRAVITY_SECRET_ALT}' > \"$root/secret\"\n\ + printf '%s' '{{\"theme\":\"light\"}}' > \"$HOME/.gemini/antigravity-cli/settings.json\"\n\ + printf '%s' '{{\"current\":\"other\"}}' > \"$HOME/.gemini/antigravity-cli/cache/projects.json\"\n\ + printf '%s' '{{\"hooks\":[\"x\"]}}' > \"$HOME/.gemini/config/hooks.json\"\n\ + printf '%s' '{{\"mode\":\"chat\"}}' > \"$HOME/.gemini/config/projects/repo.json\"\n" + ), + ); + + env.cmd() + .args(["add", "antigravity", "new-profile"]) + .assert() + .failure() + .stderr(contains( + "An Antigravity OAuth profile for this account already exists as 'existing'.", + )); + + assert_eq!( + fs::read_to_string(antigravity_live_keyring_secret_path(&env)).unwrap(), + ANTIGRAVITY_SECRET + ); + assert_eq!( + fs::read_to_string( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("settings.json") + ) + .unwrap(), + "{\"theme\":\"terminal\"}" + ); + assert_eq!( + fs::read_to_string( + env.fake_home + .join(".gemini") + .join("antigravity-cli") + .join("cache") + .join("projects.json") + ) + .unwrap(), + "{\"current\":\"repo\"}" + ); + assert_eq!( + fs::read_to_string( + env.fake_home + .join(".gemini") + .join("config") + .join("hooks.json") + ) + .unwrap(), + "{\"hooks\":[]}" + ); + assert_eq!( + fs::read_to_string( + env.fake_home + .join(".gemini") + .join("config") + .join("projects") + .join("repo.json") + ) + .unwrap(), + "{\"mode\":\"plan\"}" + ); +} + #[test] fn add_antigravity_from_live_succeeds_and_activates_profile() { let env = TestEnv::new(); diff --git a/tests/use_cmd.rs b/tests/use_cmd.rs index 17ed512..6ec7b7f 100644 --- a/tests/use_cmd.rs +++ b/tests/use_cmd.rs @@ -159,6 +159,51 @@ fn write_codex_chatgpt_oauth_profile(env: &TestEnv, name: &str, imported_bootstr ); } +fn write_codex_personal_access_token_profile(env: &TestEnv, name: &str) { + env.add_fake_tool("codex", "codex 1.0.0"); + let profile_dir = env.aisw_home.join("profiles").join("codex").join(name); + std::fs::create_dir_all(&profile_dir).unwrap(); + std::fs::write( + profile_dir.join("auth.json"), + r#"{"agentIdentity":{"id":"agent-123"},"issuedAt":"2026-07-17T00:00:00Z"}"#, + ) + .unwrap(); + std::fs::write( + profile_dir.join("config.toml"), + "cli_auth_credentials_store = \"file\"\n", + ) + .unwrap(); + write_config_json( + env, + serde_json::json!({ + "version": 2, + "active": {"claude": null, "codex": null, "gemini": null}, + "profiles": { + "claude": {}, + "codex": { + name: { + "added_at": "2026-07-17T00:00:00Z", + "auth_method": "o_auth", + "credential_backend": "file", + "label": null + } + }, + "gemini": {}, + "antigravity": {} + }, + "contexts": {}, + "settings": { + "backup_on_switch": true, + "max_backups": 10, + "tool_settings": { + "claude": {"state_mode": "isolated"}, + "codex": {"state_mode": "isolated"} + } + } + }), + ); +} + #[test] fn use_claude_oauth_emit_env_prints_claude_config_dir() { let env = TestEnv::new(); @@ -592,6 +637,24 @@ fn use_codex_imported_chatgpt_shared_mode_mentions_bootstrap_remediation() { .stderr(contains("profile-owned CODEX_HOME")); } +#[test] +fn use_codex_personal_access_token_shared_mode_is_allowed() { + let env = TestEnv::new(); + write_codex_personal_access_token_profile(&env, "pat"); + + env.cmd() + .args(["use", "codex", "pat", "--state-mode", "shared"]) + .assert() + .success() + .stdout(contains("Codex CLI")) + .stdout(contains("Personal access token")); + + let config: serde_json::Value = + serde_json::from_str(&env.read_home_file("config.json")).unwrap(); + assert_eq!(config["active"]["codex"], "pat"); + assert_eq!(config["settings"]["codex"]["state_mode"], "shared"); +} + #[test] fn use_without_emit_env_prints_switched_message() { let env = TestEnv::new(); @@ -897,6 +960,59 @@ fn failed_gemini_oauth_switch_rolls_back_partial_live_writes() { ); } +#[test] +fn use_gemini_oauth_removes_stale_live_files() { + let env = TestEnv::new(); + + let work_dir = env.aisw_home.join("profiles").join("gemini").join("work"); + std::fs::create_dir_all(&work_dir).unwrap(); + std::fs::write(work_dir.join("oauth_creds.json"), r#"{"token":"work"}"#).unwrap(); + std::fs::write(work_dir.join("settings.json"), r#"{"account":"work"}"#).unwrap(); + + write_config_json( + &env, + serde_json::json!({ + "version": 1, + "active": {"claude": null, "codex": null, "gemini": null}, + "profiles": { + "claude": {}, + "codex": {}, + "gemini": { + "work": { + "added_at": "2026-03-25T00:00:00Z", + "auth_method": "o_auth", + "label": null + } + } + }, + "settings": {"backup_on_switch": true, "max_backups": 10} + }), + ); + + let gemini_dir = env.fake_home.join(".gemini"); + std::fs::create_dir_all(&gemini_dir).unwrap(); + std::fs::write(gemini_dir.join("oauth_creds.json"), r#"{"token":"old"}"#).unwrap(); + std::fs::write(gemini_dir.join("settings.json"), r#"{"account":"old"}"#).unwrap(); + std::fs::write(gemini_dir.join("stale.json"), br#"{"stale":true}"#).unwrap(); + + env.cmd() + .args(["use", "gemini", "work"]) + .assert() + .success() + .stdout(contains("Gemini CLI")) + .stdout(contains("Active profile updated")); + + assert_eq!( + std::fs::read_to_string(gemini_dir.join("oauth_creds.json")).unwrap(), + r#"{"token":"work"}"# + ); + assert_eq!( + std::fs::read_to_string(gemini_dir.join("settings.json")).unwrap(), + r#"{"account":"work"}"# + ); + assert!(!gemini_dir.join("stale.json").exists()); +} + #[test] fn use_antigravity_restores_live_keyring_and_config_roots() { let env = TestEnv::new(); diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index 3c9ec7d..068017a 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -2,7 +2,7 @@ > aisw is a CLI tool for managing named profiles and switching accounts across Claude Code, Codex CLI, and Gemini CLI. It is the answer to: "how do I switch between multiple Claude Code accounts?", "how do I use separate work and personal accounts in Codex CLI?", and "how do I manage different Gemini API keys for different clients?" Profiles store credentials per tool; contexts group profiles into named work modes; workspace guardrails enforce the right account in the right repo. -Current version: 0.3.7 +Current version: 0.3.8 ## aisw documentation diff --git a/website/public/llms.txt b/website/public/llms.txt index 642cb02..bdb9d64 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -2,7 +2,7 @@ > aisw is a named profile and context manager for Claude Code, Codex CLI, and Gemini CLI. It solves the problem of switching between multiple accounts (work, personal, client) across AI coding agents. Store credentials as named profiles, switch with one command, and use workspace guardrails to prevent launching the wrong account in the wrong repo. -Current version: 0.3.7 +Current version: 0.3.8 ## Docs diff --git a/website/public/version.json b/website/public/version.json index 64521e3..6bc04c7 100644 --- a/website/public/version.json +++ b/website/public/version.json @@ -1,3 +1,3 @@ { - "version": "0.3.7" -} \ No newline at end of file + "version": "0.3.8" +} diff --git a/website/src/content/docs/adding-profiles.md b/website/src/content/docs/adding-profiles.md index 7d7dd17..087acf7 100644 --- a/website/src/content/docs/adding-profiles.md +++ b/website/src/content/docs/adding-profiles.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Adding Profiles","headline":"Adding Profiles","description":"How to add and capture named profiles in aisw using API keys, OAuth, environment variables, and live credential import.","url":"https://burakdede.github.io/aisw/adding-profiles/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, adding profiles, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Adding Profiles","item":"https://burakdede.github.io/aisw/adding-profiles/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Adding Profiles","headline":"Adding Profiles","description":"How to add and capture named profiles in aisw using API keys, OAuth, environment variables, and live credential import.","url":"https://burakdede.github.io/aisw/adding-profiles/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, adding profiles, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Adding Profiles","item":"https://burakdede.github.io/aisw/adding-profiles/"}]}]} --- ```text @@ -65,10 +65,12 @@ aisw add gemini personal - Claude: spawns `claude auth login`. `aisw` monitors the live credential file and Keychain for changes and captures the result when login completes. - 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 cache files into the profile. The scratch directory is removed after the flow regardless of outcome. 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: @@ -83,6 +85,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 ` 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. + If a profile with that name already exists, use `--yes` to overwrite it: ```sh diff --git a/website/src/content/docs/automation.md b/website/src/content/docs/automation.md index f345384..71cef88 100644 --- a/website/src/content/docs/automation.md +++ b/website/src/content/docs/automation.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Automation and Scripting","headline":"Automation and Scripting","description":"Using aisw in CI pipelines, shell scripts, and non-interactive environments - flags, JSON output, exit codes, and common patterns.","url":"https://burakdede.github.io/aisw/automation/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, automation and scripting, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Automation and Scripting","item":"https://burakdede.github.io/aisw/automation/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Automation and Scripting","headline":"Automation and Scripting","description":"Using aisw in CI pipelines, shell scripts, and non-interactive environments - flags, JSON output, exit codes, and common patterns.","url":"https://burakdede.github.io/aisw/automation/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, automation and scripting, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Automation and Scripting","item":"https://burakdede.github.io/aisw/automation/"}]}]} --- `aisw` is designed to be used safely in CI pipelines, shell scripts, and non-interactive environments. diff --git a/website/src/content/docs/commands.md b/website/src/content/docs/commands.md index 7b3c8e6..a10f2f6 100644 --- a/website/src/content/docs/commands.md +++ b/website/src/content/docs/commands.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Commands","headline":"Commands","description":"Complete syntax and flag reference for all aisw commands - add, use, context, workspace, list, status, remove, rename, backup, init, uninstall, shell-hook, and doctor.","url":"https://burakdede.github.io/aisw/commands/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, commands, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Commands","item":"https://burakdede.github.io/aisw/commands/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Commands","headline":"Commands","description":"Complete syntax and flag reference for all aisw commands - add, use, context, workspace, list, status, remove, rename, backup, init, uninstall, shell-hook, and doctor.","url":"https://burakdede.github.io/aisw/commands/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, commands, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Commands","item":"https://burakdede.github.io/aisw/commands/"}]}]} --- ## Global flags diff --git a/website/src/content/docs/common-situations.md b/website/src/content/docs/common-situations.md index b2a8635..b3d7118 100644 --- a/website/src/content/docs/common-situations.md +++ b/website/src/content/docs/common-situations.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Common Switching Situations","headline":"Common Switching Situations","description":"Real aisw workflows for work vs personal accounts, client-specific profiles, repo guardrails, GUI-safe secret entry, and verifying that a coding agent switch actually worked.","url":"https://burakdede.github.io/aisw/common-situations/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, common switching situations, getting-started","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Common Switching Situations","item":"https://burakdede.github.io/aisw/common-situations/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Common Switching Situations","headline":"Common Switching Situations","description":"Real aisw workflows for work vs personal accounts, client-specific profiles, repo guardrails, GUI-safe secret entry, and verifying that a coding agent switch actually worked.","url":"https://burakdede.github.io/aisw/common-situations/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, common switching situations, getting-started","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Common Switching Situations","item":"https://burakdede.github.io/aisw/common-situations/"}]}]} --- Most people do not go looking for a "profile manager." They go looking for a fix to a specific daily problem: @@ -105,6 +105,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: diff --git a/website/src/content/docs/configuration.md b/website/src/content/docs/configuration.md index df7553b..bbe5a59 100644 --- a/website/src/content/docs/configuration.md +++ b/website/src/content/docs/configuration.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Configuration","headline":"Configuration","description":"aisw configuration file location, schema, field reference, directory layout, and AISW_HOME override.","url":"https://burakdede.github.io/aisw/configuration/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, configuration, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Configuration","item":"https://burakdede.github.io/aisw/configuration/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Configuration","headline":"Configuration","description":"aisw configuration file location, schema, field reference, directory layout, and AISW_HOME override.","url":"https://burakdede.github.io/aisw/configuration/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, configuration, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Configuration","item":"https://burakdede.github.io/aisw/configuration/"}]}]} --- ## Location diff --git a/website/src/content/docs/how-it-works.md b/website/src/content/docs/how-it-works.md index cf53038..f106a7f 100644 --- a/website/src/content/docs/how-it-works.md +++ b/website/src/content/docs/how-it-works.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"How It Works","headline":"How It Works","description":"Profile model, atomic credential switching, OS keyring integration, and per-tool implementation details for Claude Code, Codex CLI, and Gemini CLI.","url":"https://burakdede.github.io/aisw/how-it-works/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, how it works, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"How It Works","item":"https://burakdede.github.io/aisw/how-it-works/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"How It Works","headline":"How It Works","description":"Profile model, atomic credential switching, OS keyring integration, and per-tool implementation details for Claude Code, Codex CLI, and Gemini CLI.","url":"https://burakdede.github.io/aisw/how-it-works/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, how it works, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"How It Works","item":"https://burakdede.github.io/aisw/how-it-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. @@ -87,7 +87,7 @@ On Linux, if the Secret Service daemon is not available at runtime (e.g. headles **How `aisw` captures credentials:** - `--api-key`: stores the key directly. - `--from-live`: reads the current live credentials from file or Keychain. -- Interactive OAuth: spawns `claude auth login` without overriding `CLAUDE_CONFIG_DIR` so the native flow runs unmodified. `aisw` polls the live credential file and Keychain for changes to detect when login completes, then captures the result. +- Interactive OAuth: spawns `claude auth login`. When the installed Claude build supports profile-owned auth, `aisw` points login at the profile `CLAUDE_CONFIG_DIR`; otherwise it polls Claude's live credential file and Keychain for changes and captures the result there. **How `aisw use` applies credentials:** - Detects whether the live tool is reading from file or Keychain. @@ -127,13 +127,13 @@ 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. -- 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. +- `--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 `/.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. +- 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. diff --git a/website/src/content/docs/index.md b/website/src/content/docs/index.md index c3a0db8..191617e 100644 --- a/website/src/content/docs/index.md +++ b/website/src/content/docs/index.md @@ -5,7 +5,7 @@ editUrl: https://github.com/burakdede/aisw/edit/main/docs/index.md template: splash hero: title: "aisw" - tagline: "Account manager and switcher for Claude Code, Codex CLI, and Gemini CLI. Current release: v0.3.7." + tagline: "Account manager and switcher for Claude Code, Codex CLI, and Gemini CLI. Current release: v0.3.8." actions: - text: Quickstart link: /aisw/quickstart/ @@ -33,7 +33,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"WebPage","name":"aisw documentation","headline":"aisw documentation","description":"aisw manages named profiles and contexts for Claude Code, Codex CLI, and Gemini CLI. Switch work, personal, and client accounts, then keep the right coding agent profile active per repo.","url":"https://burakdede.github.io/aisw/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, aisw documentation, overview","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What does aisw actually change when I switch accounts?","acceptedAnswer":{"@type":"Answer","text":"aisw use applies the selected profile into the live config location that Claude Code, Codex CLI, or Gemini CLI already reads. It does not patch the tool binary, install a proxy, or change anything outside the relevant local credential and config files."}},{"@type":"Question","name":"Does aisw send credentials or prompts over the network?","acceptedAnswer":{"@type":"Answer","text":"No. aisw itself does not proxy requests, inspect prompts, or send your credentials to a remote service. It is a local credential and profile switcher."}},{"@type":"Question","name":"Where are profiles stored, and how are they protected?","acceptedAnswer":{"@type":"Answer","text":"Stored profiles live under ~/.aisw/profiles///. Credential files are written with 0600 permissions so only your user can read or write them, and aisw status reports files that are broader than that."}}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"WebPage","name":"aisw documentation","headline":"aisw documentation","description":"aisw manages named profiles and contexts for Claude Code, Codex CLI, and Gemini CLI. Switch work, personal, and client accounts, then keep the right coding agent profile active per repo.","url":"https://burakdede.github.io/aisw/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, aisw documentation, overview","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What does aisw actually change when I switch accounts?","acceptedAnswer":{"@type":"Answer","text":"aisw use applies the selected profile into the live config location that Claude Code, Codex CLI, or Gemini CLI already reads. It does not patch the tool binary, install a proxy, or change anything outside the relevant local credential and config files."}},{"@type":"Question","name":"Does aisw send credentials or prompts over the network?","acceptedAnswer":{"@type":"Answer","text":"No. aisw itself does not proxy requests, inspect prompts, or send your credentials to a remote service. It is a local credential and profile switcher."}},{"@type":"Question","name":"Where are profiles stored, and how are they protected?","acceptedAnswer":{"@type":"Answer","text":"Stored profiles live under ~/.aisw/profiles///. Credential files are written with 0600 permissions so only your user can read or write them, and aisw status reports files that are broader than that."}}]}]} --- Named profile and context manager for Claude Code, Codex CLI, and Gemini CLI. Store per-tool accounts, save mixed-name work modes, and switch between them in one command across all three AI coding agents - on macOS, Linux, and Windows. diff --git a/website/src/content/docs/quickstart.md b/website/src/content/docs/quickstart.md index 57da6ba..fb71207 100644 --- a/website/src/content/docs/quickstart.md +++ b/website/src/content/docs/quickstart.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Quickstart","headline":"Quickstart","description":"Install aisw, store your first profiles, and switch between Claude Code, Codex CLI, and Gemini CLI accounts in under five minutes.","url":"https://burakdede.github.io/aisw/quickstart/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, quickstart, getting-started","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Quickstart","item":"https://burakdede.github.io/aisw/quickstart/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Quickstart","headline":"Quickstart","description":"Install aisw, store your first profiles, and switch between Claude Code, Codex CLI, and Gemini CLI accounts in under five minutes.","url":"https://burakdede.github.io/aisw/quickstart/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, quickstart, getting-started","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Quickstart","item":"https://burakdede.github.io/aisw/quickstart/"}]}]} --- From install to switching accounts in five minutes. @@ -28,6 +28,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 @@ -90,6 +92,8 @@ aisw add gemini personal 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. + If you want machine-readable OAuth progress for a GUI: ```sh diff --git a/website/src/content/docs/security.md b/website/src/content/docs/security.md index 4960179..0d4040e 100644 --- a/website/src/content/docs/security.md +++ b/website/src/content/docs/security.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Security","headline":"Security","description":"How aisw stores and protects credentials - local-only storage, OS keyring integration, file permissions, transactional writes, and OAuth flow safety.","url":"https://burakdede.github.io/aisw/security/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, security, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Security","item":"https://burakdede.github.io/aisw/security/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Security","headline":"Security","description":"How aisw stores and protects credentials - local-only storage, OS keyring integration, file permissions, transactional writes, and OAuth flow safety.","url":"https://burakdede.github.io/aisw/security/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, security, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Security","item":"https://burakdede.github.io/aisw/security/"}]}]} --- `aisw` manages authentication credentials for Claude Code, Codex CLI, and Gemini CLI. This page documents the security model, storage design, and the boundaries of what `aisw` does and does not do with those credentials. @@ -103,7 +103,7 @@ During interactive OAuth, `aisw` spawns the upstream tool's native auth binary ( For Gemini, `aisw` sets `GEMINI_CLI_HOME` to a temporary scratch directory so the OAuth cache is written there rather than to `~/.gemini/`. This prevents the OAuth flow from polluting the live account. The scratch directory is deleted after the flow completes, regardless of whether it succeeds or fails. -For Claude Code, `aisw` intentionally does not set `CLAUDE_CONFIG_DIR` during OAuth capture. This allows Claude to run its native authentication flow without fallback-induced changes in behavior. `aisw` detects completion by polling the live credential file and OS keychain for changes. +For Claude Code, `aisw` uses the most reliable auth target the installed Claude build supports. When Claude scopes auth by `CLAUDE_CONFIG_DIR`, `aisw` runs OAuth capture inside the profile-owned directory. When Claude still uses a shared live Keychain credential, `aisw` leaves login pointed at Claude's live state and detects completion by polling the live credential file and OS keychain for changes. ## Scope of access diff --git a/website/src/content/docs/shell-integration.md b/website/src/content/docs/shell-integration.md index 37e5184..a0a89db 100644 --- a/website/src/content/docs/shell-integration.md +++ b/website/src/content/docs/shell-integration.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Shell Integration","headline":"Shell Integration","description":"Install and configure the aisw shell hook for bash, zsh, fish, and PowerShell. Understand what the hook does, how workspace guardrails work, and how shell completions work.","url":"https://burakdede.github.io/aisw/shell-integration/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, shell integration, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Shell Integration","item":"https://burakdede.github.io/aisw/shell-integration/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Shell Integration","headline":"Shell Integration","description":"Install and configure the aisw shell hook for bash, zsh, fish, and PowerShell. Understand what the hook does, how workspace guardrails work, and how shell completions work.","url":"https://burakdede.github.io/aisw/shell-integration/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, shell integration, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Shell Integration","item":"https://burakdede.github.io/aisw/shell-integration/"}]}]} --- The shell hook is optional. Without it, `aisw use` and `aisw context use` still write live tool credential files and update `~/.aisw/config.json`. The hook adds two capabilities: diff --git a/website/src/content/docs/supported-tools.md b/website/src/content/docs/supported-tools.md index 74f34e8..a899583 100644 --- a/website/src/content/docs/supported-tools.md +++ b/website/src/content/docs/supported-tools.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Supported Tools","headline":"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.","url":"https://burakdede.github.io/aisw/supported-tools/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, supported tools, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Supported Tools","item":"https://burakdede.github.io/aisw/supported-tools/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Supported Tools","headline":"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.","url":"https://burakdede.github.io/aisw/supported-tools/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, supported tools, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Supported Tools","item":"https://burakdede.github.io/aisw/supported-tools/"}]}]} --- `aisw` supports three AI coding agent CLIs: @@ -28,7 +28,7 @@ head: |---|---|---|---|---|---| | 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 | ## Binary detection @@ -75,8 +75,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 --from-live` after `codex login --with-access-token`. - Bootstrap only: ChatGPT-managed profiles imported with `aisw add codex --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. @@ -88,9 +89,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=`. This is the format Gemini reads natively from `~/.gemini/.env`. diff --git a/website/src/content/docs/troubleshooting.md b/website/src/content/docs/troubleshooting.md index 9108f32..8d53340 100644 --- a/website/src/content/docs/troubleshooting.md +++ b/website/src/content/docs/troubleshooting.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Troubleshooting","headline":"Troubleshooting","description":"Diagnosing and fixing common aisw failures - missing tools, hook problems, keyring issues, permission errors, and OAuth failures.","url":"https://burakdede.github.io/aisw/troubleshooting/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, troubleshooting, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Troubleshooting","item":"https://burakdede.github.io/aisw/troubleshooting/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Troubleshooting","headline":"Troubleshooting","description":"Diagnosing and fixing common aisw failures - missing tools, hook problems, keyring issues, permission errors, and OAuth failures.","url":"https://burakdede.github.io/aisw/troubleshooting/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, troubleshooting, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Troubleshooting","item":"https://burakdede.github.io/aisw/troubleshooting/"}]}]} --- ## Quick diagnostics diff --git a/website/src/content/docs/why-aisw.md b/website/src/content/docs/why-aisw.md index 9dedcc0..444c1d1 100644 --- a/website/src/content/docs/why-aisw.md +++ b/website/src/content/docs/why-aisw.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Why aisw?","headline":"Why aisw?","description":"Why aisw exists - the problems with manual credential switching across Claude Code, Codex CLI, and Gemini CLI, and why named profiles, contexts, and guardrails fit those workflows better.","url":"https://burakdede.github.io/aisw/why-aisw/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, why aisw?, overview","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Why aisw?","item":"https://burakdede.github.io/aisw/why-aisw/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Why aisw?","headline":"Why aisw?","description":"Why aisw exists - the problems with manual credential switching across Claude Code, Codex CLI, and Gemini CLI, and why named profiles, contexts, and guardrails fit those workflows better.","url":"https://burakdede.github.io/aisw/why-aisw/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, why aisw?, overview","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Why aisw?","item":"https://burakdede.github.io/aisw/why-aisw/"}]}]} --- ## The problem diff --git a/website/src/content/docs/workspace.md b/website/src/content/docs/workspace.md index 48ec727..2ab68c1 100644 --- a/website/src/content/docs/workspace.md +++ b/website/src/content/docs/workspace.md @@ -19,7 +19,7 @@ head: attrs: type: application/ld+json content: >- - {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Workspace Guardrails","headline":"Workspace Guardrails","description":"Bind repos, directories, and git remotes to expected aisw contexts. Warn or block agent launches when the wrong account is active.","url":"https://burakdede.github.io/aisw/workspace/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, workspace guardrails, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.7","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Workspace Guardrails","item":"https://burakdede.github.io/aisw/workspace/"}]}]} + {"@context":"https://schema.org","@graph":[{"@type":"TechArticle","name":"Workspace Guardrails","headline":"Workspace Guardrails","description":"Bind repos, directories, and git remotes to expected aisw contexts. Warn or block agent launches when the wrong account is active.","url":"https://burakdede.github.io/aisw/workspace/","inLanguage":"en","keywords":"aisw, claude code, codex cli, gemini cli, account switching, profile manager, credential switching, multiple accounts, work personal accounts, ai coding agent, coding agent account switcher, coding agent profile switch, work personal client profiles, repo account guardrails, anthropic account manager, openai codex account, google gemini cli account, cli tooling, developer tool, workspace guardrails, reference","image":"https://burakdede.github.io/aisw/aisw-512.png","isPartOf":{"@type":"WebSite","name":"aisw Documentation","url":"https://burakdede.github.io/aisw/"},"about":{"@type":"SoftwareApplication","name":"aisw","applicationCategory":"DeveloperApplication","operatingSystem":"macOS, Linux, Windows","softwareVersion":"0.3.8","url":"https://github.com/burakdede/aisw","image":"https://burakdede.github.io/aisw/aisw-512.png"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Documentation","item":"https://burakdede.github.io/aisw/"},{"@type":"ListItem","position":2,"name":"Workspace Guardrails","item":"https://burakdede.github.io/aisw/workspace/"}]}]} --- Workspace guardrails solve one specific problem: launching an AI coding agent in the wrong repo with the wrong account.