Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b6bb1e1
feat: remove profiles from the authentication system
viadezo1er Jul 16, 2026
8942268
follow up and remove all profile code
viadezo1er Jul 16, 2026
2a8191a
chore: add compatibility code to prevent users from having to re-logi…
viadezo1er Jul 16, 2026
fd70cdf
chore: remove dead code
viadezo1er Jul 16, 2026
b18e27e
chore: shared helper for bt status/login to prevent drift
viadezo1er Jul 16, 2026
fb1ac8c
chore(auth): remove client-id flag
viadezo1er Jul 17, 2026
dedf063
chore: rename bt auth profiles to bt auth logins
viadezo1er Jul 17, 2026
780a30b
Authentication — src/auth.rs
viadezo1er Jul 22, 2026
40df301
bug fix, api url choice for cross org when there are multiple possibl…
viadezo1er Jul 23, 2026
03a8fc3
merge multiple old oauth logins into one when new bt is used
viadezo1er Jul 23, 2026
40c1fb2
fix: temp file used to wrtie config.json now has unique name
viadezo1er Jul 23, 2026
fa2b586
fix(datasets): default name used org with BRAINTRUST_API_KEY
viadezo1er Jul 23, 2026
bf9a0ef
fix: detect symlinked home ; hide api keys better
viadezo1er Jul 23, 2026
773405d
chore: failing to update auth.json no longer crashes the whole bt com…
viadezo1er Jul 24, 2026
3677203
fix(auth): prune orphaned auth secrets after migration
viadezo1er Jul 24, 2026
7d4fa22
feat(login): separate auth and org
viadezo1er Jul 28, 2026
177488b
draft
viadezo1er Jul 10, 2026
c4aac03
draft
viadezo1er Jul 10, 2026
9c721ca
logs
viadezo1er Jul 21, 2026
3809041
draft2
viadezo1er Jul 22, 2026
69f1e4d
pixel size, image, things
viadezo1er Jul 23, 2026
c88c5c3
forgot to add new files
viadezo1er Jul 23, 2026
dfd7c5c
chore: improve group-by
viadezo1er Jul 23, 2026
5863065
forgot what this does
viadezo1er Jul 28, 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
393 changes: 376 additions & 17 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ glob = "0.3"
flate2 = "1.1.2"
tempfile = "3"
uuid = { version = "1.21.0", features = ["v4"] }
plotters = { version = "0.3.7", default-features = false, features = ["line_series", "svg_backend", "bitmap_backend", "bitmap_encoder", "ttf"] }
plotters-backend = "0.3.7"

[profile.dist]
inherits = "release"
Expand Down
86 changes: 53 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ 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 switch` | Switch instance, org, and project context |
| `bt status` | Show current org and project context |
| `bt datasets` | Manage datasets and dataset pipelines |
| `bt eval` | Run eval files (Unix only) |
Expand Down Expand Up @@ -312,53 +312,73 @@ 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 and config scope.
- 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 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.
- 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`.
- 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.
- List saved auth logins:
- `bt auth logins`
- `bt auth logins --org test-org` dynamically lists only credentials that can use that organization.
- `bt auth logins --prefer-api-key` lists API-key logins only.
- 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:

- `bt status` — display current org, project, and config source
- `bt status --verbose` — show detailed config resolution
- `bt status` — display current org, project, selected auth method, and config source
- `bt status --verbose` — show detailed config and auth resolution
- `bt status -j` — JSON output

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