syncable_cli/agent/tools/platform/
select_project.rs1use 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#[derive(Debug, Deserialize)]
16pub struct SelectProjectArgs {
17 pub project_id: String,
19 pub organization_id: String,
21}
22
23#[derive(Debug, thiserror::Error)]
25#[error("Select project error: {0}")]
26pub struct SelectProjectError(String);
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct SelectProjectTool;
34
35impl SelectProjectTool {
36 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 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 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 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 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 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 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 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
192fn 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}