1use rig::completion::ToolDefinition;
8use rig::tool::Tool;
9use serde::Deserialize;
10use serde_json::json;
11
12use crate::agent::tools::ExecutionContext;
13use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
14use crate::platform::api::types::DeploymentSecretInput;
15use crate::platform::api::{PlatformApiClient, PlatformApiError};
16
17pub(super) enum SecretPromptResult {
19 Value(String),
21 Skipped,
23 Cancelled,
25}
26
27pub(super) fn prompt_secret_value(key_name: &str) -> SecretPromptResult {
31 use colored::Colorize;
32 use inquire::{InquireError, Password, PasswordDisplayMode};
33
34 println!();
35 println!(
36 " {} Enter value for {} {}",
37 "\u{1f512}".dimmed(),
38 key_name.cyan(),
39 "(hidden \u{2014} not visible to AI agent)".dimmed()
40 );
41
42 match Password::new(key_name)
43 .with_display_mode(PasswordDisplayMode::Masked)
44 .with_help_message("Esc to skip, Ctrl+C to cancel all")
45 .without_confirmation()
46 .prompt()
47 {
48 Ok(v) if v.trim().is_empty() => SecretPromptResult::Skipped,
49 Ok(v) => {
50 println!(" {} {} set", "\u{2713}".green(), key_name.cyan());
51 SecretPromptResult::Value(v)
52 }
53 Err(InquireError::OperationCanceled) => SecretPromptResult::Skipped,
54 Err(InquireError::OperationInterrupted) => SecretPromptResult::Cancelled,
55 Err(_) => SecretPromptResult::Cancelled,
56 }
57}
58
59#[derive(Debug, Deserialize)]
61pub struct SecretArg {
62 pub key: String,
64 pub value: Option<String>,
68 #[serde(default = "default_true")]
70 pub is_secret: bool,
71}
72
73pub(super) fn default_true() -> bool {
74 true
75}
76
77#[derive(Debug, Deserialize)]
79pub struct SetDeploymentSecretsArgs {
80 pub config_id: String,
82 pub secrets: Vec<SecretArg>,
84}
85
86#[derive(Debug, thiserror::Error)]
88#[error("Set deployment secrets error: {0}")]
89pub struct SetDeploymentSecretsError(String);
90
91#[derive(Debug, Clone)]
97pub struct SetDeploymentSecretsTool {
98 execution_context: ExecutionContext,
99}
100
101impl SetDeploymentSecretsTool {
102 pub fn new() -> Self {
104 Self {
105 execution_context: ExecutionContext::InteractiveCli,
106 }
107 }
108
109 pub fn with_context(ctx: ExecutionContext) -> Self {
111 Self {
112 execution_context: ctx,
113 }
114 }
115}
116
117impl Default for SetDeploymentSecretsTool {
118 fn default() -> Self {
119 Self::new()
120 }
121}
122
123impl Tool for SetDeploymentSecretsTool {
124 const NAME: &'static str = "set_deployment_secrets";
125
126 type Error = SetDeploymentSecretsError;
127 type Args = SetDeploymentSecretsArgs;
128 type Output = String;
129
130 async fn definition(&self, _prompt: String) -> ToolDefinition {
131 ToolDefinition {
132 name: Self::NAME.to_string(),
133 description: r#"Set environment variables and secrets on a deployment configuration.
134
135Secret values are sent securely to the backend and stored encrypted.
136Values are NEVER returned in tool responses - only key names are confirmed.
137
138The is_secret flag (default: true) controls:
139- true: Value masked as "********" in UI and API responses, passed via secure terraform -var flags
140- false: Value visible in UI, stored in GitOps ConfigMap
141
142For secrets (is_secret=true): OMIT the "value" field. The user will be
143prompted securely in the terminal. The value goes directly to the backend.
144NEVER ask the user to type secret values in chat.
145
146For non-secrets (is_secret=false): Include the "value" field directly.
147
148Common secrets: DATABASE_URL, API_KEY, JWT_SECRET, REDIS_URL, etc.
149Common non-secrets: NODE_ENV, PORT, LOG_LEVEL, APP_NAME, etc.
150
151**Parameters:**
152- config_id: The deployment config ID (get from deploy_service or list_deployment_configs)
153- secrets: Array of {key, value?, is_secret} objects
154
155**Prerequisites:**
156- User must be authenticated via `sync-ctl auth login`
157- A deployment config must exist (create one with deploy_service first)
158
159**Example:**
160Set DATABASE_URL as a secret (value omitted — prompted in terminal) and NODE_ENV as a plain env var:
161```json
162{
163 "config_id": "config-123",
164 "secrets": [
165 {"key": "DATABASE_URL", "is_secret": true},
166 {"key": "NODE_ENV", "value": "production", "is_secret": false}
167 ]
168}
169```
170
171**IMPORTANT - After setting secrets:**
172- Trigger a new deployment for the secrets to take effect
173- Use trigger_deployment or deploy_service with preview_only=false"#
174 .to_string(),
175 parameters: json!({
176 "type": "object",
177 "properties": {
178 "config_id": {
179 "type": "string",
180 "description": "The deployment config ID to set secrets on"
181 },
182 "secrets": {
183 "type": "array",
184 "description": "Environment variables to set. For secrets, omit value \u{2014} user is prompted in terminal.",
185 "items": {
186 "type": "object",
187 "properties": {
188 "key": {
189 "type": "string",
190 "description": "Environment variable name (e.g., DATABASE_URL)"
191 },
192 "value": {
193 "type": "string",
194 "description": "Environment variable value. Omit for secrets \u{2014} user will be prompted securely in terminal."
195 },
196 "is_secret": {
197 "type": "boolean",
198 "description": "Whether this is a secret (default: true). Secrets are masked in UI and API responses.",
199 "default": true
200 }
201 },
202 "required": ["key"]
203 }
204 }
205 },
206 "required": ["config_id", "secrets"]
207 }),
208 }
209 }
210
211 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
212 if args.config_id.trim().is_empty() {
214 return Ok(format_error_for_llm(
215 "set_deployment_secrets",
216 ErrorCategory::ValidationFailed,
217 "config_id cannot be empty",
218 Some(vec![
219 "Use list_deployment_configs to find valid config IDs",
220 "Or deploy a service first with deploy_service",
221 ]),
222 ));
223 }
224
225 if args.secrets.is_empty() {
227 return Ok(format_error_for_llm(
228 "set_deployment_secrets",
229 ErrorCategory::ValidationFailed,
230 "secrets array cannot be empty",
231 Some(vec!["Provide at least one secret with key and value"]),
232 ));
233 }
234
235 for secret in &args.secrets {
237 if secret.key.trim().is_empty() {
238 return Ok(format_error_for_llm(
239 "set_deployment_secrets",
240 ErrorCategory::ValidationFailed,
241 "Secret key cannot be empty",
242 Some(vec!["Each secret must have a non-empty key name"]),
243 ));
244 }
245 }
246
247 let mut resolved_secrets: Vec<DeploymentSecretInput> = Vec::new();
249 for secret in &args.secrets {
250 let value = match &secret.value {
251 Some(v) => v.clone(),
252 None if self.execution_context.has_terminal() => {
253 match prompt_secret_value(&secret.key) {
254 SecretPromptResult::Value(v) => v,
255 SecretPromptResult::Skipped => continue,
256 SecretPromptResult::Cancelled => {
257 return Ok(format_error_for_llm(
258 "set_deployment_secrets",
259 ErrorCategory::ValidationFailed,
260 "Secret entry cancelled by user",
261 Some(vec![
262 "The user cancelled secret input. Try again when ready.",
263 ]),
264 ));
265 }
266 }
267 }
268 None => {
269 return Ok(format_error_for_llm(
270 "set_deployment_secrets",
271 ErrorCategory::ValidationFailed,
272 &format!(
273 "Value required for secret '{}' in server mode (no terminal available)",
274 secret.key
275 ),
276 Some(vec![
277 "In server mode, all secrets must include a value",
278 "The frontend should collect secret values via its own password UI",
279 ]),
280 ));
281 }
282 };
283 resolved_secrets.push(DeploymentSecretInput {
284 key: secret.key.clone(),
285 value,
286 is_secret: secret.is_secret,
287 });
288 }
289
290 if resolved_secrets.is_empty() {
291 return Ok(format_error_for_llm(
292 "set_deployment_secrets",
293 ErrorCategory::ValidationFailed,
294 "All secrets were skipped",
295 Some(vec!["Provide at least one secret value when prompted"]),
296 ));
297 }
298
299 let client = match PlatformApiClient::new() {
301 Ok(c) => c,
302 Err(e) => {
303 return Ok(format_api_error("set_deployment_secrets", e));
304 }
305 };
306
307 match client
309 .update_deployment_config_secrets(&args.config_id, &resolved_secrets)
310 .await
311 {
312 Ok(()) => {
313 let secret_count = resolved_secrets.iter().filter(|s| s.is_secret).count();
314 let plain_count = resolved_secrets.len() - secret_count;
315
316 let secrets_set: Vec<serde_json::Value> = resolved_secrets
318 .iter()
319 .map(|s| {
320 json!({
321 "key": s.key,
322 "is_secret": s.is_secret,
323 })
324 })
325 .collect();
326
327 let result = json!({
328 "success": true,
329 "config_id": args.config_id,
330 "secrets_set": secrets_set,
331 "message": format!(
332 "Set {} environment variable(s) ({} secret, {} plain)",
333 resolved_secrets.len(),
334 secret_count,
335 plain_count
336 ),
337 "next_steps": [
338 "Trigger a new deployment for the secrets to take effect",
339 format!("Use trigger_deployment with config_id '{}'", args.config_id),
340 ],
341 });
342
343 serde_json::to_string_pretty(&result)
344 .map_err(|e| SetDeploymentSecretsError(format!("Failed to serialize: {}", e)))
345 }
346 Err(e) => Ok(format_api_error("set_deployment_secrets", e)),
347 }
348 }
349}
350
351fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
353 match error {
354 PlatformApiError::Unauthorized => format_error_for_llm(
355 tool_name,
356 ErrorCategory::PermissionDenied,
357 "Not authenticated - please run `sync-ctl auth login` first",
358 Some(vec![
359 "The user needs to authenticate with the Syncable platform",
360 "Run: sync-ctl auth login",
361 ]),
362 ),
363 PlatformApiError::NotFound(msg) => format_error_for_llm(
364 tool_name,
365 ErrorCategory::ResourceUnavailable,
366 &format!("Deployment config not found: {}", msg),
367 Some(vec![
368 "The config_id may be incorrect",
369 "Use list_deployment_configs to find valid config IDs",
370 ]),
371 ),
372 PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
373 tool_name,
374 ErrorCategory::PermissionDenied,
375 &format!("Permission denied: {}", msg),
376 Some(vec!["Contact the project admin for access"]),
377 ),
378 PlatformApiError::RateLimited => format_error_for_llm(
379 tool_name,
380 ErrorCategory::ResourceUnavailable,
381 "Rate limit exceeded - please try again later",
382 Some(vec!["Wait a moment before retrying"]),
383 ),
384 PlatformApiError::HttpError(e) => format_error_for_llm(
385 tool_name,
386 ErrorCategory::NetworkError,
387 &format!("Network error: {}", e),
388 Some(vec!["Check network connectivity"]),
389 ),
390 PlatformApiError::ParseError(msg) => format_error_for_llm(
391 tool_name,
392 ErrorCategory::InternalError,
393 &format!("Failed to parse API response: {}", msg),
394 Some(vec!["This may be a temporary API issue"]),
395 ),
396 PlatformApiError::ApiError { status, message } => format_error_for_llm(
397 tool_name,
398 ErrorCategory::ExternalCommandFailed,
399 &format!("API error ({}): {}", status, message),
400 Some(vec!["Check the error message for details"]),
401 ),
402 PlatformApiError::ServerError { status, message } => format_error_for_llm(
403 tool_name,
404 ErrorCategory::ExternalCommandFailed,
405 &format!("Server error ({}): {}", status, message),
406 Some(vec!["Try again later"]),
407 ),
408 PlatformApiError::ConnectionFailed => format_error_for_llm(
409 tool_name,
410 ErrorCategory::NetworkError,
411 "Could not connect to Syncable API",
412 Some(vec!["Check your internet connection"]),
413 ),
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_tool_name() {
423 assert_eq!(SetDeploymentSecretsTool::NAME, "set_deployment_secrets");
424 }
425
426 #[test]
427 fn test_tool_creation() {
428 let tool = SetDeploymentSecretsTool::new();
429 assert!(format!("{:?}", tool).contains("SetDeploymentSecretsTool"));
430 }
431
432 #[test]
433 fn test_tool_with_context() {
434 let tool = SetDeploymentSecretsTool::with_context(ExecutionContext::HeadlessServer);
435 assert!(format!("{:?}", tool).contains("SetDeploymentSecretsTool"));
436 }
437
438 #[test]
439 fn test_default_is_secret_true() {
440 assert!(default_true());
441 }
442}