diff --git a/CHANGELOG.md b/CHANGELOG.md index ae14a64..e9c0c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable user-facing changes are recorded here. The format follows ## [Unreleased] +### Added + +- `aas exec ` (and the bare `aas `, and `aas proxy `) runs + that provider's active account, so `aas exec codex` no longer answers `Account not found: codex` + for a name the rest of the CLI already understands. A stored account whose name happens to match + a provider still wins, so an account called `codex` keeps addressing that account; a provider + with no active account says so rather than guessing at one. + ### Fixed - A shim's re-entry guard no longer reaches the agent it launched. The shim exports `AAS_SHIM` so diff --git a/README.md b/README.md index 5349942..c916c0c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ aas switch codex personal # Run the native agent under a profile, without changing your default login aas exec work -- --version +aas exec codex # a provider name runs its active account # Cross-provider: run Claude's UI on the codex backend (via the local proxy) aas exec personal.codex claude @@ -135,6 +136,8 @@ ssh -t jiun-mini 'aas import ~/aas-vault.age' Prepend the directory it prints to `PATH`. Because the active account is resolved per invocation, it also cannot drift away from a written-once native file. - **`exec `** runs the agent under a profile-scoped home without touching your default. + `` is a stored account name, or a provider name — `aas exec codex` runs whatever + `aas status` lists as active for Codex. - **`export `** prints the env (`CODEX_HOME=…`, `ZAI_API_KEY=…`, …) to activate a profile in the current shell only. @@ -153,7 +156,7 @@ ssh -t jiun-mini 'aas import ~/aas-vault.age' | `login [provider] [name]` `--long-lived`, `--device-auth`/`--headless`, `--endpoint `, *share flags* | Login and store a new **isolated** profile. `--long-lived` uses Claude's `setup-token`; `--device-auth` uses a browserless device-code flow; `--endpoint` picks the API host for providers that run several (kimi). | | `load [provider] [name]` | Snapshot the **currently logged-in** credential as a **system** profile (auto-scans providers if none given). | | `switch ` or `switch ` (alias `s`) | Make a stored account the active credential. The one-argument form resolves a globally unique stored account name. | -| `exec [target] [args…]` (alias `e`) | Run the native CLI under a profile. If `target` ≠ the profile's provider, requests route through the local **ASX Proxy** (cross-provider). `-b` full-access bypass; cross-run share flags `-s/-i/--share/--isolate/--keep-context`; `--` passes the rest to the agent. | +| `exec [target] [args…]` (alias `e`) | Run the native CLI under a profile. `` is a stored account, or a provider whose active account should run (`aas exec codex`). If `target` ≠ the profile's provider, requests route through the local **ASX Proxy** (cross-provider). `-b` full-access bypass; cross-run share flags `-s/-i/--share/--isolate/--keep-context`; `--` passes the rest to the agent. | | `export [name]` or `export ` `--all`, `--vault`, `-o `, `--shell posix\|fish\|powershell` | Print shell env to use a profile in the current shell (`eval "$(aas export )"`), or `--all` for a portable bundle of **every account + credential**. `--vault` encrypts it with an age/scrypt passphrase. | | `sharing ` *share flags* | Show or change which state (sessions/skills/agents/hooks/settings) an isolated profile shares from the provider's home. | | `rename ` | Rename an account (moves its profile home + markers). | diff --git a/crates/aas-cli/src/exec.rs b/crates/aas-cli/src/exec.rs index 8d16fde..4e6c1ab 100644 --- a/crates/aas-cli/src/exec.rs +++ b/crates/aas-cli/src/exec.rs @@ -276,10 +276,21 @@ fn caller_system_home(raw: Option) -> Option { (!platform::is_managed_home(&platform::expand_home(&raw))).then_some(raw) } +/// The account a run targets, or an error that says which of the two lookups failed. +fn resolve_target(store: &AccountStore, name: &str) -> anyhow::Result { + if let Some(account) = store.resolve_run_target(name)? { + return Ok(account); + } + if let Some(provider) = normalize_provider(name) { + anyhow::bail!( + "No active account for provider '{provider}'. Name an account, or run: aas switch " + ); + } + anyhow::bail!("Account not found: {name}"); +} + pub async fn cmd_exec(store: &AccountStore, name: &str, rest: &[String]) -> anyhow::Result<()> { - let Some(acct) = store.get_by_name(name)? else { - anyhow::bail!("Account not found: {name}"); - }; + let acct = resolve_target(store, name)?; let profile_provider = acct.provider.clone(); let account_name = acct.name.clone(); @@ -478,9 +489,7 @@ async fn cleanup(proxy: Option, cross_home: &Option anyhow::Result<()> { - let Some(acct) = store.get_by_name(name)? else { - anyhow::bail!("Account not found: {name}"); - }; + let acct = resolve_target(store, name)?; let backend_provider = acct.provider.clone(); let frontend_norm = normalize_provider(frontend).unwrap_or_else(|| frontend.to_lowercase()); if !matches!(frontend_norm.as_str(), "claude" | "codex" | "grok" | "pi") { diff --git a/crates/aas-cli/src/main.rs b/crates/aas-cli/src/main.rs index 922c060..53a171a 100644 --- a/crates/aas-cli/src/main.rs +++ b/crates/aas-cli/src/main.rs @@ -360,7 +360,7 @@ fn rewrite_default_exec_args( "rename", "remove", "rm", "sharing", "refresh", "exec", "e", "proxy", "help", ]; if let Some(candidate) = first { - if !COMMANDS.contains(&candidate) && store.get_by_name(candidate)?.is_some() { + if !COMMANDS.contains(&candidate) && store.resolve_run_target(candidate)?.is_some() { args.insert(1, "exec".into()); } } @@ -1074,6 +1074,31 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn a_provider_name_is_rewritten_to_its_active_account() { + let (store, dir) = test_store(); + store.add(AccountRecord::new("codex", "work")).unwrap(); + store.set_active("codex", "work").unwrap(); + let args = vec!["aas".into(), "codex".into()]; + + let rewritten = rewrite_default_exec_args(&store, args).unwrap(); + assert_eq!(rewritten[1], "exec"); + assert_eq!(rewritten[2], "codex"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_provider_with_no_active_account_is_left_alone() { + let (store, dir) = test_store(); + store.add(AccountRecord::new("codex", "work")).unwrap(); + let args = vec!["aas".into(), "codex".into()]; + + let rewritten = rewrite_default_exec_args(&store, args).unwrap(); + assert_eq!(rewritten.len(), 2); + assert_eq!(rewritten[1], "codex"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn known_commands_and_unknown_names_are_not_rewritten() { let (store, dir) = test_store(); diff --git a/crates/aas-core/src/store.rs b/crates/aas-core/src/store.rs index cd3de9d..470fb2a 100644 --- a/crates/aas-core/src/store.rs +++ b/crates/aas-core/src/store.rs @@ -417,6 +417,25 @@ impl AccountStore { .find(|a| canonical_provider(&a.provider) == prov && a.name == name)) } + /// Resolve an `exec`/`proxy` target: a globally unique account name, or — when no account + /// carries that name — a provider name standing in for that provider's active account, so + /// `aas exec codex` runs whatever `aas status` lists as active for Codex. + /// + /// An account whose name happens to match a provider always wins, so naming an account + /// `codex` keeps addressing that account rather than the provider's active one. + pub fn resolve_run_target(&self, name: &str) -> Result, StoreError> { + if let Some(account) = self.get_by_name(name)? { + return Ok(Some(account)); + } + let Some(provider) = crate::naming::normalize_provider(name) else { + return Ok(None); + }; + let Some(active) = self.get_active(&provider)? else { + return Ok(None); + }; + self.get(&provider, &active) + } + /// asx `getAccountByName`: unique-by-name lookup, error if >1 provider matches. pub fn get_by_name(&self, name: &str) -> Result, StoreError> { let matches: Vec = self @@ -702,6 +721,45 @@ mod tests { assert_eq!(s.list(Some("codex")).unwrap().len(), 1); } + #[test] + fn run_target_resolves_an_account_name_or_a_provider_active_account() { + let s = AccountStore::at(tmp()); + s.add(AccountRecord::new("codex", "work")).unwrap(); + s.add(AccountRecord::new("claude", "home")).unwrap(); + + assert_eq!( + s.resolve_run_target("work").unwrap().map(|a| a.name), + Some("work".to_string()) + ); + // No active account yet: a provider name resolves to nothing rather than guessing. + assert!(s.resolve_run_target("codex").unwrap().is_none()); + + s.set_active("codex", "work").unwrap(); + let resolved = s.resolve_run_target("codex").unwrap().unwrap(); + assert_eq!(resolved.name, "work"); + assert_eq!(resolved.provider, "codex"); + + // Aliases resolve like the provider they name; unknown words do not. + s.set_active("claude", "home").unwrap(); + assert_eq!( + s.resolve_run_target("claude-code").unwrap().map(|a| a.name), + Some("home".to_string()) + ); + assert!(s.resolve_run_target("nope").unwrap().is_none()); + } + + #[test] + fn an_account_named_after_a_provider_wins_over_the_active_account() { + let s = AccountStore::at(tmp()); + s.add(AccountRecord::new("claude", "codex")).unwrap(); + s.add(AccountRecord::new("codex", "work")).unwrap(); + s.set_active("codex", "work").unwrap(); + + let resolved = s.resolve_run_target("codex").unwrap().unwrap(); + assert_eq!(resolved.provider, "claude"); + assert_eq!(resolved.name, "codex"); + } + #[test] fn active_marker_and_rename() { let s = AccountStore::at(tmp());