Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@

- This repo is managed with `mise` and `pre-commit`; prefer using the repo-defined toolchain and hooks when running local validation.

## Privacy and Test Data

- Do not add real customer, organization, profile, project, function, dataset, experiment, or user names/IDs to tests, fixtures, docs, examples, snapshots, or committed files.
- Use synthetic placeholders instead, for example `test-profile`, `test-org`, `test-project`, `fn_test_topic_map`, or UUIDs clearly marked as fake.
- If a user-provided command includes real identifiers, do not copy them into code or tests; translate them to synthetic values before writing files.

## CLI Implementation Conventions

- Follow existing resource-command patterns before adding new structure; `projects/` is a good reference for module layout and command dispatch.
Expand Down
15 changes: 15 additions & 0 deletions src/functions/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ pub async fn get_function_by_slug(
Ok(response.data.into_iter().next())
}

pub async fn get_function_by_id(client: &ApiClient, id: &str) -> Result<Option<Function>> {
let query = FunctionListQuery {
id: Some(id.to_string()),
..Default::default()
};
let page = list_functions_page(client, &query).await?;
let Some(raw) = page.objects.into_iter().next() else {
return Ok(None);
};

serde_json::from_value(raw)
.map(Some)
.context("unexpected function response shape")
}

pub async fn invoke_function(
client: &ApiClient,
body: &serde_json::Value,
Expand Down
148 changes: 128 additions & 20 deletions src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,23 @@ impl SlugArgs {
.as_deref()
.or(self.slug_flag.as_deref())
}

fn slug_positional(&self) -> Option<&str> {
self.slug_positional.as_deref()
}

fn slug_flag(&self) -> Option<&str> {
self.slug_flag.as_deref()
}
}

#[derive(Debug, Clone, Args)]
#[command(after_help = "\
Examples:
bt tools list
bt tools view my-tool
bt tools view fn_123
bt tools view --id fn_123
bt scorers list
bt scorers delete my-scorer
")]
Expand All @@ -183,6 +193,8 @@ enum FunctionCommands {
Examples:
bt functions list
bt functions view my-function
bt functions view fn_123
bt functions view --id fn_123
bt functions invoke my-function --input '{\"key\":\"value\"}'
bt functions push --file ./functions
bt functions pull --output-dir ./braintrust
Expand Down Expand Up @@ -418,17 +430,43 @@ impl PullArgs {
pub struct ViewArgs {
#[command(flatten)]
slug: SlugArgs,
/// Function id
#[arg(long = "id", env = "BT_FUNCTIONS_VIEW_ID")]
id: Option<String>,
/// Open in browser
#[arg(long)]
web: bool,
}

impl ViewArgs {
fn slug(&self) -> Option<&str> {
self.slug.slug()
fn selector(&self) -> Result<ViewSelector<'_>> {
match (
self.id.as_deref(),
self.slug.slug_positional(),
self.slug.slug_flag(),
) {
(Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
bail!("use either --id or a slug, not both")
}
(Some(id), None, None) => Ok(ViewSelector::Id(id)),
(None, Some(positional), None) if is_likely_function_id(positional) => {
Ok(ViewSelector::Id(positional))
}
(None, positional, flag) => Ok(ViewSelector::Slug(positional.or(flag))),
}
}
}

fn is_likely_function_id(value: &str) -> bool {
value.starts_with("fn_") || value.starts_with("func_")
}

#[derive(Debug)]
enum ViewSelector<'a> {
Id(&'a str),
Slug(Option<&'a str>),
}

#[derive(Debug, Clone, Args)]
pub struct DeleteArgs {
#[command(flatten)]
Expand Down Expand Up @@ -567,15 +605,29 @@ pub(crate) async fn select_function_interactive(
}

pub async fn run_typed(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(&ctx, base.json, ft).await,
Some(FunctionCommands::View(v)) => {
view::run(&ctx, v.slug(), base.json, v.web, base.verbose, ft).await
Some(FunctionCommands::View(v)) => match v.selector()? {
ViewSelector::Id(id) => {
let auth_ctx = resolve_auth_context(&base).await?;
view::run_by_id(&auth_ctx, id, base.json, v.web, base.verbose, ft).await
}
ViewSelector::Slug(slug) => {
let ctx = resolve_context(&base).await?;
view::run(&ctx, slug, base.json, v.web, base.verbose, ft).await
}
},
command => {
let ctx = resolve_context(&base).await?;
match command {
None | Some(FunctionCommands::List) => list::run(&ctx, base.json, 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,
Some(FunctionCommands::View(_)) => {
unreachable!("handled before context resolution")
}
}
}
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,
}
}

Expand All @@ -584,31 +636,35 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> {
match args.command {
Some(FunctionsCommands::Push(push_args)) => push::run(base, push_args).await,
Some(FunctionsCommands::Pull(pull_args)) => pull::run(base, pull_args).await,
Some(FunctionsCommands::View(v)) => {
let ft = v.function_type.or(function_type);
match v.inner.selector()? {
ViewSelector::Id(id) => {
let auth_ctx = resolve_auth_context(&base).await?;
view::run_by_id(&auth_ctx, id, base.json, v.inner.web, base.verbose, ft).await
}
ViewSelector::Slug(slug) => {
let ctx = resolve_context(&base).await?;
view::run(&ctx, slug, base.json, v.inner.web, base.verbose, ft).await
}
}
}
command => {
let ctx = resolve_context(&base).await?;
match command {
None => list::run(&ctx, base.json, function_type).await,
Some(FunctionsCommands::List(la)) => {
list::run(&ctx, base.json, la.function_type.or(function_type)).await
}
Some(FunctionsCommands::View(v)) => {
view::run(
&ctx,
v.inner.slug(),
base.json,
v.inner.web,
base.verbose,
v.function_type.or(function_type),
)
.await
}
Some(FunctionsCommands::Delete(d)) => {
delete::run(&ctx, d.slug(), d.force, d.function_type.or(function_type)).await
}
Some(FunctionsCommands::Invoke(i)) => {
invoke::run(&ctx, &i.inner, base.json, i.function_type.or(function_type)).await
}
Some(FunctionsCommands::Push(_)) | Some(FunctionsCommands::Pull(_)) => {
Some(FunctionsCommands::Push(_))
| Some(FunctionsCommands::Pull(_))
| Some(FunctionsCommands::View(_)) => {
unreachable!("handled before context resolution")
}
}
Expand Down Expand Up @@ -938,6 +994,58 @@ mod tests {
assert_eq!(pull.slug_flag, vec!["a", "b", "c"]);
}

#[test]
fn view_accepts_id_selector() {
let _guard = test_lock();
let parsed = parse(&["functions", "view", "--id", "f1"]).expect("parse view");
let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else {
panic!("expected view command");
};
match view.inner.selector().expect("view selector") {
ViewSelector::Id(id) => assert_eq!(id, "f1"),
ViewSelector::Slug(_) => panic!("expected id selector"),
}
}

#[test]
fn view_auto_detects_positional_function_id() {
let _guard = test_lock();
for value in ["fn_123", "func_123"] {
let parsed = parse(&["functions", "view", value]).expect("parse view");
let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else {
panic!("expected view command");
};
match view.inner.selector().expect("view selector") {
ViewSelector::Id(id) => assert_eq!(id, value),
ViewSelector::Slug(_) => panic!("expected id selector for {value}"),
}
}
}

#[test]
fn view_slug_flag_forces_slug_even_when_value_looks_like_id() {
let _guard = test_lock();
let parsed = parse(&["functions", "view", "--slug", "fn_123"]).expect("parse view");
let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else {
panic!("expected view command");
};
match view.inner.selector().expect("view selector") {
ViewSelector::Slug(Some(slug)) => assert_eq!(slug, "fn_123"),
other => panic!("expected slug selector, got {other:?}"),
}
}

#[test]
fn view_rejects_id_and_slug_together() {
let _guard = test_lock();
let parsed = parse(&["functions", "view", "--id", "f1", "slug"]).expect("parse view");
let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else {
panic!("expected view command");
};
let err = view.inner.selector().expect_err("id and slug conflict");
assert!(err.to_string().contains("either --id or a slug"));
}

#[test]
fn function_selection_label_includes_slug_when_name_differs() {
let function = Function {
Expand Down
84 changes: 75 additions & 9 deletions src/functions/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ use crate::ui::prompt_render::{
use crate::ui::{
is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus,
};
use crate::{http::ApiClient, projects::api as projects_api};

use super::{api, build_web_path, label, label_plural, select_function_interactive};
use super::{FunctionTypeFilter, ResolvedContext};
use super::{AuthContext, FunctionTypeFilter, ResolvedContext};

pub async fn run(
ctx: &ResolvedContext,
Expand Down Expand Up @@ -42,13 +43,65 @@ pub async fn run(
}
};

render_function(
&ctx.client,
&ctx.app_url,
Some(&ctx.project.name),
&function,
json,
web,
verbose,
)
.await
}

pub async fn run_by_id(
ctx: &AuthContext,
id: &str,
json: bool,
web: bool,
verbose: bool,
ft: Option<FunctionTypeFilter>,
) -> Result<()> {
let function = with_spinner(
&format!("Loading {}...", label(ft)),
api::get_function_by_id(&ctx.client, id),
)
.await?
.ok_or_else(|| anyhow!("{} with id '{id}' not found", label(ft)))?;

render_function(
&ctx.client,
&ctx.app_url,
None,
&function,
json,
web,
verbose,
)
.await
}

async fn render_function(
client: &ApiClient,
app_url: &str,
project_name: Option<&str>,
function: &api::Function,
json: bool,
web: bool,
verbose: bool,
) -> Result<()> {
if web {
let path = build_web_path(&function);
let path = build_web_path(function);
let project_name = match project_name {
Some(project_name) => project_name.to_string(),
None => resolve_project_name(client, &function.project_id).await?,
};
let url = format!(
"{}/app/{}/p/{}/{}",
ctx.app_url.trim_end_matches('/'),
encode(ctx.client.org_name()),
encode(&ctx.project.name),
app_url.trim_end_matches('/'),
encode(client.org_name()),
encode(&project_name),
path
);
open::that(&url)?;
Expand Down Expand Up @@ -266,12 +319,16 @@ pub async fn run(
writeln!(output, " {name}")?;
}
}
let path = build_web_path(&function);
let path = build_web_path(function);
let project_name = match project_name {
Some(project_name) => project_name.to_string(),
None => resolve_project_name(client, &function.project_id).await?,
};
let url = format!(
"{}/app/{}/p/{}/{}",
ctx.app_url.trim_end_matches('/'),
encode(ctx.client.org_name()),
encode(&ctx.project.name),
app_url.trim_end_matches('/'),
encode(client.org_name()),
encode(&project_name),
path
);
writeln!(
Expand Down Expand Up @@ -321,6 +378,15 @@ pub async fn run(
Ok(())
}

async fn resolve_project_name(client: &ApiClient, project_id: &str) -> Result<String> {
let projects = with_spinner("Loading project...", projects_api::list_projects(client)).await?;
projects
.into_iter()
.find(|project| project.id == project_id)
.map(|project| project.name)
.ok_or_else(|| anyhow!("project '{project_id}' not found for function"))
}

fn render_prompt_value(output: &mut String, val: &serde_json::Value) -> Result<()> {
if let Some(model) = val
.get("options")
Expand Down
Loading
Loading