Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ where
pub async fn describe_app(
config: &Config,
name: &str,
environment: Option<&str>,
) -> Result<
tower_api::models::DescribeAppResponse,
Error<tower_api::apis::default_api::DescribeAppError>,
Expand All @@ -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(
Expand All @@ -115,12 +116,15 @@ pub async fn describe_app(

pub async fn list_apps(
config: &Config,
environment: Option<&str>,
) -> Result<Vec<tower_api::models::AppSummary>, Error<tower_api::apis::default_api::ListAppsError>>
{
let api_config: configuration::Configuration = config.into();
let environment = environment.map(|s| s.to_string());
Comment thread
codingcyclist marked this conversation as resolved.

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,
Expand All @@ -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
}
Expand Down
83 changes: 77 additions & 6 deletions crates/tower-cmd/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -130,8 +150,9 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) {
let name = cmd
.get_one::<String>("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);
Expand Down Expand Up @@ -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::<String>("environment").map(|s| s.as_str());
let apps = output::with_spinner("Listing apps", api::list_apps(&config, env)).await;

let items = apps
.iter()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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::<String>("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::<String>("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::<String>("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::<String>("environment").unwrap(),
"production"
);
}
}
2 changes: 1 addition & 1 deletion crates/tower-cmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
118 changes: 109 additions & 9 deletions crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,44 @@ struct RunRequest {
#[serde(flatten)]
common: CommonParams,
parameters: Option<std::collections::HashMap<String, String>>,
/// The environment to run the app in (defaults to "default")
environment: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct DeployRequest {
#[serde(flatten)]
common: CommonParams,
/// The environment to deploy to (defaults to "default")
environment: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ListAppsRequest {
/// Filter apps by environment. If not provided, apps across all environments are returned.
environment: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ShowAppRequest {
/// Name of the app
name: String,
/// The environment to resolve the app against (defaults to "default")
environment: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ListCatalogsRequest {
/// The environment to list catalogs from (defaults to "default")
environment: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ShowCatalogRequest {
/// Name of the catalog
name: String,
/// The environment the catalog belongs to (defaults to "default")
environment: Option<String>,
}

pub fn mcp_cmd() -> Command {
Expand Down Expand Up @@ -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<CallToolResult, McpError> {
match api::list_apps(&self.config).await {
async fn tower_apps_list(
&self,
Parameters(request): Parameters<ListAppsRequest>,
) -> Result<CallToolResult, McpError> {
let environment = request.environment.as_deref();
match api::list_apps(&self.config, environment).await {
Ok(apps) => {
let apps: Vec<Value> = apps
.into_iter()
Expand All @@ -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)
})
Expand All @@ -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<NameRequest>,
Parameters(request): Parameters<ShowAppRequest>,
) -> Result<CallToolResult, McpError> {
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": {
Expand Down Expand Up @@ -657,6 +701,61 @@ impl TowerService {
}
}

#[tool(description = "List catalogs in your Tower account")]
async fn tower_catalogs_list(
&self,
Parameters(request): Parameters<ListCatalogsRequest>,
) -> Result<CallToolResult, McpError> {
let environment = request.environment.as_deref().unwrap_or("default");
match api::list_catalogs(&self.config, environment, false).await {
Ok(catalogs) => {
let catalogs: Vec<Value> = 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<ShowCatalogRequest>,
) -> Result<CallToolResult, McpError> {
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<Value> = 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<CallToolResult, McpError> {
if self.config.api_key.is_some() {
Expand Down Expand Up @@ -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<EmptyRequest>,
Parameters(request): Parameters<DeployRequest>,
) -> Result<CallToolResult, McpError> {
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()),
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading