Skip to main content

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, Azure
75- Coming soon: AWS, 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, Hetzner, or Azure in platform settings.".to_string()
177                } else {
178                    let mut parts = vec![format!(
179                        "{} provider{} ready",
180                        available_connected_count,
181                        if available_connected_count == 1 {
182                            ""
183                        } else {
184                            "s"
185                        }
186                    )];
187                    if total_clusters > 0 {
188                        parts.push(format!(
189                            "{} cluster{}",
190                            total_clusters,
191                            if total_clusters == 1 { "" } else { "s" }
192                        ));
193                    }
194                    if total_registries > 0 {
195                        parts.push(format!(
196                            "{} registr{}",
197                            total_registries,
198                            if total_registries == 1 { "y" } else { "ies" }
199                        ));
200                    }
201                    parts.join(", ")
202                };
203
204                let result = json!({
205                    "success": true,
206                    "project_id": args.project_id,
207                    "providers": provider_data,
208                    "summary": summary,
209                    "available_connected_count": available_connected_count,
210                    "total_clusters": total_clusters,
211                    "total_registries": total_registries,
212                    "coming_soon_providers": ["AWS", "Scaleway", "Cyso Cloud"],
213                    "next_steps": if available_connected_count > 0 {
214                        vec![
215                            "Use analyze_project to discover Dockerfiles in the project",
216                            "Use create_deployment_config to create a deployment configuration",
217                            "For Cloud Run deployments, no cluster is needed",
218                            "Note: AWS, Scaleway, and Cyso Cloud are coming soon"
219                        ]
220                    } else {
221                        vec![
222                            "Use open_provider_settings to connect GCP, Hetzner, or Azure",
223                            "After connecting, run this tool again to see available options",
224                            "Note: AWS, Scaleway, and Cyso Cloud are coming soon"
225                        ]
226                    }
227                });
228
229                serde_json::to_string_pretty(&result).map_err(|e| {
230                    ListDeploymentCapabilitiesError(format!("Failed to serialize: {}", e))
231                })
232            }
233            Err(e) => Ok(format_api_error("list_deployment_capabilities", e)),
234        }
235    }
236}
237
238/// Format a PlatformApiError for LLM consumption
239fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
240    match error {
241        PlatformApiError::Unauthorized => format_error_for_llm(
242            tool_name,
243            ErrorCategory::PermissionDenied,
244            "Not authenticated - please run `sync-ctl auth login` first",
245            Some(vec![
246                "The user needs to authenticate with the Syncable platform",
247                "Run: sync-ctl auth login",
248            ]),
249        ),
250        PlatformApiError::NotFound(msg) => format_error_for_llm(
251            tool_name,
252            ErrorCategory::ResourceUnavailable,
253            &format!("Resource not found: {}", msg),
254            Some(vec![
255                "The project ID may be incorrect",
256                "Use list_projects to find valid project IDs",
257            ]),
258        ),
259        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
260            tool_name,
261            ErrorCategory::PermissionDenied,
262            &format!("Permission denied: {}", msg),
263            Some(vec![
264                "The user does not have access to this project",
265                "Contact the project admin for access",
266            ]),
267        ),
268        PlatformApiError::RateLimited => format_error_for_llm(
269            tool_name,
270            ErrorCategory::ResourceUnavailable,
271            "Rate limit exceeded - please try again later",
272            Some(vec!["Wait a moment before retrying"]),
273        ),
274        PlatformApiError::HttpError(e) => format_error_for_llm(
275            tool_name,
276            ErrorCategory::NetworkError,
277            &format!("Network error: {}", e),
278            Some(vec![
279                "Check network connectivity",
280                "The Syncable API may be temporarily unavailable",
281            ]),
282        ),
283        PlatformApiError::ParseError(msg) => format_error_for_llm(
284            tool_name,
285            ErrorCategory::InternalError,
286            &format!("Failed to parse API response: {}", msg),
287            Some(vec!["This may be a temporary API issue"]),
288        ),
289        PlatformApiError::ApiError { status, message } => format_error_for_llm(
290            tool_name,
291            ErrorCategory::ExternalCommandFailed,
292            &format!("API error ({}): {}", status, message),
293            Some(vec!["Check the error message for details"]),
294        ),
295        PlatformApiError::ServerError { status, message } => format_error_for_llm(
296            tool_name,
297            ErrorCategory::ExternalCommandFailed,
298            &format!("Server error ({}): {}", status, message),
299            Some(vec![
300                "The Syncable API is experiencing issues",
301                "Try again later",
302            ]),
303        ),
304        PlatformApiError::ConnectionFailed => format_error_for_llm(
305            tool_name,
306            ErrorCategory::NetworkError,
307            "Could not connect to Syncable API",
308            Some(vec![
309                "Check your internet connection",
310                "The Syncable API may be temporarily unavailable",
311            ]),
312        ),
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_tool_name() {
322        assert_eq!(
323            ListDeploymentCapabilitiesTool::NAME,
324            "list_deployment_capabilities"
325        );
326    }
327
328    #[test]
329    fn test_tool_creation() {
330        let tool = ListDeploymentCapabilitiesTool::new();
331        assert!(format!("{:?}", tool).contains("ListDeploymentCapabilitiesTool"));
332    }
333}