Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7bb9675
feat: remove profiles from the authentication system
viadezo1er Jul 16, 2026
a147308
follow up and remove all profile code
viadezo1er Jul 16, 2026
6c81880
chore: add compatibility code to prevent users from having to re-logi…
viadezo1er Jul 16, 2026
41a27c9
chore: remove dead code
viadezo1er Jul 16, 2026
745f582
chore: shared helper for bt status/login to prevent drift
viadezo1er Jul 16, 2026
0569fa6
chore(auth): remove client-id flag
viadezo1er Jul 17, 2026
90c1386
chore: rename bt auth profiles to bt auth logins
viadezo1er Jul 17, 2026
b74a40a
Authentication — src/auth.rs
viadezo1er Jul 22, 2026
ba3c482
bug fix, api url choice for cross org when there are multiple possibl…
viadezo1er Jul 23, 2026
6968f18
merge multiple old oauth logins into one when new bt is used
viadezo1er Jul 23, 2026
426cbf9
fix: temp file used to wrtie config.json now has unique name
viadezo1er Jul 23, 2026
0cd807f
fix(datasets): default name used org with BRAINTRUST_API_KEY
viadezo1er Jul 23, 2026
1f981dc
fix: detect symlinked home ; hide api keys better
viadezo1er Jul 23, 2026
76af1b2
chore: failing to update auth.json no longer crashes the whole bt com…
viadezo1er Jul 24, 2026
c537414
fix(auth): prune orphaned auth secrets after migration
viadezo1er Jul 24, 2026
e97499e
feat(login): separate auth and org
viadezo1er Jul 28, 2026
04ba663
chore: merge `bt auth logins` into `bt status`
viadezo1er Jul 29, 2026
e2f6610
chore(auth): remove config update from `bt auth login`
viadezo1er Jul 29, 2026
e3a78dc
chore: help message in `bt auth logout` to specify how to logout whil…
viadezo1er Jul 29, 2026
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
89 changes: 53 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC
| ------------- | ------------------------------------------------------------------ |
| `bt init` | Initialize `.bt/` config directory and link to a project |
| `bt auth` | Authenticate with Braintrust |
| `bt switch` | Switch org and project context |
| `bt status` | Show current org and project context |
| `bt switch` | Switch instance, org, and project context |
| `bt status` | Show saved logins and current org/project context |
| `bt datasets` | Manage datasets and dataset pipelines |
| `bt eval` | Run eval files (Unix only) |
| `bt sql` | Run SQL queries against Braintrust |
Expand Down Expand Up @@ -312,54 +312,71 @@ Local version and pagination-key conversion helpers:

## `bt auth`

- Authenticate interactively (prompts for auth method, profile name defaults to org name):
- Authenticate interactively:
- `bt auth login`
- First prompt chooses: `OAuth (browser)` (default) or `API key`.
- If your API key can access multiple orgs, `bt` uses a searchable picker (alphabetized) and lets you choose a specific org or no default org (cross-org mode).
- After login, `bt` updates the active profile/org context immediately. If `--project` is set, it also switches that project; otherwise it clears any stale default project for the new login.
- `bt` confirms the resolved API URL before saving.
- Login with OAuth (browser-based, stores refresh token in secure credential store):
- `bt auth login --oauth --profile work`
- You can pass `--no-browser` to print the URL without auto-opening.
- On remote/SSH hosts, paste the final callback URL from your local browser if localhost callback cannot be delivered.
- List profiles:
- `bt auth profiles`
- Log out (remove a saved profile):
- `bt auth logout`
- `bt auth logout --force` (skip confirmation)
- Show current auth source/profile:
- `bt auth status`
- Force-refresh OAuth access token for debugging:
- `bt auth refresh --profile work`
- First choose `OAuth (browser)` (default) or `API key`, then choose an organization.
- OAuth is stored once per Braintrust instance, identified by app URL, and can authenticate every organization available to that user in the instance.
- API-key logins remain organization-scoped; multiple keys for one organization remain distinct.
- Login only saves credentials; use `bt init` or `bt switch` to configure the active org and project.
- Login with OAuth:
- `bt auth login --oauth --org test-org`
- You can pass `--no-browser` to print the URL without opening it automatically.
- On remote/SSH hosts, paste the final callback URL if the localhost callback cannot be delivered.
- Inspect saved auth logins and the active context with `bt status`.
- Log out:
- `bt auth logout` — choose from all saved logins interactively
- `bt auth logout --app-url https://www.example.test --oauth`
- `bt auth logout --org test-org --api-key-hint sk-****abcde`
- `bt auth logout --force` — skip confirmation
- Force-refresh the OAuth login for the selected instance:
- `bt auth refresh --app-url https://www.example.test`

Auth resolution order for commands is:

1. Explicit `--profile`
2. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set)
3. `BRAINTRUST_PROFILE`
4. Org-based profile match (profile whose org matches `--org`/config org)
5. Single-profile auto-select (if only one profile exists)
6. Interactive profile picker (if multiple profiles exist and a TTY is available)
1. Explicit `--api-key sk-...`
2. `--prefer-api-key` / `BRAINTRUST_PREFER_API_KEY` (`BRAINTRUST_API_KEY`, then a matching stored API key, then matching OAuth)
3. OAuth for the selected Braintrust instance when it can access the selected organization
4. `BRAINTRUST_API_KEY`
5. A matching stored API key

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

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.

## `bt init`

`bt init` creates a project-local `.bt/config.json`. It walks upward to the first `.bt`, `.git`, home, or filesystem-root boundary. A git marker (directory or file) selects that repository root; reaching home/root or an existing `.bt` is an error.

- `bt init --org test-org --project test-project` — initialize the containing repository
- `bt init --here` — create in the current directory without walking (including at home or `/`)
- `bt init --force` — overwrite an existing discovered `.bt/config.json`; it does not change discovery

The saved context includes the Braintrust instance URLs, organization name and ID, and project name and ID.

## `bt switch`

Interactively switch org and project context:
`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.

- `bt switch` — interactive picker for org and project
- `bt switch myproject` — switch to a project by name
- `bt switch myorg/myproject` — switch to a specific org and project
- `bt switch`
- `bt switch test-project`
- `bt switch test-org/test-project`
- `bt switch --global` — persist to global config (`~/.config/bt/config.json`)
- `bt switch --local` — persist to local config (`.bt/config.json`)
- `bt switch --local` — update an existing local config (`.bt/config.json`); it never creates one

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

## Config context merging

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.

## `bt status`

Show current org and project context:
Show saved auth logins and the current org/project context:

- `bt status` — display current org, project, and config source
- `bt status --verbose` — show detailed config resolution
- `bt status -j` — JSON output
- `bt status` — check saved login status, then display the active org, project, auth method, URLs, credential path, and config source
- `bt status --api-key sk-...` — identify the key's org using the active app/API URLs; config org/project values are ignored
- `bt status --quiet` — show compact context output
- `bt status --json` — emit the saved logins and active context as JSON

## `bt setup` and `bt docs`

Expand Down
124 changes: 57 additions & 67 deletions src/args.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};

use clap::Args;
Expand All @@ -11,7 +10,7 @@ pub enum ArgValueSource {
EnvVariable,
}

#[derive(Debug, Clone, Args)]
#[derive(Debug, Clone, Default, Args)]
pub struct BaseArgs {
/// Output as JSON
#[arg(long, global = true)]
Expand Down Expand Up @@ -39,17 +38,17 @@ pub struct BaseArgs {
#[arg(long, env = "BRAINTRUST_NO_INPUT", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)]
pub no_input: bool,

/// Use a saved login profile (or via BRAINTRUST_PROFILE)
#[arg(long, env = "BRAINTRUST_PROFILE", global = true)]
pub profile: Option<String>,

#[arg(skip = false)]
pub profile_explicit: bool,

/// Override active org (or via BRAINTRUST_ORG_NAME)
#[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true)]
#[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true, value_parser = parse_org_name)]
pub org_name: Option<String>,

#[arg(skip)]
pub org_name_source: Option<ArgValueSource>,

/// Stable org ID resolved from config or internal context selection.
#[arg(skip)]
pub org_id: Option<String>,

/// Override active project
#[arg(
short = 'p',
Expand All @@ -60,16 +59,19 @@ pub struct BaseArgs {
)]
pub project: Option<String>,

#[arg(skip)]
pub project_source: Option<ArgValueSource>,

/// Override stored API key (or via BRAINTRUST_API_KEY)
#[arg(long, env = "BRAINTRUST_API_KEY", global = true, hide = true)]
pub api_key: Option<String>,

#[arg(skip)]
pub api_key_source: Option<ArgValueSource>,

/// Prefer profile credentials even if BRAINTRUST_API_KEY/--api-key is set.
#[arg(long, global = true)]
pub prefer_profile: bool,
/// Prefer API key credentials for the selected org when available.
#[arg(long = "prefer-api-key", env = "BRAINTRUST_PREFER_API_KEY", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)]
pub prefer_api_key: bool,

/// Override API URL (or via BRAINTRUST_API_URL)
#[arg(
Expand All @@ -80,6 +82,9 @@ pub struct BaseArgs {
)]
pub api_url: Option<String>,

#[arg(skip)]
pub api_url_source: Option<ArgValueSource>,

/// Override app URL (or via BRAINTRUST_APP_URL)
#[arg(
long,
Expand All @@ -89,6 +94,9 @@ pub struct BaseArgs {
)]
pub app_url: Option<String>,

#[arg(skip)]
pub app_url_source: Option<ArgValueSource>,

/// Path to a PEM-encoded CA bundle used for HTTPS requests.
#[arg(
long = "ca-cert",
Expand Down Expand Up @@ -117,6 +125,23 @@ pub struct CLIArgs<T: Args> {
pub base: BaseArgs,
}

fn parse_org_name(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err("organization cannot be empty".to_string());
}
Ok(value.to_string())
}

pub(crate) fn custom_api_without_app_url(api_url: Option<&str>, app_url: Option<&str>) -> bool {
app_url.is_none_or(|url| url.trim().is_empty())
&& api_url.is_some_and(|url| {
!url.trim()
.trim_end_matches('/')
.eq_ignore_ascii_case(DEFAULT_API_URL.trim_end_matches('/'))
})
}

impl BaseArgs {
pub fn ca_cert(&self) -> Option<&Path> {
self.ca_cert.as_deref()
Expand All @@ -127,63 +152,28 @@ impl BaseArgs {
}
}

pub fn has_explicit_profile_arg(args: &[OsString]) -> bool {
let mut idx = 1usize;
while idx < args.len() {
let Some(arg) = args[idx].to_str() else {
idx += 1;
continue;
};

if arg == "--" {
break;
}

if arg == "--profile" || arg.starts_with("--profile=") {
return true;
}

idx += 1;
}

false
}

#[cfg(test)]
mod tests {
use super::has_explicit_profile_arg;
use std::ffi::OsString;

#[test]
fn has_explicit_profile_arg_detects_split_flag() {
let args = vec![
OsString::from("bt"),
OsString::from("status"),
OsString::from("--profile"),
OsString::from("work"),
];
assert!(has_explicit_profile_arg(&args));
}
use super::{custom_api_without_app_url, parse_org_name, DEFAULT_API_URL};

#[test]
fn has_explicit_profile_arg_detects_equals_flag() {
let args = vec![
OsString::from("bt"),
OsString::from("status"),
OsString::from("--profile=work"),
];
assert!(has_explicit_profile_arg(&args));
}

#[test]
fn has_explicit_profile_arg_ignores_passthrough_args() {
let args = vec![
OsString::from("bt"),
OsString::from("eval"),
OsString::from("--"),
OsString::from("--profile"),
OsString::from("work"),
];
assert!(!has_explicit_profile_arg(&args));
fn org_normalization() {
for (input, expected) in [
("cross-org", "cross-org"),
(" test-org ", "test-org"),
(" org_test_123 ", "org_test_123"),
] {
assert_eq!(parse_org_name(input).unwrap(), expected);
}
assert!(parse_org_name(" ").is_err());
assert!(custom_api_without_app_url(
Some("https://api.example.test"),
None
));
assert!(!custom_api_without_app_url(Some(DEFAULT_API_URL), None));
assert!(!custom_api_without_app_url(
Some("https://api.example.test"),
Some("https://app.example.test")
));
}
}
Loading
Loading