Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
177c099
add bt-review claude skill to review CLI structure and ux
parkerhendo Feb 13, 2026
52c0848
Add --org flag and extract shared prompt rendering
parkerhendo Feb 13, 2026
374c749
Add tools and scorers commands via shared functions module
parkerhendo Feb 13, 2026
b76def6
Add experiments command with list, view, and delete
parkerhendo Feb 13, 2026
224f2d1
Fix review findings: match established patterns
parkerhendo Feb 13, 2026
45da886
Hide environment variable values in help output
parkerhendo Feb 13, 2026
a71c91c
refactor(functions): replace REST API with BTQL for function queries
parkerhendo Feb 16, 2026
09833a1
fix(functions): add SQL escaping and use project names in UI
parkerhendo Feb 16, 2026
dd8878a
fix(login): use correct app_url with config fallback
parkerhendo Feb 17, 2026
068d1bb
wip refactor: clean up urls, add code preview, cleanup function signa…
parkerhendo Feb 17, 2026
f358dbb
WIP - syntax highlighting for code tools and sscorers
parkerhendo Feb 17, 2026
6d4dcba
Merge remote-tracking branch 'origin/main' into parker/remaining-func…
parkerhendo Feb 18, 2026
14b8c9b
Merge remote-tracking branch 'origin/main' into parker/remaining-func…
parkerhendo Feb 24, 2026
fc414c4
refactor(functions): add unified functions command with type filter
parkerhendo Feb 24, 2026
7b64863
refactor(functions): reduce visibility and improve documentation
parkerhendo Feb 24, 2026
7d69821
refactor(functions): extract web path building logic and add xact_id
parkerhendo Feb 24, 2026
df74544
feat(functions): add parameter type filter and display support
parkerhendo Feb 24, 2026
18f4444
Merge remote-tracking branch 'origin' into parker/remaining-function-…
parkerhendo Feb 24, 2026
ce225fb
fix(experiments,functions): align patterns and deduplicate prompt ren…
parkerhendo Feb 24, 2026
846e3b7
feat(view): add description, topics, and browser URL to function view
parkerhendo Feb 24, 2026
90baad4
refactor: remove syntect syntax highlighting dependency
parkerhendo Feb 24, 2026
8635a71
refactor(experiments): improve description handling and URL display
parkerhendo Feb 24, 2026
74eb735
bt-review + clig adherence
parkerhendo Feb 24, 2026
923f723
feat(functions): add invoke command to execute functions
parkerhendo Feb 25, 2026
d2a814a
Merge remote-tracking branch 'origin/main' into parker/remaining-func…
parkerhendo Feb 25, 2026
a37ff42
update docs
parkerhendo Feb 25, 2026
b61ce4d
refactor(http): remove default timeout from HTTP client
parkerhendo Feb 25, 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
348 changes: 259 additions & 89 deletions Cargo.lock

Large diffs are not rendered by default.

27 changes: 22 additions & 5 deletions src/args.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use clap::Args;
use std::path::PathBuf;

use clap::Args;

pub use braintrust_sdk_rust::{DEFAULT_API_URL, DEFAULT_APP_URL};

#[derive(Debug, Clone, Args)]
Expand All @@ -18,7 +19,13 @@ pub struct BaseArgs {
pub org_name: Option<String>,

/// Override active project
#[arg(short = 'p', long, env = "BRAINTRUST_DEFAULT_PROJECT", global = true)]
#[arg(
short = 'p',
long,
env = "BRAINTRUST_DEFAULT_PROJECT",
hide_env_values = true,
global = true
)]
pub project: Option<String>,

/// Override stored API key (or via BRAINTRUST_API_KEY)
Expand All @@ -34,15 +41,25 @@ pub struct BaseArgs {
pub no_input: bool,

/// Override API URL (or via BRAINTRUST_API_URL)
#[arg(long, env = "BRAINTRUST_API_URL", global = true)]
#[arg(
long,
env = "BRAINTRUST_API_URL",
hide_env_values = true,
global = true
)]
pub api_url: Option<String>,

/// Override app URL (or via BRAINTRUST_APP_URL)
#[arg(long, env = "BRAINTRUST_APP_URL", global = true)]
#[arg(
long,
env = "BRAINTRUST_APP_URL",
hide_env_values = true,
global = true
)]
pub app_url: Option<String>,

/// Path to a .env file to load before running commands.
#[arg(long, env = "BRAINTRUST_ENV_FILE")]
#[arg(long, env = "BRAINTRUST_ENV_FILE", hide_env_values = true)]
pub env_file: Option<PathBuf>,
}

Expand Down
67 changes: 67 additions & 0 deletions src/experiments/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use urlencoding::encode;

use crate::http::ApiClient;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Experiment {
pub id: String,
pub name: String,
pub project_id: String,
#[serde(default)]
pub public: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub dataset_id: Option<String>,
#[serde(default)]
pub dataset_version: Option<String>,
#[serde(default)]
pub base_exp_id: Option<String>,
#[serde(default)]
pub commit: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
struct ListResponse {
objects: Vec<Experiment>,
}

pub async fn list_experiments(client: &ApiClient, project: &str) -> Result<Vec<Experiment>> {
let path = format!(
"/v1/experiment?org_name={}&project_name={}",
encode(client.org_name()),
encode(project)
);
let list: ListResponse = client.get(&path).await?;
Ok(list.objects)
}

pub async fn get_experiment_by_name(
client: &ApiClient,
project: &str,
name: &str,
) -> Result<Option<Experiment>> {
let path = format!(
"/v1/experiment?org_name={}&project_name={}&experiment_name={}",
encode(client.org_name()),
encode(project),
encode(name)
);
let list: ListResponse = client.get(&path).await?;
Ok(list.objects.into_iter().next())
}

pub async fn delete_experiment(client: &ApiClient, experiment_id: &str) -> Result<()> {
let path = format!("/v1/experiment/{}", encode(experiment_id));
client.delete(&path).await
}
61 changes: 61 additions & 0 deletions src/experiments/delete.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use anyhow::{anyhow, bail, Result};
use dialoguer::Confirm;

use crate::ui::{is_interactive, print_command_status, with_spinner, CommandStatus};

use super::{api, ResolvedContext};

pub async fn run(ctx: &ResolvedContext, name: Option<&str>, force: bool) -> Result<()> {
let project_name = &ctx.project.name;
if force && name.is_none() {
bail!("name required when using --force. Use: bt experiments delete <name> --force");
}

let experiment = match name {
Some(n) => api::get_experiment_by_name(&ctx.client, project_name, n)
.await?
.ok_or_else(|| anyhow!("experiment '{n}' not found"))?,
None => {
if !is_interactive() {
bail!("experiment name required. Use: bt experiments delete <name>");
}
super::select_experiment_interactive(&ctx.client, project_name).await?
}
};

if !force && is_interactive() {
let confirm = Confirm::new()
.with_prompt(format!(
"Delete experiment '{}' from {}?",
&experiment.name, &project_name
))
.default(false)
.interact()?;
if !confirm {
return Ok(());
}
}

match with_spinner(
"Deleting experiment...",
api::delete_experiment(&ctx.client, &experiment.id),
)
.await
{
Ok(_) => {
print_command_status(
CommandStatus::Success,
&format!("Deleted '{}'", experiment.name),
);
eprintln!("Run `bt experiments list` to see remaining experiments.");
Ok(())
}
Err(e) => {
print_command_status(
CommandStatus::Error,
&format!("Failed to delete '{}'", experiment.name),
);
Err(e)
}
}
}
73 changes: 73 additions & 0 deletions src/experiments/list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use std::fmt::Write as _;

use anyhow::Result;
use dialoguer::console;

use crate::{
ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner},
utils::pluralize,
};

use super::{api, ResolvedContext};

pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> {
let project_name = &ctx.project.name;
let experiments = with_spinner(
"Loading experiments...",
api::list_experiments(&ctx.client, project_name),
)
.await?;

if json {
println!("{}", serde_json::to_string(&experiments)?);
return Ok(());
}

let mut output = String::new();
let count = format!(
"{} {}",
experiments.len(),
pluralize(experiments.len(), "experiment", None)
);
writeln!(
output,
"{} found in {} {} {}\n",
console::style(count),
console::style(ctx.client.org_name()).bold(),
console::style("/").dim().bold(),
console::style(project_name).bold()
)?;

let mut table = styled_table();
table.set_header(vec![
header("Name"),
header("Description"),
header("Created"),
header("Commit"),
]);
apply_column_padding(&mut table, (0, 6));

for exp in &experiments {
let desc = exp
.description
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| truncate(s, 60))
.unwrap_or_else(|| "-".to_string());
let created = exp
.created
.as_deref()
.map(|c| truncate(c, 10))
.unwrap_or_else(|| "-".to_string());
let commit = exp
.commit
.as_deref()
.map(|c| truncate(c, 7))
.unwrap_or_else(|| "-".to_string());
table.add_row(vec![&exp.name, &desc, &created, &commit]);
}

write!(output, "{table}")?;
print_with_pager(&output)?;
Ok(())
}
133 changes: 133 additions & 0 deletions src/experiments/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use anyhow::{anyhow, bail, Result};
use clap::{Args, Subcommand};

use crate::{
args::BaseArgs,
auth::login,
config,
http::ApiClient,
projects::api::{get_project_by_name, Project},
ui::{self, is_interactive, select_project_interactive, with_spinner},
};

mod api;
mod delete;
mod list;
mod view;

use api::{self as experiments_api, Experiment};

pub(crate) struct ResolvedContext {
pub client: ApiClient,
pub app_url: String,
pub project: Project,
}

#[derive(Debug, Clone, Args)]
pub struct ExperimentsArgs {
#[command(subcommand)]
command: Option<ExperimentsCommands>,
}

#[derive(Debug, Clone, Subcommand)]
enum ExperimentsCommands {
/// List all experiments
List,
/// View an experiment
View(ViewArgs),
/// Delete an experiment
Delete(DeleteArgs),
}

#[derive(Debug, Clone, Args)]
struct ViewArgs {
/// Experiment name (positional)
#[arg(value_name = "NAME")]
name_positional: Option<String>,

/// Experiment name (flag)
#[arg(long = "name", short = 'n')]
name_flag: Option<String>,

/// Open in browser
#[arg(long)]
web: bool,
}

impl ViewArgs {
fn name(&self) -> Option<&str> {
self.name_positional
.as_deref()
.or(self.name_flag.as_deref())
}
}

#[derive(Debug, Clone, Args)]
struct DeleteArgs {
/// Experiment name (positional)
#[arg(value_name = "NAME")]
name_positional: Option<String>,

/// Experiment name (flag)
#[arg(long = "name", short = 'n')]
name_flag: Option<String>,

/// Skip confirmation
#[arg(long, short = 'f')]
force: bool,
}

impl DeleteArgs {
fn name(&self) -> Option<&str> {
self.name_positional
.as_deref()
.or(self.name_flag.as_deref())
}
}

pub(crate) async fn select_experiment_interactive(
client: &ApiClient,
project: &str,
) -> Result<Experiment> {
let mut experiments = with_spinner(
"Loading experiments...",
experiments_api::list_experiments(client, project),
)
.await?;

if experiments.is_empty() {
bail!("no experiments found");
}

experiments.sort_by(|a, b| a.name.cmp(&b.name));
let names: Vec<&str> = experiments.iter().map(|e| e.name.as_str()).collect();
let selection = ui::fuzzy_select("Select experiment", &names, 0)?;
Ok(experiments[selection].clone())
}

pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> {
let auth = login(&base).await?;
let client = ApiClient::new(&auth)?;
let config_project = config::load().ok().and_then(|c| c.project);
let project_name = match base.project.as_deref().or(config_project.as_deref()) {
Some(p) => p.to_string(),
None if is_interactive() => select_project_interactive(&client, None, None).await?,
None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"),
};

let project = get_project_by_name(&client, &project_name)
.await?
.ok_or_else(|| anyhow!("project '{project_name}' not found"))?;

let ctx = ResolvedContext {
client,
app_url: auth.app_url,
project,
};

match args.command {
None | Some(ExperimentsCommands::List) => list::run(&ctx, base.json).await,
Some(ExperimentsCommands::View(v)) => view::run(&ctx, v.name(), base.json, v.web).await,
Some(ExperimentsCommands::Delete(d)) => delete::run(&ctx, d.name(), d.force).await,
}
}
Loading
Loading