syncable_cli/agent/tools/platform/
select_project.rs

1//! Select project tool for the agent
2//!
3//! Allows the agent to select a project as the current context for platform operations.
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};
12use crate::platform::PlatformSession;
13
14/// Arguments for the select project tool
15#[derive(Debug, Deserialize)]
16pub struct SelectProjectArgs {
17    /// The project ID to select
18    pub project_id: String,
19    /// The organization ID the project belongs to
20    pub organization_id: String,
21}
22
23/// Error type for select project operations
24#[derive(Debug, thiserror::Error)]
25#[error("Select project error: {0}")]
26pub struct SelectProjectError(String);
27
28/// Tool to select a project as the current context
29///
30/// This tool sets the current project context for platform operations.
31/// The selection is persisted to `~/.syncable/platform-session.json`.
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct SelectProjectTool;
34
35impl SelectProjectTool {
36    /// Create a new SelectProjectTool
37    pub fn new() -> Self {
38        Self
39    }
40}
41
42impl Tool for SelectProjectTool {
43    const NAME: &'static str = "select_project";
44
45    type Error = SelectProjectError;
46    type Args = SelectProjectArgs;
47    type Output = String;
48
49    async fn definition(&self, _prompt: String) -> ToolDefinition {
50        ToolDefinition {
51            name: Self::NAME.to_string(),
52            description: r#"Select a project as the current context for platform operations.
53
54This persists the selection so future operations will use this project context.
55The selection is stored in ~/.syncable/platform-session.json.
56
57**Prerequisites:**
58- User must be authenticated via `sync-ctl auth login`
59- The project_id and organization_id must be valid
60
61**Use Cases:**
62- Setting up context before creating tasks or deployments
63- Switching between projects
64- Establishing project context for platform-aware operations
65
66**Workflow:**
671. Use list_organizations to find the organization
682. Use list_projects to find the project within the organization
693. Call select_project with both IDs"#
70                .to_string(),
71            parameters: json!({
72                "type": "object",
73                "properties": {
74                    "project_id": {
75                        "type": "string",
76                        "description": "The UUID of the project to select"
77                    },
78                    "organization_id": {
79                        "type": "string",
80                        "description": "The UUID of the organization the project belongs to"
81                    }
82                },
83                "required": ["project_id", "organization_id"]
84            }),
85        }
86    }
87
88    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
89        // Validate inputs
90        if args.project_id.trim().is_empty() {
91            return Ok(format_error_for_llm(
92                "select_project",
93                ErrorCategory::ValidationFailed,
94                "project_id cannot be empty",
95                Some(vec![
96                    "Use list_projects to find valid project IDs",
97                    "Pass the project ID as a UUID string",
98                ]),
99            ));
100        }
101
102        if args.organization_id.trim().is_empty() {
103            return Ok(format_error_for_llm(
104                "select_project",
105                ErrorCategory::ValidationFailed,
106                "organization_id cannot be empty",
107                Some(vec![
108                    "Use list_organizations to find valid organization IDs",
109                    "Pass the organization ID as a UUID string",
110                ]),
111            ));
112        }
113
114        // Create the API client
115        let client = match PlatformApiClient::new() {
116            Ok(c) => c,
117            Err(e) => {
118                return Ok(format_api_error("select_project", e));
119            }
120        };
121
122        // Verify project exists and user has access
123        let project = match client.get_project(&args.project_id).await {
124            Ok(p) => p,
125            Err(e) => {
126                return Ok(format_api_error("select_project", e));
127            }
128        };
129
130        // Verify organization exists and user has access
131        let organization = match client.get_organization(&args.organization_id).await {
132            Ok(o) => o,
133            Err(e) => {
134                return Ok(format_api_error("select_project", e));
135            }
136        };
137
138        // Verify the project belongs to the specified organization
139        if project.organization_id != args.organization_id {
140            return Ok(format_error_for_llm(
141                "select_project",
142                ErrorCategory::ValidationFailed,
143                "Project does not belong to the specified organization",
144                Some(vec![
145                    &format!(
146                        "Project '{}' belongs to organization '{}'",
147                        project.name, project.organization_id
148                    ),
149                    "Use the correct organization_id for this project",
150                ]),
151            ));
152        }
153
154        // Create and save the session
155        let session = PlatformSession::with_project(
156            project.id.clone(),
157            project.name.clone(),
158            organization.id.clone(),
159            organization.name.clone(),
160        );
161
162        if let Err(e) = session.save() {
163            return Ok(format_error_for_llm(
164                "select_project",
165                ErrorCategory::InternalError,
166                &format!("Failed to save session: {}", e),
167                Some(vec![
168                    "The session could not be persisted to disk",
169                    "Check permissions on ~/.syncable/ directory",
170                ]),
171            ));
172        }
173
174        // Return success response
175        let result = json!({
176            "success": true,
177            "message": format!("Selected project '{}' in organization '{}'", project.name, organization.name),
178            "context": {
179                "project_id": project.id,
180                "project_name": project.name,
181                "organization_id": organization.id,
182                "organization_name": organization.name
183            },
184            "session_path": PlatformSession::session_path().display().to_string()
185        });
186
187        serde_json::to_string_pretty(&result)
188            .map_err(|e| SelectProjectError(format!("Failed to serialize: {}", e)))
189    }
190}
191
192/// Format a PlatformApiError for LLM consumption
193fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
194    match error {
195        PlatformApiError::Unauthorized => format_error_for_llm(
196            tool_name,
197            ErrorCategory::PermissionDenied,
198            "Not authenticated - please run `sync-ctl auth login` first",
199            Some(vec![
200                "The user needs to authenticate with the Syncable platform",
201                "Run: sync-ctl auth login",
202            ]),
203        ),
204        PlatformApiError::NotFound(msg) => format_error_for_llm(
205            tool_name,
206            ErrorCategory::ResourceUnavailable,
207            &format!("Resource not found: {}", msg),
208            Some(vec![
209                "The project or organization ID may be incorrect",
210                "Use list_organizations and list_projects to find valid IDs",
211            ]),
212        ),
213        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
214            tool_name,
215            ErrorCategory::PermissionDenied,
216            &format!("Permission denied: {}", msg),
217            Some(vec![
218                "The user does not have access to this resource",
219                "Contact the organization or project admin for access",
220            ]),
221        ),
222        PlatformApiError::RateLimited => format_error_for_llm(
223            tool_name,
224            ErrorCategory::ResourceUnavailable,
225            "Rate limit exceeded - please try again later",
226            Some(vec!["Wait a moment before retrying"]),
227        ),
228        PlatformApiError::HttpError(e) => format_error_for_llm(
229            tool_name,
230            ErrorCategory::NetworkError,
231            &format!("Network error: {}", e),
232            Some(vec![
233                "Check network connectivity",
234                "The Syncable API may be temporarily unavailable",
235            ]),
236        ),
237        PlatformApiError::ParseError(msg) => format_error_for_llm(
238            tool_name,
239            ErrorCategory::InternalError,
240            &format!("Failed to parse API response: {}", msg),
241            Some(vec!["This may be a temporary API issue"]),
242        ),
243        PlatformApiError::ApiError { status, message } => format_error_for_llm(
244            tool_name,
245            ErrorCategory::ExternalCommandFailed,
246            &format!("API error ({}): {}", status, message),
247            Some(vec!["Check the error message for details"]),
248        ),
249        PlatformApiError::ServerError { status, message } => format_error_for_llm(
250            tool_name,
251            ErrorCategory::ExternalCommandFailed,
252            &format!("Server error ({}): {}", status, message),
253            Some(vec![
254                "The Syncable API is experiencing issues",
255                "Try again later",
256            ]),
257        ),
258        PlatformApiError::ConnectionFailed => format_error_for_llm(
259            tool_name,
260            ErrorCategory::NetworkError,
261            "Could not connect to Syncable API",
262            Some(vec![
263                "Check your internet connection",
264                "The Syncable API may be temporarily unavailable",
265            ]),
266        ),
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_tool_name() {
276        assert_eq!(SelectProjectTool::NAME, "select_project");
277    }
278
279    #[test]
280    fn test_tool_creation() {
281        let tool = SelectProjectTool::new();
282        assert!(format!("{:?}", tool).contains("SelectProjectTool"));
283    }
284}