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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable user-facing changes are recorded here. The format follows

## [Unreleased]

### Added

- `aas exec <provider>` (and the bare `aas <provider>`, and `aas proxy <provider> <frontend>`) 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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <name>`** runs the agent under a profile-scoped home without touching your default.
`<name>` is a stored account name, or a provider name — `aas exec codex` runs whatever
`aas status` lists as active for Codex.
- **`export <name>`** prints the env (`CODEX_HOME=…`, `ZAI_API_KEY=…`, …) to activate a
profile in the current shell only.

Expand All @@ -153,7 +156,7 @@ ssh -t jiun-mini 'aas import ~/aas-vault.age'
| `login [provider] [name]` `--long-lived`, `--device-auth`/`--headless`, `--endpoint <id>`, *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 <provider> <name>` or `switch <account>` (alias `s`) | Make a stored account the active credential. The one-argument form resolves a globally unique stored account name. |
| `exec <name> [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 <name> [target] [args…]` (alias `e`) | Run the native CLI under a profile. `<name>` 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 <provider> <name>` `--all`, `--vault`, `-o <file>`, `--shell posix\|fish\|powershell` | Print shell env to use a profile in the current shell (`eval "$(aas export <name>)"`), or `--all` for a portable bundle of **every account + credential**. `--vault` encrypts it with an age/scrypt passphrase. |
| `sharing <name>` *share flags* | Show or change which state (sessions/skills/agents/hooks/settings) an isolated profile shares from the provider's home. |
| `rename <from> <to>` | Rename an account (moves its profile home + markers). |
Expand Down
21 changes: 15 additions & 6 deletions crates/aas-cli/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,21 @@ fn caller_system_home(raw: Option<String>) -> Option<String> {
(!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<aas_core::AccountRecord> {
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 <account>"
);
}
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();

Expand Down Expand Up @@ -478,9 +489,7 @@ async fn cleanup(proxy: Option<aas_proxy::ProxyHandle>, cross_home: &Option<Path
}

pub async fn cmd_proxy(store: &AccountStore, name: &str, frontend: &str) -> 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") {
Expand Down
27 changes: 26 additions & 1 deletion crates/aas-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand Down Expand Up @@ -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();
Expand Down
58 changes: 58 additions & 0 deletions crates/aas-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<AccountRecord>, 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<Option<AccountRecord>, StoreError> {
let matches: Vec<AccountRecord> = self
Expand Down Expand Up @@ -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());
Expand Down
Loading