From 177c099c7e4c7ce05a89255ab3729854de5b3521 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Thu, 12 Feb 2026 16:04:59 -0800 Subject: [PATCH 01/23] add bt-review claude skill to review CLI structure and ux --- .claude/skills/bt-review/SKILL.md | 131 +++++++ .../bt-review/references/bt-patterns.md | 336 ++++++++++++++++++ .../bt-review/references/clig-checklist.md | 128 +++++++ 3 files changed, 595 insertions(+) create mode 100644 .claude/skills/bt-review/SKILL.md create mode 100644 .claude/skills/bt-review/references/bt-patterns.md create mode 100644 .claude/skills/bt-review/references/clig-checklist.md diff --git a/.claude/skills/bt-review/SKILL.md b/.claude/skills/bt-review/SKILL.md new file mode 100644 index 0000000..2b2b5c1 --- /dev/null +++ b/.claude/skills/bt-review/SKILL.md @@ -0,0 +1,131 @@ +--- +name: bt-review +description: > + This skill should be used to review and audit the bt CLI for adherence to CLI + best practices from clig.dev AND internal codebase patterns. It checks source + code for help text, flags, error handling, output formatting, subcommand + structure, pattern consistency, and more. Triggers on "review my code", + "audit the CLI", "check CLI best practices", or /bt-review. +--- + +# CLI Best Practices Review + +Audit the `bt` CLI codebase against two reference documents: + +1. **clig.dev guidelines** — industry CLI best practices +2. **bt codebase patterns** — established internal conventions for consistency + +## When to Use + +- When a user asks to review, audit, or check the CLI +- When triggered via `/bt-review` +- After implementing new commands or subcommands +- Before releases to ensure CLI quality + +## Review Process + +### 1. Scope the Review + +Determine what to review: + +- **Full audit**: All commands and subcommands +- **Targeted review**: Specific command or area (e.g., just `prompts`, just error handling) +- **Diff review**: Only changed files on the current branch vs main + +To scope a diff review, run `git diff main --name-only -- '*.rs'` and focus on changed files. + +### 2. Load Reference Documents + +Read both reference files: + +- `references/clig-checklist.md` — industry CLI guidelines organized by category +- `references/bt-patterns.md` — established codebase patterns to check for consistency + +### 3. Analyze Source Code + +#### clig.dev Compliance + +For each category in the checklist, examine relevant source files: + +- **Args & flags**: Read `src/args.rs` and `src/main.rs` — check clap derive attributes, flag naming, long/short forms +- **Help text**: Check all `#[command]` and `#[arg]` attributes for descriptions, examples, and help templates +- **Error handling**: Grep for `anyhow::`, `.context(`, `eprintln!`, and error types — verify human-readable messages +- **Output**: Check stdout vs stderr usage, TTY detection, color handling, JSON output support +- **Subcommands**: Review `src/*/mod.rs` files for consistency in naming and structure +- **Interactivity**: Check `dialoguer` usage for TTY guards and `--no-input` support +- **Robustness**: Look for timeout handling, progress indicators (`indicatif`), signal handling +- **Config**: Check env var handling (`dotenvy`, `clap` env features), XDG compliance + +#### Pattern Consistency + +Compare new or changed code against `references/bt-patterns.md`. Check: + +- **Module structure**: Does it follow `mod.rs` / `api.rs` / `list.rs` / `view.rs` / `delete.rs` layout? +- **run() dispatcher**: Does `mod.rs` have the standard `Args → Optional → match` with `None => List`? +- **api.rs conventions**: `ListResponse { objects }` wrapper, `get_by_*` returns `Option`, URL-encoded params +- **Interactive fallback**: `match identifier { Some → fetch, None → TTY check → fuzzy_select or bail }` +- **Delete confirmation**: `Confirm::new().default(false)`, only when stdin is terminal, `return Ok(())` on decline +- **Success/error status**: `print_command_status(CommandStatus::Success/Error, "Past tense message")` +- **List output**: JSON early return → summary line → `styled_table` → `print_with_pager` +- **Spinner usage**: `with_spinner("Present participle...", future)`, stderr-only, 300ms delay +- **Positional + flag dual args**: positional precedence over flag, both optional, `.identifier()` accessor method +- **Project resolution**: `base.project → interactive select → bail with env var hint` +- **Color/styling**: bold for names, dim for secondary, green/red for status, cyan for template vars +- **Import order**: std → external crates → `crate::` → `super::` +- **Error messages**: `bail!("thing required. Use: bt ")` + +### 4. Report Findings + +Produce a structured report document to `bt-review.md`: + +``` +# CLI Review: bt + +## Summary +[1-2 sentence overall assessment] + +## clig.dev Compliance + +### [Category Name] — [PASS / NEEDS WORK / NOT APPLICABLE] + +| Guideline | Status | Details | +|-----------|--------|---------| +| [item] | PASS/FAIL/PARTIAL | [specific finding with file:line references] | + +## Pattern Consistency + +### [Pattern Name] — [CONSISTENT / INCONSISTENT / NOT APPLICABLE] + +| Expected Pattern | Status | Details | +|------------------|--------|---------| +| [pattern] | OK/DEVIATION | [what differs and where, with file:line] | + +## Priority Fixes +1. [Most impactful issue with specific fix suggestion] +2. ... + +## Good Practices Already Followed +- [List what's already done well] +``` + +### 5. Prioritization + +Rank findings by impact: + +- **P0 — Broken**: Exit codes wrong, secrets in flags, no help text, crashes +- **P1 — Inconsistent**: Deviates from established patterns, missing TTY detection, inconsistent flags +- **P2 — Polish**: Missing `--json`, no pager, could suggest next commands +- **P3 — Nice-to-have**: Man pages, completion scripts, ASCII art + +Pattern deviations are typically P1 unless the deviation is an intentional improvement. + +## Important Notes + +- This is a Rust project — check `clap` derive patterns, not manual arg parsing +- The `projects/` module is the reference implementation — new resource modules should match its patterns +- The CLI uses `anyhow` for error handling — look for `.context()` calls for user-friendly errors +- Interactive features use `dialoguer` — verify TTY checks before prompting +- Progress uses `indicatif` — check spinner/progress bar usage for long ops +- Focus findings on actionable, specific issues with file paths and line numbers +- Do not suggest changes to test files or build configuration +- When a pattern deviation is found, reference both the new code and the established pattern location diff --git a/.claude/skills/bt-review/references/bt-patterns.md b/.claude/skills/bt-review/references/bt-patterns.md new file mode 100644 index 0000000..883054a --- /dev/null +++ b/.claude/skills/bt-review/references/bt-patterns.md @@ -0,0 +1,336 @@ +# bt CLI Codebase Patterns + +Established patterns in the bt CLI that new code must follow for consistency. + +## Module Structure + +### Resource modules follow a standard layout + +Every resource (projects, prompts) uses the same file structure: + +``` +src// +├── mod.rs # Args, subcommand enum, run() dispatcher +├── api.rs # HTTP API calls, request/response types +├── list.rs # List subcommand +├── view.rs # View subcommand +├── delete.rs # Delete subcommand +└── ... # Additional subcommands +``` + +### mod.rs pattern + +Each resource module's `mod.rs` follows this exact pattern: + +```rust +// 1. Args struct with Optional subcommand (None defaults to List) +#[derive(Debug, Clone, Args)] +pub struct ResourceArgs { + #[command(subcommand)] + command: Option, +} + +// 2. Subcommand enum with doc comments as help text +#[derive(Debug, Clone, Subcommand)] +enum ResourceCommands { + /// List all resources + List, + /// View a resource + View(ViewArgs), + /// Delete a resource + Delete(DeleteArgs), +} + +// 3. run() function: login → client → match dispatch +pub async fn run(base: BaseArgs, args: ResourceArgs) -> Result<()> { + let ctx = login(&base).await?; + let client = ApiClient::new(&ctx)?; + // ... resolve project if needed ... + match args.command { + None | Some(ResourceCommands::List) => list::run(...).await, + Some(ResourceCommands::View(a)) => view::run(...).await, + Some(ResourceCommands::Delete(a)) => delete::run(...).await, + } +} +``` + +**Key rule**: `None` always maps to `List` — running `bt ` with no subcommand shows the list. + +### api.rs pattern + +```rust +// 1. Main model struct: Serialize + Deserialize, pub fields +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Resource { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, +} + +// 2. Private ListResponse wrapper +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +// 3. Functions take &ApiClient, return Result +pub async fn list_resources(client: &ApiClient, ...) -> Result> { ... } +pub async fn get_resource_by_name(client: &ApiClient, ...) -> Result> { ... } +pub async fn delete_resource(client: &ApiClient, id: &str) -> Result<()> { ... } +``` + +**Key rules**: + +- URL-encode all query params with `urlencoding::encode()` +- `get_by_name` returns `Option` (not an error for missing) +- `list_` returns `Vec` via `ListResponse.objects` + +## UX Patterns + +### Interactive fallback pattern + +When a required identifier (name, slug) isn't provided: + +```rust +let resource = match identifier { + Some(s) => fetch_by_identifier(client, s).await?, + None => { + if !std::io::stdin().is_terminal() { + bail!(" required. Use: bt "); + } + select_resource_interactive(client).await? + } +}; +``` + +**Key rules**: + +- Always check `std::io::stdin().is_terminal()` before interactive prompts +- Bail message must include the exact command syntax to use non-interactively +- Use `select__interactive()` for fuzzy selection + +### Interactive selection pattern + +```rust +pub async fn select_resource_interactive(client: &ApiClient, ...) -> Result { + let mut items = with_spinner("Loading ...", api::list_resources(client, ...)).await?; + if items.is_empty() { + bail!("no found"); + } + items.sort_by(|a, b| a.name.cmp(&b.name)); + let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect(); + let selection = ui::fuzzy_select("Select ", &names)?; + Ok(items[selection].clone()) +} +``` + +**Key rules**: + +- Sort alphabetically before display +- Use `ui::fuzzy_select()` (wraps dialoguer FuzzySelect with TTY guard) +- Bail early if list is empty +- Wrap API call in `with_spinner` + +### Delete confirmation pattern + +```rust +if std::io::stdin().is_terminal() { + let confirm = Confirm::new() + .with_prompt(format!("Delete '{}'?", name)) + .default(false) + .interact()?; + if !confirm { + return Ok(()); + } +} +``` + +**Key rules**: + +- Default to `false` (don't delete) +- Only prompt when stdin is terminal +- Silent (no confirmation) when non-interactive — relies on explicit identifier arg +- Return `Ok(())` on decline, not an error + +### Success/error status pattern + +Mutating operations (create, delete) use `print_command_status`: + +```rust +match with_spinner("Deleting...", api::delete(client, &id)).await { + Ok(_) => { + print_command_status(CommandStatus::Success, &format!("Deleted '{name}'")); + Ok(()) + } + Err(e) => { + print_command_status(CommandStatus::Error, &format!("Failed to delete '{name}'")); + Err(e) + } +} +``` + +**Key rules**: + +- `✓` green for success, `✗` red for error (via `CommandStatus` enum) +- Message format: past tense verb + quoted resource name +- Print status THEN return the error (so user sees both) + +### "Open in browser" pattern + +```rust +let url = format!( + "{}/app/{}/p/{}", + app_url.trim_end_matches('/'), + encode(org_name), + encode(&name) +); +open::that(&url)?; +print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); +``` + +### List output pattern + +```rust +if json { + println!("{}", serde_json::to_string(&items)?); +} else { + let mut output = String::new(); + // Summary line: count + context + writeln!(output, "{} found in {}\n", count, context)?; + // Table + let mut table = styled_table(); + table.set_header(vec![header("Col1"), header("Col2")]); + apply_column_padding(&mut table, (0, 6)); + for item in &items { + table.add_row(vec![...]); + } + write!(output, "{table}")?; + print_with_pager(&output)?; +} +``` + +**Key rules**: + +- JSON check first, early return +- Build output into a `String`, then pipe through `print_with_pager` +- Summary line before table: "{count} {resource}s found in {context}" +- Table uses `styled_table()` (NOTHING preset, dynamic width) +- Headers use `header()` (bold + dim) +- Column padding `(0, 6)` — no left padding, 6 right padding +- Truncate descriptions to 60 chars with `truncate(s, 60)` +- Missing descriptions display as `"-"` + +## Code Patterns + +### All subcommand functions are `pub async fn run(...) -> Result<()>` + +Entry point for every subcommand. Parameters are borrowed references, not owned. + +### BaseArgs are global and flattened via CLIArgs + +```rust +// In main.rs: +Commands::Resource(CLIArgs { base, args }) => resource::run(base, args).await? + +// BaseArgs contains: json, project, api_key, api_url, app_url, env_file +``` + +`--json` and `--project` are always available on any resource subcommand. + +### Error handling + +- Use `anyhow::Result` everywhere +- Use `.context("human-readable message")` on fallible operations +- Use `bail!("message")` for expected user errors +- Use `anyhow!("message")` for constructing error values +- HTTP errors: `"request failed ({status}): {body}"` format + +### Spinner usage + +- `with_spinner("Loading...", future)` — shows after 300ms, clears on completion +- `with_spinner_visible("Creating...", future, Duration)` — always shows, enforces minimum display time +- Spinner message: present participle + "..." (e.g., "Loading prompts...", "Deleting project...") +- Only shows when stderr is terminal + +### Positional + flag dual args pattern + +When a subcommand takes a primary identifier: + +```rust +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + /// Resource identifier (positional) + #[arg(value_name = "IDENTIFIER")] + identifier_positional: Option, + + /// Resource identifier (flag) + #[arg(long = "identifier", short = 'x')] + identifier_flag: Option, +} + +impl ViewArgs { + fn identifier(&self) -> Option<&str> { + self.identifier_positional + .as_deref() + .or(self.identifier_flag.as_deref()) + } +} +``` + +**Key rule**: Positional takes precedence over flag. Both are optional (falls back to interactive). + +### Project resolution pattern + +Commands that operate within a project scope: + +```rust +let project = match base.project { + Some(p) => p, + None if std::io::stdin().is_terminal() => select_project_interactive(&client).await?, + None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), +}; +``` + +### Color/styling conventions + +- `console::style(name).bold()` — resource names, identifiers +- `console::style(text).dim()` — secondary info, separators, headers +- `console::style(text).green()` — success, user role +- `console::style(text).blue()` — assistant role +- `console::style(text).red()` — errors (via CommandStatus) +- `console::style(text).yellow()` — tool names, function calls +- `console::style(text).cyan()` — template variables, spinners +- `console::style(text).magenta()` — section labels (e.g., "tools") + +### Import conventions + +```rust +// std imports first +use std::fmt::Write as _; +use std::io::IsTerminal; + +// External crates +use anyhow::{bail, Result}; +use dialoguer::console; + +// Internal crate imports +use crate::http::ApiClient; +use crate::ui::{...}; + +// Sibling module +use super::api; +``` + +### View output pattern (rich display) + +For detailed single-resource views: + +```rust +let mut output = String::new(); +writeln!(output, "Viewing {}", console::style(&name).bold())?; +// ... render fields ... +print_with_pager(&output)?; +``` + +Build into String, then page. Use box-drawing characters (`┃`, `│`) for structure. diff --git a/.claude/skills/bt-review/references/clig-checklist.md b/.claude/skills/bt-review/references/clig-checklist.md new file mode 100644 index 0000000..4180f5c --- /dev/null +++ b/.claude/skills/bt-review/references/clig-checklist.md @@ -0,0 +1,128 @@ +# CLI Guidelines Checklist (clig.dev) + +Audit checklist organized by category. Each item is a specific, verifiable guideline. + +## EXIT CODES & STREAMS + +- [ ] Return 0 on success, non-zero on failure +- [ ] Map distinct non-zero codes to important failure modes +- [ ] Primary output goes to stdout +- [ ] Errors, logs, status messages go to stderr + +## HELP + +- [ ] `-h` and `--help` show help +- [ ] Subcommands have their own `--help`/`-h` +- [ ] Running command with no required args shows concise help (description, examples, flag summary, pointer to `--help`) +- [ ] `myapp help` and `myapp help ` work (for git-like tools) +- [ ] Top-level help includes support path (website or GitHub link) +- [ ] Help text links to web documentation where applicable +- [ ] Help leads with examples showing common complex uses +- [ ] Most common flags and commands listed first +- [ ] Suggest corrections when users make typos in commands/flags +- [ ] If program expects piped stdin but gets interactive terminal, show help instead of hanging + +## OUTPUT + +- [ ] Detect TTY to distinguish human vs machine output +- [ ] Support `--json` flag for JSON output +- [ ] Support `--plain` flag for machine-readable plain text (if applicable) +- [ ] Display brief output on success confirming state changes +- [ ] Support `-q`/`--quiet` to suppress non-essential output +- [ ] Make current state easily visible (like `git status`) +- [ ] Suggest next commands in workflows +- [ ] Be explicit about boundary-crossing actions (file I/O, network calls) +- [ ] Use color intentionally, not saturated +- [ ] Disable color when stdout is not interactive TTY +- [ ] Disable color when `NO_COLOR` env var is set +- [ ] Disable color when `TERM=dumb` +- [ ] Support `--no-color` flag +- [ ] Disable animations when stdout is not interactive +- [ ] Don't output debug info by default +- [ ] Don't treat stderr like a log file (no log level labels by default) +- [ ] Use pager for large text output when stdout is interactive + +## ERRORS + +- [ ] Catch expected errors and rewrite for humans (conversational, suggest fixes) +- [ ] High signal-to-noise ratio (group similar errors) +- [ ] Important information at end of output where users look +- [ ] Unexpected errors: provide debug info + bug report instructions +- [ ] Streamline bug reporting (URLs with pre-populated info if possible) + +## ARGUMENTS & FLAGS + +- [ ] Prefer flags over positional arguments +- [ ] All flags have long-form versions (`--help` not just `-h`) +- [ ] Single-letter flags reserved for commonly used options only +- [ ] Use standard flag names where conventions exist: + - `-a, --all` | `-d, --debug` | `-f, --force` | `--json` + - `-h, --help` | `-n, --dry-run` | `--no-input` + - `-o, --output` | `-p, --port` | `-q, --quiet` + - `-u, --user` | `--version` | `-v` (version, not verbose — or skip) +- [ ] Defaults are correct for most users +- [ ] Prompt for missing input interactively +- [ ] Never _require_ prompts — always allow flags/args to skip them +- [ ] Skip prompts when stdin is non-interactive +- [ ] Confirm before dangerous actions (prompt or `--force`) +- [ ] Support `-` for stdin/stdout where applicable +- [ ] Arguments, flags, subcommands are order-independent where possible +- [ ] Never read secrets from flags (use files, stdin, or IPC) + +## INTERACTIVITY + +- [ ] Only use prompts when stdin is interactive TTY +- [ ] Support `--no-input` flag to disable prompts +- [ ] Don't echo passwords +- [ ] Ctrl-C works reliably to exit + +## SUBCOMMANDS + +- [ ] Consistent flag names across subcommands +- [ ] Consistent output formatting across subcommands +- [ ] Consistent naming convention (noun-verb or verb-noun) +- [ ] No ambiguous or similarly-named commands + +## ROBUSTNESS + +- [ ] Validate user input +- [ ] Output something within 100ms (responsive feel) +- [ ] Show progress for long operations (spinners/progress bars) +- [ ] Add timeouts with sensible defaults (no hanging forever) +- [ ] Handle Ctrl-C immediately, say something, then clean up +- [ ] Second Ctrl-C skips cleanup +- [ ] Handle uncleaned state from previous crashes + +## CONFIGURATION + +- [ ] Follow XDG Base Directory Specification for config files +- [ ] Parameter precedence: flags > env vars > project config > user config > system config +- [ ] Ask consent before modifying non-owned config +- [ ] Read from `.env` files where appropriate + +## ENVIRONMENT VARIABLES + +- [ ] Env var names: uppercase, numbers, underscores only +- [ ] Don't commandeer widely-used env var names +- [ ] Respect `NO_COLOR`, `EDITOR`, `HTTP_PROXY`, `TERM`, `PAGER`, `HOME` +- [ ] Don't read secrets from env vars (use credential files, stdin, IPC) + +## NAMING + +- [ ] Simple, memorable name +- [ ] Lowercase letters only (dashes if needed) +- [ ] Short enough for frequent typing +- [ ] Easy to type ergonomically + +## FUTURE-PROOFING + +- [ ] Keep changes additive (new flags rather than changed behavior) +- [ ] Warn before non-additive changes with migration guidance +- [ ] No catch-all subcommands that prevent future command names +- [ ] No arbitrary subcommand abbreviations +- [ ] Don't create time bombs (external dependencies that will disappear) + +## DISTRIBUTION + +- [ ] Distribute as single binary if possible +- [ ] Easy uninstall instructions available From 52c08488e4fee9b47642c76135bfad857d6d5279 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 13 Feb 2026 11:57:01 -0800 Subject: [PATCH 02/23] Add --org flag and extract shared prompt rendering - Add --org/-o global flag to BaseArgs (env: BRAINTRUST_DEFAULT_ORG) - Add with_org_name() to ApiClient for org override - Wire org override into prompts module - Extract prompt rendering helpers to src/ui/prompt_render.rs - Update prompts/view.rs to use shared helpers --- src/args.rs | 4 + src/http.rs | 5 ++ src/prompts/mod.rs | 7 +- src/prompts/view.rs | 156 +-------------------------------------- src/ui/mod.rs | 1 + src/ui/prompt_render.rs | 157 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 156 deletions(-) create mode 100644 src/ui/prompt_render.rs diff --git a/src/args.rs b/src/args.rs index 27a67a4..dce2bf0 100644 --- a/src/args.rs +++ b/src/args.rs @@ -23,6 +23,10 @@ pub struct BaseArgs { #[arg(long, env = "BRAINTRUST_APP_URL", global = true)] pub app_url: Option, + /// Override organization name (or via BRAINTRUST_DEFAULT_ORG) + #[arg(short = 'o', long, env = "BRAINTRUST_DEFAULT_ORG", global = true)] + pub org: Option, + /// Path to a .env file to load before running commands. #[arg(long, env = "BRAINTRUST_ENV_FILE")] pub env_file: Option, diff --git a/src/http.rs b/src/http.rs index f4660e8..ee84d68 100644 --- a/src/http.rs +++ b/src/http.rs @@ -36,6 +36,11 @@ impl ApiClient { &self.org_name } + pub fn with_org_name(mut self, org: String) -> Self { + self.org_name = org; + self + } + pub async fn get(&self, path: &str) -> Result { let url = self.url(path); let response = self diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 47685fb..2dcc8ff 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -83,7 +83,8 @@ impl DeleteArgs { pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { let ctx = login(&base).await?; - let client = ApiClient::new(&ctx)?; + let org_name = base.org.unwrap_or_else(|| ctx.login.org_name.clone()); + let client = ApiClient::new(&ctx)?.with_org_name(org_name.clone()); let project = match base.project { Some(p) => p, None if std::io::stdin().is_terminal() => select_project_interactive(&client).await?, @@ -96,14 +97,14 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { match args.command { None | Some(PromptsCommands::List) => { - list::run(&client, &project, &ctx.login.org_name, base.json).await + list::run(&client, &project, &org_name, base.json).await } Some(PromptsCommands::View(p)) => { view::run( &client, &ctx.app_url, &project, - &ctx.login.org_name, + &org_name, p.slug(), base.json, p.web, diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 328b821..49c05a3 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -1,16 +1,15 @@ use std::fmt::Write as _; use std::io::IsTerminal; -use std::sync::LazyLock; use anyhow::{anyhow, bail, Result}; use dialoguer::console; -use regex::Regex; use urlencoding::encode; -static TEMPLATE_VAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); - use crate::http::ApiClient; use crate::prompts::delete::select_prompt_interactive; +use crate::ui::prompt_render::{ + render_content_lines, render_message, render_options, render_tools, +}; use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; use super::api; @@ -112,152 +111,3 @@ pub async fn run( print_with_pager(&output)?; Ok(()) } - -fn render_message(output: &mut String, msg: &serde_json::Value) -> Result<()> { - let role = msg - .get("role") - .and_then(|r| r.as_str()) - .unwrap_or("unknown"); - let styled_role = match role { - "system" => console::style(role).dim().bold(), - "user" => console::style(role).green().bold(), - "assistant" => console::style(role).blue().bold(), - _ => console::style(role).bold(), - }; - writeln!(output, "{} {styled_role}", console::style("┃").dim())?; - - if let Some(content) = msg.get("content") { - match content { - serde_json::Value::String(s) => render_content_lines(output, s)?, - serde_json::Value::Array(parts) => { - for part in parts { - match part.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(text) = part.get("text").and_then(|t| t.as_str()) { - render_content_lines(output, text)?; - } - } - Some("image_url") => { - let url = part - .get("image_url") - .and_then(|iu| iu.get("url")) - .and_then(|u| u.as_str()) - .unwrap_or("?"); - writeln!( - output, - "{} {}", - console::style("│").dim(), - console::style(format!("[image: {url}]")).dim() - )?; - } - _ => {} - } - } - } - _ => {} - } - } - - if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) { - for tc in tool_calls { - if let Some(func) = tc.get("function") { - let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); - let args = func.get("arguments").and_then(|a| a.as_str()).unwrap_or(""); - writeln!( - output, - "{} {}({})", - console::style("│").dim(), - console::style(name).yellow(), - args - )?; - } - } - } - - writeln!(output)?; - Ok(()) -} - -fn render_content_lines(output: &mut String, content: &str) -> Result<()> { - for line in content.lines() { - let highlighted = highlight_template_vars(line); - writeln!(output, "{} {highlighted}", console::style("│").dim())?; - } - Ok(()) -} - -fn highlight_template_vars(line: &str) -> String { - let re = &*TEMPLATE_VAR_RE; - let mut result = String::new(); - let mut last_end = 0; - for cap in re.find_iter(line) { - result.push_str(&line[last_end..cap.start()]); - result.push_str(&format!("{}", console::style(cap.as_str()).cyan().bold())); - last_end = cap.end(); - } - result.push_str(&line[last_end..]); - result -} - -fn render_options(output: &mut String, options: &serde_json::Value) -> Result<()> { - let Some(params) = options.get("params").and_then(|p| p.as_object()) else { - return Ok(()); - }; - - for (key, val) in params { - if !val.is_null() { - writeln!( - output, - " {:<24}{}", - console::style(format!("{key}:")).dim(), - format_param_value(val) - )?; - } - } - - Ok(()) -} - -fn format_param_value(val: &serde_json::Value) -> String { - match val { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Array(arr) => { - let items: Vec = arr.iter().map(format_param_value).collect(); - format!("[{}]", items.join(", ")) - } - other => other.to_string(), - } -} - -fn render_tools(output: &mut String, tools: &[serde_json::Value]) -> Result<()> { - writeln!( - output, - "{} {}", - console::style("┃").dim(), - console::style("tools").magenta().bold() - )?; - for tool in tools { - let func = tool.get("function").unwrap_or(tool); - let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); - let desc = func.get("description").and_then(|d| d.as_str()); - match desc { - Some(d) => writeln!( - output, - "{} {} {}", - console::style("│").dim(), - console::style(name).yellow(), - console::style(format!("— {d}")).dim() - )?, - None => writeln!( - output, - "{} {}", - console::style("│").dim(), - console::style(name).yellow() - )?, - } - } - writeln!(output)?; - Ok(()) -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 097b702..f9c3187 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ mod pager; +pub mod prompt_render; mod select; mod shell; mod spinner; diff --git a/src/ui/prompt_render.rs b/src/ui/prompt_render.rs new file mode 100644 index 0000000..84f2899 --- /dev/null +++ b/src/ui/prompt_render.rs @@ -0,0 +1,157 @@ +use std::fmt::Write as _; +use std::sync::LazyLock; + +use anyhow::Result; +use dialoguer::console; +use regex::Regex; + +static TEMPLATE_VAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); + +pub fn render_message(output: &mut String, msg: &serde_json::Value) -> Result<()> { + let role = msg + .get("role") + .and_then(|r| r.as_str()) + .unwrap_or("unknown"); + let styled_role = match role { + "system" => console::style(role).dim().bold(), + "user" => console::style(role).green().bold(), + "assistant" => console::style(role).blue().bold(), + _ => console::style(role).bold(), + }; + writeln!(output, "{} {styled_role}", console::style("┃").dim())?; + + if let Some(content) = msg.get("content") { + match content { + serde_json::Value::String(s) => render_content_lines(output, s)?, + serde_json::Value::Array(parts) => { + for part in parts { + match part.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + render_content_lines(output, text)?; + } + } + Some("image_url") => { + let url = part + .get("image_url") + .and_then(|iu| iu.get("url")) + .and_then(|u| u.as_str()) + .unwrap_or("?"); + writeln!( + output, + "{} {}", + console::style("│").dim(), + console::style(format!("[image: {url}]")).dim() + )?; + } + _ => {} + } + } + } + _ => {} + } + } + + if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) { + for tc in tool_calls { + if let Some(func) = tc.get("function") { + let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let args = func.get("arguments").and_then(|a| a.as_str()).unwrap_or(""); + writeln!( + output, + "{} {}({})", + console::style("│").dim(), + console::style(name).yellow(), + args + )?; + } + } + } + + writeln!(output)?; + Ok(()) +} + +pub fn render_content_lines(output: &mut String, content: &str) -> Result<()> { + for line in content.lines() { + let highlighted = highlight_template_vars(line); + writeln!(output, "{} {highlighted}", console::style("│").dim())?; + } + Ok(()) +} + +fn highlight_template_vars(line: &str) -> String { + let re = &*TEMPLATE_VAR_RE; + let mut result = String::new(); + let mut last_end = 0; + for cap in re.find_iter(line) { + result.push_str(&line[last_end..cap.start()]); + result.push_str(&format!("{}", console::style(cap.as_str()).cyan().bold())); + last_end = cap.end(); + } + result.push_str(&line[last_end..]); + result +} + +pub fn render_options(output: &mut String, options: &serde_json::Value) -> Result<()> { + let Some(params) = options.get("params").and_then(|p| p.as_object()) else { + return Ok(()); + }; + + for (key, val) in params { + if !val.is_null() { + writeln!( + output, + " {:<24}{}", + console::style(format!("{key}:")).dim(), + format_param_value(val) + )?; + } + } + + Ok(()) +} + +fn format_param_value(val: &serde_json::Value) -> String { + match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Array(arr) => { + let items: Vec = arr.iter().map(format_param_value).collect(); + format!("[{}]", items.join(", ")) + } + other => other.to_string(), + } +} + +pub fn render_tools(output: &mut String, tools: &[serde_json::Value]) -> Result<()> { + writeln!( + output, + "{} {}", + console::style("┃").dim(), + console::style("tools").magenta().bold() + )?; + for tool in tools { + let func = tool.get("function").unwrap_or(tool); + let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let desc = func.get("description").and_then(|d| d.as_str()); + match desc { + Some(d) => writeln!( + output, + "{} {} {}", + console::style("│").dim(), + console::style(name).yellow(), + console::style(format!("— {d}")).dim() + )?, + None => writeln!( + output, + "{} {}", + console::style("│").dim(), + console::style(name).yellow() + )?, + } + } + writeln!(output)?; + Ok(()) +} From 374c7491686d51012a5cdc5d275f06fbb4e0b3f6 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 13 Feb 2026 11:59:27 -0800 Subject: [PATCH 03/23] Add tools and scorers commands via shared functions module - Create src/functions/ with shared API client, list, view, delete - Parameterize by FunctionKind (type_name, plural, function_type, url_segment) - Client-side filtering by function_type (API lacks server-side filter) - src/tools.rs and src/scorers.rs as thin wrappers - View renders prompt_data (messages, tools, model) and function_data - Register bt tools and bt scorers in main.rs --- src/functions/api.rs | 79 +++++++++++++++++ src/functions/delete.rs | 105 +++++++++++++++++++++++ src/functions/list.rs | 63 ++++++++++++++ src/functions/mod.rs | 141 +++++++++++++++++++++++++++++++ src/functions/view.rs | 183 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 9 ++ src/prompts/view.rs | 1 + src/scorers.rs | 10 +++ src/tools.rs | 10 +++ 9 files changed, 601 insertions(+) create mode 100644 src/functions/api.rs create mode 100644 src/functions/delete.rs create mode 100644 src/functions/list.rs create mode 100644 src/functions/mod.rs create mode 100644 src/functions/view.rs create mode 100644 src/scorers.rs create mode 100644 src/tools.rs diff --git a/src/functions/api.rs b/src/functions/api.rs new file mode 100644 index 0000000..25909e5 --- /dev/null +++ b/src/functions/api.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::http::ApiClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Function { + pub id: String, + pub name: String, + pub slug: String, + pub project_id: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub function_type: Option, + #[serde(default)] + pub prompt_data: Option, + #[serde(default)] + pub function_data: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub created: Option, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +pub async fn list_functions( + client: &ApiClient, + project: &str, + function_type: Option<&str>, +) -> Result> { + let path = format!( + "/v1/function?org_name={}&project_name={}", + encode(client.org_name()), + encode(project) + ); + let list: ListResponse = client.get(&path).await?; + + Ok(match function_type { + Some(ft) => list + .objects + .into_iter() + .filter(|f| f.function_type.as_deref() == Some(ft)) + .collect(), + None => list.objects, + }) +} + +pub async fn get_function_by_slug( + client: &ApiClient, + project: &str, + slug: &str, + function_type: Option<&str>, +) -> Result> { + let path = format!( + "/v1/function?org_name={}&project_name={}&slug={}", + encode(client.org_name()), + encode(project), + encode(slug) + ); + let list: ListResponse = client.get(&path).await?; + + Ok(list.objects.into_iter().find(|f| match function_type { + Some(ft) => f.function_type.as_deref() == Some(ft), + None => true, + })) +} + +pub async fn delete_function(client: &ApiClient, function_id: &str) -> Result<()> { + let path = format!("/v1/function/{}", encode(function_id)); + client.delete(&path).await +} diff --git a/src/functions/delete.rs b/src/functions/delete.rs new file mode 100644 index 0000000..b1d7fb2 --- /dev/null +++ b/src/functions/delete.rs @@ -0,0 +1,105 @@ +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::Confirm; + +use crate::{ + http::ApiClient, + ui::{self, print_command_status, with_spinner, CommandStatus}, +}; + +use super::{ + api::{self, Function}, + FunctionKind, +}; + +pub async fn run( + client: &ApiClient, + project: &str, + slug: Option<&str>, + force: bool, + kind: &FunctionKind, +) -> Result<()> { + if force && slug.is_none() { + bail!( + "slug required when using --force. Use: bt {} delete --force", + kind.plural + ); + } + + let function = match slug { + Some(s) => api::get_function_by_slug(client, project, s, Some(kind.function_type)) + .await? + .ok_or_else(|| anyhow!("{} with slug '{s}' not found", kind.type_name))?, + None => { + if !std::io::stdin().is_terminal() { + bail!( + "{} slug required. Use: bt {} delete ", + kind.type_name, + kind.plural + ); + } + select_function_interactive(client, project, kind).await? + } + }; + + if !force && std::io::stdin().is_terminal() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete {} '{}' from {}?", + kind.type_name, &function.name, project + )) + .default(false) + .interact()?; + if !confirm { + return Ok(()); + } + } + + match with_spinner( + &format!("Deleting {}...", kind.type_name), + api::delete_function(client, &function.id), + ) + .await + { + Ok(_) => { + print_command_status( + CommandStatus::Success, + &format!("Deleted '{}'", function.name), + ); + eprintln!( + "Run `bt {} list` to see remaining {}.", + kind.plural, kind.plural + ); + Ok(()) + } + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Failed to delete '{}'", function.name), + ); + Err(e) + } + } +} + +pub async fn select_function_interactive( + client: &ApiClient, + project: &str, + kind: &FunctionKind, +) -> Result { + let mut functions = with_spinner( + &format!("Loading {}...", kind.plural), + api::list_functions(client, project, Some(kind.function_type)), + ) + .await?; + + if functions.is_empty() { + bail!("no {} found", kind.plural); + } + + functions.sort_by(|a, b| a.name.cmp(&b.name)); + let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect(); + let selection = ui::fuzzy_select(&format!("Select {}", kind.type_name), &names)?; + Ok(functions.swap_remove(selection)) +} diff --git a/src/functions/list.rs b/src/functions/list.rs new file mode 100644 index 0000000..abd03e6 --- /dev/null +++ b/src/functions/list.rs @@ -0,0 +1,63 @@ +use std::fmt::Write as _; + +use anyhow::Result; +use dialoguer::console; + +use crate::{ + http::ApiClient, + ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, + utils::pluralize, +}; + +use super::{api, FunctionKind}; + +pub async fn run( + client: &ApiClient, + project: &str, + org: &str, + json: bool, + kind: &FunctionKind, +) -> Result<()> { + let functions = with_spinner( + &format!("Loading {}...", kind.plural), + api::list_functions(client, project, Some(kind.function_type)), + ) + .await?; + + if json { + println!("{}", serde_json::to_string(&functions)?); + } else { + let mut output = String::new(); + let count = format!( + "{} {}", + functions.len(), + pluralize(functions.len(), kind.type_name, Some(kind.plural)) + ); + writeln!( + output, + "{} found in {} {} {}\n", + console::style(count), + console::style(org).bold(), + console::style("/").dim().bold(), + console::style(project).bold() + )?; + + let mut table = styled_table(); + table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + apply_column_padding(&mut table, (0, 6)); + + for func in &functions { + let desc = func + .description + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&func.name, &desc, &func.slug]); + } + + write!(output, "{table}")?; + print_with_pager(&output)?; + } + Ok(()) +} diff --git a/src/functions/mod.rs b/src/functions/mod.rs new file mode 100644 index 0000000..06e0771 --- /dev/null +++ b/src/functions/mod.rs @@ -0,0 +1,141 @@ +use std::io::IsTerminal; + +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; + +use crate::{ + args::BaseArgs, + http::ApiClient, + login::login, + projects::{api::get_project_by_name, switch::select_project_interactive}, +}; + +pub mod api; +mod delete; +mod list; +mod view; + +pub struct FunctionKind { + pub type_name: &'static str, + pub plural: &'static str, + pub function_type: &'static str, + pub url_segment: &'static str, +} + +pub const TOOL: FunctionKind = FunctionKind { + type_name: "tool", + plural: "tools", + function_type: "tool", + url_segment: "tools", +}; + +pub const SCORER: FunctionKind = FunctionKind { + type_name: "scorer", + plural: "scorers", + function_type: "scorer", + url_segment: "scorers", +}; + +#[derive(Debug, Clone, Args)] +pub struct FunctionArgs { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum FunctionCommands { + /// List all + List, + /// View details + View(ViewArgs), + /// Delete + Delete(DeleteArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + /// Slug (positional) + #[arg(value_name = "SLUG")] + slug_positional: Option, + + /// Slug (flag) + #[arg(long = "slug", short = 's')] + slug_flag: Option, + + /// Open in browser + #[arg(long)] + web: bool, + + /// Show all configuration details + #[arg(long)] + verbose: bool, +} + +impl ViewArgs { + pub fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + +#[derive(Debug, Clone, Args)] +pub struct DeleteArgs { + /// Slug (positional) + #[arg(value_name = "SLUG")] + slug_positional: Option, + + /// Slug (flag) + #[arg(long = "slug", short = 's')] + slug_flag: Option, + + /// Skip confirmation + #[arg(long, short = 'f')] + force: bool, +} + +impl DeleteArgs { + pub fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + +pub async fn run(base: BaseArgs, args: FunctionArgs, kind: &FunctionKind) -> Result<()> { + let ctx = login(&base).await?; + let org_name = base.org.unwrap_or_else(|| ctx.login.org_name.clone()); + let client = ApiClient::new(&ctx)?.with_org_name(org_name.clone()); + let project = match base.project { + Some(p) => p, + None if std::io::stdin().is_terminal() => select_project_interactive(&client).await?, + None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), + }; + + get_project_by_name(&client, &project) + .await? + .ok_or_else(|| anyhow!("project '{project}' not found"))?; + + match args.command { + None | Some(FunctionCommands::List) => { + list::run(&client, &project, &org_name, base.json, kind).await + } + Some(FunctionCommands::View(v)) => { + view::run( + &client, + &ctx.app_url, + &project, + &org_name, + v.slug(), + base.json, + v.web, + v.verbose, + kind, + ) + .await + } + Some(FunctionCommands::Delete(d)) => { + delete::run(&client, &project, d.slug(), d.force, kind).await + } + } +} diff --git a/src/functions/view.rs b/src/functions/view.rs new file mode 100644 index 0000000..fb68c7d --- /dev/null +++ b/src/functions/view.rs @@ -0,0 +1,183 @@ +use std::fmt::Write as _; +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::console; +use urlencoding::encode; + +use crate::http::ApiClient; +use crate::ui::prompt_render::{ + render_content_lines, render_message, render_options, render_tools, +}; +use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; + +use super::{api, delete, FunctionKind}; + +#[allow(clippy::too_many_arguments)] +pub async fn run( + client: &ApiClient, + app_url: &str, + project: &str, + org_name: &str, + slug: Option<&str>, + json: bool, + web: bool, + verbose: bool, + kind: &FunctionKind, +) -> Result<()> { + let function = match slug { + Some(s) => with_spinner( + &format!("Loading {}...", kind.type_name), + api::get_function_by_slug(client, project, s, Some(kind.function_type)), + ) + .await? + .ok_or_else(|| anyhow!("{} with slug '{s}' not found", kind.type_name))?, + None => { + if !std::io::stdin().is_terminal() { + bail!( + "{} slug required. Use: bt {} view ", + kind.type_name, + kind.plural + ); + } + delete::select_function_interactive(client, project, kind).await? + } + }; + + if web { + let url = format!( + "{}/app/{}/p/{}/{}/{}", + app_url.trim_end_matches('/'), + encode(org_name), + encode(project), + kind.url_segment, + encode(&function.id) + ); + open::that(&url)?; + print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string(&function)?); + return Ok(()); + } + + let mut output = String::new(); + writeln!(output, "Viewing {}", console::style(&function.name).bold())?; + + if let Some(ft) = &function.function_type { + writeln!(output, "{} {}", console::style("Type:").dim(), ft)?; + } + + if let Some(pd) = &function.prompt_data { + let options = pd.get("options"); + if let Some(model) = options + .and_then(|o| o.get("model")) + .and_then(|m| m.as_str()) + { + writeln!(output, "{} {}", console::style("Model:").dim(), model)?; + } + if verbose { + if let Some(opts) = options { + render_options(&mut output, opts)?; + } + } + writeln!(output)?; + + if let Some(prompt_block) = pd.get("prompt") { + match prompt_block.get("type").and_then(|t| t.as_str()) { + Some("chat") => { + if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) + { + for msg in messages { + render_message(&mut output, msg)?; + } + } + } + Some("completion") => { + if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { + render_content_lines(&mut output, content)?; + writeln!(output)?; + } + } + _ => {} + } + if let Some(tools_val) = prompt_block.get("tools") { + let tools: Option> = match tools_val { + serde_json::Value::Array(arr) => Some(arr.clone()), + serde_json::Value::String(s) => serde_json::from_str(s).ok(), + _ => None, + }; + if let Some(ref tools) = tools { + render_tools(&mut output, tools)?; + } + } + } + } + + if let Some(fd) = &function.function_data { + if let Some(fd_type) = fd.get("type").and_then(|t| t.as_str()) { + match fd_type { + "code" => { + writeln!(output, "{} code", console::style("Function:").dim())?; + if let Some(data) = fd.get("data") { + if let Some(rt) = data + .get("runtime_context") + .and_then(|r| r.get("runtime")) + .and_then(|r| r.as_str()) + { + writeln!(output, " {} {}", console::style("Runtime:").dim(), rt)?; + } + if let Some(loc) = data.get("location") { + let lt = loc.get("type").and_then(|t| t.as_str()).unwrap_or("?"); + writeln!(output, " {} {}", console::style("Location:").dim(), lt)?; + } + } + } + "global" => { + writeln!( + output, + "{} global (built-in)", + console::style("Function:").dim() + )?; + if let Some(name) = fd.get("name").and_then(|n| n.as_str()) { + writeln!(output, " {} {}", console::style("Name:").dim(), name)?; + } + } + "prompt" => {} + other => { + writeln!(output, "{} {}", console::style("Function:").dim(), other)?; + } + } + } + } + + if verbose { + if let Some(tags) = &function.tags { + if !tags.is_empty() { + writeln!( + output, + "\n{} {}", + console::style("Tags:").dim(), + tags.join(", ") + )?; + } + } + if let Some(meta) = &function.metadata { + if let Some(obj) = meta.as_object() { + if !obj.is_empty() { + writeln!( + output, + "{} {}", + console::style("Metadata:").dim(), + serde_json::to_string_pretty(meta).unwrap_or_default() + )?; + } + } + } + } + + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index d0219cc..f1c662d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,15 @@ mod args; mod env; #[cfg(unix)] mod eval; +mod functions; mod http; mod login; mod projects; mod prompts; +mod scorers; mod self_update; mod sql; +mod tools; mod traces; mod ui; mod utils; @@ -42,6 +45,10 @@ enum Commands { SelfCommand(self_update::SelfArgs), /// Manage prompts Prompts(CLIArgs), + /// Manage tools + Tools(CLIArgs), + /// Manage scorers + Scorers(CLIArgs), } #[tokio::main] @@ -58,6 +65,8 @@ async fn main() -> Result<()> { Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, Commands::SelfCommand(args) => self_update::run(args).await?, Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, + Commands::Tools(cmd) => tools::run(cmd.base, cmd.args).await?, + Commands::Scorers(cmd) => scorers::run(cmd.base, cmd.args).await?, } Ok(()) diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 49c05a3..02307db 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -14,6 +14,7 @@ use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandSta use super::api; +#[allow(clippy::too_many_arguments)] pub async fn run( client: &ApiClient, app_url: &str, diff --git a/src/scorers.rs b/src/scorers.rs new file mode 100644 index 0000000..359921e --- /dev/null +++ b/src/scorers.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +use crate::args::BaseArgs; +use crate::functions::{self, FunctionArgs, SCORER}; + +pub type ScorersArgs = FunctionArgs; + +pub async fn run(base: BaseArgs, args: ScorersArgs) -> Result<()> { + functions::run(base, args, &SCORER).await +} diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..80f9d9f --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +use crate::args::BaseArgs; +use crate::functions::{self, FunctionArgs, TOOL}; + +pub type ToolsArgs = FunctionArgs; + +pub async fn run(base: BaseArgs, args: ToolsArgs) -> Result<()> { + functions::run(base, args, &TOOL).await +} From b76def6dd9bacde72a59edec9d6f87b20ccc1c78 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 13 Feb 2026 12:00:51 -0800 Subject: [PATCH 04/23] Add experiments command with list, view, and delete - Create src/experiments/ with API client, list, view, delete - Experiments use name (not slug) as identifier - List shows Name, Description, Created, Commit columns - View shows metadata fields (description, created, commit, dataset, public, tags) - Support --web to open in browser, --name/-n flag - Register bt experiments (alias: bt exp) in main.rs --- src/experiments/api.rs | 67 ++++++++++++++++++++++ src/experiments/delete.rs | 85 ++++++++++++++++++++++++++++ src/experiments/list.rs | 72 ++++++++++++++++++++++++ src/experiments/mod.rs | 114 ++++++++++++++++++++++++++++++++++++++ src/experiments/view.rs | 100 +++++++++++++++++++++++++++++++++ src/main.rs | 5 ++ 6 files changed, 443 insertions(+) create mode 100644 src/experiments/api.rs create mode 100644 src/experiments/delete.rs create mode 100644 src/experiments/list.rs create mode 100644 src/experiments/mod.rs create mode 100644 src/experiments/view.rs diff --git a/src/experiments/api.rs b/src/experiments/api.rs new file mode 100644 index 0000000..ae18a1d --- /dev/null +++ b/src/experiments/api.rs @@ -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, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub dataset_id: Option, + #[serde(default)] + pub dataset_version: Option, + #[serde(default)] + pub base_exp_id: Option, + #[serde(default)] + pub commit: Option, + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +pub async fn list_experiments(client: &ApiClient, project: &str) -> Result> { + 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> { + 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 +} diff --git a/src/experiments/delete.rs b/src/experiments/delete.rs new file mode 100644 index 0000000..b48fb26 --- /dev/null +++ b/src/experiments/delete.rs @@ -0,0 +1,85 @@ +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::Confirm; + +use crate::{ + http::ApiClient, + ui::{self, print_command_status, with_spinner, CommandStatus}, +}; + +use super::api::{self, Experiment}; + +pub async fn run(client: &ApiClient, project: &str, name: Option<&str>, force: bool) -> Result<()> { + if force && name.is_none() { + bail!("name required when using --force. Use: bt experiments delete --force"); + } + + let experiment = match name { + Some(n) => api::get_experiment_by_name(client, project, n) + .await? + .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, + None => { + if !std::io::stdin().is_terminal() { + bail!("experiment name required. Use: bt experiments delete "); + } + select_experiment_interactive(client, project).await? + } + }; + + if !force && std::io::stdin().is_terminal() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete experiment '{}' from {}?", + &experiment.name, project + )) + .default(false) + .interact()?; + if !confirm { + return Ok(()); + } + } + + match with_spinner( + "Deleting experiment...", + api::delete_experiment(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) + } + } +} + +pub async fn select_experiment_interactive( + client: &ApiClient, + project: &str, +) -> Result { + let mut experiments = with_spinner( + "Loading 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)?; + Ok(experiments.swap_remove(selection)) +} diff --git a/src/experiments/list.rs b/src/experiments/list.rs new file mode 100644 index 0000000..58ffc78 --- /dev/null +++ b/src/experiments/list.rs @@ -0,0 +1,72 @@ +use std::fmt::Write as _; + +use anyhow::Result; +use dialoguer::console; + +use crate::{ + http::ApiClient, + ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, + utils::pluralize, +}; + +use super::api; + +pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> { + let experiments = with_spinner( + "Loading experiments...", + api::list_experiments(client, project), + ) + .await?; + + if json { + println!("{}", serde_json::to_string(&experiments)?); + } else { + 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(org).bold(), + console::style("/").dim().bold(), + console::style(project).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, 40)) + .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(()) +} diff --git a/src/experiments/mod.rs b/src/experiments/mod.rs new file mode 100644 index 0000000..b0c90e6 --- /dev/null +++ b/src/experiments/mod.rs @@ -0,0 +1,114 @@ +use std::io::IsTerminal; + +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; + +use crate::{ + args::BaseArgs, + http::ApiClient, + login::login, + projects::{api::get_project_by_name, switch::select_project_interactive}, +}; + +mod api; +mod delete; +mod list; +mod view; + +#[derive(Debug, Clone, Args)] +pub struct ExperimentsArgs { + #[command(subcommand)] + command: Option, +} + +#[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, + + /// Experiment name (flag) + #[arg(long = "name", short = 'n')] + name_flag: Option, + + /// 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, + + /// Experiment name (flag) + #[arg(long = "name", short = 'n')] + name_flag: Option, + + /// 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 async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { + let ctx = login(&base).await?; + let org_name = base.org.unwrap_or_else(|| ctx.login.org_name.clone()); + let client = ApiClient::new(&ctx)?.with_org_name(org_name.clone()); + let project = match base.project { + Some(p) => p, + None if std::io::stdin().is_terminal() => select_project_interactive(&client).await?, + None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), + }; + + get_project_by_name(&client, &project) + .await? + .ok_or_else(|| anyhow!("project '{project}' not found"))?; + + match args.command { + None | Some(ExperimentsCommands::List) => { + list::run(&client, &project, &org_name, base.json).await + } + Some(ExperimentsCommands::View(v)) => { + view::run( + &client, + &ctx.app_url, + &project, + &org_name, + v.name(), + base.json, + v.web, + ) + .await + } + Some(ExperimentsCommands::Delete(d)) => { + delete::run(&client, &project, d.name(), d.force).await + } + } +} diff --git a/src/experiments/view.rs b/src/experiments/view.rs new file mode 100644 index 0000000..777794e --- /dev/null +++ b/src/experiments/view.rs @@ -0,0 +1,100 @@ +use std::fmt::Write as _; +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::console; +use urlencoding::encode; + +use crate::http::ApiClient; +use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; + +use super::{api, delete}; + +pub async fn run( + client: &ApiClient, + app_url: &str, + project: &str, + org_name: &str, + name: Option<&str>, + json: bool, + web: bool, +) -> Result<()> { + let experiment = match name { + Some(n) => with_spinner( + "Loading experiment...", + api::get_experiment_by_name(client, project, n), + ) + .await? + .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, + None => { + if !std::io::stdin().is_terminal() { + bail!("experiment name required. Use: bt experiments view "); + } + delete::select_experiment_interactive(client, project).await? + } + }; + + if web { + let url = format!( + "{}/app/{}/p/{}/experiments/{}", + app_url.trim_end_matches('/'), + encode(org_name), + encode(project), + encode(&experiment.id) + ); + open::that(&url)?; + print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string(&experiment)?); + return Ok(()); + } + + let mut output = String::new(); + writeln!( + output, + "Viewing {}", + console::style(&experiment.name).bold() + )?; + + if let Some(desc) = &experiment.description { + if !desc.is_empty() { + writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; + } + } + if let Some(created) = &experiment.created { + writeln!(output, "{} {}", console::style("Created:").dim(), created)?; + } + if let Some(commit) = &experiment.commit { + writeln!(output, "{} {}", console::style("Commit:").dim(), commit)?; + } + if let Some(dataset_id) = &experiment.dataset_id { + writeln!( + output, + "{} {}", + console::style("Dataset:").dim(), + dataset_id + )?; + } + writeln!( + output, + "{} {}", + console::style("Public:").dim(), + if experiment.public { "yes" } else { "no" } + )?; + if let Some(tags) = &experiment.tags { + if !tags.is_empty() { + writeln!( + output, + "{} {}", + console::style("Tags:").dim(), + tags.join(", ") + )?; + } + } + + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index f1c662d..da135fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod args; mod env; #[cfg(unix)] mod eval; +mod experiments; mod functions; mod http; mod login; @@ -49,6 +50,9 @@ enum Commands { Tools(CLIArgs), /// Manage scorers Scorers(CLIArgs), + /// Manage experiments + #[command(visible_alias = "exp")] + Experiments(CLIArgs), } #[tokio::main] @@ -67,6 +71,7 @@ async fn main() -> Result<()> { Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, Commands::Tools(cmd) => tools::run(cmd.base, cmd.args).await?, Commands::Scorers(cmd) => scorers::run(cmd.base, cmd.args).await?, + Commands::Experiments(cmd) => experiments::run(cmd.base, cmd.args).await?, } Ok(()) From 224f2d138c398327003df43e545c271a454db3cd Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 13 Feb 2026 12:03:21 -0800 Subject: [PATCH 05/23] Fix review findings: match established patterns - Use .clone() instead of swap_remove in interactive selection (matches prompts/delete.rs pattern) - Improve function subcommand help text to be more descriptive --- src/experiments/delete.rs | 2 +- src/functions/delete.rs | 2 +- src/functions/mod.rs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/experiments/delete.rs b/src/experiments/delete.rs index b48fb26..5e203fd 100644 --- a/src/experiments/delete.rs +++ b/src/experiments/delete.rs @@ -81,5 +81,5 @@ pub async fn select_experiment_interactive( 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)?; - Ok(experiments.swap_remove(selection)) + Ok(experiments[selection].clone()) } diff --git a/src/functions/delete.rs b/src/functions/delete.rs index b1d7fb2..b66d76e 100644 --- a/src/functions/delete.rs +++ b/src/functions/delete.rs @@ -101,5 +101,5 @@ pub async fn select_function_interactive( functions.sort_by(|a, b| a.name.cmp(&b.name)); let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect(); let selection = ui::fuzzy_select(&format!("Select {}", kind.type_name), &names)?; - Ok(functions.swap_remove(selection)) + Ok(functions[selection].clone()) } diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 06e0771..e142f36 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -44,11 +44,11 @@ pub struct FunctionArgs { #[derive(Debug, Clone, Subcommand)] pub enum FunctionCommands { - /// List all + /// List all in the current project List, - /// View details + /// View details of a specific function View(ViewArgs), - /// Delete + /// Delete a function Delete(DeleteArgs), } From 45da886180e68112d427f7b9bdffc2141818f784 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 13 Feb 2026 12:29:53 -0800 Subject: [PATCH 06/23] Hide environment variable values in help output --- src/args.rs | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/args.rs b/src/args.rs index dce2bf0..3bf35dd 100644 --- a/src/args.rs +++ b/src/args.rs @@ -8,27 +8,54 @@ pub struct BaseArgs { pub json: bool, /// 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, /// Override stored API key (or via BRAINTRUST_API_KEY) - #[arg(long, env = "BRAINTRUST_API_KEY", global = true)] + #[arg( + long, + env = "BRAINTRUST_API_KEY", + hide_env_values = true, + global = true + )] pub api_key: Option, /// 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, /// 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, /// Override organization name (or via BRAINTRUST_DEFAULT_ORG) - #[arg(short = 'o', long, env = "BRAINTRUST_DEFAULT_ORG", global = true)] + #[arg( + short = 'o', + long, + env = "BRAINTRUST_DEFAULT_ORG", + hide_env_values = true, + global = true + )] pub org: Option, /// 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, } From a71c91cbbac394d900f3e1927689a75da5237f65 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Mon, 16 Feb 2026 14:56:38 -0800 Subject: [PATCH 07/23] refactor(functions): replace REST API with BTQL for function queries --- src/args.rs | 3 ++- src/functions/api.rs | 48 ++++++++++++----------------------------- src/functions/delete.rs | 8 +++---- src/functions/list.rs | 6 +++--- src/functions/mod.rs | 13 +++++------ src/functions/view.rs | 8 +++---- src/http.rs | 24 ++++++++++++++++++++- src/main.rs | 7 +++++- 8 files changed, 63 insertions(+), 54 deletions(-) diff --git a/src/args.rs b/src/args.rs index 3bf35dd..78a11d3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,6 +1,7 @@ -use clap::Args; use std::path::PathBuf; +use clap::Args; + #[derive(Debug, Clone, Args)] pub struct BaseArgs { /// Output as JSON diff --git a/src/functions/api.rs b/src/functions/api.rs index 25909e5..336243e 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -26,51 +26,31 @@ pub struct Function { pub created: Option, } -#[derive(Debug, Deserialize)] -struct ListResponse { - objects: Vec, -} - pub async fn list_functions( client: &ApiClient, - project: &str, + project_id: &str, function_type: Option<&str>, ) -> Result> { - let path = format!( - "/v1/function?org_name={}&project_name={}", - encode(client.org_name()), - encode(project) - ); - let list: ListResponse = client.get(&path).await?; - - Ok(match function_type { - Some(ft) => list - .objects - .into_iter() - .filter(|f| f.function_type.as_deref() == Some(ft)) - .collect(), - None => list.objects, - }) + let query = match function_type { + Some(ft) => { + format!("SELECT * FROM project_functions('{project_id}') WHERE function_type = '{ft}'") + } + None => format!("SELECT * FROM project_functions('{project_id}')"), + }; + let response = client.btql::(&query).await?; + + Ok(response.data) } pub async fn get_function_by_slug( client: &ApiClient, - project: &str, + project_id: &str, slug: &str, - function_type: Option<&str>, ) -> Result> { - let path = format!( - "/v1/function?org_name={}&project_name={}&slug={}", - encode(client.org_name()), - encode(project), - encode(slug) - ); - let list: ListResponse = client.get(&path).await?; + let query = format!("SELECT * FROM project_functions('{project_id}') WHERE slug = '{slug}'"); + let response = client.btql(&query).await?; - Ok(list.objects.into_iter().find(|f| match function_type { - Some(ft) => f.function_type.as_deref() == Some(ft), - None => true, - })) + Ok(response.data.into_iter().next()) } pub async fn delete_function(client: &ApiClient, function_id: &str) -> Result<()> { diff --git a/src/functions/delete.rs b/src/functions/delete.rs index b66d76e..f47f4b7 100644 --- a/src/functions/delete.rs +++ b/src/functions/delete.rs @@ -15,7 +15,7 @@ use super::{ pub async fn run( client: &ApiClient, - project: &str, + project_id: &str, slug: Option<&str>, force: bool, kind: &FunctionKind, @@ -28,7 +28,7 @@ pub async fn run( } let function = match slug { - Some(s) => api::get_function_by_slug(client, project, s, Some(kind.function_type)) + Some(s) => api::get_function_by_slug(client, project_id, s) .await? .ok_or_else(|| anyhow!("{} with slug '{s}' not found", kind.type_name))?, None => { @@ -39,7 +39,7 @@ pub async fn run( kind.plural ); } - select_function_interactive(client, project, kind).await? + select_function_interactive(client, project_id, kind).await? } }; @@ -47,7 +47,7 @@ pub async fn run( let confirm = Confirm::new() .with_prompt(format!( "Delete {} '{}' from {}?", - kind.type_name, &function.name, project + kind.type_name, &function.name, project_id )) .default(false) .interact()?; diff --git a/src/functions/list.rs b/src/functions/list.rs index abd03e6..998b2b9 100644 --- a/src/functions/list.rs +++ b/src/functions/list.rs @@ -13,14 +13,14 @@ use super::{api, FunctionKind}; pub async fn run( client: &ApiClient, - project: &str, + project_id: &str, org: &str, json: bool, kind: &FunctionKind, ) -> Result<()> { let functions = with_spinner( &format!("Loading {}...", kind.plural), - api::list_functions(client, project, Some(kind.function_type)), + api::list_functions(client, project_id, Some(kind.function_type)), ) .await?; @@ -39,7 +39,7 @@ pub async fn run( console::style(count), console::style(org).bold(), console::style("/").dim().bold(), - console::style(project).bold() + console::style(project_id).bold() )?; let mut table = styled_table(); diff --git a/src/functions/mod.rs b/src/functions/mod.rs index e142f36..6287463 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -46,9 +46,9 @@ pub struct FunctionArgs { pub enum FunctionCommands { /// List all in the current project List, - /// View details of a specific function + /// View details View(ViewArgs), - /// Delete a function + /// Delete by slug Delete(DeleteArgs), } @@ -112,19 +112,20 @@ pub async fn run(base: BaseArgs, args: FunctionArgs, kind: &FunctionKind) -> Res None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), }; - get_project_by_name(&client, &project) + let resolved_project = get_project_by_name(&client, &project) .await? .ok_or_else(|| anyhow!("project '{project}' not found"))?; + let project_id = &resolved_project.id; match args.command { None | Some(FunctionCommands::List) => { - list::run(&client, &project, &org_name, base.json, kind).await + list::run(&client, project_id, &org_name, base.json, kind).await } Some(FunctionCommands::View(v)) => { view::run( &client, &ctx.app_url, - &project, + project_id, &org_name, v.slug(), base.json, @@ -135,7 +136,7 @@ pub async fn run(base: BaseArgs, args: FunctionArgs, kind: &FunctionKind) -> Res .await } Some(FunctionCommands::Delete(d)) => { - delete::run(&client, &project, d.slug(), d.force, kind).await + delete::run(&client, project_id, d.slug(), d.force, kind).await } } } diff --git a/src/functions/view.rs b/src/functions/view.rs index fb68c7d..66782ee 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -17,7 +17,7 @@ use super::{api, delete, FunctionKind}; pub async fn run( client: &ApiClient, app_url: &str, - project: &str, + project_id: &str, org_name: &str, slug: Option<&str>, json: bool, @@ -28,7 +28,7 @@ pub async fn run( let function = match slug { Some(s) => with_spinner( &format!("Loading {}...", kind.type_name), - api::get_function_by_slug(client, project, s, Some(kind.function_type)), + api::get_function_by_slug(client, project_id, s), ) .await? .ok_or_else(|| anyhow!("{} with slug '{s}' not found", kind.type_name))?, @@ -40,7 +40,7 @@ pub async fn run( kind.plural ); } - delete::select_function_interactive(client, project, kind).await? + delete::select_function_interactive(client, project_id, kind).await? } }; @@ -49,7 +49,7 @@ pub async fn run( "{}/app/{}/p/{}/{}/{}", app_url.trim_end_matches('/'), encode(org_name), - encode(project), + encode(project_id), kind.url_segment, encode(&function.id) ); diff --git a/src/http.rs b/src/http.rs index ee84d68..ff4107d 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,7 +1,8 @@ use anyhow::{Context, Result}; use reqwest::Client; use serde::de::DeserializeOwned; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::login::LoginContext; @@ -13,6 +14,11 @@ pub struct ApiClient { org_name: String, } +#[derive(Debug, Deserialize)] +pub struct BtqlResponse { + pub data: Vec, +} + impl ApiClient { pub fn new(ctx: &LoginContext) -> Result { let http = Client::builder() @@ -126,4 +132,20 @@ impl ApiClient { Ok(()) } + + pub async fn btql(&self, query: &str) -> Result> { + let body = json!({ + "query": query, + "fmt": "json", + }); + + let org_name = self.org_name(); + let headers = if !org_name.is_empty() { + vec![("x-bt-org-name", org_name)] + } else { + Vec::new() + }; + + self.post_with_headers("/btql", &body, &headers).await + } } diff --git a/src/main.rs b/src/main.rs index da135fd..59d77e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,12 @@ mod utils; use crate::args::CLIArgs; #[derive(Debug, Parser)] -#[command(name = "bt", about = "Braintrust CLI", version)] +#[command( + name = "bt", + about = "Braintrust CLI", + version, + after_help = "Docs: https://braintrust.dev/docs" +)] struct Cli { #[command(subcommand)] command: Commands, From 09833a1b77b7574d95df1354c113c9c87d74f4f9 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Mon, 16 Feb 2026 15:47:44 -0800 Subject: [PATCH 08/23] fix(functions): add SQL escaping and use project names in UI --- src/functions/api.rs | 14 +++++++++++--- src/functions/delete.rs | 7 +++++-- src/functions/list.rs | 7 +++++-- src/functions/mod.rs | 7 +++---- src/functions/view.rs | 6 ++++-- src/http.rs | 5 +++++ src/main.rs | 1 - 7 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/functions/api.rs b/src/functions/api.rs index 336243e..2895584 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -4,6 +4,10 @@ use urlencoding::encode; use crate::http::ApiClient; +fn escape_sql(s: &str) -> String { + s.replace('\'', "''") +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Function { pub id: String, @@ -31,11 +35,13 @@ pub async fn list_functions( project_id: &str, function_type: Option<&str>, ) -> Result> { + let pid = escape_sql(project_id); let query = match function_type { Some(ft) => { - format!("SELECT * FROM project_functions('{project_id}') WHERE function_type = '{ft}'") + let ft = escape_sql(ft); + format!("SELECT * FROM project_functions('{pid}') WHERE function_type = '{ft}'") } - None => format!("SELECT * FROM project_functions('{project_id}')"), + None => format!("SELECT * FROM project_functions('{pid}')"), }; let response = client.btql::(&query).await?; @@ -47,7 +53,9 @@ pub async fn get_function_by_slug( project_id: &str, slug: &str, ) -> Result> { - let query = format!("SELECT * FROM project_functions('{project_id}') WHERE slug = '{slug}'"); + let pid = escape_sql(project_id); + let slug = escape_sql(slug); + let query = format!("SELECT * FROM project_functions('{pid}') WHERE slug = '{slug}'"); let response = client.btql(&query).await?; Ok(response.data.into_iter().next()) diff --git a/src/functions/delete.rs b/src/functions/delete.rs index f47f4b7..1abd950 100644 --- a/src/functions/delete.rs +++ b/src/functions/delete.rs @@ -5,6 +5,7 @@ use dialoguer::Confirm; use crate::{ http::ApiClient, + projects::api::Project, ui::{self, print_command_status, with_spinner, CommandStatus}, }; @@ -15,7 +16,7 @@ use super::{ pub async fn run( client: &ApiClient, - project_id: &str, + project: &Project, slug: Option<&str>, force: bool, kind: &FunctionKind, @@ -27,6 +28,8 @@ pub async fn run( ); } + let project_id = &project.id; + let function = match slug { Some(s) => api::get_function_by_slug(client, project_id, s) .await? @@ -47,7 +50,7 @@ pub async fn run( let confirm = Confirm::new() .with_prompt(format!( "Delete {} '{}' from {}?", - kind.type_name, &function.name, project_id + kind.type_name, &function.name, &project.name )) .default(false) .interact()?; diff --git a/src/functions/list.rs b/src/functions/list.rs index 998b2b9..193b4fb 100644 --- a/src/functions/list.rs +++ b/src/functions/list.rs @@ -5,6 +5,7 @@ use dialoguer::console; use crate::{ http::ApiClient, + projects::api::Project, ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, utils::pluralize, }; @@ -13,11 +14,13 @@ use super::{api, FunctionKind}; pub async fn run( client: &ApiClient, - project_id: &str, + project: &Project, org: &str, json: bool, kind: &FunctionKind, ) -> Result<()> { + let project_id = &project.id; + let functions = with_spinner( &format!("Loading {}...", kind.plural), api::list_functions(client, project_id, Some(kind.function_type)), @@ -39,7 +42,7 @@ pub async fn run( console::style(count), console::style(org).bold(), console::style("/").dim().bold(), - console::style(project_id).bold() + console::style(&project.name).bold() )?; let mut table = styled_table(); diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 6287463..3a9f529 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -115,17 +115,16 @@ pub async fn run(base: BaseArgs, args: FunctionArgs, kind: &FunctionKind) -> Res let resolved_project = get_project_by_name(&client, &project) .await? .ok_or_else(|| anyhow!("project '{project}' not found"))?; - let project_id = &resolved_project.id; match args.command { None | Some(FunctionCommands::List) => { - list::run(&client, project_id, &org_name, base.json, kind).await + list::run(&client, &resolved_project, &org_name, base.json, kind).await } Some(FunctionCommands::View(v)) => { view::run( &client, &ctx.app_url, - project_id, + &resolved_project, &org_name, v.slug(), base.json, @@ -136,7 +135,7 @@ pub async fn run(base: BaseArgs, args: FunctionArgs, kind: &FunctionKind) -> Res .await } Some(FunctionCommands::Delete(d)) => { - delete::run(&client, project_id, d.slug(), d.force, kind).await + delete::run(&client, &resolved_project, d.slug(), d.force, kind).await } } } diff --git a/src/functions/view.rs b/src/functions/view.rs index 66782ee..2654236 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -6,6 +6,7 @@ use dialoguer::console; use urlencoding::encode; use crate::http::ApiClient; +use crate::projects::api::Project; use crate::ui::prompt_render::{ render_content_lines, render_message, render_options, render_tools, }; @@ -17,7 +18,7 @@ use super::{api, delete, FunctionKind}; pub async fn run( client: &ApiClient, app_url: &str, - project_id: &str, + project: &Project, org_name: &str, slug: Option<&str>, json: bool, @@ -25,6 +26,7 @@ pub async fn run( verbose: bool, kind: &FunctionKind, ) -> Result<()> { + let project_id = &project.id; let function = match slug { Some(s) => with_spinner( &format!("Loading {}...", kind.type_name), @@ -49,7 +51,7 @@ pub async fn run( "{}/app/{}/p/{}/{}/{}", app_url.trim_end_matches('/'), encode(org_name), - encode(project_id), + encode(&project.name), kind.url_segment, encode(&function.id) ); diff --git a/src/http.rs b/src/http.rs index ff4107d..a3b5bfd 100644 --- a/src/http.rs +++ b/src/http.rs @@ -49,6 +49,7 @@ impl ApiClient { pub async fn get(&self, path: &str) -> Result { let url = self.url(path); + eprintln!("[DEBUG] GET {url}"); let response = self .http .get(&url) @@ -68,6 +69,7 @@ impl ApiClient { pub async fn post(&self, path: &str, body: &B) -> Result { let url = self.url(path); + eprintln!("[DEBUG] POST {url}"); let response = self .http .post(&url) @@ -97,6 +99,7 @@ impl ApiClient { B: Serialize, { let url = self.url(path); + eprintln!("[DEBUG] POST {url} (with headers)"); let mut request = self.http.post(&url).bearer_auth(&self.api_key).json(body); for (key, value) in headers { @@ -116,6 +119,7 @@ impl ApiClient { pub async fn delete(&self, path: &str) -> Result<()> { let url = self.url(path); + eprintln!("[DEBUG] DELETE {url}"); let response = self .http .delete(&url) @@ -134,6 +138,7 @@ impl ApiClient { } pub async fn btql(&self, query: &str) -> Result> { + eprintln!("[DEBUG] BTQL: {query}"); let body = json!({ "query": query, "fmt": "json", diff --git a/src/main.rs b/src/main.rs index 59d77e8..a4b01dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,6 @@ enum Commands { /// Manage scorers Scorers(CLIArgs), /// Manage experiments - #[command(visible_alias = "exp")] Experiments(CLIArgs), } From dd8878a58fa722ddd54a24b98ab3ffd27215d7b5 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Mon, 16 Feb 2026 16:02:39 -0800 Subject: [PATCH 09/23] fix(login): use correct app_url with config fallback --- src/login.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/login.rs b/src/login.rs index c4daa58..6ea0151 100644 --- a/src/login.rs +++ b/src/login.rs @@ -30,12 +30,10 @@ pub async fn login(base: &BaseArgs) -> Result { .or_else(|| base.api_url.clone()) .unwrap_or_else(|| "https://api.braintrust.dev".to_string()); - // Derive app_url from api_url (api.braintrust.dev -> www.braintrust.dev) - let app_url = base.app_url.clone().unwrap_or_else(|| { - api_url - .replace("api.braintrust", "www.braintrust") - .replace("api.braintrustdata", "www.braintrustdata") - }); + let app_url = base + .app_url + .clone() + .unwrap_or_else(|| "https://braintrust.dev".to_string()); Ok(LoginContext { login, From 068d1bb941cf41559041a25fa12d1f8518801d9d Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Mon, 16 Feb 2026 17:30:35 -0800 Subject: [PATCH 10/23] wip refactor: clean up urls, add code preview, cleanup function signatures --- src/experiments/delete.rs | 15 ++++-- src/experiments/list.rs | 8 +-- src/experiments/mod.rs | 8 +-- src/experiments/view.rs | 12 +++-- src/functions/mod.rs | 6 ++- src/functions/view.rs | 107 +++++++++++++++++++++++++++++++++----- src/http.rs | 5 -- src/login.rs | 2 +- src/ui/prompt_render.rs | 16 ++++++ 9 files changed, 143 insertions(+), 36 deletions(-) diff --git a/src/experiments/delete.rs b/src/experiments/delete.rs index 5e203fd..a1d89e9 100644 --- a/src/experiments/delete.rs +++ b/src/experiments/delete.rs @@ -5,25 +5,32 @@ use dialoguer::Confirm; use crate::{ http::ApiClient, + projects::api::Project, ui::{self, print_command_status, with_spinner, CommandStatus}, }; use super::api::{self, Experiment}; -pub async fn run(client: &ApiClient, project: &str, name: Option<&str>, force: bool) -> Result<()> { +pub async fn run( + client: &ApiClient, + project: &Project, + name: Option<&str>, + force: bool, +) -> Result<()> { + let project_name = &project.name; if force && name.is_none() { bail!("name required when using --force. Use: bt experiments delete --force"); } let experiment = match name { - Some(n) => api::get_experiment_by_name(client, project, n) + Some(n) => api::get_experiment_by_name(client, project_name, n) .await? .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, None => { if !std::io::stdin().is_terminal() { bail!("experiment name required. Use: bt experiments delete "); } - select_experiment_interactive(client, project).await? + select_experiment_interactive(client, project_name).await? } }; @@ -31,7 +38,7 @@ pub async fn run(client: &ApiClient, project: &str, name: Option<&str>, force: b let confirm = Confirm::new() .with_prompt(format!( "Delete experiment '{}' from {}?", - &experiment.name, project + &experiment.name, &project_name )) .default(false) .interact()?; diff --git a/src/experiments/list.rs b/src/experiments/list.rs index 58ffc78..1ce0b4a 100644 --- a/src/experiments/list.rs +++ b/src/experiments/list.rs @@ -5,16 +5,18 @@ use dialoguer::console; use crate::{ http::ApiClient, + projects::api::Project, ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, utils::pluralize, }; use super::api; -pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> { +pub async fn run(client: &ApiClient, project: &Project, org: &str, json: bool) -> Result<()> { + let project_name = &project.name; let experiments = with_spinner( "Loading experiments...", - api::list_experiments(client, project), + api::list_experiments(client, project_name), ) .await?; @@ -33,7 +35,7 @@ pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Re console::style(count), console::style(org).bold(), console::style("/").dim().bold(), - console::style(project).bold() + console::style(project_name).bold() )?; let mut table = styled_table(); diff --git a/src/experiments/mod.rs b/src/experiments/mod.rs index b0c90e6..131eeb2 100644 --- a/src/experiments/mod.rs +++ b/src/experiments/mod.rs @@ -87,19 +87,19 @@ pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), }; - get_project_by_name(&client, &project) + let resolved_project = get_project_by_name(&client, &project) .await? .ok_or_else(|| anyhow!("project '{project}' not found"))?; match args.command { None | Some(ExperimentsCommands::List) => { - list::run(&client, &project, &org_name, base.json).await + list::run(&client, &resolved_project, &org_name, base.json).await } Some(ExperimentsCommands::View(v)) => { view::run( &client, &ctx.app_url, - &project, + &resolved_project, &org_name, v.name(), base.json, @@ -108,7 +108,7 @@ pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { .await } Some(ExperimentsCommands::Delete(d)) => { - delete::run(&client, &project, d.name(), d.force).await + delete::run(&client, &resolved_project, d.name(), d.force).await } } } diff --git a/src/experiments/view.rs b/src/experiments/view.rs index 777794e..fc1a4ee 100644 --- a/src/experiments/view.rs +++ b/src/experiments/view.rs @@ -6,6 +6,7 @@ use dialoguer::console; use urlencoding::encode; use crate::http::ApiClient; +use crate::projects::api::Project; use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; use super::{api, delete}; @@ -13,16 +14,17 @@ use super::{api, delete}; pub async fn run( client: &ApiClient, app_url: &str, - project: &str, + project: &Project, org_name: &str, name: Option<&str>, json: bool, web: bool, ) -> Result<()> { + let project_name = &project.name; let experiment = match name { Some(n) => with_spinner( "Loading experiment...", - api::get_experiment_by_name(client, project, n), + api::get_experiment_by_name(client, project_name, n), ) .await? .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, @@ -30,7 +32,7 @@ pub async fn run( if !std::io::stdin().is_terminal() { bail!("experiment name required. Use: bt experiments view "); } - delete::select_experiment_interactive(client, project).await? + delete::select_experiment_interactive(client, project_name).await? } }; @@ -39,8 +41,8 @@ pub async fn run( "{}/app/{}/p/{}/experiments/{}", app_url.trim_end_matches('/'), encode(org_name), - encode(project), - encode(&experiment.id) + encode(project_name), + encode(&experiment.name) ); open::that(&url)?; print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 3a9f529..3806b9a 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -26,14 +26,16 @@ pub const TOOL: FunctionKind = FunctionKind { type_name: "tool", plural: "tools", function_type: "tool", - url_segment: "tools", + // include query params `pr=` prefix since tools use a query param to open in a modal/dialog window + url_segment: "tools?pr=", }; pub const SCORER: FunctionKind = FunctionKind { type_name: "scorer", plural: "scorers", function_type: "scorer", - url_segment: "scorers", + // includes `/` since scorers use a route to open in a in a full-page + url_segment: "scorers/", }; #[derive(Debug, Clone, Args)] diff --git a/src/functions/view.rs b/src/functions/view.rs index 2654236..5fac346 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -8,7 +8,7 @@ use urlencoding::encode; use crate::http::ApiClient; use crate::projects::api::Project; use crate::ui::prompt_render::{ - render_content_lines, render_message, render_options, render_tools, + render_code_lines, render_content_lines, render_message, render_options, render_tools, }; use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; @@ -48,7 +48,7 @@ pub async fn run( if web { let url = format!( - "{}/app/{}/p/{}/{}/{}", + "{}/app/{}/p/{}/{}{}", app_url.trim_end_matches('/'), encode(org_name), encode(&project.name), @@ -122,18 +122,101 @@ pub async fn run( if let Some(fd_type) = fd.get("type").and_then(|t| t.as_str()) { match fd_type { "code" => { - writeln!(output, "{} code", console::style("Function:").dim())?; if let Some(data) = fd.get("data") { - if let Some(rt) = data - .get("runtime_context") - .and_then(|r| r.get("runtime")) - .and_then(|r| r.as_str()) - { - writeln!(output, " {} {}", console::style("Runtime:").dim(), rt)?; + let data_type = data.get("type").and_then(|t| t.as_str()); + + if let Some(runtime) = data.get("runtime_context").and_then(|rc| { + let rt = rc.get("runtime").and_then(|r| r.as_str())?; + let ver = rc.get("version").and_then(|v| v.as_str()).unwrap_or(""); + Some(if ver.is_empty() { + rt.to_string() + } else { + format!("{rt} {ver}") + }) + }) { + writeln!(output, "{} {}", console::style("Runtime:").dim(), runtime)?; } - if let Some(loc) = data.get("location") { - let lt = loc.get("type").and_then(|t| t.as_str()).unwrap_or("?"); - writeln!(output, " {} {}", console::style("Location:").dim(), lt)?; + + match data_type { + Some("inline") => { + if let Some(code) = data.get("code").and_then(|c| c.as_str()) { + if !code.is_empty() { + writeln!(output)?; + writeln!(output, "{}", console::style("Code:").dim())?; + render_code_lines(&mut output, code)?; + } + } + } + Some("bundle") => { + match data.get("preview").and_then(|p| p.as_str()) { + Some(p) if !p.is_empty() => { + writeln!(output)?; + writeln!( + output, + "{}", + console::style("Code (preview):").dim() + )?; + render_code_lines(&mut output, p)?; + } + _ => { + writeln!( + output, + " {}", + console::style("Code bundle — preview not available") + .dim() + )?; + } + } + + if verbose { + if let Some(bid) = + data.get("bundle_id").and_then(|b| b.as_str()) + { + writeln!( + output, + " {} {}", + console::style("Bundle ID:").dim(), + bid + )?; + } + if let Some(loc) = data.get("location") { + let loc_str = match loc.get("type").and_then(|t| t.as_str()) + { + Some("experiment") => { + let eval_name = loc + .get("eval_name") + .and_then(|e| e.as_str()) + .unwrap_or("?"); + let pos_type = loc + .get("position") + .and_then(|p| p.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or("?"); + format!("experiment/{eval_name}/{pos_type}") + } + Some("function") => { + let index = loc + .get("index") + .and_then(|i| i.as_u64()) + .map(|i| i.to_string()) + .unwrap_or_else(|| "?".to_string()); + format!("function/{index}") + } + Some(other) => other.to_string(), + None => "?".to_string(), + }; + writeln!( + output, + " {} {}", + console::style("Location:").dim(), + loc_str + )?; + } + } + } + _ => { + writeln!(output, "{} code", console::style("Function:").dim())?; + } } } } diff --git a/src/http.rs b/src/http.rs index a3b5bfd..ff4107d 100644 --- a/src/http.rs +++ b/src/http.rs @@ -49,7 +49,6 @@ impl ApiClient { pub async fn get(&self, path: &str) -> Result { let url = self.url(path); - eprintln!("[DEBUG] GET {url}"); let response = self .http .get(&url) @@ -69,7 +68,6 @@ impl ApiClient { pub async fn post(&self, path: &str, body: &B) -> Result { let url = self.url(path); - eprintln!("[DEBUG] POST {url}"); let response = self .http .post(&url) @@ -99,7 +97,6 @@ impl ApiClient { B: Serialize, { let url = self.url(path); - eprintln!("[DEBUG] POST {url} (with headers)"); let mut request = self.http.post(&url).bearer_auth(&self.api_key).json(body); for (key, value) in headers { @@ -119,7 +116,6 @@ impl ApiClient { pub async fn delete(&self, path: &str) -> Result<()> { let url = self.url(path); - eprintln!("[DEBUG] DELETE {url}"); let response = self .http .delete(&url) @@ -138,7 +134,6 @@ impl ApiClient { } pub async fn btql(&self, query: &str) -> Result> { - eprintln!("[DEBUG] BTQL: {query}"); let body = json!({ "query": query, "fmt": "json", diff --git a/src/login.rs b/src/login.rs index 6ea0151..7063536 100644 --- a/src/login.rs +++ b/src/login.rs @@ -33,7 +33,7 @@ pub async fn login(base: &BaseArgs) -> Result { let app_url = base .app_url .clone() - .unwrap_or_else(|| "https://braintrust.dev".to_string()); + .unwrap_or_else(|| "https://www.braintrust.dev".to_string()); Ok(LoginContext { login, diff --git a/src/ui/prompt_render.rs b/src/ui/prompt_render.rs index 84f2899..4817ab0 100644 --- a/src/ui/prompt_render.rs +++ b/src/ui/prompt_render.rs @@ -80,6 +80,22 @@ pub fn render_content_lines(output: &mut String, content: &str) -> Result<()> { Ok(()) } +/// Render code with line numbers. Each line is indented and prefixed with a dim line number and │. +pub fn render_code_lines(output: &mut String, code: &str) -> Result<()> { + let lines: Vec<&str> = code.lines().collect(); + let width = lines.len().to_string().len(); + for (i, line) in lines.iter().enumerate() { + writeln!( + output, + " {} {} {}", + console::style(format!("{:>width$}", i + 1)).dim(), + console::style("│").dim(), + line + )?; + } + Ok(()) +} + fn highlight_template_vars(line: &str) -> String { let re = &*TEMPLATE_VAR_RE; let mut result = String::new(); From f358dbbcf23279f584ec731d76b6699ad06601cf Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Mon, 16 Feb 2026 17:47:51 -0800 Subject: [PATCH 11/23] WIP - syntax highlighting for code tools and sscorers --- Cargo.lock | 75 ++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/functions/view.rs | 9 +++- src/ui/highlight.rs | 98 +++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/prompt_render.rs | 15 +++++-- 6 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/ui/highlight.rs diff --git a/Cargo.lock b/Cargo.lock index 9cb61bd..66ca31a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,30 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -200,6 +224,7 @@ dependencies = [ "serde", "serde_json 1.0.149", "strip-ansi-escapes", + "syntect", "tokio", "unicode-width 0.1.14", "urlencoding", @@ -576,6 +601,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1822,6 +1858,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2142,6 +2187,24 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.18", + "walkdir", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -2526,6 +2589,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2673,7 +2746,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 81243b4..778682c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ regex = "1" urlencoding = "2" lingua = { git = "https://github.com/braintrustdata/lingua", rev = "3c79f2c427d12d3e2fe104910ef3c0768ad83770" } comfy-table = "7.2.2" +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "regex-fancy"] } [profile.dist] inherits = "release" diff --git a/src/functions/view.rs b/src/functions/view.rs index 5fac346..b7c092e 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -125,6 +125,11 @@ pub async fn run( if let Some(data) = fd.get("data") { let data_type = data.get("type").and_then(|t| t.as_str()); + let runtime_name = data + .get("runtime_context") + .and_then(|rc| rc.get("runtime")) + .and_then(|r| r.as_str()); + if let Some(runtime) = data.get("runtime_context").and_then(|rc| { let rt = rc.get("runtime").and_then(|r| r.as_str())?; let ver = rc.get("version").and_then(|v| v.as_str()).unwrap_or(""); @@ -143,7 +148,7 @@ pub async fn run( if !code.is_empty() { writeln!(output)?; writeln!(output, "{}", console::style("Code:").dim())?; - render_code_lines(&mut output, code)?; + render_code_lines(&mut output, code, runtime_name)?; } } } @@ -156,7 +161,7 @@ pub async fn run( "{}", console::style("Code (preview):").dim() )?; - render_code_lines(&mut output, p)?; + render_code_lines(&mut output, p, runtime_name)?; } _ => { writeln!( diff --git a/src/ui/highlight.rs b/src/ui/highlight.rs new file mode 100644 index 0000000..26207ff --- /dev/null +++ b/src/ui/highlight.rs @@ -0,0 +1,98 @@ +use std::str::FromStr; +use std::sync::LazyLock; + +use dialoguer::console; +use syntect::easy::ScopeRegionIterator; +use syntect::highlighting::ScopeSelector; +use syntect::parsing::{ParseState, ScopeStack, SyntaxSet}; + +static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); + +static COMMENT: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("comment").unwrap()); +static STRING: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("string").unwrap()); +static CONSTANT_NUMERIC: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("constant.numeric").unwrap()); +static CONSTANT_LANGUAGE: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("constant.language").unwrap()); +static ENTITY_NAME_FUNCTION: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("entity.name.function").unwrap()); +static SUPPORT_FUNCTION: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("support.function").unwrap()); +static KEYWORD_OPERATOR: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("keyword.operator").unwrap()); +static KEYWORD: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("keyword").unwrap()); +static STORAGE: LazyLock = + LazyLock::new(|| ScopeSelector::from_str("storage").unwrap()); + +fn runtime_to_extension(runtime: &str) -> &str { + match runtime { + "python" => "py", + "node" => "js", + "typescript" => "ts", + _ => runtime, + } +} + +fn style_token(token: &str, scope_stack: &ScopeStack) -> String { + if token.is_empty() { + return String::new(); + } + + let scopes = scope_stack.as_slice(); + + if COMMENT.does_match(scopes).is_some() { + return format!("{}", console::style(token).dim()); + } + if STRING.does_match(scopes).is_some() { + return format!("{}", console::style(token).green()); + } + if CONSTANT_NUMERIC.does_match(scopes).is_some() { + return format!("{}", console::style(token).magenta()); + } + if CONSTANT_LANGUAGE.does_match(scopes).is_some() { + return format!("{}", console::style(token).cyan().bold()); + } + if ENTITY_NAME_FUNCTION.does_match(scopes).is_some() { + return format!("{}", console::style(token).yellow()); + } + if SUPPORT_FUNCTION.does_match(scopes).is_some() { + return format!("{}", console::style(token).yellow()); + } + if KEYWORD_OPERATOR.does_match(scopes).is_some() { + return format!("{}", console::style(token).red()); + } + if KEYWORD.does_match(scopes).is_some() { + return format!("{}", console::style(token).cyan().bold()); + } + if STORAGE.does_match(scopes).is_some() { + return format!("{}", console::style(token).cyan()); + } + + token.to_string() +} + +pub fn highlight_code(code: &str, language_hint: &str) -> Option> { + let ps = &*SYNTAX_SET; + let ext = runtime_to_extension(language_hint); + let syntax = ps.find_syntax_by_extension(ext)?; + let mut state = ParseState::new(syntax); + + let mut result = Vec::new(); + let mut scope_stack = ScopeStack::new(); + for line in code.lines() { + let line_nl = format!("{line}\n"); + let ops = state.parse_line(&line_nl, ps).ok()?; + + let mut highlighted = String::new(); + for (token, op) in ScopeRegionIterator::new(&ops, &line_nl) { + scope_stack.apply(op).ok()?; + let token = token.trim_end_matches('\n'); + highlighted.push_str(&style_token(token, &scope_stack)); + } + result.push(highlighted); + } + Some(result) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f9c3187..867ca5a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod highlight; mod pager; pub mod prompt_render; mod select; diff --git a/src/ui/prompt_render.rs b/src/ui/prompt_render.rs index 4817ab0..d5bddb3 100644 --- a/src/ui/prompt_render.rs +++ b/src/ui/prompt_render.rs @@ -80,17 +80,26 @@ pub fn render_content_lines(output: &mut String, content: &str) -> Result<()> { Ok(()) } -/// Render code with line numbers. Each line is indented and prefixed with a dim line number and │. -pub fn render_code_lines(output: &mut String, code: &str) -> Result<()> { +pub fn render_code_lines(output: &mut String, code: &str, language: Option<&str>) -> Result<()> { + let highlighted: Option> = if console::colors_enabled() { + language.and_then(|lang| super::highlight::highlight_code(code, lang)) + } else { + None + }; + let lines: Vec<&str> = code.lines().collect(); let width = lines.len().to_string().len(); for (i, line) in lines.iter().enumerate() { + let display = match &highlighted { + Some(hl) => hl.get(i).map(|s| s.as_str()).unwrap_or(line), + None => line, + }; writeln!( output, " {} {} {}", console::style(format!("{:>width$}", i + 1)).dim(), console::style("│").dim(), - line + display )?; } Ok(()) From fc414c48fb92aad31f50f9984d16f99d5ceca477 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 10:36:59 -0800 Subject: [PATCH 12/23] refactor(functions): add unified functions command with type filter --- src/functions/delete.rs | 69 +++------ src/functions/list.rs | 94 +++++------ src/functions/mod.rs | 336 +++++++++++++++++++++++++++++----------- src/functions/view.rs | 43 +++-- src/main.rs | 3 + src/scorers.rs | 4 +- src/tools.rs | 4 +- 7 files changed, 347 insertions(+), 206 deletions(-) diff --git a/src/functions/delete.rs b/src/functions/delete.rs index cb2ebfd..abf51f9 100644 --- a/src/functions/delete.rs +++ b/src/functions/delete.rs @@ -1,56 +1,49 @@ -use std::io::IsTerminal; - use anyhow::{anyhow, bail, Result}; use dialoguer::Confirm; -use crate::{ - http::ApiClient, - projects::api::Project, - ui::{self, print_command_status, with_spinner, CommandStatus}, -}; +use crate::ui::{is_interactive, print_command_status, with_spinner, CommandStatus}; -use super::{ - api::{self, Function}, - FunctionKind, -}; +use super::{api, label, label_plural, select_function_interactive}; +use super::{FunctionTypeFilter, ResolvedContext}; pub async fn run( - client: &ApiClient, - project: &Project, + ctx: &ResolvedContext, slug: Option<&str>, force: bool, - kind: &FunctionKind, + ft: Option, ) -> Result<()> { if force && slug.is_none() { bail!( "slug required when using --force. Use: bt {} delete --force", - kind.plural + label_plural(ft), ); } - let project_id = &project.id; + let project_id = &ctx.project.id; let function = match slug { - Some(s) => api::get_function_by_slug(client, project_id, s) + Some(s) => api::get_function_by_slug(&ctx.client, project_id, s) .await? - .ok_or_else(|| anyhow!("{} with slug '{s}' not found", kind.type_name))?, + .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, None => { - if !std::io::stdin().is_terminal() { + if !is_interactive() { bail!( "{} slug required. Use: bt {} delete ", - kind.type_name, - kind.plural + label(ft), + label_plural(ft), ); } - select_function_interactive(client, project_id, kind).await? + select_function_interactive(&ctx.client, project_id, ft).await? } }; - if !force && std::io::stdin().is_terminal() { + if !force && is_interactive() { let confirm = Confirm::new() .with_prompt(format!( "Delete {} '{}' from {}?", - kind.type_name, &function.name, &project.name + label(ft), + &function.name, + &ctx.project.name )) .default(false) .interact()?; @@ -60,8 +53,8 @@ pub async fn run( } match with_spinner( - &format!("Deleting {}...", kind.type_name), - api::delete_function(client, &function.id), + &format!("Deleting {}...", label(ft)), + api::delete_function(&ctx.client, &function.id), ) .await { @@ -72,7 +65,8 @@ pub async fn run( ); eprintln!( "Run `bt {} list` to see remaining {}.", - kind.plural, kind.plural + label_plural(ft), + label_plural(ft) ); Ok(()) } @@ -85,24 +79,3 @@ pub async fn run( } } } - -pub async fn select_function_interactive( - client: &ApiClient, - project: &str, - kind: &FunctionKind, -) -> Result { - let mut functions = with_spinner( - &format!("Loading {}...", kind.plural), - api::list_functions(client, project, Some(kind.function_type)), - ) - .await?; - - if functions.is_empty() { - bail!("no {} found", kind.plural); - } - - functions.sort_by(|a, b| a.name.cmp(&b.name)); - let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect(); - let selection = ui::fuzzy_select(&format!("Select {}", kind.type_name), &names, 0)?; - Ok(functions[selection].clone()) -} diff --git a/src/functions/list.rs b/src/functions/list.rs index 193b4fb..1a4a45e 100644 --- a/src/functions/list.rs +++ b/src/functions/list.rs @@ -3,64 +3,70 @@ use std::fmt::Write as _; use anyhow::Result; use dialoguer::console; -use crate::{ - http::ApiClient, - projects::api::Project, - ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, - utils::pluralize, +use crate::ui::{ + apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner, }; +use crate::utils::pluralize; -use super::{api, FunctionKind}; - -pub async fn run( - client: &ApiClient, - project: &Project, - org: &str, - json: bool, - kind: &FunctionKind, -) -> Result<()> { - let project_id = &project.id; +use super::{api, label, label_plural, FunctionTypeFilter, ResolvedContext}; +pub async fn run(ctx: &ResolvedContext, json: bool, ft: Option) -> Result<()> { + let function_type = ft.map(|f| f.as_str()); let functions = with_spinner( - &format!("Loading {}...", kind.plural), - api::list_functions(client, project_id, Some(kind.function_type)), + &format!("Loading {}...", label_plural(ft)), + api::list_functions(&ctx.client, &ctx.project.id, function_type), ) .await?; if json { println!("{}", serde_json::to_string(&functions)?); - } else { - let mut output = String::new(); - let count = format!( - "{} {}", - functions.len(), - pluralize(functions.len(), kind.type_name, Some(kind.plural)) - ); - writeln!( - output, - "{} found in {} {} {}\n", - console::style(count), - console::style(org).bold(), - console::style("/").dim().bold(), - console::style(&project.name).bold() - )?; + return Ok(()); + } + + let mut output = String::new(); + let count = format!( + "{} {}", + functions.len(), + pluralize(functions.len(), label(ft), Some(label_plural(ft))) + ); + writeln!( + output, + "{} found in {} {} {}\n", + console::style(count), + console::style(ctx.client.org_name()).bold(), + console::style("/").dim().bold(), + console::style(&ctx.project.name).bold() + )?; - let mut table = styled_table(); + let mut table = styled_table(); + if ft.is_none() { + table.set_header(vec![ + header("Name"), + header("Type"), + header("Description"), + header("Slug"), + ]); + } else { table.set_header(vec![header("Name"), header("Description"), header("Slug")]); - apply_column_padding(&mut table, (0, 6)); + } + apply_column_padding(&mut table, (0, 6)); - for func in &functions { - let desc = func - .description - .as_deref() - .filter(|s| !s.is_empty()) - .map(|s| truncate(s, 60)) - .unwrap_or_else(|| "-".to_string()); + for func in &functions { + let desc = func + .description + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + if ft.is_none() { + let t = func.function_type.as_deref().unwrap_or("-"); + table.add_row(vec![&func.name, t, &desc, &func.slug]); + } else { table.add_row(vec![&func.name, &desc, &func.slug]); } - - write!(output, "{table}")?; - print_with_pager(&output)?; } + + write!(output, "{table}")?; + print_with_pager(&output)?; Ok(()) } diff --git a/src/functions/mod.rs b/src/functions/mod.rs index dc99e85..a974903 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -1,11 +1,13 @@ -use std::io::IsTerminal; - -use anyhow::{anyhow, Result}; -use clap::{Args, Subcommand}; +use anyhow::{anyhow, bail, Result}; +use clap::{Args, Subcommand, ValueEnum}; use crate::{ - args::BaseArgs, auth::login, http::ApiClient, projects::api::get_project_by_name, - ui::select_project_interactive, + args::BaseArgs, + auth::login, + config, + http::ApiClient, + projects::api::{get_project_by_name, Project}, + ui::{self, is_interactive, select_project_interactive, with_spinner}, }; pub mod api; @@ -13,28 +15,119 @@ mod delete; mod list; mod view; -pub struct FunctionKind { - pub type_name: &'static str, - pub plural: &'static str, - pub function_type: &'static str, - pub url_segment: &'static str, +use api::Function; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum FunctionTypeFilter { + Llm, + Scorer, + Task, + Tool, + CustomView, + Preprocessor, + Facet, + Classifier, + Tag, + Parameters, } -pub const TOOL: FunctionKind = FunctionKind { - type_name: "tool", - plural: "tools", - function_type: "tool", - // include query params `pr=` prefix since tools use a query param to open in a modal/dialog window - url_segment: "tools?pr=", -}; +impl FunctionTypeFilter { + pub fn as_str(&self) -> &'static str { + match self { + Self::Llm => "llm", + Self::Scorer => "scorer", + Self::Task => "task", + Self::Tool => "tool", + Self::CustomView => "custom_view", + Self::Preprocessor => "preprocessor", + Self::Facet => "facet", + Self::Classifier => "classifier", + Self::Tag => "tag", + Self::Parameters => "parameters", + } + } -pub const SCORER: FunctionKind = FunctionKind { - type_name: "scorer", - plural: "scorers", - function_type: "scorer", - // includes `/` since scorers use a route to open in a in a full-page - url_segment: "scorers/", -}; + fn label(&self) -> &'static str { + match self { + Self::Llm => "LLM", + Self::CustomView => "custom view", + _ => self.as_str(), + } + } + + fn plural(&self) -> &'static str { + match self { + Self::Llm => "LLMs", + Self::Scorer => "scorers", + Self::Task => "tasks", + Self::Tool => "tools", + Self::CustomView => "custom views", + Self::Preprocessor => "preprocessors", + Self::Facet => "facets", + Self::Classifier => "classifiers", + Self::Tag => "tags", + Self::Parameters => "parameters", + } + } + + fn url_segment(&self) -> &'static str { + match self { + Self::Tool => "tools?pr=", + Self::Scorer => "scorers/", + _ => "functions/", + } + } + + fn from_api_str(s: &str) -> Option { + match s { + "llm" => Some(Self::Llm), + "scorer" => Some(Self::Scorer), + "task" => Some(Self::Task), + "tool" => Some(Self::Tool), + "custom_view" => Some(Self::CustomView), + "preprocessor" => Some(Self::Preprocessor), + "facet" => Some(Self::Facet), + "classifier" => Some(Self::Classifier), + "tag" => Some(Self::Tag), + "parameters" => Some(Self::Parameters), + _ => None, + } + } +} + +fn url_segment_for(function_type: Option<&str>) -> &'static str { + function_type + .and_then(FunctionTypeFilter::from_api_str) + .map_or("functions/", |ft| ft.url_segment()) +} + +fn label(ft: Option) -> &'static str { + ft.map_or("function", |f| f.label()) +} + +fn label_plural(ft: Option) -> &'static str { + ft.map_or("functions", |f| f.plural()) +} + +// --- Slug args (shared) --- + +#[derive(Debug, Clone, Args)] +struct SlugArgs { + #[arg(value_name = "SLUG")] + slug_positional: Option, + #[arg(long = "slug", short = 's')] + slug_flag: Option, +} + +impl SlugArgs { + fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + +// --- Wrapper args (bt tools / bt scorers) --- #[derive(Debug, Clone, Args)] pub struct FunctionArgs { @@ -52,98 +145,167 @@ pub enum FunctionCommands { Delete(DeleteArgs), } +// --- bt functions args --- + #[derive(Debug, Clone, Args)] -pub struct ViewArgs { - /// Slug (positional) - #[arg(value_name = "SLUG")] - slug_positional: Option, +pub struct FunctionsArgs { + #[command(subcommand)] + pub command: Option, +} - /// Slug (flag) - #[arg(long = "slug", short = 's')] - slug_flag: Option, +#[derive(Debug, Clone, Subcommand)] +pub enum FunctionsCommands { + /// List functions in the current project + List(FunctionsListArgs), + /// View function details + View(ViewArgs), + /// Delete a function + Delete(FunctionsDeleteArgs), +} - /// Open in browser +#[derive(Debug, Clone, Args)] +pub struct FunctionsListArgs { + #[arg(long = "type", short = 't', value_enum)] + pub function_type: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct FunctionsDeleteArgs { + #[command(flatten)] + slug: SlugArgs, + #[arg(long, short = 'f')] + force: bool, + #[arg(long = "type", short = 't', value_enum)] + pub function_type: Option, +} + +impl FunctionsDeleteArgs { + fn slug(&self) -> Option<&str> { + self.slug.slug() + } +} + +// --- Shared view/delete args --- + +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + #[command(flatten)] + slug: SlugArgs, #[arg(long)] web: bool, - - /// Show all configuration details #[arg(long)] verbose: bool, } impl ViewArgs { - pub fn slug(&self) -> Option<&str> { - self.slug_positional - .as_deref() - .or(self.slug_flag.as_deref()) + fn slug(&self) -> Option<&str> { + self.slug.slug() } } #[derive(Debug, Clone, Args)] pub struct DeleteArgs { - /// Slug (positional) - #[arg(value_name = "SLUG")] - slug_positional: Option, - - /// Slug (flag) - #[arg(long = "slug", short = 's')] - slug_flag: Option, - - /// Skip confirmation + #[command(flatten)] + slug: SlugArgs, #[arg(long, short = 'f')] force: bool, } impl DeleteArgs { - pub fn slug(&self) -> Option<&str> { - self.slug_positional - .as_deref() - .or(self.slug_flag.as_deref()) + fn slug(&self) -> Option<&str> { + self.slug.slug() } } -pub async fn run(base: BaseArgs, args: FunctionArgs, kind: &FunctionKind) -> Result<()> { - let ctx = login(&base).await?; +// --- Resolved context --- + +pub(crate) struct ResolvedContext { + pub client: ApiClient, + pub app_url: String, + pub project: Project, +} + +async fn resolve_context(base: &BaseArgs) -> Result { + let ctx = login(base).await?; let client = ApiClient::new(&ctx)?; - let project = match base.project { - Some(p) => p, - None if std::io::stdin().is_terminal() => { - select_project_interactive(&client, None, None).await? - } - None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), + 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, or run `bt switch`)" + ), }; - - let resolved_project = get_project_by_name(&client, &project) + let project = get_project_by_name(&client, &project_name) .await? - .ok_or_else(|| anyhow!("project '{project}' not found"))?; + .ok_or_else(|| anyhow!("project '{project_name}' not found"))?; + Ok(ResolvedContext { + client, + app_url: ctx.app_url, + project, + }) +} + +// --- Interactive selection --- + +pub(crate) async fn select_function_interactive( + client: &ApiClient, + project_id: &str, + ft: Option, +) -> Result { + let function_type = ft.map(|f| f.as_str()); + let mut functions = with_spinner( + &format!("Loading {}...", label_plural(ft)), + api::list_functions(client, project_id, function_type), + ) + .await?; + + if functions.is_empty() { + bail!("no {} found", label_plural(ft)); + } + functions.sort_by(|a, b| a.name.cmp(&b.name)); + + let names: Vec = if ft.is_none() { + functions + .iter() + .map(|f| { + let t = f.function_type.as_deref().unwrap_or("?"); + format!("{} ({})", f.name, t) + }) + .collect() + } else { + functions.iter().map(|f| f.name.clone()).collect() + }; + + let selection = ui::fuzzy_select(&format!("Select {}", label(ft)), &names, 0)?; + Ok(functions[selection].clone()) +} + +// --- Entry points --- + +pub async fn run(base: BaseArgs, args: FunctionArgs, kind: FunctionTypeFilter) -> Result<()> { + let ctx = resolve_context(&base).await?; + let ft = Some(kind); match args.command { - None | Some(FunctionCommands::List) => { - list::run( - &client, - &resolved_project, - client.org_name(), - base.json, - kind, - ) - .await - } + None | Some(FunctionCommands::List) => list::run(&ctx, base.json, ft).await, Some(FunctionCommands::View(v)) => { - view::run( - &client, - &ctx.app_url, - &resolved_project, - client.org_name(), - v.slug(), - base.json, - v.web, - v.verbose, - kind, - ) - .await + view::run(&ctx, v.slug(), base.json, v.web, v.verbose, ft).await + } + Some(FunctionCommands::Delete(d)) => delete::run(&ctx, d.slug(), d.force, ft).await, + } +} + +pub async fn run_functions(base: BaseArgs, args: FunctionsArgs) -> Result<()> { + let ctx = resolve_context(&base).await?; + match args.command { + None => list::run(&ctx, base.json, None).await, + Some(FunctionsCommands::List(ref la)) => list::run(&ctx, base.json, la.function_type).await, + Some(FunctionsCommands::View(v)) => { + view::run(&ctx, v.slug(), base.json, v.web, v.verbose, None).await } - Some(FunctionCommands::Delete(d)) => { - delete::run(&client, &resolved_project, d.slug(), d.force, kind).await + Some(FunctionsCommands::Delete(d)) => { + delete::run(&ctx, d.slug(), d.force, d.function_type).await } } } diff --git a/src/functions/view.rs b/src/functions/view.rs index b7c092e..8c1a7e7 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -1,58 +1,55 @@ use std::fmt::Write as _; -use std::io::IsTerminal; use anyhow::{anyhow, bail, Result}; use dialoguer::console; use urlencoding::encode; -use crate::http::ApiClient; -use crate::projects::api::Project; use crate::ui::prompt_render::{ render_code_lines, render_content_lines, render_message, render_options, render_tools, }; -use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; +use crate::ui::{ + is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, +}; -use super::{api, delete, FunctionKind}; +use super::{api, label, label_plural, select_function_interactive, url_segment_for}; +use super::{FunctionTypeFilter, ResolvedContext}; -#[allow(clippy::too_many_arguments)] pub async fn run( - client: &ApiClient, - app_url: &str, - project: &Project, - org_name: &str, + ctx: &ResolvedContext, slug: Option<&str>, json: bool, web: bool, verbose: bool, - kind: &FunctionKind, + ft: Option, ) -> Result<()> { - let project_id = &project.id; + let project_id = &ctx.project.id; let function = match slug { Some(s) => with_spinner( - &format!("Loading {}...", kind.type_name), - api::get_function_by_slug(client, project_id, s), + &format!("Loading {}...", label(ft)), + api::get_function_by_slug(&ctx.client, project_id, s), ) .await? - .ok_or_else(|| anyhow!("{} with slug '{s}' not found", kind.type_name))?, + .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, None => { - if !std::io::stdin().is_terminal() { + if !is_interactive() { bail!( "{} slug required. Use: bt {} view ", - kind.type_name, - kind.plural + label(ft), + label_plural(ft), ); } - delete::select_function_interactive(client, project_id, kind).await? + select_function_interactive(&ctx.client, project_id, ft).await? } }; if web { + let segment = url_segment_for(function.function_type.as_deref()); let url = format!( "{}/app/{}/p/{}/{}{}", - app_url.trim_end_matches('/'), - encode(org_name), - encode(&project.name), - kind.url_segment, + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(&ctx.project.name), + segment, encode(&function.id) ); open::that(&url)?; diff --git a/src/main.rs b/src/main.rs index abe22e7..3995641 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,8 @@ enum Commands { Tools(CLIArgs), /// Manage scorers Scorers(CLIArgs), + /// Manage functions (tools, scorers, and more) + Functions(CLIArgs), /// Manage experiments Experiments(CLIArgs), /// Synchronize project logs between Braintrust and local NDJSON files @@ -115,6 +117,7 @@ async fn main() -> Result<()> { Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, Commands::Tools(cmd) => tools::run(cmd.base, cmd.args).await?, Commands::Scorers(cmd) => scorers::run(cmd.base, cmd.args).await?, + Commands::Functions(cmd) => functions::run_functions(cmd.base, cmd.args).await?, Commands::Experiments(cmd) => experiments::run(cmd.base, cmd.args).await?, Commands::Sync(cmd) => sync::run(cmd.base, cmd.args).await?, Commands::SelfCommand(args) => self_update::run(args).await?, diff --git a/src/scorers.rs b/src/scorers.rs index 359921e..40fe11a 100644 --- a/src/scorers.rs +++ b/src/scorers.rs @@ -1,10 +1,10 @@ use anyhow::Result; use crate::args::BaseArgs; -use crate::functions::{self, FunctionArgs, SCORER}; +use crate::functions::{self, FunctionArgs, FunctionTypeFilter}; pub type ScorersArgs = FunctionArgs; pub async fn run(base: BaseArgs, args: ScorersArgs) -> Result<()> { - functions::run(base, args, &SCORER).await + functions::run(base, args, FunctionTypeFilter::Scorer).await } diff --git a/src/tools.rs b/src/tools.rs index 80f9d9f..255137e 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1,10 +1,10 @@ use anyhow::Result; use crate::args::BaseArgs; -use crate::functions::{self, FunctionArgs, TOOL}; +use crate::functions::{self, FunctionArgs, FunctionTypeFilter}; pub type ToolsArgs = FunctionArgs; pub async fn run(base: BaseArgs, args: ToolsArgs) -> Result<()> { - functions::run(base, args, &TOOL).await + functions::run(base, args, FunctionTypeFilter::Tool).await } From 7b6486329f44e812aec5071e252ac4ed8c42f1da Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 10:38:59 -0800 Subject: [PATCH 13/23] refactor(functions): reduce visibility and improve documentation --- src/functions/mod.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/functions/mod.rs b/src/functions/mod.rs index a974903..f4b1930 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -10,7 +10,7 @@ use crate::{ ui::{self, is_interactive, select_project_interactive, with_spinner}, }; -pub mod api; +pub(crate) mod api; mod delete; mod list; mod view; @@ -113,8 +113,10 @@ fn label_plural(ft: Option) -> &'static str { #[derive(Debug, Clone, Args)] struct SlugArgs { + /// Function slug #[arg(value_name = "SLUG")] slug_positional: Option, + /// Function slug #[arg(long = "slug", short = 's')] slug_flag: Option, } @@ -132,16 +134,16 @@ impl SlugArgs { #[derive(Debug, Clone, Args)] pub struct FunctionArgs { #[command(subcommand)] - pub command: Option, + command: Option, } #[derive(Debug, Clone, Subcommand)] -pub enum FunctionCommands { +enum FunctionCommands { /// List all in the current project List, - /// View details + /// View a function's details View(ViewArgs), - /// Delete by slug + /// Delete a function Delete(DeleteArgs), } @@ -150,11 +152,11 @@ pub enum FunctionCommands { #[derive(Debug, Clone, Args)] pub struct FunctionsArgs { #[command(subcommand)] - pub command: Option, + command: Option, } #[derive(Debug, Clone, Subcommand)] -pub enum FunctionsCommands { +enum FunctionsCommands { /// List functions in the current project List(FunctionsListArgs), /// View function details @@ -164,19 +166,22 @@ pub enum FunctionsCommands { } #[derive(Debug, Clone, Args)] -pub struct FunctionsListArgs { +struct FunctionsListArgs { + /// Filter by function type #[arg(long = "type", short = 't', value_enum)] - pub function_type: Option, + function_type: Option, } #[derive(Debug, Clone, Args)] -pub struct FunctionsDeleteArgs { +struct FunctionsDeleteArgs { #[command(flatten)] slug: SlugArgs, + /// Skip confirmation #[arg(long, short = 'f')] force: bool, + /// Filter by function type (for interactive selection) #[arg(long = "type", short = 't', value_enum)] - pub function_type: Option, + function_type: Option, } impl FunctionsDeleteArgs { @@ -191,8 +196,10 @@ impl FunctionsDeleteArgs { pub struct ViewArgs { #[command(flatten)] slug: SlugArgs, + /// Open in browser #[arg(long)] web: bool, + /// Show all configuration details #[arg(long)] verbose: bool, } @@ -207,6 +214,7 @@ impl ViewArgs { pub struct DeleteArgs { #[command(flatten)] slug: SlugArgs, + /// Skip confirmation #[arg(long, short = 'f')] force: bool, } @@ -232,9 +240,7 @@ async fn resolve_context(base: &BaseArgs) -> Result { 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, or run `bt switch`)" - ), + None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), }; let project = get_project_by_name(&client, &project_name) .await? From 7d69821a84667ff621ec1dc0481d6589397ad395 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 10:59:25 -0800 Subject: [PATCH 14/23] refactor(functions): extract web path building logic and add xact_id --- src/functions/api.rs | 2 ++ src/functions/mod.rs | 41 +++++++++++++----------------------- src/functions/view.rs | 48 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/functions/api.rs b/src/functions/api.rs index 2895584..228ffe0 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -28,6 +28,8 @@ pub struct Function { pub metadata: Option, #[serde(default)] pub created: Option, + #[serde(default)] + pub _xact_id: Option, } pub async fn list_functions( diff --git a/src/functions/mod.rs b/src/functions/mod.rs index f4b1930..6876e39 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -69,38 +69,25 @@ impl FunctionTypeFilter { Self::Parameters => "parameters", } } +} - fn url_segment(&self) -> &'static str { - match self { - Self::Tool => "tools?pr=", - Self::Scorer => "scorers/", - _ => "functions/", - } - } - - fn from_api_str(s: &str) -> Option { - match s { - "llm" => Some(Self::Llm), - "scorer" => Some(Self::Scorer), - "task" => Some(Self::Task), - "tool" => Some(Self::Tool), - "custom_view" => Some(Self::CustomView), - "preprocessor" => Some(Self::Preprocessor), - "facet" => Some(Self::Facet), - "classifier" => Some(Self::Classifier), - "tag" => Some(Self::Tag), - "parameters" => Some(Self::Parameters), - _ => None, +fn build_web_path(function: &Function) -> String { + let id = &function.id; + match function.function_type.as_deref() { + Some("tool") => format!("tools?pr={}", urlencoding::encode(id)), + Some("scorer") => format!("scorers/{}", urlencoding::encode(id)), + Some("classifier") => { + let xact_id = function._xact_id.as_deref().unwrap_or(""); + format!( + "topics?topicMapId={}&topicMapVersion={}", + urlencoding::encode(id), + urlencoding::encode(xact_id) + ) } + _ => format!("functions/{}", urlencoding::encode(id)), } } -fn url_segment_for(function_type: Option<&str>) -> &'static str { - function_type - .and_then(FunctionTypeFilter::from_api_str) - .map_or("functions/", |ft| ft.url_segment()) -} - fn label(ft: Option) -> &'static str { ft.map_or("function", |f| f.label()) } diff --git a/src/functions/view.rs b/src/functions/view.rs index 8c1a7e7..ee6fab1 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -11,7 +11,7 @@ use crate::ui::{ is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, }; -use super::{api, label, label_plural, select_function_interactive, url_segment_for}; +use super::{api, build_web_path, label, label_plural, select_function_interactive}; use super::{FunctionTypeFilter, ResolvedContext}; pub async fn run( @@ -43,14 +43,13 @@ pub async fn run( }; if web { - let segment = url_segment_for(function.function_type.as_deref()); + let path = build_web_path(&function); let url = format!( - "{}/app/{}/p/{}/{}{}", + "{}/app/{}/p/{}/{}", ctx.app_url.trim_end_matches('/'), encode(ctx.client.org_name()), encode(&ctx.project.name), - segment, - encode(&function.id) + path ); open::that(&url)?; print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); @@ -232,6 +231,45 @@ pub async fn run( writeln!(output, " {} {}", console::style("Name:").dim(), name)?; } } + "facet" => { + if let Some(model) = fd.get("model").and_then(|m| m.as_str()) { + writeln!(output, "{} {}", console::style("Model:").dim(), model)?; + } + if let Some(prompt) = fd.get("prompt").and_then(|p| p.as_str()) { + writeln!(output)?; + writeln!(output, "{}", console::style("Prompt:").dim())?; + render_content_lines(&mut output, prompt)?; + } + if let Some(pp) = fd.get("preprocessor") { + let name = pp.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let pp_type = pp.get("type").and_then(|t| t.as_str()).unwrap_or("?"); + writeln!( + output, + "{} {} ({})", + console::style("Preprocessor:").dim(), + name, + pp_type + )?; + } + } + "topic_map" => { + if let Some(facet) = fd.get("source_facet").and_then(|f| f.as_str()) { + writeln!( + output, + "{} {}", + console::style("Source facet:").dim(), + facet + )?; + } + if let Some(model) = fd.get("embedding_model").and_then(|m| m.as_str()) { + writeln!( + output, + "{} {}", + console::style("Embedding model:").dim(), + model + )?; + } + } "prompt" => {} other => { writeln!(output, "{} {}", console::style("Function:").dim(), other)?; From df74544ed654b3afd68cb343b3e6056b0c987156 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 13:29:25 -0800 Subject: [PATCH 15/23] feat(functions): add parameter type filter and display support --- src/functions/mod.rs | 6 ++- src/functions/view.rs | 107 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 6876e39..262a1c9 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -84,6 +84,7 @@ fn build_web_path(function: &Function) -> String { urlencoding::encode(xact_id) ) } + Some("parameters") => format!("parameters/{}", urlencoding::encode(id)), _ => format!("functions/{}", urlencoding::encode(id)), } } @@ -138,6 +139,9 @@ enum FunctionCommands { #[derive(Debug, Clone, Args)] pub struct FunctionsArgs { + /// Filter by function type + #[arg(long = "type", short = 't', value_enum)] + function_type: Option, #[command(subcommand)] command: Option, } @@ -292,7 +296,7 @@ pub async fn run(base: BaseArgs, args: FunctionArgs, kind: FunctionTypeFilter) - pub async fn run_functions(base: BaseArgs, args: FunctionsArgs) -> Result<()> { let ctx = resolve_context(&base).await?; match args.command { - None => list::run(&ctx, base.json, None).await, + None => list::run(&ctx, base.json, args.function_type).await, Some(FunctionsCommands::List(ref la)) => list::run(&ctx, base.json, la.function_type).await, Some(FunctionsCommands::View(v)) => { view::run(&ctx, v.slug(), base.json, v.web, v.verbose, None).await diff --git a/src/functions/view.rs b/src/functions/view.rs index ee6fab1..39302f9 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -270,6 +270,9 @@ pub async fn run( )?; } } + "parameters" => { + render_parameters(&mut output, fd)?; + } "prompt" => {} other => { writeln!(output, "{} {}", console::style("Function:").dim(), other)?; @@ -306,3 +309,107 @@ pub async fn run( print_with_pager(&output)?; Ok(()) } + +fn render_prompt_value(output: &mut String, val: &serde_json::Value) -> Result<()> { + if let Some(model) = val + .get("options") + .and_then(|o| o.get("model")) + .and_then(|m| m.as_str()) + { + writeln!(output, " {} {}", console::style("Model:").dim(), model)?; + } + + let mut prompt_buf = String::new(); + if let Some(prompt_block) = val.get("prompt") { + match prompt_block.get("type").and_then(|t| t.as_str()) { + Some("chat") => { + if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) { + for msg in messages { + render_message(&mut prompt_buf, msg)?; + } + } + } + Some("completion") => { + if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { + render_content_lines(&mut prompt_buf, content)?; + writeln!(prompt_buf)?; + } + } + _ => {} + } + if let Some(tools_val) = prompt_block.get("tools") { + let tools: Option> = match tools_val { + serde_json::Value::Array(arr) => Some(arr.clone()), + serde_json::Value::String(s) => serde_json::from_str(s).ok(), + _ => None, + }; + if let Some(ref tools) = tools { + render_tools(&mut prompt_buf, tools)?; + } + } + } + + for line in prompt_buf.lines() { + writeln!(output, " {line}")?; + } + Ok(()) +} + +fn render_parameters(output: &mut String, fd: &serde_json::Value) -> Result<()> { + let schema = fd.get("__schema"); + let data = fd.get("data"); + let properties = schema + .and_then(|s| s.get("properties")) + .and_then(|p| p.as_object()); + let required: Vec<&str> = schema + .and_then(|s| s.get("required")) + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let Some(props) = properties else { + return Ok(()); + }; + + writeln!(output)?; + writeln!(output, "{}", console::style("Fields:").dim())?; + + for (name, prop) in props { + let type_label = prop + .get("x-bt-type") + .or_else(|| prop.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let is_required = required.contains(&name.as_str()); + let tag = if is_required { "required" } else { "optional" }; + + writeln!( + output, + " {} {} {}", + console::style(name).bold(), + console::style(format!("({type_label})")).dim(), + console::style(format!("[{tag}]")).dim(), + )?; + + if let Some(desc) = prop.get("description").and_then(|d| d.as_str()) { + writeln!(output, " {desc}")?; + } + + if let Some(val) = data.and_then(|d| d.get(name)) { + if type_label == "prompt" { + render_prompt_value(output, val)?; + } else { + let display = match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => "null".to_string(), + other => serde_json::to_string(other).unwrap_or_default(), + }; + writeln!(output, " {} {}", console::style("Value:").dim(), display)?; + } + } + } + + Ok(()) +} From ce225fbee03b3c3533183ee07217925fe597eaa6 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 13:39:58 -0800 Subject: [PATCH 16/23] fix(experiments,functions): align patterns and deduplicate prompt rendering - Add config project fallback in experiments (matching functions/prompts) - Use is_interactive() instead of stdin().is_terminal() in experiments - Move select_experiment_interactive from delete.rs to mod.rs - Extract render_prompt_block helper to eliminate 3x duplication - Pass parent --type filter through to functions view subcommand --- src/experiments/delete.rs | 32 ++++------------------ src/experiments/mod.rs | 53 +++++++++++++++++++++++++----------- src/experiments/view.rs | 11 ++++---- src/functions/mod.rs | 10 ++++++- src/functions/view.rs | 57 +++------------------------------------ src/prompts/view.rs | 32 ++-------------------- src/ui/prompt_render.rs | 30 +++++++++++++++++++++ 7 files changed, 93 insertions(+), 132 deletions(-) diff --git a/src/experiments/delete.rs b/src/experiments/delete.rs index 661aaf5..c2d1ab7 100644 --- a/src/experiments/delete.rs +++ b/src/experiments/delete.rs @@ -1,15 +1,13 @@ -use std::io::IsTerminal; - use anyhow::{anyhow, bail, Result}; use dialoguer::Confirm; use crate::{ http::ApiClient, projects::api::Project, - ui::{self, print_command_status, with_spinner, CommandStatus}, + ui::{is_interactive, print_command_status, with_spinner, CommandStatus}, }; -use super::api::{self, Experiment}; +use super::api; pub async fn run( client: &ApiClient, @@ -27,14 +25,14 @@ pub async fn run( .await? .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, None => { - if !std::io::stdin().is_terminal() { + if !is_interactive() { bail!("experiment name required. Use: bt experiments delete "); } - select_experiment_interactive(client, project_name).await? + super::select_experiment_interactive(client, project_name).await? } }; - if !force && std::io::stdin().is_terminal() { + if !force && is_interactive() { let confirm = Confirm::new() .with_prompt(format!( "Delete experiment '{}' from {}?", @@ -70,23 +68,3 @@ pub async fn run( } } } - -pub async fn select_experiment_interactive( - client: &ApiClient, - project: &str, -) -> Result { - let mut experiments = with_spinner( - "Loading 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()) -} diff --git a/src/experiments/mod.rs b/src/experiments/mod.rs index 131b38d..487cfbc 100644 --- a/src/experiments/mod.rs +++ b/src/experiments/mod.rs @@ -1,11 +1,13 @@ -use std::io::IsTerminal; - -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; use clap::{Args, Subcommand}; use crate::{ - args::BaseArgs, auth::login, http::ApiClient, projects::api::get_project_by_name, - ui::select_project_interactive, + args::BaseArgs, + auth::login, + config, + http::ApiClient, + projects::api::get_project_by_name, + ui::{self, is_interactive, select_project_interactive, with_spinner}, }; mod api; @@ -13,6 +15,8 @@ mod delete; mod list; mod view; +use api::{self as experiments_api, Experiment}; + #[derive(Debug, Clone, Args)] pub struct ExperimentsArgs { #[command(subcommand)] @@ -75,30 +79,49 @@ impl DeleteArgs { } } +pub(crate) async fn select_experiment_interactive( + client: &ApiClient, + project: &str, +) -> Result { + 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 ctx = login(&base).await?; let client = ApiClient::new(&ctx)?; - let project = match base.project { - Some(p) => p, - None if std::io::stdin().is_terminal() => { - select_project_interactive(&client, None, None).await? - } + 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 resolved_project = get_project_by_name(&client, &project) + let project = get_project_by_name(&client, &project_name) .await? - .ok_or_else(|| anyhow!("project '{project}' not found"))?; + .ok_or_else(|| anyhow!("project '{project_name}' not found"))?; match args.command { None | Some(ExperimentsCommands::List) => { - list::run(&client, &resolved_project, client.org_name(), base.json).await + list::run(&client, &project, client.org_name(), base.json).await } Some(ExperimentsCommands::View(v)) => { view::run( &client, &ctx.app_url, - &resolved_project, + &project, client.org_name(), v.name(), base.json, @@ -107,7 +130,7 @@ pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { .await } Some(ExperimentsCommands::Delete(d)) => { - delete::run(&client, &resolved_project, d.name(), d.force).await + delete::run(&client, &project, d.name(), d.force).await } } } diff --git a/src/experiments/view.rs b/src/experiments/view.rs index fc1a4ee..0d3f9c4 100644 --- a/src/experiments/view.rs +++ b/src/experiments/view.rs @@ -1,5 +1,4 @@ use std::fmt::Write as _; -use std::io::IsTerminal; use anyhow::{anyhow, bail, Result}; use dialoguer::console; @@ -7,9 +6,11 @@ use urlencoding::encode; use crate::http::ApiClient; use crate::projects::api::Project; -use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; +use crate::ui::{ + is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, +}; -use super::{api, delete}; +use super::api; pub async fn run( client: &ApiClient, @@ -29,10 +30,10 @@ pub async fn run( .await? .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, None => { - if !std::io::stdin().is_terminal() { + if !is_interactive() { bail!("experiment name required. Use: bt experiments view "); } - delete::select_experiment_interactive(client, project_name).await? + super::select_experiment_interactive(client, project_name).await? } }; diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 262a1c9..a4274d1 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -299,7 +299,15 @@ pub async fn run_functions(base: BaseArgs, args: FunctionsArgs) -> Result<()> { None => list::run(&ctx, base.json, args.function_type).await, Some(FunctionsCommands::List(ref la)) => list::run(&ctx, base.json, la.function_type).await, Some(FunctionsCommands::View(v)) => { - view::run(&ctx, v.slug(), base.json, v.web, v.verbose, None).await + view::run( + &ctx, + v.slug(), + base.json, + v.web, + v.verbose, + args.function_type, + ) + .await } Some(FunctionsCommands::Delete(d)) => { delete::run(&ctx, d.slug(), d.force, d.function_type).await diff --git a/src/functions/view.rs b/src/functions/view.rs index 39302f9..bf3e7a1 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -5,7 +5,7 @@ use dialoguer::console; use urlencoding::encode; use crate::ui::prompt_render::{ - render_code_lines, render_content_lines, render_message, render_options, render_tools, + render_code_lines, render_content_lines, render_options, render_prompt_block, }; use crate::ui::{ is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, @@ -84,33 +84,7 @@ pub async fn run( writeln!(output)?; if let Some(prompt_block) = pd.get("prompt") { - match prompt_block.get("type").and_then(|t| t.as_str()) { - Some("chat") => { - if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) - { - for msg in messages { - render_message(&mut output, msg)?; - } - } - } - Some("completion") => { - if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { - render_content_lines(&mut output, content)?; - writeln!(output)?; - } - } - _ => {} - } - if let Some(tools_val) = prompt_block.get("tools") { - let tools: Option> = match tools_val { - serde_json::Value::Array(arr) => Some(arr.clone()), - serde_json::Value::String(s) => serde_json::from_str(s).ok(), - _ => None, - }; - if let Some(ref tools) = tools { - render_tools(&mut output, tools)?; - } - } + render_prompt_block(&mut output, prompt_block)?; } } @@ -321,32 +295,7 @@ fn render_prompt_value(output: &mut String, val: &serde_json::Value) -> Result<( let mut prompt_buf = String::new(); if let Some(prompt_block) = val.get("prompt") { - match prompt_block.get("type").and_then(|t| t.as_str()) { - Some("chat") => { - if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) { - for msg in messages { - render_message(&mut prompt_buf, msg)?; - } - } - } - Some("completion") => { - if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { - render_content_lines(&mut prompt_buf, content)?; - writeln!(prompt_buf)?; - } - } - _ => {} - } - if let Some(tools_val) = prompt_block.get("tools") { - let tools: Option> = match tools_val { - serde_json::Value::Array(arr) => Some(arr.clone()), - serde_json::Value::String(s) => serde_json::from_str(s).ok(), - _ => None, - }; - if let Some(ref tools) = tools { - render_tools(&mut prompt_buf, tools)?; - } - } + render_prompt_block(&mut prompt_buf, prompt_block)?; } for line in prompt_buf.lines() { diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 701b651..a784517 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -6,9 +6,7 @@ use urlencoding::encode; use crate::http::ApiClient; use crate::prompts::delete::select_prompt_interactive; -use crate::ui::prompt_render::{ - render_content_lines, render_message, render_options, render_tools, -}; +use crate::ui::prompt_render::{render_options, render_prompt_block}; use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; use super::api; @@ -79,33 +77,7 @@ pub async fn run( writeln!(output)?; if let Some(prompt_block) = prompt.prompt_data.as_ref().and_then(|pd| pd.get("prompt")) { - match prompt_block.get("type").and_then(|t| t.as_str()) { - Some("chat") => { - if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) { - for msg in messages { - render_message(&mut output, msg)?; - } - } - } - Some("completion") => { - if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { - render_content_lines(&mut output, content)?; - writeln!(output)?; - } - } - _ => {} - } - - if let Some(tools_val) = prompt_block.get("tools") { - let tools: Option> = match tools_val { - serde_json::Value::Array(arr) => Some(arr.clone()), - serde_json::Value::String(s) => serde_json::from_str(s).ok(), - _ => None, - }; - if let Some(ref tools) = tools { - render_tools(&mut output, tools)?; - } - } + render_prompt_block(&mut output, prompt_block)?; } print_with_pager(&output)?; diff --git a/src/ui/prompt_render.rs b/src/ui/prompt_render.rs index d5bddb3..403ba1f 100644 --- a/src/ui/prompt_render.rs +++ b/src/ui/prompt_render.rs @@ -118,6 +118,36 @@ fn highlight_template_vars(line: &str) -> String { result } +pub fn render_prompt_block(output: &mut String, prompt_block: &serde_json::Value) -> Result<()> { + match prompt_block.get("type").and_then(|t| t.as_str()) { + Some("chat") => { + if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) { + for msg in messages { + render_message(output, msg)?; + } + } + } + Some("completion") => { + if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { + render_content_lines(output, content)?; + writeln!(output)?; + } + } + _ => {} + } + if let Some(tools_val) = prompt_block.get("tools") { + let tools: Option> = match tools_val { + serde_json::Value::Array(arr) => Some(arr.clone()), + serde_json::Value::String(s) => serde_json::from_str(s).ok(), + _ => None, + }; + if let Some(ref tools) = tools { + render_tools(output, tools)?; + } + } + Ok(()) +} + pub fn render_options(output: &mut String, options: &serde_json::Value) -> Result<()> { let Some(params) = options.get("params").and_then(|p| p.as_object()) else { return Ok(()); From 846e3b78d064e163ab81486c3ac8ac132ca97ad0 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 13:51:37 -0800 Subject: [PATCH 17/23] feat(view): add description, topics, and browser URL to function view --- src/functions/view.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/functions/view.rs b/src/functions/view.rs index bf3e7a1..c0bf8bd 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -67,6 +67,11 @@ pub async fn run( if let Some(ft) = &function.function_type { writeln!(output, "{} {}", console::style("Type:").dim(), ft)?; } + if let Some(desc) = &function.description { + if !desc.is_empty() { + writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; + } + } if let Some(pd) = &function.prompt_data { let options = pd.get("options"); @@ -243,6 +248,37 @@ pub async fn run( model )?; } + if let Some(topics) = fd.get("topic_names").and_then(|t| t.as_object()) { + let mut names: Vec<&str> = topics + .iter() + .filter(|(k, _)| k.as_str() != "noise") + .filter_map(|(_, v)| v.as_str()) + .collect(); + names.sort(); + writeln!( + output, + "{} {}", + console::style("Topics:").dim(), + names.len() + )?; + for name in &names { + writeln!(output, " {name}")?; + } + } + let path = build_web_path(&function); + let url = format!( + "{}/app/{}/p/{}/{}", + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(&ctx.project.name), + path + ); + writeln!( + output, + "\n{} {}", + console::style("View in browser:").dim(), + console::style(&url).underlined() + )?; } "parameters" => { render_parameters(&mut output, fd)?; From 90baad46de665f8c10c247846b802c301b6eb1a7 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 14:38:32 -0800 Subject: [PATCH 18/23] refactor: remove syntect syntax highlighting dependency --- Cargo.lock | 73 ------------------------------ Cargo.toml | 1 - src/functions/view.rs | 9 +--- src/ui/highlight.rs | 98 ----------------------------------------- src/ui/mod.rs | 1 - src/ui/prompt_render.rs | 14 +----- 6 files changed, 4 insertions(+), 192 deletions(-) delete mode 100644 src/ui/highlight.rs diff --git a/Cargo.lock b/Cargo.lock index 933ae4e..96fd169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,30 +174,6 @@ dependencies = [ "ts-rs", ] -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bitflags" version = "1.3.2" @@ -267,7 +243,6 @@ dependencies = [ "serde_json 1.0.149", "sha2", "strip-ansi-escapes", - "syntect", "tempfile", "tokio", "unicode-width 0.1.14", @@ -714,17 +689,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -2274,15 +2238,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schemars" version = "0.9.0" @@ -2657,24 +2612,6 @@ dependencies = [ "syn", ] -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "fancy-regex", - "flate2", - "fnv", - "once_cell", - "regex-syntax", - "serde", - "serde_derive", - "thiserror 2.0.18", - "walkdir", -] - [[package]] name = "system-configuration" version = "0.5.1" @@ -3115,16 +3052,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index c6879d3..3f14f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ regex = "1" urlencoding = "2" lingua = { git = "https://github.com/braintrustdata/lingua", rev = "3c79f2c427d12d3e2fe104910ef3c0768ad83770" } comfy-table = "7.2.2" -syntect = { version = "5", default-features = false, features = ["default-syntaxes", "regex-fancy"] } base64 = "0.22" oauth2 = { version = "4.4", default-features = false, features = ["reqwest", "rustls-tls"] } getrandom = "0.3" diff --git a/src/functions/view.rs b/src/functions/view.rs index c0bf8bd..431bc5d 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -100,11 +100,6 @@ pub async fn run( if let Some(data) = fd.get("data") { let data_type = data.get("type").and_then(|t| t.as_str()); - let runtime_name = data - .get("runtime_context") - .and_then(|rc| rc.get("runtime")) - .and_then(|r| r.as_str()); - if let Some(runtime) = data.get("runtime_context").and_then(|rc| { let rt = rc.get("runtime").and_then(|r| r.as_str())?; let ver = rc.get("version").and_then(|v| v.as_str()).unwrap_or(""); @@ -123,7 +118,7 @@ pub async fn run( if !code.is_empty() { writeln!(output)?; writeln!(output, "{}", console::style("Code:").dim())?; - render_code_lines(&mut output, code, runtime_name)?; + render_code_lines(&mut output, code)?; } } } @@ -136,7 +131,7 @@ pub async fn run( "{}", console::style("Code (preview):").dim() )?; - render_code_lines(&mut output, p, runtime_name)?; + render_code_lines(&mut output, p)?; } _ => { writeln!( diff --git a/src/ui/highlight.rs b/src/ui/highlight.rs deleted file mode 100644 index 26207ff..0000000 --- a/src/ui/highlight.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::str::FromStr; -use std::sync::LazyLock; - -use dialoguer::console; -use syntect::easy::ScopeRegionIterator; -use syntect::highlighting::ScopeSelector; -use syntect::parsing::{ParseState, ScopeStack, SyntaxSet}; - -static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); - -static COMMENT: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("comment").unwrap()); -static STRING: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("string").unwrap()); -static CONSTANT_NUMERIC: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("constant.numeric").unwrap()); -static CONSTANT_LANGUAGE: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("constant.language").unwrap()); -static ENTITY_NAME_FUNCTION: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("entity.name.function").unwrap()); -static SUPPORT_FUNCTION: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("support.function").unwrap()); -static KEYWORD_OPERATOR: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("keyword.operator").unwrap()); -static KEYWORD: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("keyword").unwrap()); -static STORAGE: LazyLock = - LazyLock::new(|| ScopeSelector::from_str("storage").unwrap()); - -fn runtime_to_extension(runtime: &str) -> &str { - match runtime { - "python" => "py", - "node" => "js", - "typescript" => "ts", - _ => runtime, - } -} - -fn style_token(token: &str, scope_stack: &ScopeStack) -> String { - if token.is_empty() { - return String::new(); - } - - let scopes = scope_stack.as_slice(); - - if COMMENT.does_match(scopes).is_some() { - return format!("{}", console::style(token).dim()); - } - if STRING.does_match(scopes).is_some() { - return format!("{}", console::style(token).green()); - } - if CONSTANT_NUMERIC.does_match(scopes).is_some() { - return format!("{}", console::style(token).magenta()); - } - if CONSTANT_LANGUAGE.does_match(scopes).is_some() { - return format!("{}", console::style(token).cyan().bold()); - } - if ENTITY_NAME_FUNCTION.does_match(scopes).is_some() { - return format!("{}", console::style(token).yellow()); - } - if SUPPORT_FUNCTION.does_match(scopes).is_some() { - return format!("{}", console::style(token).yellow()); - } - if KEYWORD_OPERATOR.does_match(scopes).is_some() { - return format!("{}", console::style(token).red()); - } - if KEYWORD.does_match(scopes).is_some() { - return format!("{}", console::style(token).cyan().bold()); - } - if STORAGE.does_match(scopes).is_some() { - return format!("{}", console::style(token).cyan()); - } - - token.to_string() -} - -pub fn highlight_code(code: &str, language_hint: &str) -> Option> { - let ps = &*SYNTAX_SET; - let ext = runtime_to_extension(language_hint); - let syntax = ps.find_syntax_by_extension(ext)?; - let mut state = ParseState::new(syntax); - - let mut result = Vec::new(); - let mut scope_stack = ScopeStack::new(); - for line in code.lines() { - let line_nl = format!("{line}\n"); - let ops = state.parse_line(&line_nl, ps).ok()?; - - let mut highlighted = String::new(); - for (token, op) in ScopeRegionIterator::new(&ops, &line_nl) { - scope_stack.apply(op).ok()?; - let token = token.trim_end_matches('\n'); - highlighted.push_str(&style_token(token, &scope_stack)); - } - result.push(highlighted); - } - Some(result) -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 43a4ccf..5a2ba4a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,3 @@ -pub mod highlight; use std::io::IsTerminal; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/src/ui/prompt_render.rs b/src/ui/prompt_render.rs index 403ba1f..ec404fc 100644 --- a/src/ui/prompt_render.rs +++ b/src/ui/prompt_render.rs @@ -80,26 +80,16 @@ pub fn render_content_lines(output: &mut String, content: &str) -> Result<()> { Ok(()) } -pub fn render_code_lines(output: &mut String, code: &str, language: Option<&str>) -> Result<()> { - let highlighted: Option> = if console::colors_enabled() { - language.and_then(|lang| super::highlight::highlight_code(code, lang)) - } else { - None - }; - +pub fn render_code_lines(output: &mut String, code: &str) -> Result<()> { let lines: Vec<&str> = code.lines().collect(); let width = lines.len().to_string().len(); for (i, line) in lines.iter().enumerate() { - let display = match &highlighted { - Some(hl) => hl.get(i).map(|s| s.as_str()).unwrap_or(line), - None => line, - }; writeln!( output, " {} {} {}", console::style(format!("{:>width$}", i + 1)).dim(), console::style("│").dim(), - display + line )?; } Ok(()) From 8635a71d3c83271f5f30e5e9148ef366437eaed8 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 14:50:24 -0800 Subject: [PATCH 19/23] refactor(experiments): improve description handling and URL display --- src/experiments/view.rs | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/experiments/view.rs b/src/experiments/view.rs index 0d3f9c4..68ed21c 100644 --- a/src/experiments/view.rs +++ b/src/experiments/view.rs @@ -37,14 +37,15 @@ pub async fn run( } }; + let url = format!( + "{}/app/{}/p/{}/experiments/{}", + app_url.trim_end_matches('/'), + encode(org_name), + encode(project_name), + encode(&experiment.name) + ); + if web { - let url = format!( - "{}/app/{}/p/{}/experiments/{}", - app_url.trim_end_matches('/'), - encode(org_name), - encode(project_name), - encode(&experiment.name) - ); open::that(&url)?; print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); return Ok(()); @@ -62,10 +63,20 @@ pub async fn run( console::style(&experiment.name).bold() )?; - if let Some(desc) = &experiment.description { - if !desc.is_empty() { - writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; - } + let description = experiment + .description + .as_deref() + .filter(|d| !d.is_empty()) + .or_else(|| { + experiment + .metadata + .as_ref() + .and_then(|m| m.get("description")) + .and_then(|d| d.as_str()) + .filter(|d| !d.is_empty()) + }); + if let Some(desc) = description { + writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; } if let Some(created) = &experiment.created { writeln!(output, "{} {}", console::style("Created:").dim(), created)?; @@ -98,6 +109,13 @@ pub async fn run( } } + writeln!( + output, + "\n{} {}", + console::style("View experiment results:").dim(), + console::style(&url).underlined() + )?; + print_with_pager(&output)?; Ok(()) } From 74eb7357fcafb4517e071cabcf38081c455f78de Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 15:16:54 -0800 Subject: [PATCH 20/23] bt-review + clig adherence --- src/experiments/delete.rs | 23 +++------ src/experiments/list.rs | 99 +++++++++++++++++++-------------------- src/experiments/mod.rs | 39 +++++++-------- src/experiments/view.rs | 24 +++------- src/http.rs | 1 + src/prompts/delete.rs | 18 +++++-- src/prompts/list.rs | 78 +++++++++++++++--------------- src/prompts/mod.rs | 42 ++++++++--------- src/prompts/view.rs | 20 ++++---- 9 files changed, 165 insertions(+), 179 deletions(-) diff --git a/src/experiments/delete.rs b/src/experiments/delete.rs index c2d1ab7..2c55266 100644 --- a/src/experiments/delete.rs +++ b/src/experiments/delete.rs @@ -1,34 +1,25 @@ use anyhow::{anyhow, bail, Result}; use dialoguer::Confirm; -use crate::{ - http::ApiClient, - projects::api::Project, - ui::{is_interactive, print_command_status, with_spinner, CommandStatus}, -}; +use crate::ui::{is_interactive, print_command_status, with_spinner, CommandStatus}; -use super::api; +use super::{api, ResolvedContext}; -pub async fn run( - client: &ApiClient, - project: &Project, - name: Option<&str>, - force: bool, -) -> Result<()> { - let project_name = &project.name; +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 --force"); } let experiment = match name { - Some(n) => api::get_experiment_by_name(client, project_name, n) + 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 "); } - super::select_experiment_interactive(client, project_name).await? + super::select_experiment_interactive(&ctx.client, project_name).await? } }; @@ -47,7 +38,7 @@ pub async fn run( match with_spinner( "Deleting experiment...", - api::delete_experiment(client, &experiment.id), + api::delete_experiment(&ctx.client, &experiment.id), ) .await { diff --git a/src/experiments/list.rs b/src/experiments/list.rs index 1ce0b4a..2319041 100644 --- a/src/experiments/list.rs +++ b/src/experiments/list.rs @@ -4,71 +4,70 @@ use anyhow::Result; use dialoguer::console; use crate::{ - http::ApiClient, - projects::api::Project, ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, utils::pluralize, }; -use super::api; +use super::{api, ResolvedContext}; -pub async fn run(client: &ApiClient, project: &Project, org: &str, json: bool) -> Result<()> { - let project_name = &project.name; +pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { + let project_name = &ctx.project.name; let experiments = with_spinner( "Loading experiments...", - api::list_experiments(client, project_name), + api::list_experiments(&ctx.client, project_name), ) .await?; if json { println!("{}", serde_json::to_string(&experiments)?); - } else { - 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(org).bold(), - console::style("/").dim().bold(), - console::style(project_name).bold() - )?; + return Ok(()); + } - let mut table = styled_table(); - table.set_header(vec![ - header("Name"), - header("Description"), - header("Created"), - header("Commit"), - ]); - apply_column_padding(&mut table, (0, 6)); + 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() + )?; - for exp in &experiments { - let desc = exp - .description - .as_deref() - .filter(|s| !s.is_empty()) - .map(|s| truncate(s, 40)) - .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]); - } + let mut table = styled_table(); + table.set_header(vec![ + header("Name"), + header("Description"), + header("Created"), + header("Commit"), + ]); + apply_column_padding(&mut table, (0, 6)); - write!(output, "{table}")?; - print_with_pager(&output)?; + 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(()) } diff --git a/src/experiments/mod.rs b/src/experiments/mod.rs index 487cfbc..ab2c2d0 100644 --- a/src/experiments/mod.rs +++ b/src/experiments/mod.rs @@ -6,7 +6,7 @@ use crate::{ auth::login, config, http::ApiClient, - projects::api::get_project_by_name, + projects::api::{get_project_by_name, Project}, ui::{self, is_interactive, select_project_interactive, with_spinner}, }; @@ -17,6 +17,12 @@ 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)] @@ -100,8 +106,8 @@ pub(crate) async fn select_experiment_interactive( } pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { - let ctx = login(&base).await?; - let client = ApiClient::new(&ctx)?; + 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(), @@ -113,24 +119,15 @@ pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { .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(&client, &project, client.org_name(), base.json).await - } - Some(ExperimentsCommands::View(v)) => { - view::run( - &client, - &ctx.app_url, - &project, - client.org_name(), - v.name(), - base.json, - v.web, - ) - .await - } - Some(ExperimentsCommands::Delete(d)) => { - delete::run(&client, &project, d.name(), d.force).await - } + 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, } } diff --git a/src/experiments/view.rs b/src/experiments/view.rs index 68ed21c..bd3e7f8 100644 --- a/src/experiments/view.rs +++ b/src/experiments/view.rs @@ -4,28 +4,18 @@ use anyhow::{anyhow, bail, Result}; use dialoguer::console; use urlencoding::encode; -use crate::http::ApiClient; -use crate::projects::api::Project; use crate::ui::{ is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, }; -use super::api; +use super::{api, ResolvedContext}; -pub async fn run( - client: &ApiClient, - app_url: &str, - project: &Project, - org_name: &str, - name: Option<&str>, - json: bool, - web: bool, -) -> Result<()> { - let project_name = &project.name; +pub async fn run(ctx: &ResolvedContext, name: Option<&str>, json: bool, web: bool) -> Result<()> { + let project_name = &ctx.project.name; let experiment = match name { Some(n) => with_spinner( "Loading experiment...", - api::get_experiment_by_name(client, project_name, n), + api::get_experiment_by_name(&ctx.client, project_name, n), ) .await? .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, @@ -33,14 +23,14 @@ pub async fn run( if !is_interactive() { bail!("experiment name required. Use: bt experiments view "); } - super::select_experiment_interactive(client, project_name).await? + super::select_experiment_interactive(&ctx.client, project_name).await? } }; let url = format!( "{}/app/{}/p/{}/experiments/{}", - app_url.trim_end_matches('/'), - encode(org_name), + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), encode(project_name), encode(&experiment.name) ); diff --git a/src/http.rs b/src/http.rs index 19acc10..66815d9 100644 --- a/src/http.rs +++ b/src/http.rs @@ -22,6 +22,7 @@ pub struct BtqlResponse { impl ApiClient { pub fn new(ctx: &LoginContext) -> Result { let http = Client::builder() + .timeout(std::time::Duration::from_secs(30)) .build() .context("failed to build HTTP client")?; diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index 668b0f2..dbd437d 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -7,20 +7,23 @@ use crate::{ ui::{self, is_interactive, print_command_status, with_spinner, CommandStatus}, }; -pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: bool) -> Result<()> { +use super::ResolvedContext; + +pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, force: bool) -> Result<()> { + let project_name = &ctx.project.name; if force && slug.is_none() { bail!("slug required when using --force. Use: bt prompts delete --force"); } let prompt = match slug { - Some(s) => api::get_prompt_by_slug(client, project, s) + Some(s) => api::get_prompt_by_slug(&ctx.client, project_name, s) .await? .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, None => { if !is_interactive() { bail!("prompt slug required. Use: bt prompts delete "); } - select_prompt_interactive(client, project).await? + select_prompt_interactive(&ctx.client, project_name).await? } }; @@ -28,7 +31,7 @@ pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: b let confirm = Confirm::new() .with_prompt(format!( "Delete prompt '{}' from {}?", - &prompt.name, project + &prompt.name, project_name )) .default(false) .interact()?; @@ -38,7 +41,12 @@ pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: b } } - match with_spinner("Deleting prompt...", api::delete_prompt(client, &prompt.id)).await { + match with_spinner( + "Deleting prompt...", + api::delete_prompt(&ctx.client, &prompt.id), + ) + .await + { Ok(_) => { print_command_status( CommandStatus::Success, diff --git a/src/prompts/list.rs b/src/prompts/list.rs index bf58922..a7f9e7c 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -4,52 +4,56 @@ use anyhow::Result; use dialoguer::console; use crate::{ - http::ApiClient, ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, utils::pluralize, }; -use super::api; +use super::{api, ResolvedContext}; -pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> { - let prompts = with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; +pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { + let project_name = &ctx.project.name; + let prompts = with_spinner( + "Loading prompts...", + api::list_prompts(&ctx.client, project_name), + ) + .await?; if json { println!("{}", serde_json::to_string(&prompts)?); - } else { - let mut output = String::new(); - - let count = format!( - "{} {}", - prompts.len(), - pluralize(prompts.len(), "prompt", None) - ); - writeln!( - output, - "{} found in {} {} {}\n", - console::style(count), - console::style(org).bold(), - console::style("/").dim().bold(), - console::style(project).bold() - )?; - - let mut table = styled_table(); - table.set_header(vec![header("Name"), header("Description"), header("Slug")]); - apply_column_padding(&mut table, (0, 6)); - - for prompt in &prompts { - let desc = prompt - .description - .as_deref() - .filter(|s| !s.is_empty()) - .map(|s| truncate(s, 60)) - .unwrap_or_else(|| "-".to_string()); - table.add_row(vec![&prompt.name, &desc, &prompt.slug]); - } - - write!(output, "{table}")?; - print_with_pager(&output)?; + return Ok(()); } + let mut output = String::new(); + + let count = format!( + "{} {}", + prompts.len(), + pluralize(prompts.len(), "prompt", 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("Slug")]); + apply_column_padding(&mut table, (0, 6)); + + for prompt in &prompts { + let desc = prompt + .description + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&prompt.name, &desc, &prompt.slug]); + } + + write!(output, "{table}")?; + print_with_pager(&output)?; Ok(()) } diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index b6f6124..be3f554 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -5,10 +5,16 @@ use crate::{ args::BaseArgs, auth::login, http::ApiClient, - projects::api::get_project_by_name, + projects::api::{get_project_by_name, Project}, ui::{is_interactive, select_project_interactive}, }; +pub(crate) struct ResolvedContext { + pub client: ApiClient, + pub app_url: String, + pub project: Project, +} + mod api; mod delete; mod list; @@ -81,9 +87,9 @@ impl DeleteArgs { } pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { - let ctx = login(&base).await?; - let client = ApiClient::new(&ctx)?; - let project = match base + let auth = login(&base).await?; + let client = ApiClient::new(&auth)?; + let project_name = match base .project .or_else(|| crate::config::load().ok().and_then(|c| c.project)) { @@ -92,27 +98,21 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), }; - get_project_by_name(&client, &project) + let project = get_project_by_name(&client, &project_name) .await? - .ok_or_else(|| anyhow!("project '{project}' not found"))?; + .ok_or_else(|| anyhow!("project '{project_name}' not found"))?; + + let ctx = ResolvedContext { + client, + app_url: auth.app_url, + project, + }; match args.command { - None | Some(PromptsCommands::List) => { - list::run(&client, &project, client.org_name(), base.json).await - } + None | Some(PromptsCommands::List) => list::run(&ctx, base.json).await, Some(PromptsCommands::View(p)) => { - view::run( - &client, - &ctx.app_url, - &project, - client.org_name(), - p.slug(), - base.json, - p.web, - p.verbose, - ) - .await + view::run(&ctx, p.slug(), base.json, p.web, p.verbose).await } - Some(PromptsCommands::Delete(p)) => delete::run(&client, &project, p.slug(), p.force).await, + Some(PromptsCommands::Delete(p)) => delete::run(&ctx, p.slug(), p.force).await, } } diff --git a/src/prompts/view.rs b/src/prompts/view.rs index a784517..66494c4 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -4,28 +4,24 @@ use anyhow::{anyhow, bail, Result}; use dialoguer::console; use urlencoding::encode; -use crate::http::ApiClient; use crate::prompts::delete::select_prompt_interactive; use crate::ui::prompt_render::{render_options, render_prompt_block}; use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; -use super::api; +use super::{api, ResolvedContext}; -#[allow(clippy::too_many_arguments)] pub async fn run( - client: &ApiClient, - app_url: &str, - project: &str, - org_name: &str, + ctx: &ResolvedContext, slug: Option<&str>, json: bool, web: bool, verbose: bool, ) -> Result<()> { + let project_name = &ctx.project.name; let prompt = match slug { Some(s) => with_spinner( "Loading prompt...", - api::get_prompt_by_slug(client, project, s), + api::get_prompt_by_slug(&ctx.client, project_name, s), ) .await? .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, @@ -33,16 +29,16 @@ pub async fn run( if !crate::ui::is_interactive() { bail!("prompt slug required. Use: bt prompts view "); } - select_prompt_interactive(client, project).await? + select_prompt_interactive(&ctx.client, project_name).await? } }; if web { let url = format!( "{}/app/{}/p/{}/prompts/{}", - app_url.trim_end_matches('/'), - encode(org_name), - encode(project), + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(project_name), encode(&prompt.id) ); open::that(&url)?; From 923f723c0ec6e189ad90e12f3e904e5684e79120 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 16:20:27 -0800 Subject: [PATCH 21/23] feat(functions): add invoke command to execute functions --- src/functions/api.rs | 16 ++++++ src/functions/invoke.rs | 120 ++++++++++++++++++++++++++++++++++++++++ src/functions/mod.rs | 18 ++++++ src/http.rs | 18 ++++++ 4 files changed, 172 insertions(+) create mode 100644 src/functions/invoke.rs diff --git a/src/functions/api.rs b/src/functions/api.rs index 228ffe0..a230046 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -63,6 +63,22 @@ pub async fn get_function_by_slug( Ok(response.data.into_iter().next()) } +pub async fn invoke_function( + client: &ApiClient, + body: &serde_json::Value, +) -> Result { + let org_name = client.org_name(); + let headers = if !org_name.is_empty() { + vec![("x-bt-org-name", org_name)] + } else { + Vec::new() + }; + let timeout = std::time::Duration::from_secs(300); + client + .post_with_headers_timeout("/function/invoke", body, &headers, Some(timeout)) + .await +} + pub async fn delete_function(client: &ApiClient, function_id: &str) -> Result<()> { let path = format!("/v1/function/{}", encode(function_id)); client.delete(&path).await diff --git a/src/functions/invoke.rs b/src/functions/invoke.rs new file mode 100644 index 0000000..7b16502 --- /dev/null +++ b/src/functions/invoke.rs @@ -0,0 +1,120 @@ +use std::io::{self, IsTerminal, Read}; + +use anyhow::{bail, Context, Result}; +use clap::Args; +use serde_json::{json, Value}; + +use super::{select_function_interactive, FunctionTypeFilter, ResolvedContext, SlugArgs}; +use crate::ui::is_interactive; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt functions invoke my-fn --input '{\"key\": \"value\"}' + bt functions invoke my-fn --message \"What is 2+2?\" + bt functions invoke my-fn -i '{\"my-var\": \"A very long text...\"}' -m \"Summarize this\" + bt functions invoke my-fn --mode json --version abc123 + ")] +pub(crate) struct InvokeArgs { + #[command(flatten)] + slug: SlugArgs, + + /// JSON input to the function + #[arg(long, short = 'i')] + input: Option, + + /// User message (repeatable, for LLM functions) + #[arg(long, short = 'm')] + message: Vec, + + /// Response format: auto, json, text, parallel + #[arg(long)] + mode: Option, + + /// Pin to a specific function version + #[arg(long)] + version: Option, +} + +impl InvokeArgs { + pub fn slug(&self) -> Option<&str> { + self.slug.slug() + } +} + +fn resolve_input(input_arg: &Option) -> Result> { + if let Some(raw) = input_arg { + let parsed: Value = serde_json::from_str(raw).context("invalid JSON in --input")?; + return Ok(Some(parsed)); + } + + if !io::stdin().is_terminal() { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .context("failed to read from stdin")?; + let trimmed = buf.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed: Value = serde_json::from_str(trimmed).context("invalid JSON from stdin")?; + return Ok(Some(parsed)); + } + + Ok(None) +} + +pub async fn run( + ctx: &ResolvedContext, + args: &InvokeArgs, + json_output: bool, + ft: Option, +) -> Result<()> { + let slug = match args.slug() { + Some(s) => s.to_string(), + None if is_interactive() => { + let f = select_function_interactive(&ctx.client, &ctx.project.id, ft).await?; + f.slug + } + None => bail!(" required"), + }; + + let resolved_input = resolve_input(&args.input)?; + + let mut body = json!({ + "project_name": ctx.project.name, + "slug": slug, + }); + + if let Some(input) = resolved_input { + body["input"] = input; + } + if !args.message.is_empty() { + let messages: Vec = args + .message + .iter() + .map(|m| json!({"role": "user", "content": m})) + .collect(); + body["messages"] = json!(messages); + } + if let Some(mode) = &args.mode { + body["mode"] = json!(mode); + } + if let Some(version) = &args.version { + body["version"] = json!(version); + } + + let result = super::api::invoke_function(&ctx.client, &body).await?; + + if json_output { + println!("{}", serde_json::to_string(&result)?); + } else { + match &result { + Value::String(s) => println!("{s}"), + Value::Null => {} + _ => println!("{}", serde_json::to_string_pretty(&result)?), + } + } + + Ok(()) +} diff --git a/src/functions/mod.rs b/src/functions/mod.rs index a4274d1..f084b85 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -12,6 +12,7 @@ use crate::{ pub(crate) mod api; mod delete; +mod invoke; mod list; mod view; @@ -133,6 +134,8 @@ enum FunctionCommands { View(ViewArgs), /// Delete a function Delete(DeleteArgs), + /// Invoke a function + Invoke(invoke::InvokeArgs), } // --- bt functions args --- @@ -154,6 +157,8 @@ enum FunctionsCommands { View(ViewArgs), /// Delete a function Delete(FunctionsDeleteArgs), + /// Invoke a function + Invoke(FunctionsInvokeArgs), } #[derive(Debug, Clone, Args)] @@ -181,6 +186,15 @@ impl FunctionsDeleteArgs { } } +#[derive(Debug, Clone, Args)] +struct FunctionsInvokeArgs { + #[command(flatten)] + inner: invoke::InvokeArgs, + /// Filter by function type (for interactive selection) + #[arg(long = "type", short = 't', value_enum)] + function_type: Option, +} + // --- Shared view/delete args --- #[derive(Debug, Clone, Args)] @@ -290,6 +304,7 @@ pub async fn run(base: BaseArgs, args: FunctionArgs, kind: FunctionTypeFilter) - view::run(&ctx, v.slug(), base.json, v.web, v.verbose, ft).await } Some(FunctionCommands::Delete(d)) => delete::run(&ctx, d.slug(), d.force, ft).await, + Some(FunctionCommands::Invoke(i)) => invoke::run(&ctx, &i, base.json, ft).await, } } @@ -312,5 +327,8 @@ pub async fn run_functions(base: BaseArgs, args: FunctionsArgs) -> Result<()> { Some(FunctionsCommands::Delete(d)) => { delete::run(&ctx, d.slug(), d.force, d.function_type).await } + Some(FunctionsCommands::Invoke(i)) => { + invoke::run(&ctx, &i.inner, base.json, i.function_type).await + } } } diff --git a/src/http.rs b/src/http.rs index 66815d9..87f50c3 100644 --- a/src/http.rs +++ b/src/http.rs @@ -93,6 +93,21 @@ impl ApiClient { body: &B, headers: &[(&str, &str)], ) -> Result + where + T: DeserializeOwned, + B: Serialize, + { + self.post_with_headers_timeout(path, body, headers, None) + .await + } + + pub async fn post_with_headers_timeout( + &self, + path: &str, + body: &B, + headers: &[(&str, &str)], + timeout: Option, + ) -> Result where T: DeserializeOwned, B: Serialize, @@ -103,6 +118,9 @@ impl ApiClient { for (key, value) in headers { request = request.header(*key, *value); } + if let Some(t) = timeout { + request = request.timeout(t); + } let response = request.send().await.context("request failed")?; From a37ff42eb0d491eccd0744c4e537719408b66245 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 16:34:27 -0800 Subject: [PATCH 22/23] update docs --- src/main.rs | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index edd0539..4cc56fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,25 +48,29 @@ const HELP_TEMPLATE: &str = "\ {before-help}{about} - {usage} Core - init Initialize .bt config directory and files - auth Authenticate bt with Braintrust - switch Switch org and project context - view View logs, traces, and spans + init Initialize .bt config directory and files + auth Authenticate bt with Braintrust + switch Switch org and project context + view View logs, traces, and spans Projects & resources - projects Manage projects - prompts Manage prompts + projects Manage projects + prompts Manage prompts + functions Manage functions (tools, scorers, and more) + tools Manage tools + scorers Manage scorers + experiments Manage experiments Data & evaluation - eval Run eval files - sql Run SQL queries against Braintrust - sync Synchronize project logs between Braintrust and local NDJSON files + eval Run eval files + sql Run SQL queries against Braintrust + sync Synchronize project logs between Braintrust and local NDJSON files Additional - docs Manage workflow docs for coding agents - self Self-management commands - setup Configure Braintrust setup flows - status Show current org and project context + docs Manage workflow docs for coding agents + self Self-management commands + setup Configure Braintrust setup flows + status Show current org and project context Flags --profile Use a saved login profile [env: BRAINTRUST_PROFILE] From b61ce4d634215fb5ce780cb0de18ff1c1e554bb1 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 17:10:54 -0800 Subject: [PATCH 23/23] refactor(http): remove default timeout from HTTP client --- src/http.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/http.rs b/src/http.rs index 87f50c3..2e43844 100644 --- a/src/http.rs +++ b/src/http.rs @@ -22,7 +22,6 @@ pub struct BtqlResponse { impl ApiClient { pub fn new(ctx: &LoginContext) -> Result { let http = Client::builder() - .timeout(std::time::Duration::from_secs(30)) .build() .context("failed to build HTTP client")?;