From 3ad4dce041e25f8d8c9a242db91cc9ddc00cbf35 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Wed, 18 Feb 2026 08:21:03 -0800 Subject: [PATCH] fix some stuff --- src/functions/api.rs | 97 +++++++++++++++ src/functions/delete.rs | 72 ++++++++++++ src/functions/list.rs | 18 +++ src/functions/mod.rs | 81 +++++++++++++ src/functions/view.rs | 66 +++++++++++ src/main.rs | 6 + src/project_command.rs | 36 ++++++ src/prompts/api.rs | 15 +++ src/prompts/delete.rs | 16 +-- src/prompts/list.rs | 42 +------ src/prompts/mod.rs | 37 ++---- src/resource_cmd.rs | 96 +++++++++++++++ src/setup/mod.rs | 255 +++++++++++++++++++++++++++++++--------- 13 files changed, 701 insertions(+), 136 deletions(-) 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/project_command.rs create mode 100644 src/resource_cmd.rs diff --git a/src/functions/api.rs b/src/functions/api.rs new file mode 100644 index 0000000..9fdc172 --- /dev/null +++ b/src/functions/api.rs @@ -0,0 +1,97 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::http::ApiClient; +use crate::resource_cmd::NamedResource; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Function { + pub id: String, + pub name: String, + #[serde(default)] + pub slug: String, + pub project_id: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub function_type: Option, + #[serde(default)] + pub function_data: Option, +} + +impl Function { + pub fn display_type(&self) -> String { + if let Some(function_type) = self + .function_type + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return function_type.to_string(); + } + + self.function_data + .as_ref() + .and_then(|fd| fd.get("type")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| "-".to_string()) + } +} + +impl NamedResource for Function { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn slug(&self) -> &str { + &self.slug + } + + fn resource_type(&self) -> Option { + Some(self.display_type()) + } +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +pub async fn list_functions(client: &ApiClient, project: &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(list.objects) +} + +pub async fn get_function_by_slug( + client: &ApiClient, + project: &str, + slug: &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().next()) +} + +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..26700cc --- /dev/null +++ b/src/functions/delete.rs @@ -0,0 +1,72 @@ +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::Confirm; + +use crate::{ + functions::api::{self, Function}, + http::ApiClient, + resource_cmd::select_named_resource_interactive, + ui::{print_command_status, with_spinner, CommandStatus}, +}; + +pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: bool) -> Result<()> { + if force && slug.is_none() { + bail!("slug required when using --force. Use: bt functions delete --force"); + } + + let function = match slug { + Some(s) => api::get_function_by_slug(client, project, s) + .await? + .ok_or_else(|| anyhow!("function with slug '{s}' not found"))?, + None => { + if !std::io::stdin().is_terminal() { + bail!("function slug required. Use: bt functions delete "); + } + select_function_interactive(client, project).await? + } + }; + + if !force && std::io::stdin().is_terminal() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete function '{}' from {}?", + &function.name, project + )) + .default(false) + .interact()?; + + if !confirm { + return Ok(()); + } + } + + match with_spinner( + "Deleting function...", + api::delete_function(client, &function.id), + ) + .await + { + Ok(_) => { + print_command_status( + CommandStatus::Success, + &format!("Deleted '{}'", function.name), + ); + eprintln!("Run `bt functions list` to see remaining functions."); + 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) -> Result { + let functions = + with_spinner("Loading functions...", api::list_functions(client, project)).await?; + select_named_resource_interactive(functions, "no functions found", "Select function") +} diff --git a/src/functions/list.rs b/src/functions/list.rs new file mode 100644 index 0000000..2ad77f1 --- /dev/null +++ b/src/functions/list.rs @@ -0,0 +1,18 @@ +use anyhow::Result; + +use crate::{http::ApiClient, resource_cmd::print_named_resource_list, ui::with_spinner}; + +use super::api; + +pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> { + let functions = + with_spinner("Loading functions...", api::list_functions(client, project)).await?; + + if json { + println!("{}", serde_json::to_string(&functions)?); + } else { + print_named_resource_list(&functions, "function", org, project, true)?; + } + + Ok(()) +} diff --git a/src/functions/mod.rs b/src/functions/mod.rs new file mode 100644 index 0000000..7baa6cb --- /dev/null +++ b/src/functions/mod.rs @@ -0,0 +1,81 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::{args::BaseArgs, project_command::resolve_project_command_context}; + +mod api; +mod delete; +mod list; +mod view; + +#[derive(Debug, Clone, Args)] +pub struct FunctionsArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +enum FunctionsCommands { + /// List all functions + List, + /// View a function's details + View(ViewArgs), + /// Delete a function + Delete(DeleteArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + /// Function slug (positional) + #[arg(value_name = "SLUG")] + slug_positional: Option, + + /// Function slug (flag) + #[arg(long = "slug", short = 's')] + slug_flag: Option, +} + +impl ViewArgs { + fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + +#[derive(Debug, Clone, Args)] +pub struct DeleteArgs { + /// Function slug (positional) of the function to delete + #[arg(value_name = "SLUG")] + slug_positional: Option, + + /// Function slug (flag) of the function to delete + #[arg(long = "slug", short = 's')] + slug_flag: Option, + + /// Skip confirmation prompt (requires slug) + #[arg(long, short = 'f')] + force: bool, +} + +impl DeleteArgs { + fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + +pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { + let ctx = resolve_project_command_context(&base).await?; + let client = &ctx.client; + let project = &ctx.project; + + match args.command { + None | Some(FunctionsCommands::List) => { + list::run(client, project, &ctx.login.login.org_name, base.json).await + } + Some(FunctionsCommands::View(f)) => view::run(client, project, f.slug(), base.json).await, + Some(FunctionsCommands::Delete(f)) => delete::run(client, project, f.slug(), f.force).await, + } +} diff --git a/src/functions/view.rs b/src/functions/view.rs new file mode 100644 index 0000000..969e968 --- /dev/null +++ b/src/functions/view.rs @@ -0,0 +1,66 @@ +use std::fmt::Write as _; +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::console; + +use crate::http::ApiClient; +use crate::ui::{print_with_pager, with_spinner}; + +use super::api; +use super::delete::select_function_interactive; + +pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, json: bool) -> Result<()> { + let function = match slug { + Some(s) => with_spinner( + "Loading function...", + api::get_function_by_slug(client, project, s), + ) + .await? + .ok_or_else(|| anyhow!("function with slug '{s}' not found"))?, + None => { + if !std::io::stdin().is_terminal() { + bail!("function slug required. Use: bt functions view "); + } + select_function_interactive(client, project).await? + } + }; + + if json { + println!("{}", serde_json::to_string(&function)?); + return Ok(()); + } + + let mut output = String::new(); + writeln!(output, "Viewing {}", console::style(&function.name).bold())?; + writeln!( + output, + "{} {}", + console::style("Slug:").dim(), + function.slug + )?; + writeln!( + output, + "{} {}", + console::style("Type:").dim(), + function.display_type() + )?; + + if let Some(desc) = function + .description + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; + } + + if let Some(function_data) = &function.function_data { + writeln!(output)?; + writeln!(output, "{}", console::style("Function Data").bold())?; + writeln!(output, "{}", serde_json::to_string_pretty(function_data)?)?; + } + + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 622bbe2..8e7d723 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,10 +6,13 @@ mod args; mod env; #[cfg(unix)] mod eval; +mod functions; mod http; mod login; +mod project_command; mod projects; mod prompts; +mod resource_cmd; mod self_update; mod setup; mod sql; @@ -41,6 +44,8 @@ enum Commands { Docs(CLIArgs), /// Run SQL queries against Braintrust Sql(CLIArgs), + /// Manage project functions + Functions(CLIArgs), /// Manage login profiles and persistent auth Login(CLIArgs), /// View logs, traces, and spans @@ -69,6 +74,7 @@ async fn main() -> Result<()> { Commands::Setup(cmd) => setup::run_setup_top(cmd.base, cmd.args).await?, Commands::Docs(cmd) => setup::run_docs_top(cmd.base, cmd.args).await?, Commands::Sql(cmd) => sql::run(cmd.base, cmd.args).await?, + Commands::Functions(cmd) => functions::run(cmd.base, cmd.args).await?, Commands::Login(cmd) => login::run(cmd.base, cmd.args).await?, Commands::View(cmd) => traces::run(cmd.base, cmd.args).await?, #[cfg(unix)] diff --git a/src/project_command.rs b/src/project_command.rs new file mode 100644 index 0000000..c3c0335 --- /dev/null +++ b/src/project_command.rs @@ -0,0 +1,36 @@ +use std::io::IsTerminal; + +use anyhow::{anyhow, Result}; + +use crate::{ + args::BaseArgs, + http::ApiClient, + login::{login, LoginContext}, + projects::{api::get_project_by_name, switch::select_project_interactive}, +}; + +pub struct ProjectCommandContext { + pub login: LoginContext, + pub client: ApiClient, + pub project: String, +} + +pub async fn resolve_project_command_context(base: &BaseArgs) -> Result { + let login = login(base).await?; + let client = ApiClient::new(&login)?; + let project = match &base.project { + Some(p) => p.clone(), + 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"))?; + + Ok(ProjectCommandContext { + login, + client, + project, + }) +} diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 5a40a8e..8fb88c5 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use urlencoding::encode; use crate::http::ApiClient; +use crate::resource_cmd::NamedResource; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Prompt { @@ -16,6 +17,20 @@ pub struct Prompt { pub prompt_data: Option, } +impl NamedResource for Prompt { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn slug(&self) -> &str { + &self.slug + } +} + #[derive(Debug, Deserialize)] struct ListResponse { objects: Vec, diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index d05ba75..bb4b2de 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -6,7 +6,8 @@ use dialoguer::Confirm; use crate::{ http::ApiClient, prompts::api::{self, Prompt}, - ui::{self, print_command_status, with_spinner, CommandStatus}, + resource_cmd::select_named_resource_interactive, + ui::{print_command_status, with_spinner, CommandStatus}, }; pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: bool) -> Result<()> { @@ -60,15 +61,6 @@ pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: b } pub async fn select_prompt_interactive(client: &ApiClient, project: &str) -> Result { - let mut prompts = - with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; - if prompts.is_empty() { - bail!("no prompts found"); - } - - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect(); - - let selection = ui::fuzzy_select("Select prompt", &names)?; - Ok(prompts[selection].clone()) + let prompts = with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; + select_named_resource_interactive(prompts, "no prompts found", "Select prompt") } diff --git a/src/prompts/list.rs b/src/prompts/list.rs index bf58922..97c1c2c 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -1,13 +1,6 @@ -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 crate::{http::ApiClient, resource_cmd::print_named_resource_list, ui::with_spinner}; use super::api; @@ -17,38 +10,7 @@ pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Re 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)?; + print_named_resource_list(&prompts, "prompt", org, project, false)?; } Ok(()) diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 47685fb..94b9ed4 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1,14 +1,7 @@ -use std::io::IsTerminal; - -use anyhow::{anyhow, Result}; +use anyhow::Result; use clap::{Args, Subcommand}; -use crate::{ - args::BaseArgs, - http::ApiClient, - login::login, - projects::{api::get_project_by_name, switch::select_project_interactive}, -}; +use crate::{args::BaseArgs, project_command::resolve_project_command_context}; mod api; mod delete; @@ -82,28 +75,20 @@ 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.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"))?; + let ctx = resolve_project_command_context(&base).await?; + let client = &ctx.client; + let project = &ctx.project; match args.command { None | Some(PromptsCommands::List) => { - list::run(&client, &project, &ctx.login.org_name, base.json).await + list::run(client, project, &ctx.login.login.org_name, base.json).await } Some(PromptsCommands::View(p)) => { view::run( - &client, - &ctx.app_url, - &project, - &ctx.login.org_name, + client, + &ctx.login.app_url, + project, + &ctx.login.login.org_name, p.slug(), base.json, p.web, @@ -111,6 +96,6 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { ) .await } - Some(PromptsCommands::Delete(p)) => delete::run(&client, &project, p.slug(), p.force).await, + Some(PromptsCommands::Delete(p)) => delete::run(client, project, p.slug(), p.force).await, } } diff --git a/src/resource_cmd.rs b/src/resource_cmd.rs new file mode 100644 index 0000000..14701c8 --- /dev/null +++ b/src/resource_cmd.rs @@ -0,0 +1,96 @@ +use std::fmt::Write as _; + +use anyhow::{bail, Result}; +use dialoguer::console; + +use crate::{ + ui::{apply_column_padding, header, print_with_pager, styled_table, truncate}, + utils::pluralize, +}; + +pub trait NamedResource: Clone { + fn name(&self) -> &str; + fn description(&self) -> Option<&str>; + fn slug(&self) -> &str; + fn resource_type(&self) -> Option { + None + } +} + +pub fn print_named_resource_list( + resources: &[T], + singular: &str, + org: &str, + project: &str, + include_type_column: bool, +) -> Result<()> { + let mut output = String::new(); + + let count = format!( + "{} {}", + resources.len(), + pluralize(resources.len(), singular, 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(); + if include_type_column { + table.set_header(vec![ + header("Name"), + header("Description"), + header("Type"), + header("Slug"), + ]); + } else { + table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + } + apply_column_padding(&mut table, (0, 6)); + + for resource in resources { + let desc = resource + .description() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + + if include_type_column { + let resource_type = resource.resource_type().unwrap_or_else(|| "-".to_string()); + table.add_row(vec![ + resource.name(), + &desc, + &resource_type, + resource.slug(), + ]); + } else { + table.add_row(vec![resource.name(), &desc, resource.slug()]); + } + } + + write!(output, "{table}")?; + print_with_pager(&output)?; + + Ok(()) +} + +pub fn select_named_resource_interactive( + mut resources: Vec, + empty_message: &str, + prompt: &str, +) -> Result { + if resources.is_empty() { + bail!("{empty_message}"); + } + + resources.sort_by(|a, b| a.name().cmp(b.name())); + let names: Vec<&str> = resources.iter().map(|item| item.name()).collect(); + + let selection = crate::ui::fuzzy_select(prompt, &names)?; + Ok(resources[selection].clone()) +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 62bb679..fc0dafc 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -20,8 +20,6 @@ const SHARED_SKILL_BODY: &str = include_str!("../../skills/shared/braintrust-cli const SHARED_WORKFLOW_GUIDE: &str = include_str!("../../skills/shared/workflows.md"); const SHARED_SKILL_TEMPLATE: &str = include_str!("../../skills/shared/skill_template.md"); const SKILL_FRONTMATTER: &str = include_str!("../../skills/shared/skill_frontmatter.md"); -const CURSOR_RULE_FRONTMATTER: &str = - include_str!("../../skills/shared/cursor_rule_frontmatter.md"); const BT_README: &str = include_str!("../../README.md"); const README_AGENT_SECTION_MARKERS: &[&str] = &[ "bt eval", "bt sql", "bt view", "bt login", "bt setup", "bt docs", @@ -265,6 +263,12 @@ struct McpSelection { selected_agents: Vec, } +#[derive(Debug, Clone)] +struct SkillsAliasResult { + changed: bool, + path: PathBuf, +} + pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { match args.command { Some(SetupSubcommand::Skills(setup)) => run_setup(base, setup).await, @@ -683,16 +687,12 @@ fn run_doctor(base: BaseArgs, args: AgentsDoctorArgs) -> Result<()> { let docs_output_dir = setup_docs_output_dir(scope, local_root.as_deref(), &home)?; let detected = detect_agents(local_root.as_deref(), &home); - let mut warnings = Vec::new(); + let warnings = Vec::new(); let agents = [Agent::Claude, Agent::Codex, Agent::Cursor, Agent::Opencode] .iter() .map(|agent| doctor_agent_status(*agent, scope, local_root.as_deref(), &home, &detected)) .collect::>(); - if matches!(scope, InstallScope::Global) { - warnings.push("cursor is local-only in this setup flow".to_string()); - } - if base.json { let report = DoctorJsonReport { scope: scope.as_str().to_string(), @@ -786,7 +786,6 @@ fn doctor_agent_status( .collect::>(); let detected_any = !detected_signals.is_empty(); - let mut notes = Vec::new(); let config_path = match (agent, scope) { (Agent::Claude, InstallScope::Local) => local_root .map(|root| root.join(".claude/skills/braintrust/SKILL.md")) @@ -805,12 +804,13 @@ fn doctor_agent_status( .to_string(), ), (Agent::Cursor, InstallScope::Local) => local_root - .map(|root| root.join(".cursor/rules/braintrust.mdc")) + .map(|root| root.join(".cursor/skills/braintrust/SKILL.md")) .map(|p| p.display().to_string()), - (Agent::Cursor, InstallScope::Global) => { - notes.push("cursor currently supports local-only setup in this flow".to_string()); - None - } + (Agent::Cursor, InstallScope::Global) => Some( + home.join(".cursor/skills/braintrust/SKILL.md") + .display() + .to_string(), + ), }; let configured = config_path @@ -824,7 +824,7 @@ fn doctor_agent_status( detected_signals, configured, config_path, - notes, + notes: Vec::new(), } } @@ -1050,6 +1050,9 @@ fn detect_agents(local_root: Option<&Path>, home: &Path) -> Vec if home.join(".claude").exists() { add_signal(&mut by_agent, Agent::Claude, "~/.claude exists"); } + if home.join(".cursor").exists() { + add_signal(&mut by_agent, Agent::Cursor, "~/.cursor exists"); + } if home.join(".codex").exists() { add_signal(&mut by_agent, Agent::Codex, "~/.codex exists"); } @@ -1086,23 +1089,19 @@ fn add_signal(map: &mut BTreeMap>, agent: Agent, reason: map.entry(agent).or_default().insert(reason.to_string()); } -fn install_skill_for_agent( - agent: Agent, +fn install_claude( scope: InstallScope, local_root: Option<&Path>, home: &Path, ) -> Result { let root = scope_root(scope, local_root, home)?; - let skill_path = match agent { - Agent::Claude => root.join(".claude/skills/braintrust/SKILL.md"), - Agent::Codex | Agent::Opencode => root.join(".agents/skills/braintrust/SKILL.md"), - Agent::Cursor => bail!("cursor uses rule-based config, not shared skills"), - }; let skill_content = render_braintrust_skill(); - let changed = write_text_file_if_changed(&skill_path, &skill_content)?; + let (skill_changed, skill_path) = install_canonical_skill(root, &skill_content)?; + let alias = ensure_agent_skills_alias(root, ".claude", &skill_content)?; + let changed = skill_changed || alias.changed; Ok(AgentInstallResult { - agent, + agent: Agent::Claude, status: if changed { InstallStatus::Installed } else { @@ -1113,24 +1112,36 @@ fn install_skill_for_agent( } else { "already configured".to_string() }, - paths: vec![skill_path.display().to_string()], + paths: vec![ + skill_path.display().to_string(), + alias.path.display().to_string(), + ], }) } -fn install_claude( - scope: InstallScope, - local_root: Option<&Path>, - home: &Path, -) -> Result { - install_skill_for_agent(Agent::Claude, scope, local_root, home) -} - fn install_codex( scope: InstallScope, local_root: Option<&Path>, home: &Path, ) -> Result { - install_skill_for_agent(Agent::Codex, scope, local_root, home) + let root = scope_root(scope, local_root, home)?; + let skill_content = render_braintrust_skill(); + let (changed, skill_path) = install_canonical_skill(root, &skill_content)?; + + Ok(AgentInstallResult { + agent: Agent::Codex, + status: if changed { + InstallStatus::Installed + } else { + InstallStatus::Skipped + }, + message: if changed { + "installed skill".to_string() + } else { + "already configured".to_string() + }, + paths: vec![skill_path.display().to_string()], + }) } fn install_opencode( @@ -1138,28 +1149,36 @@ fn install_opencode( local_root: Option<&Path>, home: &Path, ) -> Result { - install_skill_for_agent(Agent::Opencode, scope, local_root, home) + let root = scope_root(scope, local_root, home)?; + let skill_content = render_braintrust_skill(); + let (changed, skill_path) = install_canonical_skill(root, &skill_content)?; + + Ok(AgentInstallResult { + agent: Agent::Opencode, + status: if changed { + InstallStatus::Installed + } else { + InstallStatus::Skipped + }, + message: if changed { + "installed skill".to_string() + } else { + "already configured".to_string() + }, + paths: vec![skill_path.display().to_string()], + }) } fn install_cursor( scope: InstallScope, local_root: Option<&Path>, - _home: &Path, + home: &Path, ) -> Result { - if matches!(scope, InstallScope::Global) { - return Ok(AgentInstallResult { - agent: Agent::Cursor, - status: InstallStatus::Skipped, - message: "warning: cursor currently supports only --local in bt setup skills" - .to_string(), - paths: Vec::new(), - }); - } - - let root = scope_root(scope, local_root, _home)?; - let rule_path = root.join(".cursor/rules/braintrust.mdc"); - let cursor_rule = render_cursor_rule(); - let changed = write_text_file_if_changed(&rule_path, &cursor_rule)?; + let root = scope_root(scope, local_root, home)?; + let skill_content = render_braintrust_skill(); + let (skill_changed, skill_path) = install_canonical_skill(root, &skill_content)?; + let alias = ensure_agent_skills_alias(root, ".cursor", &skill_content)?; + let changed = skill_changed || alias.changed; Ok(AgentInstallResult { agent: Agent::Cursor, @@ -1169,14 +1188,120 @@ fn install_cursor( InstallStatus::Skipped }, message: if changed { - "installed rule".to_string() + "installed skill".to_string() } else { "already configured".to_string() }, - paths: vec![rule_path.display().to_string()], + paths: vec![ + skill_path.display().to_string(), + alias.path.display().to_string(), + ], }) } +fn install_canonical_skill(root: &Path, skill_content: &str) -> Result<(bool, PathBuf)> { + let skill_path = root.join(".agents/skills/braintrust/SKILL.md"); + let changed = write_text_file_if_changed(&skill_path, skill_content)?; + Ok((changed, skill_path)) +} + +fn ensure_agent_skills_alias( + root: &Path, + agent_dir: &str, + skill_content: &str, +) -> Result { + let canonical_skills_dir = root.join(".agents/skills"); + fs::create_dir_all(&canonical_skills_dir).with_context(|| { + format!( + "failed to create canonical skills directory {}", + canonical_skills_dir.display() + ) + })?; + + let alias_path = root.join(agent_dir).join("skills"); + if let Some(parent) = alias_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + + if let Ok(metadata) = fs::symlink_metadata(&alias_path) { + if metadata.file_type().is_symlink() { + if symlink_points_to(&alias_path, &canonical_skills_dir) { + return Ok(SkillsAliasResult { + changed: false, + path: alias_path, + }); + } + fs::remove_file(&alias_path) + .with_context(|| format!("failed to replace symlink {}", alias_path.display()))?; + } else { + let mirror_skill_path = alias_path.join("braintrust/SKILL.md"); + let changed = write_text_file_if_changed(&mirror_skill_path, skill_content)?; + return Ok(SkillsAliasResult { + changed, + path: mirror_skill_path, + }); + } + } + + match create_dir_symlink(&canonical_skills_dir, &alias_path) { + Ok(()) => Ok(SkillsAliasResult { + changed: true, + path: alias_path, + }), + Err(_) => { + let mirror_skill_path = alias_path.join("braintrust/SKILL.md"); + let changed = write_text_file_if_changed(&mirror_skill_path, skill_content)?; + Ok(SkillsAliasResult { + changed, + path: mirror_skill_path, + }) + } + } +} + +fn symlink_points_to(link_path: &Path, target: &Path) -> bool { + let Ok(link_target) = fs::read_link(link_path) else { + return false; + }; + let resolved_link_target = if link_target.is_absolute() { + link_target + } else { + link_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(link_target) + }; + match (resolved_link_target.canonicalize(), target.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +#[cfg(unix)] +fn create_dir_symlink(target: &Path, link: &Path) -> Result<()> { + std::os::unix::fs::symlink(target, link).with_context(|| { + format!( + "failed to create symlink {} -> {}", + link.display(), + target.display() + ) + })?; + Ok(()) +} + +#[cfg(windows)] +fn create_dir_symlink(target: &Path, link: &Path) -> Result<()> { + std::os::windows::fs::symlink_dir(target, link).with_context(|| { + format!( + "failed to create symlink {} -> {}", + link.display(), + target.display() + ) + })?; + Ok(()) +} + fn install_mcp_for_agent( agent: Agent, scope: InstallScope, @@ -1299,10 +1424,6 @@ fn render_braintrust_skill() -> String { render_skill_document(SKILL_FRONTMATTER) } -fn render_cursor_rule() -> String { - render_skill_document(CURSOR_RULE_FRONTMATTER) -} - fn render_skill_document(frontmatter: &str) -> String { let readme_excerpt = render_agent_readme_excerpt(); let body = SHARED_SKILL_TEMPLATE @@ -1726,12 +1847,12 @@ mod tests { } #[test] - fn doctor_agent_status_marks_cursor_global_as_local_only() { + fn doctor_agent_status_reports_cursor_global_skill_path() { let home = std::env::temp_dir(); let status = doctor_agent_status(Agent::Cursor, InstallScope::Global, None, &home, &[]); assert!(!status.configured); - assert!(status.config_path.is_none()); - assert!(status.notes.iter().any(|note| note.contains("local-only"))); + assert!(status.config_path.is_some()); + assert!(status.notes.is_empty()); } #[test] @@ -1829,6 +1950,24 @@ mod tests { assert!(second.message.contains("already configured")); } + #[test] + fn install_cursor_uses_canonical_agents_skill_path() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("bt-agents-cursor-skill-{unique}")); + fs::create_dir_all(&root).expect("create temp root"); + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + + let result = + install_cursor(InstallScope::Local, Some(&root), &home).expect("install cursor"); + assert!(matches!(result.status, InstallStatus::Installed)); + assert!(root.join(".agents/skills/braintrust/SKILL.md").exists()); + assert!(root.join(".cursor/skills").exists()); + } + #[test] fn docs_cache_has_required_files_checks_workflows_and_sql_reference() { let unique = SystemTime::now()