diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 4daeab17..a09ce26f 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -92,6 +92,7 @@ where pub async fn describe_app( config: &Config, name: &str, + environment: Option<&str>, ) -> Result< tower_api::models::DescribeAppResponse, Error, @@ -104,7 +105,7 @@ pub async fn describe_app( start_at: None, end_at: None, timezone: None, - environment: None, + environment: environment.map(|s| s.to_string()), }; unwrap_api_response(tower_api::apis::default_api::describe_app( @@ -115,12 +116,15 @@ pub async fn describe_app( pub async fn list_apps( config: &Config, + environment: Option<&str>, ) -> Result, Error> { let api_config: configuration::Configuration = config.into(); + let environment = environment.map(|s| s.to_string()); fetch_all_pages(|page, page_size| { let api_config = &api_config; + let environment = &environment; async move { let params = tower_api::apis::default_api::ListAppsParams { query: None, @@ -129,7 +133,7 @@ pub async fn list_apps( num_runs: Some(0), sort: None, filter: None, - environment: None, + environment: environment.clone(), }; unwrap_api_response(tower_api::apis::default_api::list_apps(api_config, params)).await } diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index 3a779426..e04c3dc9 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -5,13 +5,24 @@ use tokio::time::{sleep, Duration, Instant}; use tower_api::models::{Run, RunLogLine}; -use crate::{api, output}; +use crate::{api, output, util::cmd}; pub fn apps_cmd() -> Command { Command::new("apps") .about("Manage the apps in your current Tower account") .arg_required_else_help(true) - .subcommand(Command::new("list").about("List all apps in your Tower account")) + .subcommand( + Command::new("list") + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .value_parser(value_parser!(String)) + .help("Filter apps by environment") + .action(clap::ArgAction::Set), + ) + .about("List all apps in your Tower account"), + ) .subcommand( Command::new("show") .arg( @@ -21,6 +32,15 @@ pub fn apps_cmd() -> Command { .required(true) .help("Name of the app"), ) + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .default_value("default") + .value_parser(value_parser!(String)) + .help("The environment to resolve the app against") + .action(clap::ArgAction::Set), + ) .about("Show details for a Tower app and its recent runs"), ) .subcommand( @@ -130,8 +150,9 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name is required"); + let env = cmd::get_string_flag(cmd, "environment"); - match api::describe_app(&config, &name).await { + match api::describe_app(&config, &name, Some(&env)).await { Ok(app_response) => { if output::get_output_mode().is_json() { output::json(&app_response); @@ -209,8 +230,9 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) { } } -pub async fn do_list_apps(config: Config) { - let apps = output::with_spinner("Listing apps", api::list_apps(&config)).await; +pub async fn do_list_apps(config: Config, args: &ArgMatches) { + let env = args.get_one::("environment").map(|s| s.as_str()); + let apps = output::with_spinner("Listing apps", api::list_apps(&config, env)).await; let items = apps .iter() @@ -269,7 +291,7 @@ pub async fn do_cancel(config: Config, cmd: &ArgMatches) { } async fn latest_run_number(config: &Config, name: &str) -> i64 { - match api::describe_app(config, name).await { + match api::describe_app(config, name, None).await { Ok(resp) => resp .runs .iter() @@ -808,4 +830,53 @@ mod tests { let result = apps_cmd().try_get_matches_from(["apps", "cancel"]); assert!(result.is_err()); } + + #[test] + fn list_defaults_to_no_environment_filter() { + let matches = apps_cmd() + .try_get_matches_from(["apps", "list"]) + .unwrap(); + let (_, list_args) = matches.subcommand().unwrap(); + + assert_eq!(list_args.get_one::("environment"), None); + } + + #[test] + fn list_accepts_environment_flag() { + let matches = apps_cmd() + .try_get_matches_from(["apps", "list", "-e", "production"]) + .unwrap(); + let (_, list_args) = matches.subcommand().unwrap(); + + assert_eq!( + list_args.get_one::("environment").map(|s| s.as_str()), + Some("production") + ); + } + + #[test] + fn show_defaults_to_default_environment() { + let matches = apps_cmd() + .try_get_matches_from(["apps", "show", "my-app"]) + .unwrap(); + let (_, show_args) = matches.subcommand().unwrap(); + + assert_eq!( + show_args.get_one::("environment").unwrap(), + "default" + ); + } + + #[test] + fn show_accepts_environment_flag() { + let matches = apps_cmd() + .try_get_matches_from(["apps", "show", "my-app", "-e", "production"]) + .unwrap(); + let (_, show_args) = matches.subcommand().unwrap(); + + assert_eq!( + show_args.get_one::("environment").unwrap(), + "production" + ); + } } diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index e42789b7..5873aac8 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -123,7 +123,7 @@ impl App { let apps_command = sub_matches.subcommand(); match apps_command { - Some(("list", _)) => apps::do_list_apps(sessionized_config).await, + Some(("list", args)) => apps::do_list_apps(sessionized_config, args).await, Some(("create", args)) => apps::do_create(sessionized_config, args).await, Some(("show", args)) => apps::do_show(sessionized_config, args).await, Some(("logs", args)) => apps::do_logs(sessionized_config, args).await, diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index 59cb2296..aeedb285 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -159,6 +159,44 @@ struct RunRequest { #[serde(flatten)] common: CommonParams, parameters: Option>, + /// The environment to run the app in (defaults to "default") + environment: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct DeployRequest { + #[serde(flatten)] + common: CommonParams, + /// The environment to deploy to (defaults to "default") + environment: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ListAppsRequest { + /// Filter apps by environment. If not provided, apps across all environments are returned. + environment: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ShowAppRequest { + /// Name of the app + name: String, + /// The environment to resolve the app against (defaults to "default") + environment: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ListCatalogsRequest { + /// The environment to list catalogs from (defaults to "default") + environment: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ShowCatalogRequest { + /// Name of the catalog + name: String, + /// The environment the catalog belongs to (defaults to "default") + environment: Option, } pub fn mcp_cmd() -> Command { @@ -464,8 +502,12 @@ impl TowerService { // share constants directly. MCP-only descriptions (with Prerequisites/Optional) are // intentionally more detailed and don't need a CLI counterpart. #[tool(description = "List all apps in your Tower account")] - async fn tower_apps_list(&self) -> Result { - match api::list_apps(&self.config).await { + async fn tower_apps_list( + &self, + Parameters(request): Parameters, + ) -> Result { + let environment = request.environment.as_deref(); + match api::list_apps(&self.config, environment).await { Ok(apps) => { let apps: Vec = apps .into_iter() @@ -474,6 +516,7 @@ impl TowerService { json!({ "name": app.name, "description": app.short_description, + "version": app.version, "created_at": app.created_at, "status": format!("{:?}", app.status) }) @@ -499,9 +542,10 @@ impl TowerService { #[tool(description = "Show details for a Tower app and its recent runs")] async fn tower_apps_show( &self, - Parameters(request): Parameters, + Parameters(request): Parameters, ) -> Result { - match api::describe_app(&self.config, &request.name).await { + let environment = request.environment.as_deref().unwrap_or("default"); + match api::describe_app(&self.config, &request.name, Some(environment)).await { Ok(response) => { let data = json!({ "app": { @@ -657,6 +701,61 @@ impl TowerService { } } + #[tool(description = "List catalogs in your Tower account")] + async fn tower_catalogs_list( + &self, + Parameters(request): Parameters, + ) -> Result { + let environment = request.environment.as_deref().unwrap_or("default"); + match api::list_catalogs(&self.config, environment, false).await { + Ok(catalogs) => { + let catalogs: Vec = catalogs + .into_iter() + .map(|catalog| { + json!({ + "name": catalog.name, + "type": catalog.r#type, + "environment": catalog.environment, + }) + }) + .collect(); + Self::json_success(json!({"catalogs": catalogs})) + } + Err(e) => Self::error_result("Failed to list catalogs", e), + } + } + + #[tool(description = "Show details for a catalog, including its property names")] + async fn tower_catalogs_show( + &self, + Parameters(request): Parameters, + ) -> Result { + let environment = request.environment.as_deref().unwrap_or("default"); + match api::describe_catalog(&self.config, &request.name, environment).await { + Ok(response) => { + let catalog = &response.catalog; + let properties: Vec = catalog + .properties + .iter() + .map(|prop| { + json!({ + "name": prop.name, + "environment_variable": prop.environment_variable, + "preview": prop.preview, + }) + }) + .collect(); + Self::json_success(json!({ + "name": catalog.name, + "type": catalog.r#type, + "environment": catalog.environment, + "properties": properties, + })) + } + Err(e) => Self::error_result("Failed to show catalog", e), + } + } + #[tool(description = "List teams you belong to")] async fn tower_teams_list(&self) -> Result { if self.config.api_key.is_some() { @@ -703,14 +802,15 @@ impl TowerService { } #[tool( - description = "Deploy to Tower cloud. Prerequisites: Towerfile, tower_apps_create. Optional: working_directory." + description = "Deploy to Tower cloud. Prerequisites: Towerfile, tower_apps_create. Optional: working_directory, environment." )] async fn tower_deploy( &self, - Parameters(request): Parameters, + Parameters(request): Parameters, ) -> Result { let working_dir = Self::resolve_working_directory(&request.common); - let deploy_target = deploy::DeployTarget::Environment("default".to_string()); + let env = request.environment.unwrap_or_else(|| "default".to_string()); + let deploy_target = deploy::DeployTarget::Environment(env); match deploy::deploy_from_dir(self.config.clone(), working_dir, true, deploy_target).await { Ok(_) => Self::text_success("Deploy completed successfully".to_string()), @@ -770,7 +870,7 @@ impl TowerService { let config = self.config.clone(); let working_dir = Self::resolve_working_directory(&request.common); let path = working_dir; - let env = "default"; + let env = request.environment.unwrap_or_else(|| "default".to_string()); let params = request.parameters.unwrap_or_default(); // Load Towerfile to get app name @@ -782,7 +882,7 @@ impl TowerService { let app_name = towerfile.app.name.clone(); let (result, output) = Self::execute_with_streaming(&ctx, || { - run::do_run_remote(config, path, env, params, None, true) + run::do_run_remote(config, path, &env, params, None, true) }) .await; match result {