Skip to content

cost subcommand - #267

Draft
Cedric / ViaDézo1er (viadezo1er) wants to merge 24 commits into
mainfrom
cedric/cost-subcommand
Draft

cost subcommand#267
Cedric / ViaDézo1er (viadezo1er) wants to merge 24 commits into
mainfrom
cedric/cost-subcommand

Conversation

@viadezo1er

@viadezo1er Cedric / ViaDézo1er (viadezo1er) commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

STACKED BARS for bt cost --group-by user --group-by hour

say the cost is only for tokens not for storage cost

create a skill to teach the agent to use btql to see the data however they want, because we're not able to think of all the ways customers will want to use our data

TODO

repeated --group-by
too few abscissa legends in images
legends too small in images

caching?

Rebased on cedric/prune-profiles to dogfood.
#274 will be merged before this pr.

bt cost logs:

7 cost groups in BT Staging / cedric-traces
2026-07-14T01:35:49Z to 2026-07-21T01:35:49Z

Model                        Purpose    Cost       Coverage     Sources                          
claude-opus-4-8              default    $48.40     430/430      Braintrust 430                   
zai-org/GLM-5.2              default    $45.49     2267/2267    Braintrust 2267, no usage 272    
gpt-5.5                      default    $32.57     705/705      Braintrust 705, no usage 9       
gpt-5.6-sol                  default    $14.97     741/741      Braintrust 741, no usage 11      
claude-haiku-4-5-20251001    default    $0.339     50/50        Braintrust 50                    
<missing>                    default    unknown    0/4          —                                
glm-5.2                      default    unknown    0/1749       no usage 11                      

Total: ~$141.76  1753 unpriced token spans  303 without cost or usage

bt cost logs --json|jq:

{
  "project": "cedric-traces",
  "org": "BT Staging",
  "currency": "USD",
  "since": "2026-07-14T01:35:29Z",
  "until": "2026-07-21T01:35:29Z",
  "excludes_scorers": false,
  "pricing_file": null,
  "rows": [
    {
      "model": "claude-opus-4-8",
      "purpose": null,
      "candidate_spans": 430,
      "cost": 48.40370225,
      "braintrust_cost": 48.40370225,
      "file_cost": null,
      "braintrust_priced_spans": 430,
      "file_priced_spans": 0,
      "unpriced_token_spans": 0,
      "no_usage_spans": 0,
      "coverage": "full"
    },
    {
      "model": "zai-org/GLM-5.2",
      "purpose": null,
      "candidate_spans": 2539,
      "cost": 45.48671468,
      "braintrust_cost": 45.48671468,
      "file_cost": null,
      "braintrust_priced_spans": 2267,
      "file_priced_spans": 0,
      "unpriced_token_spans": 0,
      "no_usage_spans": 272,
      "coverage": "full"
    },
    {
      "model": "gpt-5.5",
      "purpose": null,
      "candidate_spans": 714,
      "cost": 32.5653,
      "braintrust_cost": 32.5653,
      "file_cost": null,
      "braintrust_priced_spans": 705,
      "file_priced_spans": 0,
      "unpriced_token_spans": 0,
      "no_usage_spans": 9,
      "coverage": "full"
    },
    {
      "model": "gpt-5.6-sol",
      "purpose": null,
      "candidate_spans": 752,
      "cost": 14.96779075,
      "braintrust_cost": 14.96779075,
      "file_cost": null,
      "braintrust_priced_spans": 741,
      "file_priced_spans": 0,
      "unpriced_token_spans": 0,
      "no_usage_spans": 11,
      "coverage": "full"
    },
    {
      "model": "claude-haiku-4-5-20251001",
      "purpose": null,
      "candidate_spans": 50,
      "cost": 0.33857415,
      "braintrust_cost": 0.33857415,
      "file_cost": null,
      "braintrust_priced_spans": 50,
      "file_priced_spans": 0,
      "unpriced_token_spans": 0,
      "no_usage_spans": 0,
      "coverage": "full"
    },
    {
      "model": null,
      "purpose": null,
      "candidate_spans": 4,
      "cost": null,
      "braintrust_cost": null,
      "file_cost": null,
      "braintrust_priced_spans": 0,
      "file_priced_spans": 0,
      "unpriced_token_spans": 4,
      "no_usage_spans": 0,
      "coverage": "unknown"
    },
    {
      "model": "glm-5.2",
      "purpose": null,
      "candidate_spans": 1760,
      "cost": null,
      "braintrust_cost": null,
      "file_cost": null,
      "braintrust_priced_spans": 0,
      "file_priced_spans": 0,
      "unpriced_token_spans": 1749,
      "no_usage_spans": 11,
      "coverage": "unknown"
    }
  ],
  "totals": {
    "candidate_spans": 6249,
    "cost": 141.76208183,
    "braintrust_cost": 141.76208183,
    "file_cost": null,
    "braintrust_priced_spans": 4193,
    "file_priced_spans": 0,
    "unpriced_token_spans": 1753,
    "no_usage_spans": 303,
    "coverage": "partial"
  }
}

@viadezo1er Cedric / ViaDézo1er (viadezo1er) changed the title cedric/cost subcommand cost subcommand Jul 10, 2026
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Latest downloadable build artifacts for this PR commit 0c407076eb84:

Available artifact names
  • artifacts-build-global
  • artifacts-build-local-x86_64-apple-darwin
  • artifacts-build-local-aarch64-pc-windows-msvc
  • artifacts-build-local-x86_64-pc-windows-msvc
  • artifacts-build-local-x86_64-unknown-linux-musl
  • artifacts-build-local-x86_64-unknown-linux-gnu
  • artifacts-build-local-aarch64-apple-darwin
  • artifacts-build-local-aarch64-unknown-linux-gnu
  • artifacts-plan-dist-manifest
  • cargo-dist-cache

first draft

BREAKING-CHANGE: --profile and --prefer-profile removed
Breaks any script using them
…n to use the new auth storage format orgid,email
The field is needed by OAuth but it's unused both bt and the backend
Therefore exposing it is useless
Now bt_cli is hardcoded as its value
@viadezo1er
Cedric / ViaDézo1er (viadezo1er) force-pushed the cedric/cost-subcommand branch 2 times, most recently from d81f21a to 0c40707 Compare July 21, 2026 01:33
 - Preserves separate OAuth and API-key login identities.
 - Keeps multiple API keys for the same org distinct using key hints.
 - Implements the intended precedence:
     1. Explicit --api-key
     2. --prefer-api-key
     3. OAuth
     4. BRAINTRUST_API_KEY
     5. Stored API key
 - Validates explicit/environment API keys against the requested org.
 - Does not fall back to OAuth when a selected key is invalid or belongs to another org.
 - Rejects API-key authentication in cross-org mode.
 - Pins the exact API-key login selected by bt switch or bt init.
 - Adds bt auth login --global/--local.
 - Adds filtering to bt auth logins --org/--prefer-api-key.
 - Changes bare bt auth logout to select from all saved logins instead of deleting the currently active one.
 - Adds bt auth logout --oauth and retains --api-key-hint.

 Config handling — src/config/mod.rs

 - Makes global/local merging org-safe:
     - A project is never inherited from another org.
     - project and project_id remain coupled.
     - Local project-without-org does not inherit the global org.

 - Stores cross-org as:

   ```json
     { "org": "" }
   ```

 - Ignores the legacy profile field.

 - Preserves unknown config keys during updates.

 - Only treats .bt/config.json as a local config; a bare .bt directory is not one.

 - Adds separate filesystem discovery rules for bt init.

 bt switch — src/switch.rs

 - Starts with a saved-login picker:
     - OAuth entries collapse into org choices.
     - API keys remain separate and display their hints.
     - Cross-org OAuth is selectable.
 - Automatically selects a sole login/project.
 - Prompts for project when multiple projects exist.
 - Pins a selected API-key slot exactly.
 - Implements global/local scope rules:
     - --global does not search for local config.
     - --local requires an existing .bt/config.json.
     - Interactive ambiguity opens a scope picker.
     - Non-interactive ambiguity errors before network work.
 - Preserves unknown config fields.
 - Fixes the scope-picker ANSI corruption you reported by passing plain labels to Dialoguer.

 bt init — src/init.rs

 - Adds:
     - --here
     - -f/--force

 - Searches upward for .bt, .git, home, or filesystem root using the specified boundary rules.

 - Resolves destination errors before authentication.

 - Uses the same saved-login/project selection behavior as switch.

 - Excludes cross-org from the picker because init requires a project.

 - Writes org, project, and project_id.

 - Reports permission/write failures with the affected path.

 - Now exposes the real destination error instead of only saying:

   ```text
     could not resolve `bt init` destination
   ```

 Cross-org CLI/status — src/args.rs, src/main.rs, src/status.rs

 - Normalizes these to canonical cross-org:

   ```sh
     --org cross-org
     --org ""
   ```

 - Displays cross-org as cross-org in human and JSON status output.

 - Prevents stale projects from being combined with cross-org context.

 - Makes --prefer-api-key fail actionably from cross-org context.
@viadezo1er
Cedric / ViaDézo1er (viadezo1er) force-pushed the cedric/cost-subcommand branch 2 times, most recently from 1bac553 to ec0f4aa Compare July 23, 2026 20:52
before it was always the same name which could lead to corruption if multiple bt used it at the same time
`bt datasets snapshots create my-dataset` would use the name of the OAuth login when the credentials used were BRAINTRUST_API_KEY, ie not OAuth
Always show current auth in bt status even if it's from API key in env
OAuth logins are instance-scoped by normalized app_url; API keys remain org-scoped.gc
Added OAuth migration and duplicate pruning.gc
Added org_id, app_url, and api_url config support with correct precedence and context coupling.gc
Reworked bt auth login, bt init, and bt switch.gc
Removed cross-org/profile selection behavior.gc
Updated login filtering, logout, status, setup compatibility, URL-derived context hints, tests, and README.gc
diff --git a/README.md b/README.mdgc
index f7bbc60..88de925 100644gc
--- a/README.mdgc
+++ b/README.mdgc
@@ -139,7 +139,7 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyCgc
 | ------------- | ------------------------------------------------------------------ |gc
 | `bt init`     | Initialize `.bt/` config directory and link to a project           |gc
 | `bt auth`     | Authenticate with Braintrust                                       |gc
-| `bt switch`   | Switch org and project context                                     |gc
+| `bt switch`   | Switch instance, org, and project context                          |gc
 | `bt status`   | Show current org and project context                               |gc
 | `bt datasets` | Manage datasets and dataset pipelines                              |gc
 | `bt eval`     | Run eval files (Unix only)                                         |gc
@@ -312,43 +312,40 @@ Local version and pagination-key conversion helpers:gc
gc
 ## `bt auth`gc
gc
-- Authenticate interactively (prompts for auth method and organization):gc
+- Authenticate interactively:gc
   - `bt auth login`gc
-  - First prompt chooses: `OAuth (browser)` (default) or `API key`.gc
-  - OAuth can be saved for an org or in cross-org mode. API-key logins are saved per org; if a key can access multiple orgs, `bt` uses a searchable org picker.gc
-  - After login, `bt` updates the active org context immediately. If `--project` is set, it validates and saves that project's name and ID. Without `--project`, a same-org login preserves the existing project; changing orgs clears stale project context.gc
+  - First choose `OAuth (browser)` (default) or `API key`, then choose an organization and config scope.gc
+  - OAuth is stored once per Braintrust instance, identified by app URL, and can authenticate every organization available to that user in the instance.gc
+  - API-key logins remain organization-scoped; multiple keys for one organization remain distinct.gc
+  - Login writes `org`, `org_id`, `project`, `project_id`, `app_url`, and `api_url` to the selected config scope. A same-context login preserves the existing project when no project is requested.gc
   - Use `--global` or `--local` to choose the config scope. Without either flag, an existing local config causes an interactive scope picker (default: local); non-interactive runs must pass a scope. `--local` never creates `.bt`.gc
-  - `bt` confirms the resolved API URL before saving.gc
-- Login with OAuth (browser-based, stores refresh token in secure credential store):gc
-  - `bt auth login --oauth --org myorg`gc
-  - You can pass `--no-browser` to print the URL without auto-opening.gc
-  - On remote/SSH hosts, paste the final callback URL from your local browser if localhost callback cannot be delivered.gc
+- Login with OAuth:gc
+  - `bt auth login --oauth --org test-org`gc
+  - You can pass `--no-browser` to print the URL without opening it automatically.gc
+  - On remote/SSH hosts, paste the final callback URL if the localhost callback cannot be delivered.gc
 - List saved auth logins:gc
   - `bt auth logins`gc
-  - `bt auth logins --org test-org` (matches stored org name or ID)gc
-  - `bt auth logins --prefer-api-key` (API-key logins only)gc
-  - Both filters can be combined.gc
+  - `bt auth logins --org test-org` dynamically lists only credentials that can use that organization.gc
+  - `bt auth logins --prefer-api-key` lists API-key logins only.gc
 - Log out:gc
   - `bt auth logout` — choose from all saved logins interactivelygc
-  - `bt auth logout --org test-org --oauth` — filter to the org's OAuth logingc
-  - `bt auth logout --org test-org --api-key-hint sk-****abcde` — select an API-key logingc
-  - `bt auth logout --force` (skip confirmation after selecting a login)gc
-- Show current auth context:gc
-  - `bt status`gc
-- Force-refresh OAuth access token for debugging:gc
-  - `bt auth refresh --org myorg`gc
+  - `bt auth logout --app-url https://www.example.test --oauth`gc
+  - `bt auth logout --org test-org --api-key-hint sk-****abcde`gc
+  - `bt auth logout --force` — skip confirmationgc
+- Force-refresh the OAuth login for the selected instance:gc
+  - `bt auth refresh --app-url https://www.example.test`gc
gc
 Auth resolution order for commands is:gc
gc
 1. Explicit `--api-key sk-...`gc
-2. `--prefer-api-key` / `BRAINTRUST_PREFER_API_KEY` (uses `BRAINTRUST_API_KEY` first, then a stored API key for the selected org, then falls back to OAuth)gc
-3. Stored OAuth login for the selected org (or cross-org OAuth when selected)gc
+2. `--prefer-api-key` / `BRAINTRUST_PREFER_API_KEY` (`BRAINTRUST_API_KEY`, then a matching stored API key, then matching OAuth)gc
+3. OAuth for the selected Braintrust instance when it can access the selected organizationgc
 4. `BRAINTRUST_API_KEY`gc
-5. Stored API key login for the selected orggc
+5. A matching stored API keygc
gc
-`--prefer-api-key` without `--org` targets the org shown by `bt status`. It cannot be used from cross-org context; pass a concrete `--org`. Once a key is selected, an invalid key or a key belonging to another requested org is an error and does not fall back to OAuth. Multiple keys in one org remain separate and are shown with key hints.gc
+OAuth credentials are matched by app URL. API-key credentials are matched by app URL, API URL, and organization. Explicit flags override environment variables, which override local config, global config, and finally the built-in Braintrust URLs.gc
gc
-On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If a secure store is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions.gc
+On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If secure storage is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions.gc
gc
 ## `bt init`gc
gc
@@ -358,24 +355,23 @@ On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Segc
 - `bt init --here` — create in the current directory without walking (including at home or `/`)gc
 - `bt init --force` — overwrite an existing discovered `.bt/config.json`; it does not change discoverygc
gc
-The saved context includes `org`, `project`, and `project_id`. Cross-org is excluded from the init picker because init always requires a project.gc
+The saved context includes the Braintrust instance URLs, organization name and ID, and project name and ID.gc
gc
 ## `bt switch`gc
gc
-Interactively switch org and project context:gc
+`bt switch` changes context without selecting a credential. It chooses a Braintrust instance, discovers the organizations available through that instance's credentials, and then chooses a project.gc
gc
-- `bt switch` — first choose a saved OAuth-backed org or a specific API-key login, then choose a projectgc
-- `bt switch myproject` — switch the current org to a project by namegc
-- `bt switch test-org/test-project` — switch to a specific org and projectgc
-- `bt switch --org cross-org` — select cross-org OAuth and clear project contextgc
+- `bt switch`gc
+- `bt switch test-project`gc
+- `bt switch test-org/test-project`gc
 - `bt switch --global` — persist to global config (`~/.config/bt/config.json`)gc
 - `bt switch --local` — update an existing local config (`.bt/config.json`); it never creates onegc
gc
-A sole login/project is selected automatically. Multiple API keys in one org remain separate picker entries with hints; OAuth accounts collapse to an org choice, so run `bt auth login` again to change OAuth accounts within that org. With an existing local config and no scope flag, interactive mode asks for global/local (default: local); non-interactive mode requires `--global` or `--local`.gc
+A sole instance, organization, or project is selected automatically. With an existing local config and no scope flag, interactive mode asks for global/local (default: local); non-interactive mode requires `--global` or `--local`.gc
gc
 ## Config context merginggc
gc
-Global config is `~/.config/bt/config.json`; local config is the first discovered `.bt/config.json`. Local context wins, but a local org inherits the global project only when both configs select the same org. A local project with no org never inherits a global org. Cross-org is stored as `"org": ""`, treated as a distinct org, and rendered as `cross-org` by `bt status`. Legacy `profile` fields are ignored; unknown extra keys are preserved when context is updated.gc
+Global config is `~/.config/bt/config.json`; local config is the first discovered `.bt/config.json`. Both use the fields `org`, `org_id`, `project`, `project_id`, `app_url`, and `api_url`. Local values win. Organization IDs stay coupled to organization names, and organization/project context is inherited only within the same app URL. Legacy `profile` fields and obsolete empty cross-org contexts are ignored; unknown extra keys are preserved during updates.gc
gc
 ## `bt status`gc
gc
diff --git a/src/args.rs b/src/args.rsgc
index 1770da9..cfe4e4c 100644gc
--- a/src/args.rsgc
+++ b/src/args.rsgc
@@ -45,6 +45,10 @@ pub struct BaseArgs {gc
     #[arg(skip)]gc
     pub org_name_source: Option<ArgValueSource>,gc
gc
+    /// Stable org ID resolved from config or internal context selection.gc
+    #[arg(skip)]gc
+    pub org_id: Option<String>,gc
+gc
     /// Override active projectgc
     #[arg(gc
         short = 'p',gc
@@ -65,10 +69,6 @@ pub struct BaseArgs {gc
     #[arg(skip)]gc
     pub api_key_source: Option<ArgValueSource>,gc
gc
-    /// Exact auth slot selected internally by switch/init.gc
-    #[arg(skip)]gc
-    pub pinned_auth_slot: Option<String>,gc
-gc
     /// Prefer API key credentials for the selected org when available.gc
     #[arg(long = "prefer-api-key", env = "BRAINTRUST_PREFER_API_KEY", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)]gc
     pub prefer_api_key: bool,gc
@@ -82,6 +82,9 @@ pub struct BaseArgs {gc
     )]gc
     pub api_url: Option<String>,gc
gc
+    #[arg(skip)]gc
+    pub api_url_source: Option<ArgValueSource>,gc
+gc
     /// Override app URL (or via BRAINTRUST_APP_URL)gc
     #[arg(gc
         long,gc
@@ -91,6 +94,9 @@ pub struct BaseArgs {gc
     )]gc
     pub app_url: Option<String>,gc
gc
+    #[arg(skip)]gc
+    pub app_url_source: Option<ArgValueSource>,gc
+gc
     /// Path to a PEM-encoded CA bundle used for HTTPS requests.gc
     #[arg(gc
         long = "ca-cert",gc
@@ -120,7 +126,11 @@ pub struct CLIArgs<T: Args> {gc
 }gc
gc
 fn parse_org_name(value: &str) -> Result<String, String> {gc
-    Ok(crate::config::normalize_org(value).to_string())gc
+    let value = value.trim();gc
+    if value.is_empty() {gc
+        return Err("organization cannot be empty".to_string());gc
+    }gc
+    Ok(value.to_string())gc
 }gc
gc
 pub(crate) fn custom_api_without_app_url(api_url: Option<&str>, app_url: Option<&str>) -> bool {gc
@@ -149,13 +159,13 @@ mod tests {gc
     #[test]gc
     fn org_normalization() {gc
         for (input, expected) in [gc
-            ("cross-org", ""),gc
-            ("   ", ""),gc
+            ("cross-org", "cross-org"),gc
             (" test-org ", "test-org"),gc
             (" org_test_123 ", "org_test_123"),gc
         ] {gc
             assert_eq!(parse_org_name(input).unwrap(), expected);gc
         }gc
+        assert!(parse_org_name("   ").is_err());gc
         assert!(custom_api_without_app_url(gc
             Some("https://api.example.test"),gc
             Nonegc
diff --git a/src/auth.rs b/src/auth.rsgc
index 78f9ccb..f422677 100644gc
--- a/src/auth.rsgc
+++ b/src/auth.rsgc
@@ -59,6 +59,7 @@ pub struct ResolvedAuth {gc
     pub api_url: Option<String>,gc
     pub app_url: Option<String>,gc
     pub org_name: Option<String>,gc
+    pub org_id: Option<String>,gc
     pub is_oauth: bool,gc
     slot_key: Option<String>,gc
 }gc
@@ -72,6 +73,11 @@ pub struct ProfileInfo {gc
     pub api_key_hint: Option<String>,gc
 }gc
gc
+#[derive(Debug, Clone, PartialEq, Eq)]gc
+pub struct AvailableInstance {gc
+    pub app_url: String,gc
+}gc
+gc
 #[derive(Debug, Clone, PartialEq, Eq)]gc
 pub struct AvailableOrg {gc
     pub id: String,gc
@@ -82,6 +88,7 @@ pub struct AvailableOrg {gc
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]gc
 enum RecoverableAuthErrorKind {gc
     OauthRefreshToken,gc
+    OauthOrgAccess,gc
     StoredCredential,gc
 }gc
gc
@@ -103,6 +110,14 @@ fn recoverable_auth_error(kind: RecoverableAuthErrorKind, message: String) -> angc
     anyhow::Error::new(RecoverableAuthError { kind, message })gc
 }gc
gc
+fn is_oauth_org_access_error(err: &anyhow::Error) -> bool {gc
+    err.chain().any(|source| {gc
+        sourcegc
+            .downcast_ref::<RecoverableAuthError>()gc
+            .is_some_and(|err| err.kind == RecoverableAuthErrorKind::OauthOrgAccess)gc
+    })gc
+}gc
+gc
 pub fn is_missing_credential_error(err: &anyhow::Error) -> bool {gc
     err.chain().any(|source| {gc
         sourcegc
@@ -126,6 +141,14 @@ pub fn list_profiles() -> Result<Vec<ProfileInfo>> {gc
         .collect())gc
 }gc
gc
+pub(crate) fn has_oauth_login_for_instance(base: &BaseArgs) -> Result<bool> {gc
+    let store = load_auth_store()?;gc
+    Ok(storegc
+        .profilesgc
+        .values()gc
+        .any(|profile| profile.auth_kind == AuthKind::Oauth && profile_matches_urls(base, profile)))gc
+}gc
+gc
 pub async fn list_available_orgs(base: &BaseArgs) -> Result<Vec<AvailableOrg>> {gc
     let resolved = resolve_auth(base).await?;gc
     let app_url = resolvedgc
@@ -163,6 +186,133 @@ async fn available_orgs(api_key: &str, app_url: &str) -> Result<Vec<AvailableOrggc
         .collect())gc
 }gc
gc
+pub(crate) fn available_instances(base: &BaseArgs) -> Result<Vec<AvailableInstance>> {gc
+    let store = load_auth_store()?;gc
+    let constrain_app = matches!(gc
+        base.app_url_source,gc
+        Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable)gc
+    );gc
+    let requested_app = constrain_appgc
+        .then_some(base.app_url.as_deref())gc
+        .flatten()gc
+        .map(canonical_url);gc
+    let mut apps = storegc
+        .profilesgc
+        .values()gc
+        .map(profile_app_url)gc
+        .filter(|app| requested_app.is_none_or(|requested| canonical_url(app) == requested))gc
+        .map(|app| canonical_url(app).to_string())gc
+        .collect::<BTreeSet<_>>();gc
+gc
+    if basegc
+        .api_keygc
+        .as_deref()gc
+        .is_some_and(|key| !key.trim().is_empty())gc
+    {gc
+        let app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+        if requested_app.is_none_or(|requested| canonical_url(app) == requested) {gc
+            apps.insert(canonical_url(app).to_string());gc
+        }gc
+    }gc
+gc
+    if apps.is_empty() && constrain_app {gc
+        bail!(gc
+            "no credentials found for app URL '{}'; run `bt auth login --app-url {}`",gc
+            base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL),gc
+            shell_quote_arg(base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL))gc
+        );gc
+    }gc
+    Ok(appsgc
+        .into_iter()gc
+        .map(|app_url| AvailableInstance { app_url })gc
+        .collect())gc
+}gc
+gc
+pub(crate) async fn available_orgs_for_instance(gc
+    base: &BaseArgs,gc
+    app_url: &str,gc
+) -> Result<Vec<AvailableOrg>> {gc
+    let mut store = load_auth_store()?;gc
+    let explicit_api = matches!(gc
+        base.api_url_source,gc
+        Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable)gc
+    )gc
+    .then(|| base.api_url.as_deref())gc
+    .flatten();gc
+    let mut orgs = BTreeMap::<String, AvailableOrg>::new();gc
+    let matching = storegc
+        .profilesgc
+        .iter()gc
+        .filter(|(_, profile)| canonical_url(profile_app_url(profile)) == canonical_url(app_url))gc
+        .filter(|(_, profile)| {gc
+            profile.auth_kind == AuthKind::Oauthgc
+                || explicit_apigc
+                    .is_none_or(|url| canonical_url(url) == canonical_url(profile_api_url(profile)))gc
+        })gc
+        .map(|(slot, profile)| (slot.clone(), profile.clone()))gc
+        .collect::<Vec<_>>();gc
+gc
+    for (slot, profile) in matching {gc
+        match profile.auth_kind {gc
+            AuthKind::Oauth => {gc
+                let mut oauth_base = base.clone();gc
+                oauth_base.app_url = Some(app_url.to_string());gc
+                if oauth_base.api_url_source.is_none() {gc
+                    oauth_base.api_url = profile.api_url.clone();gc
+                }gc
+                let token = load_oauth_access_token(&oauth_base, &mut store, &slot).await?;gc
+                for org in fetch_login_orgs(&token, app_url).await? {gc
+                    orgs.insert(gc
+                        org.id.clone(),gc
+                        AvailableOrg {gc
+                            id: org.id,gc
+                            name: org.name,gc
+                            api_url: org.api_url,gc
+                        },gc
+                    );gc
+                }gc
+            }gc
+            AuthKind::ApiKey => {gc
+                if let (Some(id), Some(name)) = (profile.org_id, profile.org_name) {gc
+                    orgs.entry(id.clone()).or_insert(AvailableOrg {gc
+                        id,gc
+                        name,gc
+                        api_url: profile.api_url,gc
+                    });gc
+                }gc
+            }gc
+        }gc
+    }gc
+gc
+    if let Some(api_key) = base.api_key.as_deref().filter(|key| !key.trim().is_empty()) {gc
+        let base_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+        if canonical_url(base_app) == canonical_url(app_url) {gc
+            for org in fetch_login_orgs(api_key, app_url).await? {gc
+                orgs.insert(gc
+                    org.id.clone(),gc
+                    AvailableOrg {gc
+                        id: org.id,gc
+                        name: org.name,gc
+                        api_url: org.api_url,gc
+                    },gc
+                );gc
+            }gc
+        }gc
+    }gc
+gc
+    let mut orgs = orgs.into_values().collect::<Vec<_>>();gc
+    orgs.sort_by(|a, b| {gc
+        a.namegc
+            .to_ascii_lowercase()gc
+            .cmp(&b.name.to_ascii_lowercase())gc
+            .then_with(|| a.name.cmp(&b.name))gc
+    });gc
+    if orgs.is_empty() {gc
+        bail!("no organizations are available for Braintrust instance '{app_url}'");gc
+    }gc
+    Ok(orgs)gc
+}gc
+gc
 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]gc
 struct AuthStore {gc
     #[serde(default)]gc
@@ -279,7 +429,7 @@ pub struct AuthArgs {gc
 enum AuthCommand {gc
     /// Authenticate with Braintrust (OAuth or API key)gc
     Login(AuthLoginArgs),gc
-    /// Force-refresh OAuth access token for the selected orggc
+    /// Force-refresh the OAuth access token for the selected instancegc
     Refresh,gc
     /// List saved auth logins and check connection statusgc
     Logins(AuthLoginsArgs),gc
@@ -332,7 +482,7 @@ pub async fn run(base: BaseArgs, args: AuthArgs) -> Result<()> {gc
         }gc
         AuthCommand::Refresh => run_login_refresh(&base).await,gc
         AuthCommand::Logins(logins_args) => run_logins(&base, logins_args).await,gc
-        AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args),gc
+        AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args).await,gc
     }gc
 }gc
gc
@@ -371,7 +521,7 @@ pub async fn fast_login(base: &BaseArgs) -> Result<LoginContext> {gc
     let login = LoginState::new();gc
     login.set(gc
         api_key,gc
-        String::new(),gc
+        auth.org_id.clone().unwrap_or_default(),gc
         org_name,gc
         api_url.clone(),gc
         app_url.clone(),gc
@@ -428,7 +578,7 @@ pub async fn login(base: &BaseArgs) -> Result<LoginContext> {gc
             let login = LoginState::new();gc
             login.set(gc
                 api_key.clone(),gc
-                String::new(),gc
+                auth.org_id.clone().unwrap_or_default(),gc
                 org_name,gc
                 auth.api_urlgc
                     .clone()gc
@@ -752,8 +902,20 @@ fn config_auth_context(base: &BaseArgs) -> Option<String> {gc
     config_auth_context_from_config(base, &cfg)gc
 }gc
gc
+fn configured_org_for_app_url(app_url: &str) -> Option<String> {gc
+    let cfg = crate::config::load().ok()?;gc
+    let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+    crate::config::urls_equal(app_url, config_app)gc
+        .then_some(cfg.org)gc
+        .flatten()gc
+}gc
+gc
 fn config_auth_context_from_config(base: &BaseArgs, cfg: &crate::config::Config) -> Option<String> {gc
-    if crate::config::org_option(base.org_name.as_deref()).is_none() {gc
+    let base_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+    let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+    if crate::config::org_option(base.org_name.as_deref()).is_none()gc
+        && crate::config::urls_equal(base_app, config_app)gc
+    {gc
         crate::config::org_option(cfg.org.as_deref()).map(str::to_string)gc
     } else {gc
         Nonegc
@@ -831,11 +993,6 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result<ResolvedAuth> {gc
     let can_prompt = ui::can_prompt();gc
gc
     let effective_org = effective_org_name(base, &cfg_org);gc
-    reject_cross_org_api_key_preference(base.prefer_api_key, effective_org, &store)?;gc
-gc
-    if let Some(slot) = base.pinned_auth_slot.clone() {gc
-        return resolve_saved_auth_slot(base, &mut store, &None, &slot).await;gc
-    }gc
gc
     let source = resolve_auth_source(gc
         base.prefer_api_key,gc
@@ -849,13 +1006,49 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result<ResolvedAuth> {gc
         AuthSource::CliApiKey(api_key) | AuthSource::EnvApiKey(api_key) => {gc
             resolve_ad_hoc_api_key_auth(base, &cfg_org, api_key).awaitgc
         }gc
-        AuthSource::Oauth(slot) | AuthSource::ApiKey(slot) => {gc
+        AuthSource::Oauth(slot) => {gc
+            match resolve_saved_auth_slot(base, &mut store, &cfg_org, &slot).await {gc
+                Ok(auth) => Ok(auth),gc
+                Err(err) if is_oauth_org_access_error(&err) && !base.prefer_api_key => {gc
+                    if let Some(api_key) = resolve_env_api_key(base) {gc
+                        return resolve_ad_hoc_api_key_auth(base, &cfg_org, api_key).await;gc
+                    }gc
+                    if let Some(api_key_slot) = select_profile_for_auth(gc
+                        base,gc
+                        &store,gc
+                        &cfg_org,gc
+                        AuthKind::ApiKey,gc
+                        can_prompt,gc
+                    )? {gc
+                        return resolve_saved_auth_slot(base, &mut store, &cfg_org, &api_key_slot)gc
+                            .await;gc
+                    }gc
+                    Err(err)gc
+                }gc
+                Err(err) => Err(err),gc
+            }gc
+        }gc
+        AuthSource::ApiKey(slot) => {gc
             resolve_saved_auth_slot(base, &mut store, &cfg_org, &slot).awaitgc
         }gc
         AuthSource::None => {gc
             if base.prefer_api_key {gc
                 bail!("--prefer-api-key requires an API key or OAuth login for the selected org");gc
             }gc
+            if !store.profiles.is_empty()gc
+                && !storegc
+                    .profilesgc
+                    .values()gc
+                    .any(|profile| profile_matches_urls(base, profile))gc
+            {gc
+                let app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+                let api = base.api_url.as_deref().unwrap_or(DEFAULT_API_URL);gc
+                bail!(gc
+                    "no credentials match app URL '{}' and API URL '{}'; run `bt auth login` with these URLs",gc
+                    app,gc
+                    apigc
+                );gc
+            }gc
             if effective_org.is_none() {gc
                 if let Some(err) = missing_org_for_stored_logins_error(&store) {gc
                     return Err(err);gc
@@ -866,6 +1059,7 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result<ResolvedAuth> {gc
                 api_url: base.api_url.clone(),gc
                 app_url: base.app_url.clone(),gc
                 org_name: effective_org.map(str::to_string),gc
+                org_id: base.org_id.clone(),gc
                 is_oauth: false,gc
                 slot_key: None,gc
             })gc
@@ -884,6 +1078,7 @@ async fn resolve_ad_hoc_api_key_auth(gc
     }gc
gc
     let mut resolved_org = requested_org.map(str::to_string);gc
+    let mut resolved_org_id = base.org_id.clone();gc
     let mut resolved_api_url = base.api_url.clone();gc
     if let Some(requested_org) = requested_org {gc
         if crate::args::custom_api_without_app_url(base.api_url.as_deref(), base.app_url.as_deref())gc
@@ -905,6 +1100,7 @@ async fn resolve_ad_hoc_api_key_auth(gc
             )gc
         })?;gc
         resolved_org = Some(selected_org.name.clone());gc
+        resolved_org_id = Some(selected_org.id.clone());gc
         resolved_api_url = resolved_api_url.or_else(|| selected_org.api_url.clone());gc
     }gc
gc
@@ -913,6 +1109,7 @@ async fn resolve_ad_hoc_api_key_auth(gc
         api_url: resolved_api_url,gc
         app_url: base.app_url.clone(),gc
         org_name: resolved_org,gc
+        org_id: resolved_org_id,gc
         is_oauth: false,gc
         slot_key: None,gc
     })gc
@@ -980,6 +1177,7 @@ fn resolve_api_key_profile_auth(gc
         org_name: effective_org_name(base, cfg_org)gc
             .map(str::to_string)gc
             .or_else(|| profile.org_name.clone()),gc
+        org_id: profile.org_id.clone().or_else(|| base.org_id.clone()),gc
         is_oauth: false,gc
         slot_key: Some(profile_name.to_string()),gc
     };gc
@@ -1067,57 +1265,6 @@ fn maybe_rekey_api_key_profile_after_secret_load(gc
     Ok(())gc
 }gc
gc
-async fn reconcile_oauth_slot_from_access_token(gc
-    store: &mut AuthStore,gc
-    slot_key: &str,gc
-    access_token: &str,gc
-    app_url: &str,gc
-) -> Result<()> {gc
-    let Some(mut profile) = store.profiles.get(slot_key).cloned() else {gc
-        return Ok(());gc
-    };gc
-    if profile.auth_kind != AuthKind::Oauth || profile.org_id.is_some() {gc
-        return Ok(());gc
-    }gc
-gc
-    let Some(org_name) = profilegc
-        .org_namegc
-        .as_deref()gc
-        .map(str::trim)gc
-        .filter(|org| !org.is_empty())gc
-    else {gc
-        profile.org_id = Some(String::new());gc
-        if replace_with_canonical_auth_profile(store, slot_key, profile) {gc
-            save_auth_store(store)?;gc
-        }gc
-        return Ok(());gc
-    };gc
-gc
-    // A legacy auth entry only cached the org name. Resolve the stable ID fromgc
-    // the newly refreshed token; a failed best-effort lookup must not turn agc
-    // successful token refresh into a failed command.gc
-    let Ok(orgs) = fetch_login_orgs(access_token, app_url).await else {gc
-        return Ok(());gc
-    };gc
-    let Some(org) = find_login_org(&orgs, org_name) else {gc
-        return Ok(());gc
-    };gc
-gc
-    profile.org_id = Some(org.id.clone());gc
-    profile.org_name = Some(org.name.clone());gc
-    let identity = decode_jwt_identity(access_token);gc
-    if identity.email.is_some() {gc
-        profile.email = identity.email;gc
-    }gc
-    if identity.name.is_some() {gc
-        profile.user_name = identity.name;gc
-    }gc
-    if replace_with_canonical_auth_profile(store, slot_key, profile) {gc
-        save_auth_store(store)?;gc
-    }gc
-    Ok(())gc
-}gc
-gc
 fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Result<()> {gc
     let Some(slot_key) = auth.slot_key.as_deref() else {gc
         return Ok(());gc
@@ -1127,40 +1274,25 @@ fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Resugc
         return Ok(());gc
     };gc
gc
+    if profile.auth_kind == AuthKind::Oauth {gc
+        return Ok(());gc
+    }gc
     let login_org_id = login.org_id().unwrap_or_default();gc
-    let is_cross_org = profile.auth_kind == AuthKind::Oauthgc
-        && authgc
-            .org_namegc
-            .as_deref()gc
-            .is_none_or(|org| org.trim().is_empty());gc
-    if login_org_id.trim().is_empty() && !is_cross_org {gc
-        // The SDK's OAuth compatibility path does not always return orggc
-        // metadata. `bt auth logins` performs the same reconciliation aftergc
-        // its explicit credential verification request.gc
+    if login_org_id.trim().is_empty() {gc
         return Ok(());gc
     }gc
gc
     profile.org_id = Some(login_org_id);gc
-    profile.org_name = if is_cross_org {gc
-        Nonegc
-    } else {gc
-        logingc
-            .org_name()gc
-            .filter(|org| !org.trim().is_empty())gc
-            .or_else(|| auth.org_name.clone())gc
+    profile.org_name = logingc
+        .org_name()gc
+        .filter(|org| !org.trim().is_empty())gc
+        .or_else(|| auth.org_name.clone());gc
+    let Some(api_key) = auth.api_key.as_deref() else {gc
+        return Ok(());gc
     };gc
-    match profile.auth_kind {gc
-        AuthKind::ApiKey => {gc
-            let Some(api_key) = auth.api_key.as_deref() else {gc
-                return Ok(());gc
-            };gc
-            profile.api_key_hash = Some(api_key_hash(api_key));gc
-            if profile.api_key_hint.is_none() {gc
-                profile.api_key_hint = Some(obscure_api_key(api_key));gc
-            }gc
-        }gc
-        AuthKind::Oauth if profile.email.as_deref().is_none_or(str::is_empty) => return Ok(()),gc
-        AuthKind::Oauth => {}gc
+    profile.api_key_hash = Some(api_key_hash(api_key));gc
+    if profile.api_key_hint.is_none() {gc
+        profile.api_key_hint = Some(obscure_api_key(api_key));gc
     }gc
gc
     if replace_with_canonical_auth_profile(&mut store, slot_key, profile) {gc
@@ -1169,43 +1301,22 @@ fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Resugc
     Ok(())gc
 }gc
gc
-async fn resolve_oauth_profile_auth(gc
+async fn load_oauth_access_token(gc
     base: &BaseArgs,gc
     store: &mut AuthStore,gc
-    cfg_org: &Option<String>,gc
     profile_name: &str,gc
-) -> Result<ResolvedAuth> {gc
+) -> Result<String> {gc
     let profile = storegc
         .profilesgc
         .get(profile_name)gc
         .cloned()gc
         .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt auth logins`"))?;gc
-    let api_url = basegc
-        .api_urlgc
-        .clone()gc
-        .or_else(|| profile.api_url.clone())gc
-        .unwrap_or_else(|| DEFAULT_API_URL.to_string());gc
-    let app_url = base.app_url.clone().or_else(|| profile.app_url.clone());gc
-    let org_name = effective_org_name(base, cfg_org)gc
-        .map(str::to_string)gc
-        .or_else(|| profile.org_name.clone());gc
-gc
-    let mut auth = ResolvedAuth {gc
-        api_key: None,gc
-        api_url: Some(api_url.clone()),gc
-        app_url,gc
-        org_name,gc
-        is_oauth: true,gc
-        slot_key: Some(profile_name.to_string()),gc
-    };gc
-gc
-    if let Some(cached_access_token) = load_valid_cached_oauth_access_token(gc
+    if let Some(cached) = load_valid_cached_oauth_access_token(gc
         profile_name,gc
         &profile,gc
         profile.oauth_access_expires_at,gc
     )? {gc
-        auth.api_key = Some(cached_access_token);gc
-        return Ok(auth);gc
+        return Ok(cached);gc
     }gc
gc
     let refresh_token = load_profile_oauth_refresh_token_for_profile(profile_name, &profile)?gc
@@ -1219,7 +1330,11 @@ async fn resolve_oauth_profile_auth(gc
                 ),gc
             )gc
         })?;gc
-    let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, &profile).await?;gc
+    let api_url = basegc
+        .api_urlgc
+        .as_deref()gc
+        .unwrap_or_else(|| profile_api_url(&profile));gc
+    let refreshed = refresh_oauth_access_token(api_url, &refresh_token, &profile).await?;gc
     save_profile_oauth_access_token(profile_name, &refreshed.access_token)?;gc
     let mut refresh_rotated = false;gc
     if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() {gc
@@ -1239,14 +1354,70 @@ async fn resolve_oauth_profile_auth(gc
         }gc
     }gc
     save_auth_store(store)?;gc
-    reconcile_oauth_slot_from_access_token(gc
-        store,gc
-        profile_name,gc
-        &refreshed.access_token,gc
-        auth.app_url.as_deref().unwrap_or(DEFAULT_APP_URL),gc
-    )gc
-    .await?;gc
-    auth.api_key = Some(refreshed.access_token);gc
+    Ok(refreshed.access_token)gc
+}gc
+gc
+async fn resolve_oauth_profile_auth(gc
+    base: &BaseArgs,gc
+    store: &mut AuthStore,gc
+    cfg_org: &Option<String>,gc
+    profile_name: &str,gc
+) -> Result<ResolvedAuth> {gc
+    let profile = storegc
+        .profilesgc
+        .get(profile_name)gc
+        .cloned()gc
+        .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt auth logins`"))?;gc
+    let access_token = load_oauth_access_token(base, store, profile_name).await?;gc
+    let auth = ResolvedAuth {gc
+        api_key: Some(access_token),gc
+        api_url: Some(gc
+            base.api_urlgc
+                .clone()gc
+                .or_else(|| profile.api_url.clone())gc
+                .unwrap_or_else(|| DEFAULT_API_URL.to_string()),gc
+        ),gc
+        app_url: Some(gc
+            base.app_urlgc
+                .clone()gc
+                .or_else(|| profile.app_url.clone())gc
+                .unwrap_or_else(|| DEFAULT_APP_URL.to_string()),gc
+        ),gc
+        org_name: effective_org_name(base, cfg_org).map(str::to_string),gc
+        org_id: base.org_id.clone(),gc
+        is_oauth: true,gc
+        slot_key: Some(profile_name.to_string()),gc
+    };gc
+    resolve_oauth_org_context(auth).awaitgc
+}gc
+gc
+async fn resolve_oauth_org_context(mut auth: ResolvedAuth) -> Result<ResolvedAuth> {gc
+    let requested_org = auth.org_name.as_deref().ok_or_else(|| {gc
+        recoverable_auth_error(gc
+            RecoverableAuthErrorKind::OauthOrgAccess,gc
+            "an active organization is required; run `bt switch` or pass --org <ORG>".to_string(),gc
+        )gc
+    })?;gc
+    let credential = authgc
+        .api_keygc
+        .as_deref()gc
+        .context("OAuth access token is missing")?;gc
+    let app_url = auth.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+    let orgs = fetch_login_orgs(credential, app_url).await?;gc
+    let selected = find_login_org(&orgs, requested_org).ok_or_else(|| {gc
+        recoverable_auth_error(gc
+            RecoverableAuthErrorKind::OauthOrgAccess,gc
+            format!(gc
+                "OAuth login for '{}' cannot access organization '{requested_org}'",gc
+                canonical_url(app_url)gc
+            ),gc
+        )gc
+    })?;gc
+    auth.org_name = Some(selected.name.clone());gc
+    auth.org_id = Some(selected.id.clone());gc
+    if auth.api_url.is_none() {gc
+        auth.api_url = selected.api_url.clone();gc
+    }gc
     Ok(auth)gc
 }gc
gc
@@ -1281,6 +1452,44 @@ pub async fn resolved_runner_env(base: &BaseArgs) -> Result<Vec<(String, String)gc
     Ok(envs)gc
 }gc
gc
+fn canonical_url(url: &str) -> &str {gc
+    url.trim().trim_end_matches('/')gc
+}gc
+gc
+fn profile_app_url(profile: &AuthProfile) -> &str {gc
+    profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL)gc
+}gc
+gc
+fn profile_api_url(profile: &AuthProfile) -> &str {gc
+    profile.api_url.as_deref().unwrap_or(DEFAULT_API_URL)gc
+}gc
+gc
+fn profile_matches_urls(base: &BaseArgs, profile: &AuthProfile) -> bool {gc
+    let app_url = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL);gc
+    if canonical_url(app_url) != canonical_url(profile_app_url(profile)) {gc
+        return false;gc
+    }gc
+    profile.auth_kind == AuthKind::Oauthgc
+        || canonical_url(base.api_url.as_deref().unwrap_or(DEFAULT_API_URL))gc
+            == canonical_url(profile_api_url(profile))gc
+}gc
+gc
+/// Match only URL filters the caller actually supplied. Listing and logout usegc
+/// this variant so an absent filter means "all instances", while command authgc
+/// uses [`profile_matches_urls`] and therefore honors the built-in URL defaults.gc
+fn profile_matches_url_filters(base: &BaseArgs, profile: &AuthProfile) -> bool {gc
+    let app_matches = basegc
+        .app_urlgc
+        .as_deref()gc
+        .is_none_or(|url| canonical_url(url) == canonical_url(profile_app_url(profile)));gc
+    app_matchesgc
+        && (profile.auth_kind == AuthKind::Oauthgc
+            || basegc
+                .api_urlgc
+                .as_deref()gc
+                .is_none_or(|url| canonical_url(url) == canonical_url(profile_api_url(profile))))gc
+}gc
+gc
 fn profile_matches_org_identifier(profile: &AuthProfile, org: &str) -> bool {gc
     profile.org_id.as_deref() == Some(org) || profile.org_name.as_deref() == Some(org)gc
 }gc
@@ -1298,13 +1507,13 @@ fn profile_org(profile: &AuthProfile) -> &str {gc
 }gc
gc
 fn profile_org_label(profile: &AuthProfile) -> String {gc
-    config::display_org(profile_org(profile)).to_string()gc
+    profile_org(profile).to_string()gc
 }gc
gc
 fn oauth_reauth_command(profile: &AuthProfile) -> String {gc
     format!(gc
-        "bt auth login --oauth --org {}",gc
-        shell_quote_arg(config::display_org(profile_org(profile)))gc
+        "bt auth login --oauth --app-url {}",gc
+        shell_quote_arg(profile_app_url(profile))gc
     )gc
 }gc
gc
@@ -1333,32 +1542,18 @@ fn profile_identity_label(profile: &AuthProfile) -> Option<String> {gc
 }gc
gc
 fn auth_slot_label(profile: &AuthProfile) -> String {gc
-    let mut parts = vec![profile_org_label(profile)];gc
-    parts.push(auth_kind_label(profile.auth_kind).to_string());gc
+    let mut parts = match profile.auth_kind {gc
+        AuthKind::Oauth => vec![profile_app_url(profile).to_string(), "oauth".to_string()],gc
+        AuthKind::ApiKey => vec![profile_org_label(profile), "api_key".to_string()],gc
+    };gc
     if let Some(identity) = profile_identity_label(profile) {gc
         parts.push(identity);gc
     }gc
     parts.join(" — ")gc
 }gc
gc
-fn is_cross_org_oauth_profile(profile: &AuthProfile) -> bool {gc
-    profile.auth_kind == AuthKind::Oauth && profile_org(profile).is_empty()gc
-}gc
-gc
-fn reject_cross_org_api_key_preference(gc
-    prefer_api_key: bool,gc
-    org: Option<&str>,gc
-    store: &AuthStore,gc
-) -> Result<()> {gc
-    let cross_org = org == Some("")gc
-        || (org.is_none() && store.profiles.values().any(is_cross_org_oauth_profile));gc
-    if prefer_api_key && cross_org {gc
-        bail!("--prefer-api-key cannot be used from cross-org context; rerun with --org <ORG>");gc
-    }gc
-    Ok(())gc
-}gc
-gc
 fn auth_profile_names_by_kind<'a>(gc
+    base: &BaseArgs,gc
     store: &'a AuthStore,gc
     org: Option<&str>,gc
     kind: AuthKind,gc
@@ -1367,10 +1562,10 @@ fn auth_profile_names_by_kind<'a>(gc
         .profilesgc
         .iter()gc
         .filter(|(_, profile)| profile.auth_kind == kind)gc
-        .filter(|(_, profile)| match org {gc
-            Some(org) => profile_matches_org_identifier(profile, org),gc
-            None if kind == AuthKind::Oauth => is_cross_org_oauth_profile(profile),gc
-            None => false,gc
+        .filter(|(_, profile)| profile_matches_urls(base, profile))gc
+        .filter(|(_, profile)| {gc
+            kind == AuthKind::Oauthgc
+                || org.is_some_and(|org| profile_matches_org_identifier(profile, org))gc
         })gc
         .map(|(name, _)| name.as_str())gc
         .collect()gc
@@ -1403,9 +1598,7 @@ fn ad_hoc_api_key_profile(org: Option<&str>, api_key: &str) -> ProfileInfo {gc
 pub(crate) fn active_auth_info(base: &BaseArgs, org: Option<&str>) -> Result<Option<ProfileInfo>> {gc
     let store = load_auth_store().unwrap_or_default();gc
gc
-    reject_cross_org_api_key_preference(base.prefer_api_key, org, &store)?;gc
-gc
-    let select = |kind| match auth_profile_names_by_kind(&store, org, kind).as_slice() {gc
+    let select = |kind| match auth_profile_names_by_kind(base, &store, org, kind).as_slice() {gc
         [] => Ok(None),gc
         [name] => Ok(Some((*name).to_string())),gc
         _ => bail!("multiple {kind:?} logins"),gc
@@ -1434,13 +1627,7 @@ pub(crate) fn active_auth_info(base: &BaseArgs, org: Option<&str>) -> Result<Optgc
 }gc
gc
 fn missing_org_for_stored_logins_error(store: &AuthStore) -> Option<anyhow::Error> {gc
-    let candidates = storegc
-        .profilesgc
-        .iter()gc
-        .filter(|(_, profile)| {gc
-            !is_cross_org_oauth_profile(profile) && !profile_org(profile).is_empty()gc
-        })gc
-        .collect::<Vec<_>>();gc
+    let candidates = store.profiles.iter().collect::<Vec<_>>();gc
     if candidates.is_empty() {gc
         return None;gc
     }gc
@@ -1483,16 +1670,7 @@ fn select_profile_from_store(gc
     current: Option<&str>,gc
     store: &AuthStore,gc
 ) -> Result<String> {gc
-    // Surface cross-org OAuth first so it is a predictable top-of-list choicegc
-    // rather than falling wherever its slot key happens to sort. The stablegc
-    // sort preserves the existing order of the remaining entries.gc
-    let mut names: Vec<&str> = names.to_vec();gc
-    names.sort_by_key(|name| {gc
-        !storegc
-            .profilesgc
-            .get(*name)gc
-            .is_some_and(is_cross_org_oauth_profile)gc
-    });gc
+    let names: Vec<&str> = names.to_vec();gc
     let labels: Vec<String> = namesgc
         .iter()gc
         .map(|name| profile_label_from_store(name, store))gc
@@ -1513,53 +1691,6 @@ fn select_profile_from_store(gc
     Ok(names[idx].to_string())gc
 }gc
gc
-fn saved_login_names(store: &AuthStore, include_cross_org: bool) -> Vec<&str> {gc
-    let mut oauth_orgs = BTreeSet::new();gc
-    storegc
-        .profilesgc
-        .iter()gc
-        .filter(|(_, profile)| {gc
-            profile.auth_kind == AuthKind::ApiKeygc
-                || ((include_cross_org || !is_cross_org_oauth_profile(profile))gc
-                    && oauth_orgs.insert(gc
-                        profilegc
-                            .org_idgc
-                            .as_deref()gc
-                            .filter(|id| !id.is_empty())gc
-                            .unwrap_or_else(|| profile_org(profile))gc
-                            .to_ascii_lowercase(),gc
-                    ))gc
-        })gc
-        .map(|(name, _)| name.as_str())gc
-        .collect()gc
-}gc
-gc
-pub(crate) fn select_saved_login(gc
-    base: &mut BaseArgs,gc
-    current_org: Option<&str>,gc
-    include_cross_org: bool,gc
-) -> Result<bool> {gc
-    let store = load_auth_store()?;gc
-    let names = saved_login_names(&store, include_cross_org);gc
-    let selected = match names.as_slice() {gc
-        [] => return Ok(false),gc
-        [name] => (*name).to_string(),gc
-        _ if ui::can_prompt() => {gc
-            select_profile_from_store("Select login", &names, current_org, &store)?gc
-        }gc
-        _ => {gc
-            bail!("multiple saved logins match; pass --org <ORG>, or rerun interactively to choose")gc
-        }gc
-    };gc
-    let profile = &store.profiles[&selected];gc
-    if profile.auth_kind == AuthKind::ApiKey {gc
-        base.pinned_auth_slot = Some(selected);gc
-    } else {gc
-        base.org_name = Some(profile_org(profile).to_string());gc
-    }gc
-    Ok(true)gc
-}gc
-gc
 fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec<String> {gc
     namesgc
         .iter()gc
@@ -1583,12 +1714,18 @@ fn select_profile_for_auth(gc
     can_prompt: bool,gc
 ) -> Result<Option<String>> {gc
     let org = effective_org_name(base, cfg_org);gc
-    let candidates = auth_profile_names_by_kind(store, org, kind);gc
+    let candidates = auth_profile_names_by_kind(base, store, org, kind);gc
     let label = match kind {gc
         AuthKind::Oauth => "OAuth login",gc
         AuthKind::ApiKey => "API key",gc
     };gc
-    select_auth_profile_candidate(label, org, &candidates, store, can_prompt)gc
+    select_auth_profile_candidate(gc
+        label,gc
+        org,gc
+        &candidates,gc
+        store,gc
+        can_prompt && kind == AuthKind::ApiKey,gc
+    )gc
 }gc
gc
 fn select_auth_profile_candidate(gc
@@ -1609,13 +1746,18 @@ fn select_auth_profile_candidate(gc
         }gc
         _ => {gc
             let identities = candidate_identities(candidates, store).join(", ");gc
+            if kind_label == "OAuth login" {gc
+                bail!(gc
+                    "multiple Braintrust OAuth instances are available: {identities}. Run `bt switch` or pass --app-url <URL>."gc
+                );gc
+            }gc
             if let Some(org) = org {gc
                 bail!(gc
                     "multiple {kind_label} logins for org '{org}': {identities}. Rerun interactively or remove one with `bt auth logout`."gc
                 );gc
             }gc
             bail!(gc
-                "multiple cross-org {kind_label} logins available: {identities}. Rerun interactively or remove one with `bt auth logout`."gc
+                "multiple {kind_label} logins available: {identities}. Pass --app-url <URL>, rerun interactively, or remove one with `bt auth logout`."gc
             );gc
         }gc
     }gc
@@ -1625,10 +1767,12 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {gc
     if args.oauth {gc
         return run_login_oauth(base, args).await;gc
     }gc
-    if base.org_name.as_deref() == Some("") {gc
-        bail!(gc
-            "API-key login requires a concrete org; cross-org API keys do not exist. Use --oauth, or rerun with --org <ORG>"gc
-        );gc
+    if basegc
+        .org_namegc
+        .as_deref()gc
+        .is_some_and(|org| org.trim().is_empty())gc
+    {gc
+        bail!("API-key login requires a non-empty organization");gc
     }gc
gc
     let has_explicit_api_key = base.api_key.as_ref().is_some_and(|k| !k.trim().is_empty());gc
@@ -1662,7 +1806,7 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {gc
     if requested_org_resolution == RequestedOrgResolution::SwitchToOauth {gc
         return run_login_oauth(base, args).await;gc
     }gc
-    let configured_org = config::load().ok().and_then(|cfg| cfg.org);gc
+    let configured_org = configured_org_for_app_url(&login_app_url);gc
     let selected_org = select_login_org(gc
         login_orgs.clone(),gc
         match requested_org_resolution {gc
@@ -1675,7 +1819,6 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {gc
         configured_org.as_deref(),gc
         interactive,gc
         base.verbose,gc
-        false,gc
         explicitly_quiet(base),gc
     )?;gc
     let selected_org = selected_org.ok_or_else(|| {gc
@@ -1691,7 +1834,7 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {gc
     commit_api_key_profile(gc
         &api_key,gc
         selected_api_url.clone(),gc
-        base.app_url.clone(),gc
+        Some(login_app_url.clone()),gc
         selected_org.id.clone(),gc
         selected_org.name.clone(),gc
     )?;gc
@@ -1784,48 +1927,46 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {gc
         exchange_oauth_authorization_code(&api_url, &redirect_uri, &auth_code, pkce_verifier)gc
             .await?;gc
     let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?;gc
-    let configured_org = config::load().ok().and_then(|cfg| cfg.org);gc
+    let configured_org = configured_org_for_app_url(&app_url);gc
     let selected_org = select_login_org(gc
         login_orgs.clone(),gc
         base.org_name.as_deref(),gc
         configured_org.as_deref(),gc
         ui::can_prompt(),gc
         base.verbose,gc
-        true,gc
         explicitly_quiet(base),gc
     )?;gc
+    let selected_org = selected_org.ok_or_else(|| {gc
+        anyhow::anyhow!(gc
+            "OAuth login requires an organization; pass --org <ORG> or rerun interactively"gc
+        )gc
+    })?;gc
     let selected_api_url = resolve_profile_api_url(gc
         base.api_url.clone(),gc
-        selected_org.as_ref(),gc
+        Some(&selected_org),gc
         &login_orgs,gc
         ui::can_prompt(),gc
     )?;gc
gc
-    commit_oauth_profile(gc
-        &oauth_tokens,gc
-        selected_api_url.clone(),gc
-        app_url.clone(),gc
-        selected_org.as_ref(),gc
-    )?;gc
+    commit_oauth_profile(&oauth_tokens, api_url.clone(), app_url.clone())?;gc
     let context_update = persist_post_login_context(gc
         base,gc
         &oauth_tokens.access_token,gc
         &selected_api_url,gc
         &app_url,gc
-        selected_org.as_ref(),gc
+        Some(&selected_org),gc
         &args.scope,gc
     )gc
     .awaitgc
     .context("login succeeded, but failed to update active context")?;gc
gc
-    let human = format_login_success(selected_org.as_ref(), &selected_api_url);gc
+    let human = format_login_success(Some(&selected_org), &selected_api_url);gc
     emit_result(gc
         base.json,gc
         serde_json::json!({gc
             "auth": "oauth",gc
-            "org": selected_org.as_ref().map(|org| org.name.clone()),gc
-            "org_id": selected_org.as_ref().map(|org| org.id.clone()),gc
-            "cross_org": selected_org.is_none(),gc
+            "org": selected_org.name,gc
+            "org_id": selected_org.id,gc
             "api_url": selected_api_url,gc
             "app_url": app_url,gc
             "status": "ok",gc
@@ -1881,7 +2022,6 @@ fn commit_oauth_profile(gc
     tokens: &OAuthTokenResponse,gc
     api_url: String,gc
     app_url: String,gc
-    selected_org: Option<&LoginOrgInfo>,gc
 ) -> Result<()> {gc
     let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| {gc
         anyhow::anyhow!(gc
@@ -1891,7 +2031,7 @@ fn commit_oauth_profile(gc
gc
     let oauth_access_expires_at = determine_oauth_access_expiry_epoch(tokens);gc
     let jwt_id = decode_jwt_identity(&tokens.access_token);gc
-    let email = jwt_idgc
+    let _email = jwt_idgc
         .emailgc
         .clone()gc
         .filter(|email| !email.trim().is_empty())gc
@@ -1900,25 +2040,25 @@ fn commit_oauth_profile(gc
                 "oauth token did not include an email; cannot create persistent oauth login"gc
             )gc
         })?;gc
-    let org_id = selected_org.map(|org| org.id.clone()).unwrap_or_default();gc
-    let slot_key = oauth_slot_key(&org_id, &email);gc
+    let app_url = canonical_url(&app_url).to_string();gc
+    let slot_key = oauth_slot_key(&app_url);gc
gc
+    let mut store = load_auth_store()?;gc
+    if let Some(old_profile) = store.profiles.get(&slot_key) {gc
+        delete_all_profile_secrets(&slot_key, old_profile);gc
+    }gc
     save_profile_oauth_refresh_token(&slot_key, refresh_token)?;gc
     save_profile_oauth_access_token(&slot_key, &tokens.access_token)?;gc
     let _ = delete_profile_secret(&slot_key);gc
gc
-    let mut store = load_auth_store()?;gc
-    if let Some(old_profile) = store.profiles.get(&slot_key) {gc
-        delete_legacy_profile_secrets(old_profile);gc
-    }gc
     store.profiles.insert(gc
         slot_key,gc
         AuthProfile {gc
             auth_kind: AuthKind::Oauth,gc
             api_url: Some(api_url),gc
             app_url: Some(app_url),gc
-            org_id: Some(org_id),gc
-            org_name: selected_org.map(|org| org.name.clone()),gc
+            org_id: None,gc
+            org_name: None,gc
             oauth_access_expires_at,gc
             user_name: jwt_id.name,gc
             email: jwt_id.email,gc
@@ -1942,7 +2082,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> {gc
     )?gc
     .ok_or_else(|| {gc
             anyhow::anyhow!(gc
-                "no OAuth login selected; pass --org <ORG> or run `bt auth logins` to see available logins"gc
+                "no OAuth login selected; pass --app-url <URL> or run `bt auth logins` to see available logins"gc
             )gc
         })?;gc
     let profile = storegc
@@ -1953,9 +2093,10 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> {gc
             anyhow::anyhow!("OAuth login not found; run `bt auth logins` to see available logins")gc
         })?;gc
gc
-    let api_url = profilegc
+    let api_url = basegc
         .api_urlgc
         .clone()gc
+        .or_else(|| profile.api_url.clone())gc
         .unwrap_or_else(|| DEFAULT_API_URL.to_string());gc
     let previous_expires_at = profile.oauth_access_expires_at;gc
     let refresh_token =gc
@@ -2005,14 +2146,6 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> {gc
         }gc
     }gc
     save_auth_store(&store)?;gc
-    reconcile_oauth_slot_from_access_token(gc
-        &mut store,gc
-        profile_name.as_str(),gc
-        &refreshed.access_token,gc
-        profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL),gc
-    )gc
-    .await?;gc
-gc
     if let Some(expires_at) = new_expires_at {gc
         let now = current_unix_timestamp();gc
         let remaining = expires_at.saturating_sub(now);gc
@@ -2030,8 +2163,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> {gc
         base.json,gc
         serde_json::json!({gc
             "auth": "oauth",gc
-            "org": profile.org_name,gc
-            "org_id": profile.org_id.filter(|org_id| !org_id.trim().is_empty()),gc
+            "app_url": profile.app_url,gc
             "user_email": profile.email,gc
             "access_expires_at": new_expires_at,gc
             "refresh_token_rotated": refresh_rotated,gc
@@ -2042,10 +2174,9 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> {gc
 }gc
gc
 fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String {gc
-    match selected_org {gc
-        Some(org) => format!("Logged in as {} (api: {api_url})", org.name),gc
-        None => format!("Logged in (cross-org, api: {api_url})"),gc
-    }gc
+    selected_orggc
+        .map(|org| format!("Logged in as {} (api: {api_url})", org.name))gc
+        .unwrap_or_else(|| format!("Logged in (api: {api_url})"))gc
 }gc
gc
 fn build_login_context_for_selected_org(gc
@@ -2076,7 +2207,7 @@ fn format_post_login_context(gc
     match (selected_org, project) {gc
         (Some(org), Some(project)) => format!("{}/{}", org.name, project.name),gc
         (Some(org), None) => org.name.clone(),gc
-        (None, _) => "cross-org mode".to_string(),gc
+        (None, _) => "Braintrust".to_string(),gc
     }gc
 }gc
gc
@@ -2091,11 +2222,8 @@ async fn resolve_post_login_project(gc
         return Ok(None);gc
     };gc
gc
-    let selected_org = selected_org.ok_or_else(|| {gc
-        anyhow::anyhow!(gc
-            "cannot set a default project in cross-org mode; rerun `bt auth login --org <ORG> --project <PROJECT>`"gc
-        )gc
-    })?;gc
+    let selected_org = selected_orggc
+        .ok_or_else(|| anyhow::anyhow!("an organization is required to select a project"))?;gc
     let ctx =gc
         build_login_context_for_selected_org(credential, api_url, app_url, Some(selected_org));gc
     let client = ApiClient::new(&ctx)?;gc
@@ -2117,23 +2245,35 @@ async fn persist_post_login_context(gc
         resolve_post_login_project(base, credential, api_url, app_url, selected_org).await?;gc
     let (path, _) = scope.resolve(ui::can_prompt(), "Where to use this login")?;gc
     let mut cfg = config::load_file(&path);gc
-    let org = selected_org.map_or("", |org| org.name.as_str());gc
+    let selected_org = selected_orggc
+        .ok_or_else(|| anyhow::anyhow!("an organization is required to update config"))?;gc
     let preserve_project = project.is_none()gc
-        && selected_org.is_some()gc
-        && config::org_option(cfg.org.as_deref()) == Some(org);gc
-    if !preserve_project {gc
-        cfg.set_context(gc
-            Some(org),gc
-            projectgc
-                .as_ref()gc
-                .map(|project| (project.name.as_str(), project.id.as_str())),gc
-        );gc
-    }gc
+        && config::org_option(cfg.org.as_deref()) == Some(selected_org.name.as_str())gc
+        && cfg.org_id.as_deref() == Some(selected_org.id.as_str())gc
+        && cfggc
+            .app_urlgc
+            .as_deref()gc
+            .is_some_and(|url| config::urls_equal(url, app_url));gc
+    let selected_project = if preserve_project {gc
+        cfg.project.clone().zip(cfg.project_id.clone())gc
+    } else {gc
+        projectgc
+            .as_ref()gc
+            .map(|project| (project.name.clone(), project.id.clone()))gc
+    };gc
+    cfg.set_context(gc
+        (selected_org.name.as_str(), selected_org.id.as_str()),gc
+        selected_projectgc
+            .as_ref()gc
+            .map(|(name, id)| (name.as_str(), id.as_str())),gc
+        app_url,gc
+        api_url,gc
+    );gc
     config::save_file(&path, &cfg)gc
         .with_context(|| format!("Could not save config to {}", path.display()))?;gc
gc
     Ok(PostLoginContextUpdate {gc
-        display: format_post_login_context(selected_org, project.as_ref()),gc
+        display: format_post_login_context(Some(selected_org), project.as_ref()),gc
         path,gc
     })gc
 }gc
@@ -2150,14 +2290,14 @@ fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) ->gc
 }gc
gc
 fn filter_auth_store(gc
+    base: &BaseArgs,gc
     store: &AuthStore,gc
-    org: Option<&str>,gc
     kind: Option<AuthKind>,gc
     api_key_hint: Option<&str>,gc
 ) -> AuthStore {gc
     let mut filtered = store.clone();gc
     filtered.profiles.retain(|_, profile| {gc
-        org.is_none_or(|org| profile_matches_org_identifier(profile, org))gc
+        profile_matches_url_filters(base, profile)gc
             && kind.is_none_or(|kind| profile.auth_kind == kind)gc
             && api_key_hint.is_none_or(|hint| {gc
                 profile.auth_kind == AuthKind::ApiKeygc
@@ -2167,15 +2307,68 @@ fn filter_auth_store(gc
     filteredgc
 }gc
gc
+async fn filter_auth_store_for_org(gc
+    base: &BaseArgs,gc
+    store: &mut AuthStore,gc
+    candidates: AuthStore,gc
+    org: Option<&str>,gc
+) -> Result<AuthStore> {gc
+    let Some(org) = org else {gc
+        return Ok(candidates);gc
+    };gc
+    let mut filtered = AuthStore::default();gc
+    for (slot, profile) in candidates.profiles {gc
+        let matches = match profile.auth_kind {gc
+            AuthKind::ApiKey => profile_matches_org_identifier(&profile, org),gc
+            AuthKind::Oauth => {gc
+                let mut oauth_base = base.clone();gc
+                oauth_base.app_url = Some(profile_app_url(&profile).to_string());gc
+                if oauth_base.api_url_source.is_none() {gc
+                    oauth_base.api_url = profile.api_url.clone();gc
+                }gc
+                let token = load_oauth_access_token(&oauth_base, store, &slot).await?;gc
+                let orgs = fetch_login_orgs(&token, profile_app_url(&profile)).await?;gc
+                find_login_org(&orgs, org).is_some()gc
+            }gc
+        };gc
+        if matches {gc
+            filtered.profiles.insert(slot, profile);gc
+        }gc
+    }gc
+    Ok(filtered)gc
+}gc
+gc
 async fn run_logins(base: &BaseArgs, _args: AuthLoginsArgs) -> Result<()> {gc
     let mut store = load_auth_store()?;gc
-    let has_filter = base.org_name.is_some() || base.prefer_api_key;gc
-    let filtered = filter_auth_store(gc
+    let requested_org = matches!(gc
+        base.org_name_source,gc
+        Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable)gc
+    )gc
+    .then(|| base.org_name.as_deref())gc
+    .flatten();gc
+    let has_url_filter = matches!(gc
+        base.app_url_source,gc
+        Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable)gc
+    ) || matches!(gc
+        base.api_url_source,gc
+        Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable)gc
+    );gc
+    let has_filter = requested_org.is_some() || base.prefer_api_key || has_url_filter;gc
+    let mut filter_base = base.clone();gc
+    if filter_base.app_url_source.is_none() {gc
+        filter_base.app_url = None;gc
+    }gc
+    if filter_base.api_url_source.is_none() {gc
+        filter_base.api_url = None;gc
+    }gc
+    let candidates = filter_auth_store(gc
+        &filter_base,gc
         &store,gc
-        base.org_name.as_deref(),gc
         base.prefer_api_key.then_some(AuthKind::ApiKey),gc
         None,gc
     );gc
+    let filtered =gc
+        filter_auth_store_for_org(&filter_base, &mut store, candidates, requested_org).await?;gc
     if filtered.profiles.is_empty() {gc
         return emit_result(base.json, serde_json::json!([]), || {gc
             if store.profiles.is_empty() && !has_filter {gc
@@ -2226,6 +2419,8 @@ fn auth_profile_json(profile: &AuthProfile, status: &str) -> serde_json::Value {gc
         "user_name": profile.user_name,gc
         "user_email": profile.email,gc
         "api_key_hint": profile.api_key_hint,gc
+        "app_url": profile.app_url,gc
+        "api_url": profile.api_url,gc
         "status": status,gc
     })gc
 }gc
@@ -2277,7 +2472,7 @@ fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result<gc
     })gc
 }gc
gc
-fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> {gc
+async fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> {gc
     let store = load_auth_store()?;gc
     if store.profiles.is_empty() {gc
         return emit_result(base.json, serde_json::json!({ "status": "empty" }), || {gc
@@ -2287,18 +2482,29 @@ fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> {gc
gc
     let requested_org = if matches!(gc
         base.org_name_source,gc
-        Some(crate::args::ArgValueSource::CommandLine)gc
+        Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable)gc
     ) {gc
         config::org_option(base.org_name.as_deref())gc
     } else {gc
         Nonegc
     };gc
-    let filtered = filter_auth_store(gc
+    let mut filter_base = base.clone();gc
+    if filter_base.app_url_source.is_none() {gc
+        filter_base.app_url = None;gc
+    }gc
+    if filter_base.api_url_source.is_none() {gc
+        filter_base.api_url = None;gc
+    }gc
+    let candidates = filter_auth_store(gc
+        &filter_base,gc
         &store,gc
-        requested_org,gc
         args.oauth.then_some(AuthKind::Oauth),gc
         args.api_key_hint.as_deref(),gc
     );gc
+    let mut mutable_store = store.clone();gc
+    let filtered =gc
+        filter_auth_store_for_org(&filter_base, &mut mutable_store, candidates, requested_org)gc
+            .await?;gc
     let candidates = filteredgc
         .profilesgc
         .keys()gc
@@ -2319,7 +2525,7 @@ fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> {gc
         _ => {gc
             let labels = candidate_identities(&candidates, &filtered).join(", ");gc
             bail!(gc
-                "multiple auth logins match: {labels}. Rerun interactively, or use --org <ORG> with --oauth or --api-key-hint <HINT> to disambiguate."gc
+                "multiple auth logins match: {labels}. Rerun interactively, use --app-url <URL> with --oauth, or use --org <ORG> with --api-key-hint <HINT>."gc
             );gc
         }gc
     };gc
@@ -2382,6 +2588,10 @@ pub struct ProfileVerification {gc
     pub user_email: Option<String>,gc
     #[serde(skip_serializing_if = "Option::is_none")]gc
     pub api_key_hint: Option<String>,gc
+    #[serde(skip_serializing_if = "Option::is_none")]gc
+    pub app_url: Option<String>,gc
+    #[serde(skip_serializing_if = "Option::is_none")]gc
+    pub api_url: Option<String>,gc
     pub status: String,gc
     #[serde(skip_serializing_if = "Option::is_none")]gc
     pub error: Option<String>,gc
@@ -2389,9 +2599,7 @@ pub struct ProfileVerification {gc
gc
 fn build_verification(gc
     name: &str,gc
-    auth_kind: &str,gc
-    org: Option<String>,gc
-    org_id: Option<String>,gc
+    profile: &AuthProfile,gc
     jwt_id: Option<JwtIdentity>,gc
     api_key_hint: Option<String>,gc
     status: ProfileStatus,gc
@@ -2405,12 +2613,17 @@ fn build_verification(gc
     ProfileVerification {gc
         name: name.to_string(),gc
         slot_hash: None,gc
-        auth: auth_kind.to_string(),gc
-        org,gc
-        org_id,gc
+        auth: auth_kind_label(profile.auth_kind).to_string(),gc
+        org: profile.org_name.clo…
multiple group-by work
sort by time when grouped by day/hour
sort by the dimension (eg user) in the output data when grouped by user
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant