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>,
}
Comment on lines +8 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not for this PR but it would be nice if we could derive these from our openapi spec


#[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