From f33ea964a141f354af6c1ca36f089a62100e0c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 21 Aug 2026 16:55:18 -0700 Subject: [PATCH 01/11] feat(prompt): --environment flag control which environment is shown or assigned to the prompts --- src/functions/api.rs | 8 ++ src/functions/delete.rs | 2 +- src/functions/mod.rs | 88 +++++++++++++++---- src/functions/view.rs | 90 +++++++++++-------- src/http.rs | 20 +++++ src/prompts/api.rs | 80 +++++++++++++++-- src/prompts/delete.rs | 2 +- src/prompts/list.rs | 21 +++-- src/prompts/mod.rs | 185 +++++++++++++++++++++++++++++++++++++--- src/prompts/promote.rs | 45 ++++++++++ src/prompts/view.rs | 15 +++- 11 files changed, 470 insertions(+), 86 deletions(-) create mode 100644 src/prompts/promote.rs 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..4b1b29de 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -438,9 +438,16 @@ 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, @@ -629,10 +636,13 @@ pub(crate) async fn run_typed_command( view::run_by_id( &auth_ctx, id, - v.version.as_deref(), - base.json, - v.web, - base.verbose, + view::ViewOptions { + version: v.version.as_deref(), + environment: v.environment.as_deref(), + json: base.json, + web: v.web, + verbose: base.verbose, + }, ft, ) .await @@ -642,10 +652,13 @@ pub(crate) async fn run_typed_command( view::run( &ctx, slug, - v.version.as_deref(), - base.json, - v.web, - base.verbose, + view::ViewOptions { + version: v.version.as_deref(), + environment: v.environment.as_deref(), + json: base.json, + web: v.web, + verbose: base.verbose, + }, ft, ) .await @@ -684,10 +697,13 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { view::run_by_id( &auth_ctx, id, - v.inner.version.as_deref(), - base.json, - v.inner.web, - base.verbose, + view::ViewOptions { + version: v.inner.version.as_deref(), + environment: v.inner.environment.as_deref(), + json: base.json, + web: v.inner.web, + verbose: base.verbose, + }, ft, ) .await @@ -697,10 +713,13 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { view::run( &ctx, slug, - v.inner.version.as_deref(), - base.json, - v.inner.web, - base.verbose, + view::ViewOptions { + version: v.inner.version.as_deref(), + environment: v.inner.environment.as_deref(), + json: base.json, + web: v.inner.web, + verbose: base.verbose, + }, ft, ) .await @@ -1052,6 +1071,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..7c5867f7 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -15,20 +15,28 @@ 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,11 +113,11 @@ 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(), @@ -127,7 +130,7 @@ async fn render_function( return Ok(()); } - if json { + if options.json { println!("{}", serde_json::to_string(&function)?); return Ok(()); } @@ -140,6 +143,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 +167,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 +236,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 +382,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 8676d015..00cbacf4 100644 --- a/src/http.rs +++ b/src/http.rs @@ -233,6 +233,26 @@ impl ApiClient { parse_json_response(response, "POST", path).await } + pub async fn put(&self, path: &str, body: &B) -> Result { + let url = self.url(path); + let response = self + .http + .put(&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, "PUT", path).await + } + pub async fn patch( &self, path: &str, diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 5a40a8e7..b84f89ee 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -14,11 +14,28 @@ 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> { @@ -27,26 +44,71 @@ pub async fn list_prompts(client: &ApiClient, project: &str) -> Result = client.get(&path).await?; Ok(list.objects) } -pub async fn get_prompt_by_slug( +pub async fn list_prompts_by_environment( client: &ApiClient, project: &str, - slug: &str, -) -> Result> { + environment: &str, +) -> Result> { let path = format!( - "/v1/prompt?org_name={}&project_name={}&slug={}", + "/v1/prompt?org_name={}&project_name={}&environment={}", encode(client.org_name()), encode(project), - encode(slug) + encode(environment) ); - let list: ListResponse = client.get(&path).await?; + let list: ListResponse = client.get(&path).await?; + Ok(list.objects) +} + +pub async fn get_prompt_by_slug( + client: &ApiClient, + project: &str, + slug: &str, + version: Option<&str>, + environment: Option<&str>, +) -> Result> { + let mut params = vec![ + ("org_name", client.org_name()), + ("project_name", project), + ("slug", slug), + ]; + if let Some(version) = version { + 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 promote_prompt( + client: &ApiClient, + prompt_id: &str, + environment: &str, + object_version: &str, +) -> Result { + let path = format!( + "/environment-object/prompt/{}/{}", + encode(prompt_id), + encode(environment) + ); + let body = serde_json::json!({ + "object_version": object_version, + "org_name": client.org_name(), + }); + client.put(&path, &body).await +} + 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 index 3fd969e5..8692e810 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 => { diff --git a/src/prompts/list.rs b/src/prompts/list.rs index a7f9e7c9..1be9d0bc 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -10,12 +10,16 @@ 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), - ) + let prompts = with_spinner("Loading prompts...", async { + match environment { + Some(environment) => { + api::list_prompts_by_environment(&ctx.client, project_name, environment).await + } + None => api::list_prompts(&ctx.client, project_name).await, + } + }) .await?; if json { @@ -32,11 +36,14 @@ 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(); diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 440ac341..9f50a999 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{anyhow, bail, Result}; use clap::{Args, Subcommand}; use crate::{args::BaseArgs, project_context::resolve_project_command_context_with_auth_mode}; @@ -8,13 +8,16 @@ pub(crate) use crate::project_context::ProjectContext as ResolvedContext; mod api; mod delete; mod list; +mod promote; 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 view my-prompt --environment production + bt prompts promote my-prompt --environment production --version 1234 bt prompts delete my-prompt ")] pub struct PromptsArgs { @@ -25,13 +28,54 @@ pub struct PromptsArgs { #[derive(Debug, Clone, Subcommand)] enum PromptsCommands { /// List all prompts - List, + List(ListArgs), /// View a prompt's content View(ViewArgs), + /// Promote a prompt version to an environment + Promote(PromoteArgs), /// Delete a prompt Delete(DeleteArgs), } +#[derive(Debug, Clone, Args)] +struct PromptEnvironmentArgs { + /// Environment slug (for example, production) + #[arg(long, env = "BT_PROMPTS_ENVIRONMENT")] + environment: Option, +} + +#[derive(Debug, Clone, Args)] +struct PromptVersionArgs { + /// Prompt version identifier (for example, a transaction ID) + #[arg(long, env = "BT_PROMPTS_VERSION")] + version: Option, +} + +#[derive(Debug, Clone, Args)] +struct PromptSelectorArgs { + #[command(flatten)] + version: PromptVersionArgs, + + #[command(flatten)] + environment: PromptEnvironmentArgs, +} + +impl PromptSelectorArgs { + fn version(&self) -> Option<&str> { + self.version.version.as_deref() + } + + fn environment(&self) -> Option<&str> { + self.environment.environment.as_deref() + } +} + +#[derive(Debug, Clone, Args)] +pub struct ListArgs { + #[command(flatten)] + environment: PromptEnvironmentArgs, +} + #[derive(Debug, Clone, Args)] pub struct ViewArgs { /// Prompt slug (positional) @@ -42,6 +86,9 @@ pub struct ViewArgs { #[arg(long = "slug", short = 's')] slug_flag: Option, + #[command(flatten)] + selector: PromptSelectorArgs, + /// Open in browser instead of showing in terminal #[arg(long)] web: bool, @@ -55,6 +102,28 @@ impl ViewArgs { } } +#[derive(Debug, Clone, Args)] +pub struct PromoteArgs { + /// Prompt slug (positional) + #[arg(value_name = "SLUG")] + slug_positional: Option, + + /// Prompt slug (flag) + #[arg(long = "slug", short = 's')] + slug_flag: Option, + + #[command(flatten)] + selector: PromptSelectorArgs, +} + +impl PromoteArgs { + fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + #[derive(Debug, Clone, Args)] pub struct DeleteArgs { /// Prompt slug (positional) of the prompt to delete @@ -83,43 +152,135 @@ 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.environment.as_deref(), 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(), + args.selector.version(), + args.selector.environment(), + base.json, + args.web, + base.verbose, + ) + .await + } + Some(PromptsCommands::Promote(args)) => { + let hint = + "Use: bt prompts promote --environment --version "; + let version = args + .selector + .version() + .ok_or_else(|| anyhow!("--version is required. {hint}"))?; + let environment = args + .selector + .environment() + .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; + promote::run(&ctx, args.slug(), environment, version, base.json).await } - Some(PromptsCommands::Delete(p)) => delete::run(&ctx, p.slug(), p.force).await, + Some(PromptsCommands::Delete(args)) => delete::run(&ctx, args.slug(), args.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(_)) ) } #[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: PromptVersionArgs { + version: version.map(ToOwned::to_owned), + }, + environment: PromptEnvironmentArgs { + environment: environment.map(ToOwned::to_owned), + }, + } + } + + #[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.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 promote = CliHarness::try_parse_from([ + "bt-prompts", + "promote", + "test-prompt", + "--environment", + "production", + "--version", + "1234", + ]) + .expect("parse promote"); + let Some(PromptsCommands::Promote(promote)) = promote.prompts.command else { + panic!("expected promote command"); + }; + assert_eq!(promote.selector.version(), Some("1234")); + assert_eq!(promote.selector.environment(), 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: PromptEnvironmentArgs { environment: None }, + } + )))); assert!(prompts_command_is_read_only(Some(&PromptsCommands::View( ViewArgs { - slug_positional: Some("my-prompt".to_string()), + slug_positional: Some("test-prompt".to_string()), slug_flag: None, + selector: selectors(None, None), web: false, } )))); } #[test] - fn prompts_routes_delete_to_validated_auth() { + fn prompts_routes_mutations_to_validated_auth() { + assert!(!prompts_command_is_read_only(Some( + &PromptsCommands::Promote(PromoteArgs { + slug_positional: Some("test-prompt".to_string()), + slug_flag: None, + selector: selectors(Some("1234"), Some("production")), + }) + ))); assert!(!prompts_command_is_read_only(Some( &PromptsCommands::Delete(DeleteArgs { - slug_positional: Some("my-prompt".to_string()), + slug_positional: Some("test-prompt".to_string()), slug_flag: None, force: true, }) diff --git a/src/prompts/promote.rs b/src/prompts/promote.rs new file mode 100644 index 00000000..1fec9c61 --- /dev/null +++ b/src/prompts/promote.rs @@ -0,0 +1,45 @@ +use anyhow::{anyhow, bail, Result}; + +use crate::ui::{print_command_status, with_spinner, CommandStatus}; + +use super::{api, ResolvedContext}; + +pub async fn run( + ctx: &ResolvedContext, + slug: Option<&str>, + environment: &str, + version: &str, + json: bool, +) -> Result<()> { + let Some(slug) = slug else { + bail!("prompt slug required. Use: bt prompts promote --environment --version "); + }; + + let prompt = with_spinner( + "Loading prompt version...", + api::get_prompt_by_slug(&ctx.client, &ctx.project.name, slug, Some(version), None), + ) + .await? + .ok_or_else(|| anyhow!("prompt with slug '{slug}' not found at version {version}"))?; + + let object_version = prompt._xact_id.as_deref().ok_or_else(|| { + anyhow!("prompt version response did not include a transaction version; cannot promote") + })?; + + let association = with_spinner( + "Promoting prompt...", + api::promote_prompt(&ctx.client, &prompt.id, environment, object_version), + ) + .await?; + + if json { + println!("{}", serde_json::to_string(&association)?); + } else { + print_command_status( + CommandStatus::Success, + &format!("Promoted prompt '{slug}' version {version} to environment '{environment}'"), + ); + } + + Ok(()) +} diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 64f1e96a..535f9629 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -13,6 +13,8 @@ use super::{api, ResolvedContext}; pub async fn run( ctx: &ResolvedContext, slug: Option<&str>, + version: Option<&str>, + environment: Option<&str>, json: bool, web: bool, verbose: bool, @@ -21,7 +23,7 @@ pub async fn run( let prompt = match slug { Some(s) => with_spinner( "Loading prompt...", - api::get_prompt_by_slug(&ctx.client, project_name, s), + api::get_prompt_by_slug(&ctx.client, project_name, s, version, environment), ) .await? .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, @@ -53,6 +55,17 @@ 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(), version)?; + } let options = prompt.prompt_data.as_ref().and_then(|pd| pd.get("options")); From c206befa39fcb27c8052705814705c2cc59d9b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 12:13:41 -0700 Subject: [PATCH 02/11] chore(prompt): listing prompts can filter by environement --- src/prompts/list.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/prompts/list.rs b/src/prompts/list.rs index 1be9d0bc..3969f422 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -47,7 +47,16 @@ pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) - )?; let mut table = styled_table(); - table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + if environment.is_some() { + table.set_header(vec![ + header("Name"), + header("Description"), + header("Slug"), + header("Version"), + ]); + } else { + table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + } apply_column_padding(&mut table, (0, 6)); for prompt in &prompts { @@ -57,7 +66,20 @@ pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) - .filter(|s| !s.is_empty()) .map(|s| truncate(s, 60)) .unwrap_or_else(|| "-".to_string()); - table.add_row(vec![&prompt.name, &desc, &prompt.slug]); + if environment.is_some() { + table.add_row(vec![ + prompt.name.as_str(), + desc.as_str(), + prompt.slug.as_str(), + prompt._xact_id.as_deref().unwrap_or("-"), + ]); + } else { + table.add_row(vec![ + prompt.name.as_str(), + desc.as_str(), + prompt.slug.as_str(), + ]); + } } write!(output, "{table}")?; From efb0aa5194faee6d9c2f167bb8f17afc4cdc7401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 12:56:36 -0700 Subject: [PATCH 03/11] chore(prompt): assign rather than promote prompt --- src/prompts/api.rs | 2 +- src/prompts/{promote.rs => assign.rs} | 10 ++++---- src/prompts/mod.rs | 34 +++++++++++++-------------- 3 files changed, 23 insertions(+), 23 deletions(-) rename src/prompts/{promote.rs => assign.rs} (75%) diff --git a/src/prompts/api.rs b/src/prompts/api.rs index b84f89ee..74c04cd7 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -91,7 +91,7 @@ pub async fn get_prompt_by_slug( Ok(list.objects.into_iter().next()) } -pub async fn promote_prompt( +pub async fn assign_prompt( client: &ApiClient, prompt_id: &str, environment: &str, diff --git a/src/prompts/promote.rs b/src/prompts/assign.rs similarity index 75% rename from src/prompts/promote.rs rename to src/prompts/assign.rs index 1fec9c61..23657dae 100644 --- a/src/prompts/promote.rs +++ b/src/prompts/assign.rs @@ -12,7 +12,7 @@ pub async fn run( json: bool, ) -> Result<()> { let Some(slug) = slug else { - bail!("prompt slug required. Use: bt prompts promote --environment --version "); + bail!("prompt slug required. Use: bt prompts assign --environment --version "); }; let prompt = with_spinner( @@ -23,12 +23,12 @@ pub async fn run( .ok_or_else(|| anyhow!("prompt with slug '{slug}' not found at version {version}"))?; let object_version = prompt._xact_id.as_deref().ok_or_else(|| { - anyhow!("prompt version response did not include a transaction version; cannot promote") + anyhow!("prompt version response did not include a transaction version; cannot assign") })?; let association = with_spinner( - "Promoting prompt...", - api::promote_prompt(&ctx.client, &prompt.id, environment, object_version), + "Assigning prompt...", + api::assign_prompt(&ctx.client, &prompt.id, environment, object_version), ) .await?; @@ -37,7 +37,7 @@ pub async fn run( } else { print_command_status( CommandStatus::Success, - &format!("Promoted prompt '{slug}' version {version} to environment '{environment}'"), + &format!("Assigned prompt '{slug}' version {version} to environment '{environment}'"), ); } diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 9f50a999..adb70f11 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -6,9 +6,9 @@ use crate::{args::BaseArgs, project_context::resolve_project_command_context_wit pub(crate) use crate::project_context::ProjectContext as ResolvedContext; mod api; +mod assign; mod delete; mod list; -mod promote; mod view; #[derive(Debug, Clone, Args)] @@ -17,7 +17,7 @@ Examples: bt prompts list bt prompts list --environment production bt prompts view my-prompt --environment production - bt prompts promote my-prompt --environment production --version 1234 + bt prompts assign my-prompt --environment production --version 1234 bt prompts delete my-prompt ")] pub struct PromptsArgs { @@ -31,8 +31,8 @@ enum PromptsCommands { List(ListArgs), /// View a prompt's content View(ViewArgs), - /// Promote a prompt version to an environment - Promote(PromoteArgs), + /// Assign a prompt version to an environment + Assign(AssignArgs), /// Delete a prompt Delete(DeleteArgs), } @@ -103,7 +103,7 @@ impl ViewArgs { } #[derive(Debug, Clone, Args)] -pub struct PromoteArgs { +pub struct AssignArgs { /// Prompt slug (positional) #[arg(value_name = "SLUG")] slug_positional: Option, @@ -116,7 +116,7 @@ pub struct PromoteArgs { selector: PromptSelectorArgs, } -impl PromoteArgs { +impl AssignArgs { fn slug(&self) -> Option<&str> { self.slug_positional .as_deref() @@ -171,9 +171,9 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { ) .await } - Some(PromptsCommands::Promote(args)) => { + Some(PromptsCommands::Assign(args)) => { let hint = - "Use: bt prompts promote --environment --version "; + "Use: bt prompts assign --environment --version "; let version = args .selector .version() @@ -182,7 +182,7 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { .selector .environment() .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; - promote::run(&ctx, args.slug(), environment, version, base.json).await + assign::run(&ctx, args.slug(), environment, version, base.json).await } Some(PromptsCommands::Delete(args)) => delete::run(&ctx, args.slug(), args.force).await, } @@ -234,21 +234,21 @@ mod tests { .to_string() .contains("unexpected argument '--version'")); - let promote = CliHarness::try_parse_from([ + let assign = CliHarness::try_parse_from([ "bt-prompts", - "promote", + "assign", "test-prompt", "--environment", "production", "--version", "1234", ]) - .expect("parse promote"); - let Some(PromptsCommands::Promote(promote)) = promote.prompts.command else { - panic!("expected promote command"); + .expect("parse assign"); + let Some(PromptsCommands::Assign(assign)) = assign.prompts.command else { + panic!("expected assign command"); }; - assert_eq!(promote.selector.version(), Some("1234")); - assert_eq!(promote.selector.environment(), Some("production")); + assert_eq!(assign.selector.version(), Some("1234")); + assert_eq!(assign.selector.environment(), Some("production")); } #[test] @@ -272,7 +272,7 @@ mod tests { #[test] fn prompts_routes_mutations_to_validated_auth() { assert!(!prompts_command_is_read_only(Some( - &PromptsCommands::Promote(PromoteArgs { + &PromptsCommands::Assign(AssignArgs { slug_positional: Some("test-prompt".to_string()), slug_flag: None, selector: selectors(Some("1234"), Some("production")), From 974222a9745cdfa48551789f5d606badcfb4adcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 14:13:49 -0700 Subject: [PATCH 04/11] feat(prompts): unassign a prompt from an env --- src/prompts/api.rs | 14 +++++++++ src/prompts/assign.rs | 72 +++++++++++++++++++++++++++++++++---------- src/prompts/mod.rs | 54 ++++++++++++++++++++++++++++++-- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 74c04cd7..81dbe0a0 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -109,6 +109,20 @@ pub async fn assign_prompt( 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_with_response(&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 diff --git a/src/prompts/assign.rs b/src/prompts/assign.rs index 23657dae..b96a17af 100644 --- a/src/prompts/assign.rs +++ b/src/prompts/assign.rs @@ -4,41 +4,79 @@ 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, - version: &str, + action: Action<'_>, json: bool, ) -> Result<()> { let Some(slug) = slug else { - bail!("prompt slug required. Use: bt prompts assign --environment --version "); + 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 prompt version...", - api::get_prompt_by_slug(&ctx.client, &ctx.project.name, slug, Some(version), None), + loading_message, + api::get_prompt_by_slug(&ctx.client, &ctx.project.name, slug, version, None), ) .await? - .ok_or_else(|| anyhow!("prompt with slug '{slug}' not found at version {version}"))?; - - let object_version = prompt._xact_id.as_deref().ok_or_else(|| { - anyhow!("prompt version response did not include a transaction version; cannot assign") + .ok_or_else(|| match version { + Some(version) => anyhow!("prompt with slug '{slug}' not found at version {version}"), + None => anyhow!("prompt with slug '{slug}' not found"), })?; - let association = with_spinner( - "Assigning prompt...", - api::assign_prompt(&ctx.client, &prompt.id, environment, object_version), - ) - .await?; + 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 { - print_command_status( - CommandStatus::Success, - &format!("Assigned prompt '{slug}' version {version} to environment '{environment}'"), - ); + let message = match action { + Action::Assign { version } => { + format!("Assigned prompt '{slug}' version {version} to environment '{environment}'") + } + Action::Unassign => { + format!("Unassigned prompt '{slug}' from environment '{environment}'") + } + }; + print_command_status(CommandStatus::Success, &message); } Ok(()) diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index adb70f11..06c2c582 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -18,6 +18,7 @@ Examples: bt prompts list --environment production 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 { @@ -33,6 +34,8 @@ enum PromptsCommands { View(ViewArgs), /// Assign a prompt version to an environment Assign(AssignArgs), + /// Unassign a prompt from an environment + Unassign(UnassignArgs), /// Delete a prompt Delete(DeleteArgs), } @@ -40,14 +43,14 @@ enum PromptsCommands { #[derive(Debug, Clone, Args)] struct PromptEnvironmentArgs { /// Environment slug (for example, production) - #[arg(long, env = "BT_PROMPTS_ENVIRONMENT")] + #[arg(long)] environment: Option, } #[derive(Debug, Clone, Args)] struct PromptVersionArgs { /// Prompt version identifier (for example, a transaction ID) - #[arg(long, env = "BT_PROMPTS_VERSION")] + #[arg(long)] version: Option, } @@ -124,6 +127,28 @@ impl AssignArgs { } } +#[derive(Debug, Clone, Args)] +pub struct UnassignArgs { + /// Prompt slug (positional) + #[arg(value_name = "SLUG")] + slug_positional: Option, + + /// Prompt slug (flag) + #[arg(long = "slug", short = 's')] + slug_flag: Option, + + #[command(flatten)] + environment: PromptEnvironmentArgs, +} + +impl UnassignArgs { + fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + #[derive(Debug, Clone, Args)] pub struct DeleteArgs { /// Prompt slug (positional) of the prompt to delete @@ -182,7 +207,30 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { .selector .environment() .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; - assign::run(&ctx, args.slug(), environment, version, base.json).await + assign::run( + &ctx, + args.slug(), + environment, + assign::Action::Assign { version }, + base.json, + ) + .await + } + Some(PromptsCommands::Unassign(args)) => { + let hint = "Use: bt prompts unassign --environment "; + let environment = args + .environment + .environment + .as_deref() + .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; + assign::run( + &ctx, + args.slug(), + environment, + assign::Action::Unassign, + base.json, + ) + .await } Some(PromptsCommands::Delete(args)) => delete::run(&ctx, args.slug(), args.force).await, } From 8169a03370b373ffa21f743334fbf4fe4c2a7c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 16:42:30 -0700 Subject: [PATCH 05/11] feat(prompts): add `bt prompts versions` to see the available versions of a prompt --- README.md | 2 +- src/prompts/api.rs | 102 ++++++++++++++++++++++++++++++++++ src/prompts/mod.rs | 118 ++++++++++++++++++---------------------- src/prompts/versions.rs | 62 +++++++++++++++++++++ src/util_cmd.rs | 2 +- 5 files changed, 220 insertions(+), 66 deletions(-) create mode 100644 src/prompts/versions.rs diff --git a/README.md b/README.md index f4a1181b..3565fb5b 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 environments` | Manage deployment environments (list, view, create, update, delete) | | `bt sync` | Synchronize project logs between Braintrust and local NDJSON files | diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 81dbe0a0..a868141d 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; @@ -38,6 +39,12 @@ struct ListResponse { objects: Vec, } +#[derive(Debug, Deserialize)] +struct PromptVersionsResponse { + #[serde(default)] + data: Vec, +} + pub async fn list_prompts(client: &ApiClient, project: &str) -> Result> { let path = format!( "/v1/prompt?org_name={}&project_name={}", @@ -91,6 +98,66 @@ pub async fn get_prompt_by_slug( 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" }], + "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, @@ -127,3 +194,38 @@ 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/mod.rs b/src/prompts/mod.rs index 06c2c582..0a6c5512 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -9,6 +9,7 @@ mod api; mod assign; mod delete; mod list; +mod versions; mod view; #[derive(Debug, Clone, Args)] @@ -16,6 +17,7 @@ mod view; Examples: bt prompts list 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 @@ -32,6 +34,8 @@ enum PromptsCommands { 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 @@ -80,7 +84,7 @@ pub struct ListArgs { } #[derive(Debug, Clone, Args)] -pub struct ViewArgs { +struct PromptSlugArgs { /// Prompt slug (positional) #[arg(value_name = "SLUG")] slug_positional: Option, @@ -88,16 +92,9 @@ pub struct ViewArgs { /// Prompt slug (flag) #[arg(long = "slug", short = 's')] slug_flag: Option, - - #[command(flatten)] - selector: PromptSelectorArgs, - - /// 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() @@ -106,72 +103,46 @@ impl ViewArgs { } #[derive(Debug, Clone, Args)] -pub struct AssignArgs { - /// Prompt slug (positional) - #[arg(value_name = "SLUG")] - slug_positional: Option, - - /// Prompt slug (flag) - #[arg(long = "slug", short = 's')] - slug_flag: Option, +pub struct ViewArgs { + #[command(flatten)] + slug: PromptSlugArgs, #[command(flatten)] selector: PromptSelectorArgs, + + /// Open in browser instead of showing in terminal + #[arg(long)] + web: bool, } -impl AssignArgs { - fn slug(&self) -> Option<&str> { - self.slug_positional - .as_deref() - .or(self.slug_flag.as_deref()) - } +#[derive(Debug, Clone, Args)] +pub struct AssignArgs { + #[command(flatten)] + slug: PromptSlugArgs, + + #[command(flatten)] + selector: PromptSelectorArgs, } #[derive(Debug, Clone, Args)] pub struct UnassignArgs { - /// Prompt slug (positional) - #[arg(value_name = "SLUG")] - slug_positional: Option, - - /// Prompt slug (flag) - #[arg(long = "slug", short = 's')] - slug_flag: Option, + #[command(flatten)] + slug: PromptSlugArgs, #[command(flatten)] environment: PromptEnvironmentArgs, } -impl UnassignArgs { - fn slug(&self) -> Option<&str> { - self.slug_positional - .as_deref() - .or(self.slug_flag.as_deref()) - } -} - #[derive(Debug, Clone, Args)] pub struct DeleteArgs { - /// Prompt slug (positional) of the prompt to delete - #[arg(value_name = "SLUG")] - slug_positional: Option, - - /// Prompt slug (flag) of the prompt to delete - #[arg(long = "slug", short = 's')] - slug_flag: Option, + #[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()) - } -} - pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { let read_only = prompts_command_is_read_only(args.command.as_ref()); let ctx = resolve_project_command_context_with_auth_mode(&base, read_only).await?; @@ -181,13 +152,14 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { Some(PromptsCommands::List(args)) => { list::run(&ctx, args.environment.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(), + args.slug.slug(), args.selector.version(), args.selector.environment(), base.json, @@ -209,7 +181,7 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; assign::run( &ctx, - args.slug(), + args.slug.slug(), environment, assign::Action::Assign { version }, base.json, @@ -225,21 +197,25 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; assign::run( &ctx, - args.slug(), + args.slug.slug(), environment, assign::Action::Unassign, base.json, ) .await } - Some(PromptsCommands::Delete(args)) => delete::run(&ctx, args.slug(), args.force).await, + Some(PromptsCommands::Delete(args)) => { + delete::run(&ctx, args.slug.slug(), args.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(_)) ) } @@ -266,6 +242,13 @@ mod tests { } } + fn slug(slug: &str) -> PromptSlugArgs { + PromptSlugArgs { + slug_positional: Some(slug.to_string()), + slug_flag: None, + } + } + #[test] fn subcommands_only_expose_supported_selectors() { let list = @@ -282,6 +265,13 @@ mod tests { .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", @@ -309,27 +299,27 @@ mod tests { )))); assert!(prompts_command_is_read_only(Some(&PromptsCommands::View( ViewArgs { - slug_positional: Some("test-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_mutations_to_validated_auth() { assert!(!prompts_command_is_read_only(Some( &PromptsCommands::Assign(AssignArgs { - slug_positional: Some("test-prompt".to_string()), - slug_flag: None, + slug: slug("test-prompt"), selector: selectors(Some("1234"), Some("production")), }) ))); assert!(!prompts_command_is_read_only(Some( &PromptsCommands::Delete(DeleteArgs { - slug_positional: Some("test-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..2309d79a --- /dev/null +++ b/src/prompts/versions.rs @@ -0,0 +1,62 @@ +use std::fmt::Write as _; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::console; + +use crate::prompts::delete::select_prompt_interactive; +use crate::ui::{header, print_with_pager, styled_table, with_spinner}; +use crate::utils::pluralize; + +use super::{api, ResolvedContext}; + +pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, json: bool) -> Result<()> { + let project_name = &ctx.project.name; + let prompt = match slug { + Some(slug) => with_spinner( + "Loading prompt...", + api::get_prompt_by_slug(&ctx.client, project_name, slug, None, None), + ) + .await? + .ok_or_else(|| anyhow!("prompt with slug '{slug}' not found"))?, + None => { + if !crate::ui::is_interactive() { + bail!("prompt slug required. Use: bt prompts versions "); + } + select_prompt_interactive(&ctx.client, project_name).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/util_cmd.rs b/src/util_cmd.rs index 7ff723e9..889410ad 100644 --- a/src/util_cmd.rs +++ b/src/util_cmd.rs @@ -388,7 +388,7 @@ 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}") } From e24c7ae3158c14a819c18eebd15eb2f1797ddd4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 17:39:33 -0700 Subject: [PATCH 06/11] fix: bt prompts versions/view showing the same id in 2 different ways --- src/prompts/view.rs | 59 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 535f9629..52b18b41 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -31,7 +31,34 @@ pub async fn run( if !crate::ui::is_interactive() { bail!("prompt slug required. Use: bt prompts view "); } - select_prompt_interactive(&ctx.client, project_name).await? + let selected = select_prompt_interactive(&ctx.client, project_name).await?; + if version.is_some() || environment.is_some() { + with_spinner( + "Loading prompt...", + api::get_prompt_by_slug( + &ctx.client, + project_name, + &selected.slug, + 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!( + "prompt with slug '{}' not found at {selector}", + selected.slug + ) + })? + } else { + selected + } } }; @@ -64,7 +91,12 @@ pub async fn run( )?; } if let Some(version) = prompt._xact_id.as_deref().or(version) { - writeln!(output, "{} {}", console::style("Version:").dim(), version)?; + writeln!( + output, + "{} {}", + console::style("Version:").dim(), + display_version(version) + )?; } let options = prompt.prompt_data.as_ref().and_then(|pd| pd.get("options")); @@ -91,3 +123,26 @@ pub async fn run( print_with_pager(&output)?; Ok(()) } + +fn display_version(version: &str) -> String { + if version.len() == 16 && version.chars().all(|c| c.is_ascii_hexdigit()) { + return version.to_string(); + } + + version + .parse::() + .map(crate::util_cmd::prettify_xact) + .unwrap_or_else(|_| version.to_string()) +} + +#[cfg(test)] +mod tests { + use super::display_version; + + #[test] + fn display_version_uses_pretty_encoding_for_xact_ids() { + assert_eq!(display_version("1000192656880881099"), "81cd05ee665fdfb3"); + assert_eq!(display_version("81cd05ee665fdfb3"), "81cd05ee665fdfb3"); + assert_eq!(display_version("1234567890123456"), "1234567890123456"); + } +} From 30a87ce2fc867d8380925ea90e3769e513a6bcb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 18:14:30 -0700 Subject: [PATCH 07/11] chore(prompts): sort `bt prompt versions` output newest first --- src/prompts/api.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/prompts/api.rs b/src/prompts/api.rs index a868141d..d7806766 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -125,6 +125,10 @@ fn prompt_versions_request(project_id: &str, prompt_id: &str) -> Value { "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"] }, From 02715c0b702f0401458a6605f75dc4044d81ac06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 18:30:34 -0700 Subject: [PATCH 08/11] chore(prompts): the slop machine removed 140 lines of code without changing anything --- src/functions/mod.rs | 66 +++++------------------ src/http.rs | 89 ++++++++++--------------------- src/prompts/api.rs | 25 +++------ src/prompts/delete.rs | 7 ++- src/prompts/list.rs | 38 ++++--------- src/prompts/mod.rs | 115 +++++++++++++++++++++++----------------- src/prompts/versions.rs | 21 ++------ src/prompts/view.rs | 49 ++--------------- 8 files changed, 137 insertions(+), 273 deletions(-) diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 4b1b29de..a395c6ba 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -454,6 +454,16 @@ pub struct ViewArgs { } 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(), @@ -633,35 +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, - view::ViewOptions { - version: v.version.as_deref(), - environment: v.environment.as_deref(), - json: base.json, - web: v.web, - verbose: 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, - view::ViewOptions { - version: v.version.as_deref(), - environment: v.environment.as_deref(), - json: base.json, - web: v.web, - verbose: base.verbose, - }, - ft, - ) - .await + view::run(&ctx, slug, v.options(&base), ft).await } }, command => { @@ -694,35 +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, - view::ViewOptions { - version: v.inner.version.as_deref(), - environment: v.inner.environment.as_deref(), - json: base.json, - web: v.inner.web, - verbose: 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, - view::ViewOptions { - version: v.inner.version.as_deref(), - environment: v.inner.environment.as_deref(), - json: base.json, - web: v.inner.web, - verbose: base.verbose, - }, - ft, - ) - .await + view::run(&ctx, slug, v.inner.options(&base), ft).await } } } diff --git a/src/http.rs b/src/http.rs index 00cbacf4..5f2265f0 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,44 +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 { - let url = self.url(path); - let response = self - .http - .put(&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, "PUT", path).await + self.send_json(Method::PUT, path, body).await } pub async fn patch( @@ -258,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( @@ -342,37 +316,28 @@ impl ApiClient { request.send().await.context("request failed") } - pub async fn delete(&self, path: &str) -> Result<()> { - let response = self.send_delete(path).await?; - + async fn send_delete(&self, path: &str) -> Result { + let response = self + .http + .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(()) + Ok(response) } - pub async fn delete_with_response(&self, path: &str) -> Result { - let response = self.send_delete(path).await?; - - 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, "DELETE", path).await + pub async fn delete(&self, path: &str) -> Result<()> { + self.send_delete(path).await.map(|_| ()) } - async fn send_delete(&self, path: &str) -> Result { - self.http - .delete(self.url(path)) - .bearer_auth(&self.api_key) - .send() - .await - .context("request failed") + pub async fn delete_with_response(&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 d7806766..736a2efa 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -45,28 +45,19 @@ struct PromptVersionsResponse { data: 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 list_prompts_by_environment( +pub async fn list_prompts( client: &ApiClient, project: &str, - environment: &str, + environment: Option<&str>, ) -> Result> { - let path = format!( - "/v1/prompt?org_name={}&project_name={}&environment={}", + let mut path = format!( + "/v1/prompt?org_name={}&project_name={}", encode(client.org_name()), - encode(project), - encode(environment) + encode(project) ); + if let Some(environment) = environment { + path.push_str(&format!("&environment={}", encode(environment))); + } let list: ListResponse = client.get(&path).await?; Ok(list.objects) } diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index 8692e810..1f280c3b 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -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 3969f422..ebc9ad1a 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -12,14 +12,10 @@ use super::{api, ResolvedContext}; pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) -> Result<()> { let project_name = &ctx.project.name; - let prompts = with_spinner("Loading prompts...", async { - match environment { - Some(environment) => { - api::list_prompts_by_environment(&ctx.client, project_name, environment).await - } - None => api::list_prompts(&ctx.client, project_name).await, - } - }) + let prompts = with_spinner( + "Loading prompts...", + api::list_prompts(&ctx.client, project_name, environment), + ) .await?; if json { @@ -47,16 +43,11 @@ pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) - )?; let mut table = styled_table(); + let mut headers = vec![header("Name"), header("Description"), header("Slug")]; if environment.is_some() { - table.set_header(vec![ - header("Name"), - header("Description"), - header("Slug"), - header("Version"), - ]); - } else { - table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + headers.push(header("Version")); } + table.set_header(headers); apply_column_padding(&mut table, (0, 6)); for prompt in &prompts { @@ -66,20 +57,11 @@ pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) - .filter(|s| !s.is_empty()) .map(|s| truncate(s, 60)) .unwrap_or_else(|| "-".to_string()); + let mut row = vec![prompt.name.as_str(), desc.as_str(), prompt.slug.as_str()]; if environment.is_some() { - table.add_row(vec![ - prompt.name.as_str(), - desc.as_str(), - prompt.slug.as_str(), - prompt._xact_id.as_deref().unwrap_or("-"), - ]); - } else { - table.add_row(vec![ - prompt.name.as_str(), - desc.as_str(), - prompt.slug.as_str(), - ]); + row.push(prompt._xact_id.as_deref().unwrap_or("-")); } + table.add_row(row); } write!(output, "{table}")?; diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 0a6c5512..9543467a 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1,6 +1,7 @@ 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; @@ -45,42 +46,21 @@ enum PromptsCommands { } #[derive(Debug, Clone, Args)] -struct PromptEnvironmentArgs { - /// Environment slug (for example, production) - #[arg(long)] - environment: Option, -} - -#[derive(Debug, Clone, Args)] -struct PromptVersionArgs { +struct PromptSelectorArgs { /// Prompt version identifier (for example, a transaction ID) #[arg(long)] version: Option, -} -#[derive(Debug, Clone, Args)] -struct PromptSelectorArgs { - #[command(flatten)] - version: PromptVersionArgs, - - #[command(flatten)] - environment: PromptEnvironmentArgs, -} - -impl PromptSelectorArgs { - fn version(&self) -> Option<&str> { - self.version.version.as_deref() - } - - fn environment(&self) -> Option<&str> { - self.environment.environment.as_deref() - } + /// Environment slug (for example, production) + #[arg(long)] + environment: Option, } #[derive(Debug, Clone, Args)] pub struct ListArgs { - #[command(flatten)] - environment: PromptEnvironmentArgs, + /// Environment slug (for example, production) + #[arg(long)] + environment: Option, } #[derive(Debug, Clone, Args)] @@ -129,8 +109,9 @@ pub struct UnassignArgs { #[command(flatten)] slug: PromptSlugArgs, - #[command(flatten)] - environment: PromptEnvironmentArgs, + /// Environment slug (for example, production) + #[arg(long)] + environment: Option, } #[derive(Debug, Clone, Args)] @@ -143,6 +124,47 @@ pub struct DeleteArgs { force: bool, } +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 {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<()> { let read_only = prompts_command_is_read_only(args.command.as_ref()); let ctx = resolve_project_command_context_with_auth_mode(&base, read_only).await?; @@ -150,18 +172,18 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { match args.command { None => list::run(&ctx, None, base.json).await, Some(PromptsCommands::List(args)) => { - list::run(&ctx, args.environment.environment.as_deref(), base.json).await + 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() { + 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(), - args.selector.environment(), + args.selector.version.as_deref(), + args.selector.environment.as_deref(), base.json, args.web, base.verbose, @@ -173,11 +195,13 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { "Use: bt prompts assign --environment --version "; let version = args .selector - .version() + .version + .as_deref() .ok_or_else(|| anyhow!("--version is required. {hint}"))?; let environment = args .selector - .environment() + .environment + .as_deref() .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; assign::run( &ctx, @@ -191,7 +215,6 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { Some(PromptsCommands::Unassign(args)) => { let hint = "Use: bt prompts unassign --environment "; let environment = args - .environment .environment .as_deref() .ok_or_else(|| anyhow!("--environment is required. {hint}"))?; @@ -233,12 +256,8 @@ mod tests { fn selectors(version: Option<&str>, environment: Option<&str>) -> PromptSelectorArgs { PromptSelectorArgs { - version: PromptVersionArgs { - version: version.map(ToOwned::to_owned), - }, - environment: PromptEnvironmentArgs { - environment: environment.map(ToOwned::to_owned), - }, + version: version.map(ToOwned::to_owned), + environment: environment.map(ToOwned::to_owned), } } @@ -257,7 +276,7 @@ mod tests { let Some(PromptsCommands::List(list)) = list.prompts.command else { panic!("expected list command"); }; - assert_eq!(list.environment.environment.as_deref(), Some("production")); + 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"); @@ -285,17 +304,15 @@ mod tests { let Some(PromptsCommands::Assign(assign)) = assign.prompts.command else { panic!("expected assign command"); }; - assert_eq!(assign.selector.version(), Some("1234")); - assert_eq!(assign.selector.environment(), Some("production")); + 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( - ListArgs { - environment: PromptEnvironmentArgs { environment: None }, - } + ListArgs { environment: None } )))); assert!(prompts_command_is_read_only(Some(&PromptsCommands::View( ViewArgs { diff --git a/src/prompts/versions.rs b/src/prompts/versions.rs index 2309d79a..c00e318b 100644 --- a/src/prompts/versions.rs +++ b/src/prompts/versions.rs @@ -1,30 +1,15 @@ 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::{header, print_with_pager, styled_table, with_spinner}; use crate::utils::pluralize; -use super::{api, ResolvedContext}; +use super::{api, resolve_prompt, ResolvedContext}; pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, json: bool) -> Result<()> { - let project_name = &ctx.project.name; - let prompt = match slug { - Some(slug) => with_spinner( - "Loading prompt...", - api::get_prompt_by_slug(&ctx.client, project_name, slug, None, None), - ) - .await? - .ok_or_else(|| anyhow!("prompt with slug '{slug}' not found"))?, - None => { - if !crate::ui::is_interactive() { - bail!("prompt slug required. Use: bt prompts versions "); - } - select_prompt_interactive(&ctx.client, project_name).await? - } - }; + let prompt = resolve_prompt(ctx, slug, None, None, "bt prompts versions ").await?; let versions = with_spinner( "Loading prompt versions...", diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 52b18b41..5fdf7b46 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -1,14 +1,13 @@ 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::ui::{print_command_status, print_with_pager, CommandStatus}; use crate::utils::app_project_url; -use super::{api, ResolvedContext}; +use super::{resolve_prompt, ResolvedContext}; pub async fn run( ctx: &ResolvedContext, @@ -20,47 +19,7 @@ pub async fn run( 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, version, environment), - ) - .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 "); - } - let selected = select_prompt_interactive(&ctx.client, project_name).await?; - if version.is_some() || environment.is_some() { - with_spinner( - "Loading prompt...", - api::get_prompt_by_slug( - &ctx.client, - project_name, - &selected.slug, - 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!( - "prompt with slug '{}' not found at {selector}", - selected.slug - ) - })? - } else { - selected - } - } - }; + let prompt = resolve_prompt(ctx, slug, version, environment, "bt prompts view ").await?; if web { let url = app_project_url( From 6a94efaee20705ed0f67f6f63da78dfb1229b642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 18:58:16 -0700 Subject: [PATCH 09/11] chore(prompts): output short id everywhere --- src/prompts/api.rs | 5 ++++- src/prompts/assign.rs | 10 ++++++++-- src/prompts/list.rs | 7 ++++++- src/prompts/mod.rs | 4 ++-- src/prompts/view.rs | 25 +------------------------ src/util_cmd.rs | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 736a2efa..c9f8b6bd 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -69,12 +69,15 @@ pub async fn get_prompt_by_slug( 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) = version { + if let Some(version) = normalized_version.as_deref() { params.push(("version", version)); } if let Some(environment) = environment { diff --git a/src/prompts/assign.rs b/src/prompts/assign.rs index b96a17af..ecf98a87 100644 --- a/src/prompts/assign.rs +++ b/src/prompts/assign.rs @@ -39,7 +39,10 @@ pub async fn run( ) .await? .ok_or_else(|| match version { - Some(version) => anyhow!("prompt with slug '{slug}' not found at version {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"), })?; @@ -70,7 +73,10 @@ pub async fn run( } else { let message = match action { Action::Assign { version } => { - format!("Assigned prompt '{slug}' version {version} to environment '{environment}'") + format!( + "Assigned prompt '{slug}' version {} to environment '{environment}'", + crate::util_cmd::display_xact_id(version) + ) } Action::Unassign => { format!("Unassigned prompt '{slug}' from environment '{environment}'") diff --git a/src/prompts/list.rs b/src/prompts/list.rs index ebc9ad1a..80e6aa8e 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -57,9 +57,14 @@ pub async fn run(ctx: &ResolvedContext, environment: Option<&str>, json: bool) - .filter(|s| !s.is_empty()) .map(|s| truncate(s, 60)) .unwrap_or_else(|| "-".to_string()); + 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(prompt._xact_id.as_deref().unwrap_or("-")); + row.push(version.as_str()); } table.add_row(row); } diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 9543467a..127d95b4 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -47,7 +47,7 @@ enum PromptsCommands { #[derive(Debug, Clone, Args)] struct PromptSelectorArgs { - /// Prompt version identifier (for example, a transaction ID) + /// Prompt version ID (short or decimal transaction ID) #[arg(long)] version: Option, @@ -155,7 +155,7 @@ async fn resolve_prompt( .ok_or_else(|| { if interactive_selection { let selector = version - .map(|value| format!("version {value}")) + .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}") diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 5fdf7b46..5a0cfda9 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -54,7 +54,7 @@ pub async fn run( output, "{} {}", console::style("Version:").dim(), - display_version(version) + crate::util_cmd::display_xact_id(version) )?; } @@ -82,26 +82,3 @@ pub async fn run( print_with_pager(&output)?; Ok(()) } - -fn display_version(version: &str) -> String { - if version.len() == 16 && version.chars().all(|c| c.is_ascii_hexdigit()) { - return version.to_string(); - } - - version - .parse::() - .map(crate::util_cmd::prettify_xact) - .unwrap_or_else(|_| version.to_string()) -} - -#[cfg(test)] -mod tests { - use super::display_version; - - #[test] - fn display_version_uses_pretty_encoding_for_xact_ids() { - assert_eq!(display_version("1000192656880881099"), "81cd05ee665fdfb3"); - assert_eq!(display_version("81cd05ee665fdfb3"), "81cd05ee665fdfb3"); - assert_eq!(display_version("1234567890123456"), "1234567890123456"); - } -} diff --git a/src/util_cmd.rs b/src/util_cmd.rs index 889410ad..842f65a1 100644 --- a/src/util_cmd.rs +++ b/src/util_cmd.rs @@ -393,6 +393,27 @@ pub(crate) fn prettify_xact(value: u64) -> String { 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; From a1bc1681d180f48e4e2f4ef295c69fb7013d8cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 19:12:19 -0700 Subject: [PATCH 10/11] fix(prompts): Positional slug and --slug are now incompatible in `bt prompts` --- src/prompts/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 127d95b4..7f754e81 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -66,7 +66,7 @@ pub struct ListArgs { #[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) From 0d4b76a94c636f69a05376a91690f51997db7f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 25 Aug 2026 19:18:51 -0700 Subject: [PATCH 11/11] fix(prompts): bt prompts/functions view --web now respect --version and --environment --- src/functions/view.rs | 10 +++++++-- src/prompts/view.rs | 5 +++-- src/utils/app_url.rs | 47 +++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 4 +++- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/functions/view.rs b/src/functions/view.rs index 7c5867f7..d6074548 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -9,7 +9,7 @@ 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}; @@ -123,8 +123,14 @@ async fn render_function( 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(()); diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 5a0cfda9..862c5a44 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -5,7 +5,7 @@ use dialoguer::console; use crate::ui::prompt_render::{render_options, render_prompt_block}; use crate::ui::{print_command_status, print_with_pager, CommandStatus}; -use crate::utils::app_project_url; +use crate::utils::{app_project_url, app_url_with_selected_version}; use super::{resolve_prompt, ResolvedContext}; @@ -22,12 +22,13 @@ pub async fn run( 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(()); 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,