From 53c625fc00165dfa70216c2baa88efc9c9f96a82 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] 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 d5a500d0..b21e1c3a 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"));