syncable_cli/agent/tools/platform/
list_projects.rs

1//! List projects tool for the agent
2//!
3//! Allows the agent to list all projects within an organization.
4
5use rig::completion::ToolDefinition;
6use rig::tool::Tool;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
11use crate::platform::api::{PlatformApiClient, PlatformApiError};
12
13/// Arguments for the list projects tool
14#[derive(Debug, Deserialize)]
15pub struct ListProjectsArgs {
16    /// The organization ID to list projects for
17    pub organization_id: String,
18}
19
20/// Error type for list projects operations
21#[derive(Debug, thiserror::Error)]
22#[error("List projects error: {0}")]
23pub struct ListProjectsError(String);
24
25/// Tool to list all projects within an organization
26///
27/// This tool queries the Syncable Platform API to retrieve all projects
28/// in the specified organization that the user has access to.
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct ListProjectsTool;
31
32impl ListProjectsTool {
33    /// Create a new ListProjectsTool
34    pub fn new() -> Self {
35        Self
36    }
37}
38
39impl Tool for ListProjectsTool {
40    const NAME: &'static str = "list_projects";
41
42    type Error = ListProjectsError;
43    type Args = ListProjectsArgs;
44    type Output = String;
45
46    async fn definition(&self, _prompt: String) -> ToolDefinition {
47        ToolDefinition {
48            name: Self::NAME.to_string(),
49            description: r#"List all projects within an organization.
50
51Returns a list of projects with their IDs, names, and descriptions.
52Use this after getting organization IDs from list_organizations.
53
54**Prerequisites:**
55- User must be authenticated via `sync-ctl auth login`
56- User must have access to the specified organization
57
58**Use Cases:**
59- Finding project IDs to select a project context
60- Discovering available projects in an organization
61- Getting project details before selection"#
62                .to_string(),
63            parameters: json!({
64                "type": "object",
65                "properties": {
66                    "organization_id": {
67                        "type": "string",
68                        "description": "The UUID of the organization to list projects for"
69                    }
70                },
71                "required": ["organization_id"]
72            }),
73        }
74    }
75
76    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
77        // Validate organization_id is not empty
78        if args.organization_id.trim().is_empty() {
79            return Ok(format_error_for_llm(
80                "list_projects",
81                ErrorCategory::ValidationFailed,
82                "organization_id cannot be empty",
83                Some(vec![
84                    "Use list_organizations to find valid organization IDs",
85                    "Pass the organization ID as a UUID string",
86                ]),
87            ));
88        }
89
90        // Create the API client
91        let client = match PlatformApiClient::new() {
92            Ok(c) => c,
93            Err(e) => {
94                return Ok(format_api_error("list_projects", e));
95            }
96        };
97
98        // Fetch projects for the organization
99        match client.list_projects(&args.organization_id).await {
100            Ok(projects) => {
101                if projects.is_empty() {
102                    return Ok(json!({
103                        "success": true,
104                        "organization_id": args.organization_id,
105                        "projects": [],
106                        "count": 0,
107                        "message": "No projects found in this organization. You may need to create a project."
108                    })
109                    .to_string());
110                }
111
112                let project_list: Vec<serde_json::Value> = projects
113                    .iter()
114                    .map(|proj| {
115                        json!({
116                            "id": proj.id,
117                            "name": proj.name,
118                            "description": proj.description,
119                            "organization_id": proj.organization_id,
120                            "created_at": proj.created_at.to_rfc3339()
121                        })
122                    })
123                    .collect();
124
125                let result = json!({
126                    "success": true,
127                    "organization_id": args.organization_id,
128                    "projects": project_list,
129                    "count": projects.len()
130                });
131
132                serde_json::to_string_pretty(&result)
133                    .map_err(|e| ListProjectsError(format!("Failed to serialize: {}", e)))
134            }
135            Err(e) => Ok(format_api_error("list_projects", e)),
136        }
137    }
138}
139
140/// Format a PlatformApiError for LLM consumption
141fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
142    match error {
143        PlatformApiError::Unauthorized => format_error_for_llm(
144            tool_name,
145            ErrorCategory::PermissionDenied,
146            "Not authenticated - please run `sync-ctl auth login` first",
147            Some(vec![
148                "The user needs to authenticate with the Syncable platform",
149                "Run: sync-ctl auth login",
150            ]),
151        ),
152        PlatformApiError::NotFound(msg) => format_error_for_llm(
153            tool_name,
154            ErrorCategory::ResourceUnavailable,
155            &format!("Organization not found: {}", msg),
156            Some(vec![
157                "The organization ID may be incorrect",
158                "Use list_organizations to find valid organization IDs",
159            ]),
160        ),
161        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
162            tool_name,
163            ErrorCategory::PermissionDenied,
164            &format!("Permission denied: {}", msg),
165            Some(vec![
166                "The user does not have access to this organization",
167                "Contact the organization admin for access",
168            ]),
169        ),
170        PlatformApiError::RateLimited => format_error_for_llm(
171            tool_name,
172            ErrorCategory::ResourceUnavailable,
173            "Rate limit exceeded - please try again later",
174            Some(vec!["Wait a moment before retrying"]),
175        ),
176        PlatformApiError::HttpError(e) => format_error_for_llm(
177            tool_name,
178            ErrorCategory::NetworkError,
179            &format!("Network error: {}", e),
180            Some(vec![
181                "Check network connectivity",
182                "The Syncable API may be temporarily unavailable",
183            ]),
184        ),
185        PlatformApiError::ParseError(msg) => format_error_for_llm(
186            tool_name,
187            ErrorCategory::InternalError,
188            &format!("Failed to parse API response: {}", msg),
189            Some(vec!["This may be a temporary API issue"]),
190        ),
191        PlatformApiError::ApiError { status, message } => format_error_for_llm(
192            tool_name,
193            ErrorCategory::ExternalCommandFailed,
194            &format!("API error ({}): {}", status, message),
195            Some(vec!["Check the error message for details"]),
196        ),
197        PlatformApiError::ServerError { status, message } => format_error_for_llm(
198            tool_name,
199            ErrorCategory::ExternalCommandFailed,
200            &format!("Server error ({}): {}", status, message),
201            Some(vec![
202                "The Syncable API is experiencing issues",
203                "Try again later",
204            ]),
205        ),
206        PlatformApiError::ConnectionFailed => format_error_for_llm(
207            tool_name,
208            ErrorCategory::NetworkError,
209            "Could not connect to Syncable API",
210            Some(vec![
211                "Check your internet connection",
212                "The Syncable API may be temporarily unavailable",
213            ]),
214        ),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_tool_name() {
224        assert_eq!(ListProjectsTool::NAME, "list_projects");
225    }
226
227    #[test]
228    fn test_tool_creation() {
229        let tool = ListProjectsTool::new();
230        assert!(format!("{:?}", tool).contains("ListProjectsTool"));
231    }
232}