syncable_cli/agent/tools/platform/
list_deployment_capabilities.rs

1//! List deployment capabilities tool for the agent
2//!
3//! Wraps the existing `get_provider_deployment_statuses` function to allow
4//! the agent to discover available deployment options for a project.
5
6use rig::completion::ToolDefinition;
7use rig::tool::Tool;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
12use crate::platform::api::{PlatformApiClient, PlatformApiError};
13use crate::wizard::get_provider_deployment_statuses;
14
15/// Arguments for the list deployment capabilities tool
16#[derive(Debug, Deserialize)]
17pub struct ListDeploymentCapabilitiesArgs {
18    /// The project UUID to check capabilities for
19    pub project_id: String,
20}
21
22/// Error type for list deployment capabilities operations
23#[derive(Debug, thiserror::Error)]
24#[error("List deployment capabilities error: {0}")]
25pub struct ListDeploymentCapabilitiesError(String);
26
27/// Tool to list available deployment capabilities for a project
28///
29/// Returns information about connected providers, available clusters,
30/// registries, and Cloud Run availability.
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct ListDeploymentCapabilitiesTool;
33
34impl ListDeploymentCapabilitiesTool {
35    /// Create a new ListDeploymentCapabilitiesTool
36    pub fn new() -> Self {
37        Self
38    }
39}
40
41impl Tool for ListDeploymentCapabilitiesTool {
42    const NAME: &'static str = "list_deployment_capabilities";
43
44    type Error = ListDeploymentCapabilitiesError;
45    type Args = ListDeploymentCapabilitiesArgs;
46    type Output = String;
47
48    async fn definition(&self, _prompt: String) -> ToolDefinition {
49        ToolDefinition {
50            name: Self::NAME.to_string(),
51            description: r#"List available deployment capabilities for a project.
52
53Returns information about which cloud providers are connected and what deployment
54targets are available (clusters, registries, Cloud Run).
55
56**Parameters:**
57- project_id: The UUID of the project to check
58
59**Prerequisites:**
60- User must be authenticated via `sync-ctl auth login`
61- User must have access to the project
62
63**What it returns:**
64- providers: Array of provider status objects with:
65  - provider: Provider name (Gcp, Hetzner, Aws, Azure, Scaleway, Cyso)
66  - is_available: Whether the provider is currently supported (false = coming soon)
67  - is_connected: Whether the provider has cloud credentials
68  - cloud_runner_available: Whether Cloud Run/serverless is available
69  - clusters: Array of available Kubernetes clusters
70  - registries: Array of available container registries
71  - summary: Human-readable status
72
73**Provider Availability:**
74- Available now: GCP, Hetzner
75- Coming soon: AWS, Azure, Scaleway, Cyso Cloud
76
77**Use Cases:**
78- Before creating a deployment, check what options are available
79- Verify a provider is connected before attempting deployment
80- Find cluster and registry IDs for deployment configuration"#
81                .to_string(),
82            parameters: json!({
83                "type": "object",
84                "properties": {
85                    "project_id": {
86                        "type": "string",
87                        "description": "The UUID of the project"
88                    }
89                },
90                "required": ["project_id"]
91            }),
92        }
93    }
94
95    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
96        // Validate project_id
97        if args.project_id.trim().is_empty() {
98            return Ok(format_error_for_llm(
99                "list_deployment_capabilities",
100                ErrorCategory::ValidationFailed,
101                "project_id cannot be empty",
102                Some(vec![
103                    "Use list_projects to find valid project IDs",
104                    "Use current_context to get the currently selected project",
105                ]),
106            ));
107        }
108
109        // Create the API client
110        let client = match PlatformApiClient::new() {
111            Ok(c) => c,
112            Err(e) => {
113                return Ok(format_api_error("list_deployment_capabilities", e));
114            }
115        };
116
117        // Get provider deployment statuses
118        match get_provider_deployment_statuses(&client, &args.project_id).await {
119            Ok(statuses) => {
120                // Count available and connected providers (only available providers can deploy)
121                let available_connected_count = statuses
122                    .iter()
123                    .filter(|s| s.provider.is_available() && s.is_connected)
124                    .count();
125                let total_clusters: usize = statuses.iter().map(|s| s.clusters.len()).sum();
126                let total_registries: usize = statuses.iter().map(|s| s.registries.len()).sum();
127
128                // Build provider data
129                let provider_data: Vec<serde_json::Value> = statuses
130                    .iter()
131                    .map(|s| {
132                        let clusters: Vec<serde_json::Value> = s
133                            .clusters
134                            .iter()
135                            .map(|c| {
136                                json!({
137                                    "id": c.id,
138                                    "name": c.name,
139                                    "region": c.region,
140                                    "is_healthy": c.is_healthy,
141                                })
142                            })
143                            .collect();
144
145                        let registries: Vec<serde_json::Value> = s
146                            .registries
147                            .iter()
148                            .map(|r| {
149                                json!({
150                                    "id": r.id,
151                                    "name": r.name,
152                                    "region": r.region,
153                                    "is_ready": r.is_ready,
154                                })
155                            })
156                            .collect();
157
158                        json!({
159                            "provider": format!("{:?}", s.provider),
160                            "is_available": s.provider.is_available(),
161                            "is_connected": s.is_connected,
162                            "cloud_runner_available": s.cloud_runner_available,
163                            "clusters": clusters,
164                            "registries": registries,
165                            "summary": if s.provider.is_available() {
166                                s.summary.clone()
167                            } else {
168                                "Coming soon".to_string()
169                            },
170                        })
171                    })
172                    .collect();
173
174                // Build summary
175                let summary = if available_connected_count == 0 {
176                    "No available providers connected. Connect GCP or Hetzner in platform settings.".to_string()
177                } else {
178                    let mut parts = vec![format!("{} provider{} ready", available_connected_count, if available_connected_count == 1 { "" } else { "s" })];
179                    if total_clusters > 0 {
180                        parts.push(format!("{} cluster{}", total_clusters, if total_clusters == 1 { "" } else { "s" }));
181                    }
182                    if total_registries > 0 {
183                        parts.push(format!("{} registr{}", total_registries, if total_registries == 1 { "y" } else { "ies" }));
184                    }
185                    parts.join(", ")
186                };
187
188                let result = json!({
189                    "success": true,
190                    "project_id": args.project_id,
191                    "providers": provider_data,
192                    "summary": summary,
193                    "available_connected_count": available_connected_count,
194                    "total_clusters": total_clusters,
195                    "total_registries": total_registries,
196                    "coming_soon_providers": ["AWS", "Azure", "Scaleway", "Cyso Cloud"],
197                    "next_steps": if available_connected_count > 0 {
198                        vec![
199                            "Use analyze_project to discover Dockerfiles in the project",
200                            "Use create_deployment_config to create a deployment configuration",
201                            "For Cloud Run deployments, no cluster is needed",
202                            "Note: AWS, Azure, Scaleway, and Cyso Cloud are coming soon"
203                        ]
204                    } else {
205                        vec![
206                            "Use open_provider_settings to connect GCP or Hetzner",
207                            "After connecting, run this tool again to see available options",
208                            "Note: AWS, Azure, Scaleway, and Cyso Cloud are coming soon"
209                        ]
210                    }
211                });
212
213                serde_json::to_string_pretty(&result)
214                    .map_err(|e| ListDeploymentCapabilitiesError(format!("Failed to serialize: {}", e)))
215            }
216            Err(e) => Ok(format_api_error("list_deployment_capabilities", e)),
217        }
218    }
219}
220
221/// Format a PlatformApiError for LLM consumption
222fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
223    match error {
224        PlatformApiError::Unauthorized => format_error_for_llm(
225            tool_name,
226            ErrorCategory::PermissionDenied,
227            "Not authenticated - please run `sync-ctl auth login` first",
228            Some(vec![
229                "The user needs to authenticate with the Syncable platform",
230                "Run: sync-ctl auth login",
231            ]),
232        ),
233        PlatformApiError::NotFound(msg) => format_error_for_llm(
234            tool_name,
235            ErrorCategory::ResourceUnavailable,
236            &format!("Resource not found: {}", msg),
237            Some(vec![
238                "The project ID may be incorrect",
239                "Use list_projects to find valid project IDs",
240            ]),
241        ),
242        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
243            tool_name,
244            ErrorCategory::PermissionDenied,
245            &format!("Permission denied: {}", msg),
246            Some(vec![
247                "The user does not have access to this project",
248                "Contact the project admin for access",
249            ]),
250        ),
251        PlatformApiError::RateLimited => format_error_for_llm(
252            tool_name,
253            ErrorCategory::ResourceUnavailable,
254            "Rate limit exceeded - please try again later",
255            Some(vec!["Wait a moment before retrying"]),
256        ),
257        PlatformApiError::HttpError(e) => format_error_for_llm(
258            tool_name,
259            ErrorCategory::NetworkError,
260            &format!("Network error: {}", e),
261            Some(vec![
262                "Check network connectivity",
263                "The Syncable API may be temporarily unavailable",
264            ]),
265        ),
266        PlatformApiError::ParseError(msg) => format_error_for_llm(
267            tool_name,
268            ErrorCategory::InternalError,
269            &format!("Failed to parse API response: {}", msg),
270            Some(vec!["This may be a temporary API issue"]),
271        ),
272        PlatformApiError::ApiError { status, message } => format_error_for_llm(
273            tool_name,
274            ErrorCategory::ExternalCommandFailed,
275            &format!("API error ({}): {}", status, message),
276            Some(vec!["Check the error message for details"]),
277        ),
278        PlatformApiError::ServerError { status, message } => format_error_for_llm(
279            tool_name,
280            ErrorCategory::ExternalCommandFailed,
281            &format!("Server error ({}): {}", status, message),
282            Some(vec![
283                "The Syncable API is experiencing issues",
284                "Try again later",
285            ]),
286        ),
287        PlatformApiError::ConnectionFailed => format_error_for_llm(
288            tool_name,
289            ErrorCategory::NetworkError,
290            "Could not connect to Syncable API",
291            Some(vec![
292                "Check your internet connection",
293                "The Syncable API may be temporarily unavailable",
294            ]),
295        ),
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_tool_name() {
305        assert_eq!(ListDeploymentCapabilitiesTool::NAME, "list_deployment_capabilities");
306    }
307
308    #[test]
309    fn test_tool_creation() {
310        let tool = ListDeploymentCapabilitiesTool::new();
311        assert!(format!("{:?}", tool).contains("ListDeploymentCapabilitiesTool"));
312    }
313}