diff --git a/Cargo.lock b/Cargo.lock index 8cd98a6..3bb74ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,7 +135,8 @@ dependencies = [ "anyhow", "braintrust-sdk-rust", "clap", - "crossterm", + "comfy-table", + "crossterm 0.28.1", "dialoguer", "dotenvy", "indicatif", @@ -259,6 +260,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -308,6 +320,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.3", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -376,6 +402,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -936,6 +971,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1216,7 +1257,7 @@ dependencies = [ "bitflags", "cassowary", "compact_str", - "crossterm", + "crossterm 0.28.1", "indoc", "instability", "itertools", diff --git a/Cargo.toml b/Cargo.toml index 9cd362c..b4df087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ dialoguer = { version = "0.11", features = ["fuzzy-select"] } dotenvy = "0.15" open = "5" urlencoding = "2" +comfy-table = "7.2.2" [profile.dist] inherits = "release" diff --git a/src/args.rs b/src/args.rs index d5e928e..27a67a4 100644 --- a/src/args.rs +++ b/src/args.rs @@ -4,23 +4,23 @@ use std::path::PathBuf; #[derive(Debug, Clone, Args)] pub struct BaseArgs { /// Output as JSON - #[arg(short = 'j', long)] + #[arg(short = 'j', long, global = true)] pub json: bool, /// Override active project - #[arg(short = 'p', long, env = "BRAINTRUST_DEFAULT_PROJECT")] + #[arg(short = 'p', long, env = "BRAINTRUST_DEFAULT_PROJECT", global = true)] pub project: Option, /// Override stored API key (or via BRAINTRUST_API_KEY) - #[arg(long, env = "BRAINTRUST_API_KEY")] + #[arg(long, env = "BRAINTRUST_API_KEY", global = true)] pub api_key: Option, /// Override API URL (or via BRAINTRUST_API_URL) - #[arg(long, env = "BRAINTRUST_API_URL")] + #[arg(long, env = "BRAINTRUST_API_URL", global = true)] pub api_url: Option, /// Override app URL (or via BRAINTRUST_APP_URL) - #[arg(long, env = "BRAINTRUST_APP_URL")] + #[arg(long, env = "BRAINTRUST_APP_URL", global = true)] pub app_url: Option, /// Path to a .env file to load before running commands. diff --git a/src/main.rs b/src/main.rs index baabc0d..1d82b52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,11 @@ mod eval; mod http; mod login; mod projects; +mod prompts; mod self_update; mod sql; mod ui; +mod utils; use crate::args::CLIArgs; @@ -34,6 +36,8 @@ enum Commands { #[command(name = "self")] /// Self-management commands SelfCommand(self_update::SelfArgs), + /// Manage prompts + Prompts(CLIArgs), } #[tokio::main] @@ -48,6 +52,7 @@ async fn main() -> Result<()> { Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, 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?, } Ok(()) diff --git a/src/projects/api.rs b/src/projects/api.rs index fd24213..fc92703 100644 --- a/src/projects/api.rs +++ b/src/projects/api.rs @@ -36,7 +36,7 @@ pub async fn delete_project(client: &ApiClient, project_id: &str) -> Result<()> pub async fn get_project_by_name(client: &ApiClient, name: &str) -> Result> { let path = format!( - "/v1/project?org_name={}&name={}", + "/v1/project?org_name={}&project_name={}", encode(client.org_name()), encode(name) ); diff --git a/src/projects/list.rs b/src/projects/list.rs index 7854366..7d230c4 100644 --- a/src/projects/list.rs +++ b/src/projects/list.rs @@ -1,9 +1,12 @@ +use std::fmt::Write as _; + use anyhow::Result; use dialoguer::console; -use unicode_width::UnicodeWidthStr; use crate::http::ApiClient; -use crate::ui::with_spinner; +use crate::ui::{ + apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner, +}; use super::api; @@ -13,45 +16,31 @@ pub async fn run(client: &ApiClient, org_name: &str, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string(&projects)?); } else { - println!( + let mut output = String::new(); + + writeln!( + output, "{} projects found in {}\n", - console::style(&projects.len()), + console::style(projects.len()), console::style(org_name).bold() - ); - - // Calculate column widths - let name_width = projects - .iter() - .map(|p| p.name.width()) - .max() - .unwrap_or(20) - .max(20); - - // Print header - println!( - "{} {}", - console::style(format!("{:width$}", "Project name", width = name_width)) - .dim() - .bold(), - console::style("Description").dim().bold() - ); - - // Print rows + )?; + + let mut table = styled_table(); + table.set_header(vec![header("Name"), header("Description")]); + apply_column_padding(&mut table, (0, 6)); + for project in &projects { let desc = project .description .as_deref() .filter(|s| !s.is_empty()) - .unwrap_or("-"); - let padding = name_width - project.name.width(); - println!( - "{}{:padding$} {}", - project.name, - "", - desc, - padding = padding - ); + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&project.name, &desc]); } + + write!(output, "{table}")?; + print_with_pager(&output)?; } Ok(()) diff --git a/src/projects/mod.rs b/src/projects/mod.rs index b24cc43..4b89675 100644 --- a/src/projects/mod.rs +++ b/src/projects/mod.rs @@ -5,7 +5,7 @@ use crate::args::BaseArgs; use crate::http::ApiClient; use crate::login::login; -mod api; +pub mod api; mod create; mod delete; mod list; diff --git a/src/prompts/api.rs b/src/prompts/api.rs new file mode 100644 index 0000000..39253bd --- /dev/null +++ b/src/prompts/api.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::http::ApiClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prompt { + pub id: String, + pub name: String, + pub slug: String, + pub project_id: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +pub async fn list_prompts(client: &ApiClient, project: &str) -> Result> { + let path = format!( + "/v1/prompt?org_name={}&project_name={}", + encode(client.org_name()), + encode(project) + ); + let list: ListResponse = client.get(&path).await?; + + Ok(list.objects) +} + +pub async fn get_prompt_by_name(client: &ApiClient, project: &str, name: &str) -> Result { + let path = format!( + "/v1/prompt?org_name={}&project_name={}&prompt_name={}", + encode(client.org_name()), + encode(project), + encode(name) + ); + let list: ListResponse = client.get(&path).await?; + list.objects + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("prompt '{name}' not found")) +} + +pub async fn delete_prompt(client: &ApiClient, prompt_id: &str) -> Result<()> { + let path = format!("/v1/prompt/{}", encode(prompt_id)); + client.delete(&path).await +} diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs new file mode 100644 index 0000000..b209c3e --- /dev/null +++ b/src/prompts/delete.rs @@ -0,0 +1,67 @@ +use std::io::IsTerminal; + +use anyhow::{bail, Result}; +use dialoguer::Confirm; + +use crate::{ + http::ApiClient, + prompts::api::{self, Prompt}, + ui::{self, print_command_status, with_spinner, CommandStatus}, +}; + +pub async fn run(client: &ApiClient, project: &str, name: Option<&str>) -> Result<()> { + let prompt = match name { + Some(n) => api::get_prompt_by_name(client, project, n).await?, + None => { + if !std::io::stdin().is_terminal() { + bail!("prompt name required. Use: bt prompts delete "); + } + select_prompt_interactive(client, project).await? + } + }; + + if std::io::stdin().is_terminal() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete prompt '{}' from {}?", + &prompt.name, project + )) + .default(false) + .interact()?; + + if !confirm { + return Ok(()); + } + } + + match with_spinner("Deleting prompt...", api::delete_prompt(client, &prompt.id)).await { + Ok(_) => { + print_command_status( + CommandStatus::Success, + &format!("Deleted '{}'", prompt.name), + ); + Ok(()) + } + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Failed to delete '{}'", prompt.name), + ); + Err(e) + } + } +} + +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()) +} diff --git a/src/prompts/list.rs b/src/prompts/list.rs new file mode 100644 index 0000000..bf58922 --- /dev/null +++ b/src/prompts/list.rs @@ -0,0 +1,55 @@ +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 prompts = with_spinner("Loading prompts...", api::list_prompts(client, project)).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)?; + } + + Ok(()) +} diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs new file mode 100644 index 0000000..e61943d --- /dev/null +++ b/src/prompts/mod.rs @@ -0,0 +1,77 @@ +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; + +use crate::{args::BaseArgs, http::ApiClient, login::login, projects::api::get_project_by_name}; + +mod api; +mod delete; +mod list; +mod view; + +#[derive(Debug, Clone, Args)] +pub struct PromptsArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +enum PromptsCommands { + List, + View(ViewArgs), + Delete(DeleteArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + /// Prompt name (positional) + #[arg(value_name = "NAME")] + name_positional: Option, + + /// Prompt name (flag) + #[arg(long = "name", short = 'n')] + name_flag: Option, +} + +impl ViewArgs { + fn name(&self) -> Option<&str> { + self.name_positional + .as_deref() + .or(self.name_flag.as_deref()) + } +} + +#[derive(Debug, Clone, Args)] +pub struct DeleteArgs { + /// Name of the prompt to delete + name: Option, +} + +pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { + let ctx = login(&base).await?; + let client = ApiClient::new(&ctx)?; + let project = &base + .project + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--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(PromptsCommands::List) => { + list::run(&client, project, &ctx.login.org_name, base.json).await + } + Some(PromptsCommands::View(p)) => { + view::run( + &client, + &ctx.app_url, + project, + &ctx.login.org_name, + p.name(), + ) + .await + } + Some(PromptsCommands::Delete(p)) => delete::run(&client, project, p.name.as_deref()).await, + } +} diff --git a/src/prompts/view.rs b/src/prompts/view.rs new file mode 100644 index 0000000..a057ec4 --- /dev/null +++ b/src/prompts/view.rs @@ -0,0 +1,41 @@ +use std::io::IsTerminal; + +use anyhow::{bail, Result}; +use urlencoding::encode; + +use crate::http::ApiClient; +use crate::prompts::delete::select_prompt_interactive; +use crate::ui::{print_command_status, CommandStatus}; + +use super::api; + +pub async fn run( + client: &ApiClient, + app_url: &str, + project: &str, + org_name: &str, + name: Option<&str>, +) -> Result<()> { + let prompt = match name { + Some(n) => api::get_prompt_by_name(client, project, n).await?, + None => { + if !std::io::stdin().is_terminal() { + bail!("prompt name required. Use: bt prompts view "); + } + select_prompt_interactive(client, project).await? + } + }; + + let url = format!( + "{}/app/{}/p/{}/prompts/{}", + app_url.trim_end_matches('/'), + encode(org_name), + encode(project), + encode(&prompt.id) + ); + + open::that(&url)?; + print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); + + Ok(()) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8f0cbe3..097b702 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,10 +1,13 @@ +mod pager; mod select; mod shell; mod spinner; mod status; +mod table; +pub use pager::print_with_pager; pub use select::fuzzy_select; pub use shell::print_env_export; pub use spinner::{with_spinner, with_spinner_visible}; - pub use status::{print_command_status, CommandStatus}; +pub use table::{apply_column_padding, header, styled_table, truncate}; diff --git a/src/ui/pager.rs b/src/ui/pager.rs new file mode 100644 index 0000000..d19dd0a --- /dev/null +++ b/src/ui/pager.rs @@ -0,0 +1,41 @@ +use std::io::{self, IsTerminal, Write}; +use std::process::{Command, Stdio}; + +pub fn print_with_pager(output: &str) -> io::Result<()> { + let stdout = io::stdout(); + if !stdout.is_terminal() { + println!("{output}"); + return Ok(()); + } + + let (_, term_height) = crossterm::terminal::size().unwrap_or((80, 24)); + let line_count = output.lines().count(); + if line_count <= term_height as usize { + println!("{output}"); + return Ok(()); + } + + let pager = std::env::var("PAGER") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "less -R".into()); + + let mut parts = pager.split_whitespace(); + let cmd = parts.next().unwrap_or("less"); + let args: Vec<&str> = parts.collect(); + + let mut child = match Command::new(cmd).args(args).stdin(Stdio::piped()).spawn() { + Ok(c) => c, + Err(_) => { + println!("{output}"); + return Ok(()); + } + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "{output}"); + } + + let _ = child.wait(); + Ok(()) +} diff --git a/src/ui/table.rs b/src/ui/table.rs new file mode 100644 index 0000000..1fbaac9 --- /dev/null +++ b/src/ui/table.rs @@ -0,0 +1,35 @@ +use comfy_table::{presets::NOTHING, Attribute, Cell, ContentArrangement, Table}; + +pub fn styled_table() -> Table { + let mut table = Table::new(); + table.load_preset(NOTHING); + if let Ok((width, _)) = crossterm::terminal::size() { + table.set_width(width); + } + table.set_content_arrangement(ContentArrangement::Dynamic); + table +} + +pub fn truncate(text: &str, max_len: usize) -> String { + if text.chars().count() <= max_len { + return text.to_string(); + } + + let keep = max_len.saturating_sub(1); + let truncated: String = text.chars().take(keep).collect(); + format!("{truncated}…") +} + +pub fn apply_column_padding(table: &mut Table, padding: (u16, u16)) { + for i in 0..table.column_count() { + if let Some(col) = table.column_mut(i) { + col.set_padding(padding); + } + } +} + +pub fn header(text: &str) -> Cell { + Cell::new(text) + .add_attribute(Attribute::Bold) + .add_attribute(Attribute::Dim) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..ef1b708 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +mod plurals; + +pub use plurals::pluralize; diff --git a/src/utils/plurals.rs b/src/utils/plurals.rs new file mode 100644 index 0000000..0fc7064 --- /dev/null +++ b/src/utils/plurals.rs @@ -0,0 +1,10 @@ +pub fn pluralize(count: usize, singular: &str, plural: Option<&str>) -> String { + if count == 1 { + return singular.to_string(); + } + + match plural { + Some(p) => p.to_string(), + None => format!("{singular}s"), + } +}