diff --git a/README.md b/README.md index aa927ea6..a9cea8b8 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC | `bt view` | View logs, traces, and spans | | `bt projects` | Manage projects (list, create, view, delete) | | `bt datasets` | Manage remote datasets (list, create, update, view, delete) | -| `bt prompts` | Manage prompts (list, view, delete) | +| `bt prompts` | Manage prompts (list, view, versions, assign, delete) | | `bt scorers` | Manage scorers (list, create, view, invoke, delete) | | `bt sync` | Synchronize project logs between Braintrust and local NDJSON files | | `bt update` | Update bt in-place | diff --git a/src/functions/api.rs b/src/functions/api.rs index ea5d6bd9..76f1669f 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -40,6 +40,7 @@ pub struct FunctionListQuery { pub slug: Option, pub id: Option, pub version: Option, + pub environment: Option, pub cursor: Option, pub snapshot: Option, } @@ -116,11 +117,13 @@ pub async fn get_function_by_slug( project_id: &str, slug: &str, version: Option<&str>, + environment: Option<&str>, ) -> Result> { let query = FunctionListQuery { project_id: Some(project_id.to_string()), slug: Some(slug.to_string()), version: version.map(ToOwned::to_owned), + environment: environment.map(ToOwned::to_owned), ..Default::default() }; let page = list_functions_page(client, &query).await?; @@ -137,10 +140,12 @@ pub async fn get_function_by_id( client: &ApiClient, id: &str, version: Option<&str>, + environment: Option<&str>, ) -> Result> { let query = FunctionListQuery { id: Some(id.to_string()), version: version.map(ToOwned::to_owned), + environment: environment.map(ToOwned::to_owned), ..Default::default() }; let page = list_functions_page(client, &query).await?; @@ -194,6 +199,9 @@ pub async fn list_functions_page( if let Some(version) = &query.version { params.push(("version", version.clone())); } + if let Some(environment) = &query.environment { + params.push(("environment", environment.clone())); + } if let Some(cursor) = &query.cursor { params.push(("cursor", cursor.clone())); } diff --git a/src/functions/delete.rs b/src/functions/delete.rs index 96df5fb9..29ec6066 100644 --- a/src/functions/delete.rs +++ b/src/functions/delete.rs @@ -22,7 +22,7 @@ pub async fn run( let project_id = &ctx.project.id; let function = match slug { - Some(s) => api::get_function_by_slug(&ctx.client, project_id, s, None) + Some(s) => api::get_function_by_slug(&ctx.client, project_id, s, None, None) .await? .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, None => { diff --git a/src/functions/mod.rs b/src/functions/mod.rs index a43e2618..a395c6ba 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -438,15 +438,32 @@ pub struct ViewArgs { /// Function id #[arg(long = "id", env = "BT_FUNCTIONS_VIEW_ID")] id: Option, - /// Version selector. - #[arg(long, env = "BT_FUNCTIONS_VIEW_VERSION")] + /// Function version identifier (for example, a transaction ID) + #[arg( + long, + env = "BT_FUNCTIONS_VIEW_VERSION", + conflicts_with = "environment" + )] version: Option, + /// Environment slug whose assigned function version should be shown + #[arg(long, env = "BT_FUNCTIONS_VIEW_ENVIRONMENT")] + environment: Option, /// Open in browser #[arg(long)] web: bool, } impl ViewArgs { + fn options(&self, base: &BaseArgs) -> view::ViewOptions<'_> { + view::ViewOptions { + version: self.version.as_deref(), + environment: self.environment.as_deref(), + json: base.json, + web: self.web, + verbose: base.verbose, + } + } + fn selector(&self) -> Result> { match ( self.id.as_deref(), @@ -626,29 +643,11 @@ pub(crate) async fn run_typed_command( 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, - v.version.as_deref(), - base.json, - v.web, - base.verbose, - ft, - ) - .await + view::run_by_id(&auth_ctx, id, v.options(&base), ft).await } ViewSelector::Slug(slug) => { let ctx = resolve_context(&base).await?; - view::run( - &ctx, - slug, - v.version.as_deref(), - base.json, - v.web, - base.verbose, - ft, - ) - .await + view::run(&ctx, slug, v.options(&base), ft).await } }, command => { @@ -681,29 +680,11 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { match v.inner.selector()? { ViewSelector::Id(id) => { let auth_ctx = resolve_auth_context(&base).await?; - view::run_by_id( - &auth_ctx, - id, - v.inner.version.as_deref(), - base.json, - v.inner.web, - base.verbose, - ft, - ) - .await + view::run_by_id(&auth_ctx, id, v.inner.options(&base), ft).await } ViewSelector::Slug(slug) => { let ctx = resolve_context(&base).await?; - view::run( - &ctx, - slug, - v.inner.version.as_deref(), - base.json, - v.inner.web, - base.verbose, - ft, - ) - .await + view::run(&ctx, slug, v.inner.options(&base), ft).await } } } @@ -1052,6 +1033,39 @@ mod tests { assert_eq!(pull.slug_flag, vec!["a", "b", "c"]); } + #[test] + fn view_accepts_environment_selector() { + let _guard = test_lock(); + let parsed = parse(&[ + "functions", + "view", + "test-function", + "--environment", + "production", + ]) + .expect("parse view"); + let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else { + panic!("expected view command"); + }; + assert_eq!(view.inner.environment.as_deref(), Some("production")); + } + + #[test] + fn view_rejects_version_with_environment() { + let _guard = test_lock(); + let err = parse(&[ + "functions", + "view", + "test-function", + "--version", + "1234", + "--environment", + "production", + ]) + .expect_err("selectors should conflict"); + assert!(err.to_string().contains("cannot be used with")); + } + #[test] fn view_accepts_id_selector() { let _guard = test_lock(); diff --git a/src/functions/view.rs b/src/functions/view.rs index 7d9c79c2..d6074548 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -9,26 +9,34 @@ use crate::ui::prompt_render::{ use crate::ui::{ is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, }; -use crate::utils::app_project_url_with_encoded_path; +use crate::utils::{app_project_url_with_encoded_path, app_url_with_selected_version}; use crate::{http::ApiClient, projects::api as projects_api}; use super::{api, build_web_path, label, label_plural, select_function_interactive}; use super::{AuthContext, FunctionTypeFilter, ResolvedContext}; +#[derive(Debug, Clone, Copy)] +pub(crate) struct ViewOptions<'a> { + pub version: Option<&'a str>, + pub environment: Option<&'a str>, + pub json: bool, + pub web: bool, + pub verbose: bool, +} + pub async fn run( ctx: &ResolvedContext, slug: Option<&str>, - version: Option<&str>, - json: bool, - web: bool, - verbose: bool, + options: ViewOptions<'_>, ft: Option, ) -> Result<()> { + let version = options.version; + let environment = options.environment; let project_id = &ctx.project.id; let function = match slug { Some(s) => with_spinner( &format!("Loading {}...", label(ft)), - api::get_function_by_slug(&ctx.client, project_id, s, version), + api::get_function_by_slug(&ctx.client, project_id, s, version, environment), ) .await? .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, @@ -41,20 +49,27 @@ pub async fn run( ); } let selected = select_function_interactive(&ctx.client, project_id, ft).await?; - if let Some(version) = version { + if version.is_some() || environment.is_some() { with_spinner( &format!("Loading {}...", label(ft)), api::get_function_by_slug( &ctx.client, project_id, &selected.slug, - Some(version), + version, + environment, ), ) .await? .ok_or_else(|| { + let selector = version + .map(|version| format!("version {version}")) + .or_else(|| { + environment.map(|environment| format!("environment {environment}")) + }) + .unwrap_or_default(); anyhow!( - "{} with slug '{}' not found at version {version}", + "{} with slug '{}' not found at {selector}", label(ft), selected.slug ) @@ -70,9 +85,7 @@ pub async fn run( &ctx.app_url, Some(&ctx.project.name), &function, - json, - web, - verbose, + options, ) .await } @@ -80,29 +93,19 @@ pub async fn run( pub async fn run_by_id( ctx: &AuthContext, id: &str, - version: Option<&str>, - json: bool, - web: bool, - verbose: bool, + options: ViewOptions<'_>, ft: Option, ) -> Result<()> { + let version = options.version; + let environment = options.environment; let function = with_spinner( &format!("Loading {}...", label(ft)), - api::get_function_by_id(&ctx.client, id, version), + api::get_function_by_id(&ctx.client, id, version, environment), ) .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 + render_function(&ctx.client, &ctx.app_url, None, &function, options).await } async fn render_function( @@ -110,24 +113,30 @@ async fn render_function( app_url: &str, project_name: Option<&str>, function: &api::Function, - json: bool, - web: bool, - verbose: bool, + options: ViewOptions<'_>, ) -> Result<()> { - if web { + let requested_version = options.version; + let environment = options.environment; + if options.web { 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 = + let mut url = app_project_url_with_encoded_path(app_url, client.org_name(), &project_name, &path); + url = app_url_with_selected_version( + url, + requested_version, + environment, + function._xact_id.as_deref(), + ); open::that(&url)?; print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); return Ok(()); } - if json { + if options.json { println!("{}", serde_json::to_string(&function)?); return Ok(()); } @@ -140,6 +149,19 @@ async fn render_function( console::style("Slug:").dim(), function.slug )?; + if let Some(environment) = environment { + writeln!( + output, + "{} {}", + console::style("Environment:").dim(), + environment + )?; + } + if requested_version.is_some() || environment.is_some() { + if let Some(version) = function._xact_id.as_deref().or(requested_version) { + writeln!(output, "{} {}", console::style("Version:").dim(), version)?; + } + } if let Some(ft) = &function.function_type { writeln!(output, "{} {}", console::style("Type:").dim(), ft)?; @@ -151,15 +173,15 @@ async fn render_function( } if let Some(pd) = &function.prompt_data { - let options = pd.get("options"); - if let Some(model) = options + let prompt_options = pd.get("options"); + if let Some(model) = prompt_options .and_then(|o| o.get("model")) .and_then(|m| m.as_str()) { writeln!(output, "{} {}", console::style("Model:").dim(), model)?; } - if verbose { - if let Some(opts) = options { + if options.verbose { + if let Some(opts) = prompt_options { render_options(&mut output, opts)?; } } @@ -220,7 +242,7 @@ async fn render_function( } } - if verbose { + if options.verbose { if let Some(bid) = data.get("bundle_id").and_then(|b| b.as_str()) { @@ -366,7 +388,7 @@ async fn render_function( } } - if verbose { + if options.verbose { if let Some(tags) = &function.tags { if !tags.is_empty() { writeln!( diff --git a/src/http.rs b/src/http.rs index d5a500d0..e7ca7ab2 100644 --- a/src/http.rs +++ b/src/http.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use reqwest::header::{HeaderValue, CONTENT_TYPE}; -use reqwest::{Client, ClientBuilder, StatusCode}; +use reqwest::{Client, ClientBuilder, Method, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -213,24 +213,34 @@ impl ApiClient { parse_json_response(response, "GET", path).await } - pub async fn post(&self, path: &str, body: &B) -> Result { - let url = self.url(path); + async fn send_json( + &self, + method: Method, + path: &str, + body: &B, + ) -> Result { let response = self .http - .post(&url) + .request(method.clone(), self.url(path)) .bearer_auth(&self.api_key) .json(body) .send() .await .context("request failed")?; - if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(HttpError { status, body }.into()); } + parse_json_response(response, method.as_str(), path).await + } - parse_json_response(response, "POST", path).await + pub async fn post(&self, path: &str, body: &B) -> Result { + self.send_json(Method::POST, path, body).await + } + + pub async fn put(&self, path: &str, body: &B) -> Result { + self.send_json(Method::PUT, path, body).await } pub async fn patch( @@ -238,23 +248,7 @@ impl ApiClient { path: &str, body: &B, ) -> Result { - let url = self.url(path); - let response = self - .http - .patch(&url) - .bearer_auth(&self.api_key) - .json(body) - .send() - .await - .context("request failed")?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(HttpError { status, body }.into()); - } - - parse_json_response(response, "PATCH", path).await + self.send_json(Method::PATCH, path, body).await } pub async fn post_with_headers( @@ -322,23 +316,28 @@ impl ApiClient { request.send().await.context("request failed") } - pub async fn delete(&self, path: &str) -> Result<()> { - let url = self.url(path); + async fn send_delete(&self, path: &str) -> Result { let response = self .http - .delete(&url) + .delete(self.url(path)) .bearer_auth(&self.api_key) .send() .await .context("request failed")?; - if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(HttpError { status, body }.into()); } + Ok(response) + } + + pub async fn delete(&self, path: &str) -> Result<()> { + self.send_delete(path).await.map(|_| ()) + } - Ok(()) + pub async fn delete_json(&self, path: &str) -> Result { + parse_json_response(self.send_delete(path).await?, "DELETE", path).await } pub async fn btql(&self, query: &str) -> Result> { diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 5a40a8e7..6d0d54f8 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -1,5 +1,6 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use urlencoding::encode; use crate::http::ApiClient; @@ -14,21 +15,50 @@ pub struct Prompt { pub description: Option, #[serde(default)] pub prompt_data: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub _xact_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentObject { + pub id: String, + pub object_type: String, + pub object_id: String, + pub object_version: String, + pub environment_slug: String, + #[serde(default)] + pub environment_id: Option, + #[serde(default)] + pub created: Option, } #[derive(Debug, Deserialize)] -struct ListResponse { - objects: Vec, +struct ListResponse { + objects: Vec, } -pub async fn list_prompts(client: &ApiClient, project: &str) -> Result> { - let path = format!( +#[derive(Debug, Deserialize)] +struct PromptVersionsResponse { + #[serde(default)] + data: Vec, +} + +pub async fn list_prompts( + client: &ApiClient, + project: &str, + environment: Option<&str>, +) -> Result> { + let mut path = format!( "/v1/prompt?org_name={}&project_name={}", encode(client.org_name()), encode(project) ); - let list: ListResponse = client.get(&path).await?; - + if let Some(environment) = environment { + path.push_str(&format!("&environment={}", encode(environment))); + } + let list: ListResponse = client.get(&path).await?; Ok(list.objects) } @@ -36,18 +66,164 @@ pub async fn get_prompt_by_slug( client: &ApiClient, project: &str, slug: &str, + version: Option<&str>, + environment: Option<&str>, ) -> Result> { + let normalized_version = version + .map(crate::util_cmd::normalize_xact_id) + .transpose()?; + let mut params = vec![ + ("org_name", client.org_name()), + ("project_name", project), + ("slug", slug), + ]; + if let Some(version) = normalized_version.as_deref() { + params.push(("version", version)); + } + if let Some(environment) = environment { + params.push(("environment", environment)); + } + let query = params + .into_iter() + .map(|(key, value)| format!("{}={}", encode(key), encode(value))) + .collect::>() + .join("&"); + let list: ListResponse = client.get(&format!("/v1/prompt?{query}")).await?; + Ok(list.objects.into_iter().next()) +} + +pub async fn list_prompt_versions( + client: &ApiClient, + project_id: &str, + prompt_id: &str, +) -> Result> { + let body = prompt_versions_request(project_id, prompt_id); + let org_name = client.org_name(); + let headers = if org_name.is_empty() { + Vec::new() + } else { + vec![("x-bt-org-name", org_name)] + }; + let response: PromptVersionsResponse = + client.post_with_headers("/btql", &body, &headers).await?; + + Ok(prompt_versions_from_rows(response.data)) +} + +fn prompt_versions_request(project_id: &str, prompt_id: &str) -> Value { + json!({ + "query": { + "from": { + "op": "function", + "name": { "op": "ident", "name": ["project_prompts"] }, + "args": [{ "op": "literal", "value": project_id }] + }, + "select": [{ "op": "star" }], + "sort": [{ + "expr": { "op": "ident", "name": ["_xact_id"] }, + "dir": "desc" + }], + "filter": { + "op": "eq", + "left": { "op": "ident", "name": ["id"] }, + "right": { "op": "literal", "value": prompt_id } + } + }, + "audit_log": true, + "use_columnstore": false, + "brainstore_realtime": true, + "fmt": "json" + }) +} + +fn prompt_versions_from_rows(rows: Vec) -> Vec { + rows.into_iter() + .filter(|row| { + matches!( + row.pointer("/audit_data/action").and_then(Value::as_str), + Some("upsert" | "merge") + ) + }) + .filter_map(|row| { + let xact_id = row.get("_xact_id")?; + let raw = xact_id + .as_str() + .map(ToOwned::to_owned) + .or_else(|| xact_id.as_u64().map(|value| value.to_string()))?; + let value = raw.parse::().ok()?; + Some(crate::util_cmd::prettify_xact(value)) + }) + .collect() +} + +pub async fn assign_prompt( + client: &ApiClient, + prompt_id: &str, + environment: &str, + object_version: &str, +) -> Result { let path = format!( - "/v1/prompt?org_name={}&project_name={}&slug={}", - encode(client.org_name()), - encode(project), - encode(slug) + "/environment-object/prompt/{}/{}", + encode(prompt_id), + encode(environment) ); - let list: ListResponse = client.get(&path).await?; - Ok(list.objects.into_iter().next()) + let body = serde_json::json!({ + "object_version": object_version, + "org_name": client.org_name(), + }); + client.put(&path, &body).await +} + +pub async fn unassign_prompt( + client: &ApiClient, + prompt_id: &str, + environment: &str, +) -> Result { + let path = format!( + "/environment-object/prompt/{}/{}?org_name={}", + encode(prompt_id), + encode(environment), + encode(client.org_name()) + ); + client.delete_json(&path).await } pub async fn delete_prompt(client: &ApiClient, prompt_id: &str) -> Result<()> { let path = format!("/v1/prompt/{}", encode(prompt_id)); client.delete(&path).await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_versions_request_scopes_audit_log_to_prompt() { + let request = prompt_versions_request("proj_test", "prompt_test"); + + assert_eq!( + request["query"]["from"]["name"]["name"], + json!(["project_prompts"]) + ); + assert_eq!(request["query"]["from"]["args"][0]["value"], "proj_test"); + assert_eq!(request["query"]["filter"]["right"]["value"], "prompt_test"); + assert_eq!(request["audit_log"], true); + assert_eq!(request["use_columnstore"], false); + assert_eq!(request["brainstore_realtime"], true); + } + + #[test] + fn prompt_versions_include_upserts_and_merges() { + let rows = vec![ + json!({"_xact_id": "1000192656880881099", "audit_data": {"action": "upsert"}}), + json!({"_xact_id": 1000192656880881100_u64, "audit_data": {"action": "merge"}}), + json!({"_xact_id": "1000192656880881101", "audit_data": {"action": "delete"}}), + json!({"audit_data": {"action": "upsert"}}), + ]; + + assert_eq!( + prompt_versions_from_rows(rows), + vec!["81cd05ee665fdfb3", "81cdc1302a2a586c"] + ); + } +} diff --git a/src/prompts/assign.rs b/src/prompts/assign.rs new file mode 100644 index 00000000..ecf98a87 --- /dev/null +++ b/src/prompts/assign.rs @@ -0,0 +1,89 @@ +use anyhow::{anyhow, bail, Result}; + +use crate::ui::{print_command_status, with_spinner, CommandStatus}; + +use super::{api, ResolvedContext}; + +#[derive(Clone, Copy)] +pub enum Action<'a> { + Assign { version: &'a str }, + Unassign, +} + +pub async fn run( + ctx: &ResolvedContext, + slug: Option<&str>, + environment: &str, + action: Action<'_>, + json: bool, +) -> Result<()> { + let Some(slug) = slug else { + match action { + Action::Assign { .. } => bail!("prompt slug required. Use: bt prompts assign --environment --version "), + Action::Unassign => bail!("prompt slug required. Use: bt prompts unassign --environment "), + } + }; + + let version = match action { + Action::Assign { version } => Some(version), + Action::Unassign => None, + }; + let loading_message = if version.is_some() { + "Loading prompt version..." + } else { + "Loading prompt..." + }; + let prompt = with_spinner( + loading_message, + api::get_prompt_by_slug(&ctx.client, &ctx.project.name, slug, version, None), + ) + .await? + .ok_or_else(|| match version { + Some(version) => anyhow!( + "prompt with slug '{slug}' not found at version {}", + crate::util_cmd::display_xact_id(version) + ), + None => anyhow!("prompt with slug '{slug}' not found"), + })?; + + let association = match action { + Action::Assign { .. } => { + let object_version = prompt._xact_id.as_deref().ok_or_else(|| { + anyhow!( + "prompt version response did not include a transaction version; cannot assign" + ) + })?; + with_spinner( + "Assigning prompt...", + api::assign_prompt(&ctx.client, &prompt.id, environment, object_version), + ) + .await? + } + Action::Unassign => { + with_spinner( + "Unassigning prompt...", + api::unassign_prompt(&ctx.client, &prompt.id, environment), + ) + .await? + } + }; + + if json { + println!("{}", serde_json::to_string(&association)?); + } else { + let message = match action { + Action::Assign { version } => { + format!( + "Assigned prompt '{slug}' version {} to environment '{environment}'", + crate::util_cmd::display_xact_id(version) + ) + } + Action::Unassign => { + format!("Unassigned prompt '{slug}' from environment '{environment}'") + } + }; + print_command_status(CommandStatus::Success, &message); + } + + Ok(()) +} diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index 3fd969e5..1f280c3b 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -16,7 +16,7 @@ pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, force: bool) -> Resu } let prompt = match slug { - Some(s) => api::get_prompt_by_slug(&ctx.client, project_name, s) + Some(s) => api::get_prompt_by_slug(&ctx.client, project_name, s, None, None) .await? .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, None => { @@ -68,8 +68,11 @@ pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, force: bool) -> Resu } pub async fn select_prompt_interactive(client: &ApiClient, project: &str) -> Result { - let mut prompts = - with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; + let mut prompts = with_spinner( + "Loading prompts...", + api::list_prompts(client, project, None), + ) + .await?; if prompts.is_empty() { bail!("no prompts found"); } diff --git a/src/prompts/list.rs b/src/prompts/list.rs index a7f9e7c9..80e6aa8e 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -10,11 +10,11 @@ use crate::{ use super::{api, ResolvedContext}; -pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { +pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) -> Result<()> { let project_name = &ctx.project.name; let prompts = with_spinner( "Loading prompts...", - api::list_prompts(&ctx.client, project_name), + api::list_prompts(&ctx.client, project_name, environment), ) .await?; @@ -32,15 +32,22 @@ pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { ); writeln!( output, - "{} found in {} {} {}\n", + "{} found in {} {} {}{}\n", console::style(count), console::style(ctx.client.org_name()).bold(), console::style("/").dim().bold(), - console::style(project_name).bold() + console::style(project_name).bold(), + environment + .map(|environment| format!(" for environment {}", console::style(environment).bold())) + .unwrap_or_default() )?; let mut table = styled_table(); - table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + let mut headers = vec![header("Name"), header("Description"), header("Slug")]; + if environment.is_some() { + headers.push(header("Version")); + } + table.set_header(headers); apply_column_padding(&mut table, (0, 6)); for prompt in &prompts { @@ -50,7 +57,16 @@ pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { .filter(|s| !s.is_empty()) .map(|s| truncate(s, 60)) .unwrap_or_else(|| "-".to_string()); - table.add_row(vec![&prompt.name, &desc, &prompt.slug]); + let version = prompt + ._xact_id + .as_deref() + .map(crate::util_cmd::display_xact_id) + .unwrap_or_else(|| "-".to_string()); + let mut row = vec![prompt.name.as_str(), desc.as_str(), prompt.slug.as_str()]; + if environment.is_some() { + row.push(version.as_str()); + } + table.add_row(row); } write!(output, "{table}")?; diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 440ac341..7f754e81 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1,20 +1,27 @@ -use anyhow::Result; +use anyhow::{anyhow, bail, Result}; use clap::{Args, Subcommand}; +use crate::ui::{is_interactive, with_spinner}; use crate::{args::BaseArgs, project_context::resolve_project_command_context_with_auth_mode}; pub(crate) use crate::project_context::ProjectContext as ResolvedContext; mod api; +mod assign; mod delete; mod list; +mod versions; mod view; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: bt prompts list - bt prompts view my-prompt + bt prompts list --environment production + bt prompts versions my-prompt + bt prompts view my-prompt --environment production + bt prompts assign my-prompt --environment production --version 1234 + bt prompts unassign my-prompt --environment production bt prompts delete my-prompt ")] pub struct PromptsArgs { @@ -25,29 +32,49 @@ pub struct PromptsArgs { #[derive(Debug, Clone, Subcommand)] enum PromptsCommands { /// List all prompts - List, + List(ListArgs), /// View a prompt's content View(ViewArgs), + /// List all versions of a prompt + Versions(PromptSlugArgs), + /// Assign a prompt version to an environment + Assign(AssignArgs), + /// Unassign a prompt from an environment + Unassign(UnassignArgs), /// Delete a prompt Delete(DeleteArgs), } #[derive(Debug, Clone, Args)] -pub struct ViewArgs { +struct PromptSelectorArgs { + /// Prompt version ID (short or decimal transaction ID) + #[arg(long)] + version: Option, + + /// Environment slug (for example, production) + #[arg(long)] + environment: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct ListArgs { + /// Environment slug (for example, production) + #[arg(long)] + environment: Option, +} + +#[derive(Debug, Clone, Args)] +struct PromptSlugArgs { /// Prompt slug (positional) - #[arg(value_name = "SLUG")] + #[arg(value_name = "SLUG", conflicts_with = "slug_flag")] slug_positional: Option, /// Prompt slug (flag) #[arg(long = "slug", short = 's')] slug_flag: Option, - - /// Open in browser instead of showing in terminal - #[arg(long)] - web: bool, } -impl ViewArgs { +impl PromptSlugArgs { fn slug(&self) -> Option<&str> { self.slug_positional .as_deref() @@ -56,26 +83,86 @@ impl ViewArgs { } #[derive(Debug, Clone, Args)] -pub struct DeleteArgs { - /// Prompt slug (positional) of the prompt to delete - #[arg(value_name = "SLUG")] - slug_positional: Option, +pub struct ViewArgs { + #[command(flatten)] + slug: PromptSlugArgs, - /// Prompt slug (flag) of the prompt to delete - #[arg(long = "slug", short = 's')] - slug_flag: Option, + #[command(flatten)] + selector: PromptSelectorArgs, + + /// Open in browser instead of showing in terminal + #[arg(long)] + web: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct AssignArgs { + #[command(flatten)] + slug: PromptSlugArgs, + + #[command(flatten)] + selector: PromptSelectorArgs, +} + +#[derive(Debug, Clone, Args)] +pub struct UnassignArgs { + #[command(flatten)] + slug: PromptSlugArgs, + + /// Environment slug (for example, production) + #[arg(long)] + environment: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct DeleteArgs { + #[command(flatten)] + slug: PromptSlugArgs, /// 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()) +async fn resolve_prompt( + ctx: &ResolvedContext, + slug: Option<&str>, + version: Option<&str>, + environment: Option<&str>, + usage: &str, +) -> Result { + let interactive_selection = slug.is_none(); + let selected = if interactive_selection { + if !is_interactive() { + bail!("prompt slug required. Use: {usage}"); + } + Some(delete::select_prompt_interactive(&ctx.client, &ctx.project.name).await?) + } else { + None + }; + if version.is_none() && environment.is_none() { + if let Some(prompt) = selected { + return Ok(prompt); + } } + + let slug = slug.unwrap_or_else(|| &selected.as_ref().unwrap().slug); + with_spinner( + "Loading prompt...", + api::get_prompt_by_slug(&ctx.client, &ctx.project.name, slug, version, environment), + ) + .await? + .ok_or_else(|| { + if interactive_selection { + let selector = version + .map(|value| format!("version {}", crate::util_cmd::display_xact_id(value))) + .or_else(|| environment.map(|value| format!("environment {value}"))) + .unwrap_or_default(); + anyhow!("prompt with slug '{slug}' not found at {selector}") + } else { + anyhow!("prompt with slug '{slug}' not found") + } + }) } pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { @@ -83,44 +170,173 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { let ctx = resolve_project_command_context_with_auth_mode(&base, read_only).await?; match args.command { - None | Some(PromptsCommands::List) => list::run(&ctx, base.json).await, - Some(PromptsCommands::View(p)) => { - view::run(&ctx, p.slug(), base.json, p.web, base.verbose).await + None => list::run(&ctx, None, base.json).await, + Some(PromptsCommands::List(args)) => { + list::run(&ctx, args.environment.as_deref(), base.json).await + } + Some(PromptsCommands::Versions(args)) => versions::run(&ctx, args.slug(), base.json).await, + Some(PromptsCommands::View(args)) => { + if args.selector.version.is_some() && args.selector.environment.is_some() { + bail!("--version and --environment cannot be used together"); + } + view::run( + &ctx, + args.slug.slug(), + args.selector.version.as_deref(), + args.selector.environment.as_deref(), + base.json, + args.web, + base.verbose, + ) + .await + } + Some(PromptsCommands::Assign(args)) => { + let hint = + "Use: bt prompts assign --environment --version "; + let version = args + .selector + .version + .as_deref() + .ok_or_else(|| anyhow!("--version is required. {hint}"))?; + let environment = args + .selector + .environment + .as_deref() + .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; + assign::run( + &ctx, + args.slug.slug(), + environment, + assign::Action::Assign { version }, + base.json, + ) + .await + } + Some(PromptsCommands::Unassign(args)) => { + let hint = "Use: bt prompts unassign --environment "; + let environment = args + .environment + .as_deref() + .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; + assign::run( + &ctx, + args.slug.slug(), + environment, + assign::Action::Unassign, + base.json, + ) + .await + } + Some(PromptsCommands::Delete(args)) => { + delete::run(&ctx, args.slug.slug(), args.force).await } - Some(PromptsCommands::Delete(p)) => delete::run(&ctx, p.slug(), p.force).await, } } fn prompts_command_is_read_only(command: Option<&PromptsCommands>) -> bool { matches!( command, - None | Some(PromptsCommands::List) | Some(PromptsCommands::View(_)) + None | Some(PromptsCommands::List(_)) + | Some(PromptsCommands::View(_)) + | Some(PromptsCommands::Versions(_)) ) } #[cfg(test)] mod tests { + use clap::Parser; + use super::*; + #[derive(Debug, Parser)] + struct CliHarness { + #[command(flatten)] + prompts: PromptsArgs, + } + + fn selectors(version: Option<&str>, environment: Option<&str>) -> PromptSelectorArgs { + PromptSelectorArgs { + version: version.map(ToOwned::to_owned), + environment: environment.map(ToOwned::to_owned), + } + } + + fn slug(slug: &str) -> PromptSlugArgs { + PromptSlugArgs { + slug_positional: Some(slug.to_string()), + slug_flag: None, + } + } + + #[test] + fn subcommands_only_expose_supported_selectors() { + let list = + CliHarness::try_parse_from(["bt-prompts", "list", "--environment", "production"]) + .expect("parse list"); + let Some(PromptsCommands::List(list)) = list.prompts.command else { + panic!("expected list command"); + }; + assert_eq!(list.environment.as_deref(), Some("production")); + + let error = CliHarness::try_parse_from(["bt-prompts", "list", "--version", "1234"]) + .expect_err("list should reject version"); + assert!(error + .to_string() + .contains("unexpected argument '--version'")); + + let versions = CliHarness::try_parse_from(["bt-prompts", "versions", "test-prompt"]) + .expect("parse versions"); + let Some(PromptsCommands::Versions(versions)) = versions.prompts.command else { + panic!("expected versions command"); + }; + assert_eq!(versions.slug(), Some("test-prompt")); + + let assign = CliHarness::try_parse_from([ + "bt-prompts", + "assign", + "test-prompt", + "--environment", + "production", + "--version", + "1234", + ]) + .expect("parse assign"); + let Some(PromptsCommands::Assign(assign)) = assign.prompts.command else { + panic!("expected assign command"); + }; + assert_eq!(assign.selector.version.as_deref(), Some("1234")); + assert_eq!(assign.selector.environment.as_deref(), Some("production")); + } + #[test] fn prompts_routes_list_and_view_to_read_only_auth() { assert!(prompts_command_is_read_only(None)); - assert!(prompts_command_is_read_only(Some(&PromptsCommands::List))); + assert!(prompts_command_is_read_only(Some(&PromptsCommands::List( + ListArgs { environment: None } + )))); assert!(prompts_command_is_read_only(Some(&PromptsCommands::View( ViewArgs { - slug_positional: Some("my-prompt".to_string()), - slug_flag: None, + slug: slug("test-prompt"), + selector: selectors(None, None), web: false, } )))); + assert!(prompts_command_is_read_only(Some( + &PromptsCommands::Versions(slug("test-prompt")) + ))); } #[test] - fn prompts_routes_delete_to_validated_auth() { + fn prompts_routes_mutations_to_validated_auth() { + assert!(!prompts_command_is_read_only(Some( + &PromptsCommands::Assign(AssignArgs { + slug: slug("test-prompt"), + selector: selectors(Some("1234"), Some("production")), + }) + ))); assert!(!prompts_command_is_read_only(Some( &PromptsCommands::Delete(DeleteArgs { - slug_positional: Some("my-prompt".to_string()), - slug_flag: None, + slug: slug("test-prompt"), force: true, }) ))); diff --git a/src/prompts/versions.rs b/src/prompts/versions.rs new file mode 100644 index 00000000..c00e318b --- /dev/null +++ b/src/prompts/versions.rs @@ -0,0 +1,47 @@ +use std::fmt::Write as _; + +use anyhow::Result; +use dialoguer::console; + +use crate::ui::{header, print_with_pager, styled_table, with_spinner}; +use crate::utils::pluralize; + +use super::{api, resolve_prompt, ResolvedContext}; + +pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, json: bool) -> Result<()> { + let prompt = resolve_prompt(ctx, slug, None, None, "bt prompts versions ").await?; + + let versions = with_spinner( + "Loading prompt versions...", + api::list_prompt_versions(&ctx.client, &ctx.project.id, &prompt.id), + ) + .await?; + + if json { + println!("{}", serde_json::to_string(&versions)?); + return Ok(()); + } + + let mut output = String::new(); + let count = format!( + "{} {}", + versions.len(), + pluralize(versions.len(), "version", None) + ); + writeln!( + output, + "{} found for {}\n", + console::style(count), + console::style(&prompt.slug).bold() + )?; + + let mut table = styled_table(); + table.set_header(vec![header("Version")]); + for version in versions { + table.add_row(vec![version]); + } + + write!(output, "{table}")?; + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 64f1e96a..862c5a44 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -1,45 +1,34 @@ use std::fmt::Write as _; -use anyhow::{anyhow, bail, Result}; +use anyhow::Result; use dialoguer::console; -use crate::prompts::delete::select_prompt_interactive; use crate::ui::prompt_render::{render_options, render_prompt_block}; -use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; -use crate::utils::app_project_url; +use crate::ui::{print_command_status, print_with_pager, CommandStatus}; +use crate::utils::{app_project_url, app_url_with_selected_version}; -use super::{api, ResolvedContext}; +use super::{resolve_prompt, ResolvedContext}; pub async fn run( ctx: &ResolvedContext, slug: Option<&str>, + version: Option<&str>, + environment: Option<&str>, json: bool, web: bool, verbose: bool, ) -> Result<()> { let project_name = &ctx.project.name; - let prompt = match slug { - Some(s) => with_spinner( - "Loading prompt...", - api::get_prompt_by_slug(&ctx.client, project_name, s), - ) - .await? - .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, - None => { - if !crate::ui::is_interactive() { - bail!("prompt slug required. Use: bt prompts view "); - } - select_prompt_interactive(&ctx.client, project_name).await? - } - }; + let prompt = resolve_prompt(ctx, slug, version, environment, "bt prompts view ").await?; if web { - let url = app_project_url( + let mut url = app_project_url( &ctx.app_url, ctx.client.org_name(), project_name, &["prompts", &prompt.id], ); + url = app_url_with_selected_version(url, version, environment, prompt._xact_id.as_deref()); open::that(&url)?; print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); return Ok(()); @@ -53,6 +42,22 @@ pub async fn run( let mut output = String::new(); writeln!(output, "Viewing {}", console::style(&prompt.name).bold())?; + if let Some(environment) = environment { + writeln!( + output, + "{} {}", + console::style("Environment:").dim(), + environment + )?; + } + if let Some(version) = prompt._xact_id.as_deref().or(version) { + writeln!( + output, + "{} {}", + console::style("Version:").dim(), + crate::util_cmd::display_xact_id(version) + )?; + } let options = prompt.prompt_data.as_ref().and_then(|pd| pd.get("options")); diff --git a/src/util_cmd.rs b/src/util_cmd.rs index 7ff723e9..842f65a1 100644 --- a/src/util_cmd.rs +++ b/src/util_cmd.rs @@ -388,11 +388,32 @@ fn modular_multiply(value: u64, prime: u64) -> u64 { ((value as u128 * prime as u128) % MODULUS) as u64 } -fn prettify_xact(value: u64) -> String { +pub(crate) fn prettify_xact(value: u64) -> String { let encoded = modular_multiply(value, COPRIME); format!("{encoded:016x}") } +/// Format a transaction ID as the canonical short version ID. +/// +/// Values that are already short version IDs, or are not valid decimal +/// transaction IDs, are returned unchanged. +pub(crate) fn display_xact_id(value: &str) -> String { + if is_pretty_version(value) { + return value.to_string(); + } + + value + .parse::() + .map(prettify_xact) + .unwrap_or_else(|_| value.to_string()) +} + +/// Convert a short version ID to the decimal transaction ID expected by APIs. +/// Decimal transaction IDs are returned unchanged. +pub(crate) fn normalize_xact_id(value: &str) -> Result { + load_pretty_xact(value) +} + fn load_pretty_xact(encoded_hex: &str) -> Result { if encoded_hex.len() != 16 { return Ok(encoded_hex.to_string()); @@ -518,6 +539,18 @@ mod tests { } } + #[test] + fn display_and_normalize_xact_ids_accept_both_forms() { + let long = "1000192656880881099"; + let short = "81cd05ee665fdfb3"; + + assert_eq!(display_xact_id(long), short); + assert_eq!(display_xact_id(short), short); + assert_eq!(display_xact_id("1234567890123456"), "1234567890123456"); + assert_eq!(normalize_xact_id(long).unwrap(), long); + assert_eq!(normalize_xact_id(short).unwrap(), long); + } + #[test] fn from_time_to_xact_and_back() { let unix_seconds = 1_710_209_616u64; diff --git a/src/utils/app_url.rs b/src/utils/app_url.rs index ed69a0f4..7b291858 100644 --- a/src/utils/app_url.rs +++ b/src/utils/app_url.rs @@ -39,6 +39,26 @@ pub(crate) fn app_project_url_with_encoded_path( url } +pub(crate) fn app_url_with_selected_version( + mut url: String, + requested_version: Option<&str>, + environment: Option<&str>, + resolved_version: Option<&str>, +) -> String { + if requested_version.is_none() && environment.is_none() { + return url; + } + let Some(version) = resolved_version.or(requested_version) else { + return url; + }; + + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str("pt=activity&vn="); + url.push_str(&encode(version)); + url +} + #[cfg(test)] mod tests { use super::*; @@ -64,6 +84,33 @@ mod tests { ), "https://www.example.test/app/test%20org/p/test%20project/tools?pr=function%2Fid", ), + ( + app_url_with_selected_version( + "https://www.example.test/app/test/prompt".to_string(), + Some("requested-version"), + None, + Some("resolved-version"), + ), + "https://www.example.test/app/test/prompt?pt=activity&vn=resolved-version", + ), + ( + app_url_with_selected_version( + "https://www.example.test/app/test/tools?pr=fn%2Ftest".to_string(), + None, + Some("production"), + Some("version/test"), + ), + "https://www.example.test/app/test/tools?pr=fn%2Ftest&pt=activity&vn=version%2Ftest", + ), + ( + app_url_with_selected_version( + "https://www.example.test/app/test/prompt".to_string(), + None, + None, + Some("resolved-version"), + ), + "https://www.example.test/app/test/prompt", + ), ]; for (actual, expected) in cases { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1429bebe..57c69b72 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -9,7 +9,9 @@ mod profile; mod structured_source; mod text_source; -pub(crate) use app_url::{app_project_url, app_project_url_with_encoded_path}; +pub(crate) use app_url::{ + app_project_url, app_project_url_with_encoded_path, app_url_with_selected_version, +}; pub use duration::parse_duration_to_seconds; pub use fs_atomic::{ write_bytes_atomic, write_json_atomic, write_json_atomic_private, write_text_atomic,