Skip to content
45 changes: 43 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 5 additions & 5 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<String>,

/// 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<String>,

/// 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<String>,

/// Path to a .env file to load before running commands.
Expand Down
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -34,6 +36,8 @@ enum Commands {
#[command(name = "self")]
/// Self-management commands
SelfCommand(self_update::SelfArgs),
/// Manage prompts
Prompts(CLIArgs<prompts::PromptsArgs>),
}

#[tokio::main]
Expand All @@ -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(())
Expand Down
2 changes: 1 addition & 1 deletion src/projects/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Project>> {
let path = format!(
"/v1/project?org_name={}&name={}",
"/v1/project?org_name={}&project_name={}",
encode(client.org_name()),
encode(name)
);
Expand Down
55 changes: 22 additions & 33 deletions src/projects/list.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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(())
Expand Down
2 changes: 1 addition & 1 deletion src/projects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions src/prompts/api.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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

pub async fn list_prompts(client: &ApiClient, project: &str) -> Result<Vec<Prompt>> {
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<Prompt> {
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
}
67 changes: 67 additions & 0 deletions src/prompts/delete.rs
Original file line number Diff line number Diff line change
@@ -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 <name>");
}
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<Prompt> {
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())
}
Loading
Loading