syncable_cli/agent/
config.rs

1//! Agent configuration and credentials management
2//!
3//! Handles storing and retrieving LLM provider credentials securely.
4//! Credentials are stored in ~/.syncable/credentials.toml
5
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10use super::{AgentError, AgentResult, ProviderType};
11
12/// Credentials for LLM providers
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct AgentCredentials {
15    /// Default provider to use
16    #[serde(default)]
17    pub default_provider: Option<String>,
18    
19    /// Default model to use
20    #[serde(default)]
21    pub default_model: Option<String>,
22    
23    /// OpenAI API key
24    #[serde(default)]
25    pub openai_api_key: Option<String>,
26    
27    /// Anthropic API key
28    #[serde(default)]
29    pub anthropic_api_key: Option<String>,
30}
31
32impl AgentCredentials {
33    /// Get the syncable config directory (~/.syncable)
34    pub fn config_dir() -> Option<PathBuf> {
35        dirs::home_dir().map(|h| h.join(".syncable"))
36    }
37    
38    /// Get the credentials file path
39    pub fn credentials_path() -> Option<PathBuf> {
40        Self::config_dir().map(|d| d.join("credentials.toml"))
41    }
42    
43    /// Load credentials from file
44    pub fn load() -> AgentResult<Self> {
45        let path = Self::credentials_path()
46            .ok_or_else(|| AgentError::ClientError("Could not determine home directory".into()))?;
47        
48        if !path.exists() {
49            return Ok(Self::default());
50        }
51        
52        let content = fs::read_to_string(&path)
53            .map_err(|e| AgentError::ClientError(format!("Failed to read credentials: {}", e)))?;
54        
55        toml::from_str(&content)
56            .map_err(|e| AgentError::ClientError(format!("Failed to parse credentials: {}", e)))
57    }
58    
59    /// Save credentials to file
60    pub fn save(&self) -> AgentResult<()> {
61        let dir = Self::config_dir()
62            .ok_or_else(|| AgentError::ClientError("Could not determine home directory".into()))?;
63        
64        // Create directory if it doesn't exist
65        if !dir.exists() {
66            fs::create_dir_all(&dir)
67                .map_err(|e| AgentError::ClientError(format!("Failed to create config dir: {}", e)))?;
68        }
69        
70        let path = dir.join("credentials.toml");
71        let content = toml::to_string_pretty(self)
72            .map_err(|e| AgentError::ClientError(format!("Failed to serialize credentials: {}", e)))?;
73        
74        fs::write(&path, content)
75            .map_err(|e| AgentError::ClientError(format!("Failed to write credentials: {}", e)))?;
76        
77        // Set restrictive permissions on Unix
78        #[cfg(unix)]
79        {
80            use std::os::unix::fs::PermissionsExt;
81            let perms = fs::Permissions::from_mode(0o600);
82            fs::set_permissions(&path, perms).ok();
83        }
84        
85        Ok(())
86    }
87    
88    /// Check if credentials exist for a provider
89    pub fn has_credentials(&self, provider: ProviderType) -> bool {
90        match provider {
91            ProviderType::OpenAI => self.openai_api_key.is_some(),
92            ProviderType::Anthropic => self.anthropic_api_key.is_some(),
93        }
94    }
95    
96    /// Get the API key for a provider
97    pub fn get_api_key(&self, provider: ProviderType) -> Option<&str> {
98        match provider {
99            ProviderType::OpenAI => self.openai_api_key.as_deref(),
100            ProviderType::Anthropic => self.anthropic_api_key.as_deref(),
101        }
102    }
103    
104    /// Set the API key for a provider
105    pub fn set_api_key(&mut self, provider: ProviderType, key: String) {
106        match provider {
107            ProviderType::OpenAI => self.openai_api_key = Some(key),
108            ProviderType::Anthropic => self.anthropic_api_key = Some(key),
109        }
110    }
111    
112    /// Get the default provider
113    pub fn get_default_provider(&self) -> Option<ProviderType> {
114        self.default_provider.as_ref().and_then(|p| p.parse().ok())
115    }
116    
117    /// Set the default provider
118    pub fn set_default_provider(&mut self, provider: ProviderType) {
119        self.default_provider = Some(provider.to_string());
120    }
121}
122
123/// Run the first-time setup wizard for agent credentials
124pub fn run_setup_wizard() -> AgentResult<(ProviderType, Option<String>)> {
125    use dialoguer::{Select, Input, theme::ColorfulTheme};
126    
127    println!("\n Welcome to Syncable Agent Setup\n");
128    println!("This wizard will help you configure your LLM provider.\n");
129    
130    // Provider selection
131    let providers = &["OpenAI (GPT-4)", "Anthropic (Claude)"];
132    let selection = Select::with_theme(&ColorfulTheme::default())
133        .with_prompt("Select your LLM provider")
134        .items(providers)
135        .default(0)
136        .interact()
137        .map_err(|e| AgentError::ClientError(format!("Selection failed: {}", e)))?;
138    
139    let provider = match selection {
140        0 => ProviderType::OpenAI,
141        1 => ProviderType::Anthropic,
142        _ => ProviderType::OpenAI,
143    };
144    
145    // API key input
146    let env_var = match provider {
147        ProviderType::OpenAI => "OPENAI_API_KEY",
148        ProviderType::Anthropic => "ANTHROPIC_API_KEY",
149    };
150    
151    let key_hint = match provider {
152        ProviderType::OpenAI => "sk-... (from platform.openai.com)",
153        ProviderType::Anthropic => "sk-ant-... (from console.anthropic.com)",
154    };
155    
156    println!("\nYou can get your API key from:");
157    match provider {
158        ProviderType::OpenAI => println!("  https://platform.openai.com/api-keys"),
159        ProviderType::Anthropic => println!("  https://console.anthropic.com/settings/keys"),
160    }
161    println!();
162    
163    let api_key: String = Input::with_theme(&ColorfulTheme::default())
164        .with_prompt(format!("Enter your API key {}", key_hint))
165        .interact_text()
166        .map_err(|e| AgentError::ClientError(format!("Input failed: {}", e)))?;
167    
168    if api_key.is_empty() {
169        return Err(AgentError::MissingApiKey(env_var.into()));
170    }
171    
172    // Model selection (optional)
173    let default_models = match provider {
174        ProviderType::OpenAI => vec!["gpt-4o (recommended)", "gpt-4", "gpt-3.5-turbo"],
175        ProviderType::Anthropic => vec!["claude-3-5-sonnet-latest (recommended)", "claude-3-opus-latest", "claude-3-haiku-20240307"],
176    };
177    
178    let model_selection = Select::with_theme(&ColorfulTheme::default())
179        .with_prompt("Select default model")
180        .items(&default_models)
181        .default(0)
182        .interact()
183        .map_err(|e| AgentError::ClientError(format!("Selection failed: {}", e)))?;
184    
185    let model = match provider {
186        ProviderType::OpenAI => match model_selection {
187            0 => "gpt-4o",
188            1 => "gpt-4",
189            2 => "gpt-3.5-turbo",
190            _ => "gpt-4o",
191        },
192        ProviderType::Anthropic => match model_selection {
193            0 => "claude-3-5-sonnet-latest",
194            1 => "claude-3-opus-latest",
195            2 => "claude-3-haiku-20240307",
196            _ => "claude-3-5-sonnet-latest",
197        },
198    };
199    
200    // Save credentials
201    let mut creds = AgentCredentials::load().unwrap_or_default();
202    creds.set_api_key(provider, api_key.clone());
203    creds.set_default_provider(provider);
204    creds.default_model = Some(model.to_string());
205    creds.save()?;
206    
207    // Also set the environment variable for this session
208    // SAFETY: We're setting a well-known env var with a valid string value
209    unsafe { std::env::set_var(env_var, &api_key) };
210    
211    println!("\n Credentials saved to ~/.syncable/credentials.toml");
212    println!("You can update them anytime by running: sync-ctl chat --setup\n");
213    
214    Ok((provider, Some(model.to_string())))
215}
216
217/// Ensure credentials are available, prompting for setup if needed
218pub fn ensure_credentials(provider: Option<ProviderType>) -> AgentResult<(ProviderType, Option<String>)> {
219    let creds = AgentCredentials::load().unwrap_or_default();
220    
221    // Determine which provider to use
222    let provider = provider
223        .or_else(|| creds.get_default_provider())
224        .unwrap_or(ProviderType::OpenAI);
225    
226    // Check if we have credentials for this provider
227    let env_var = match provider {
228        ProviderType::OpenAI => "OPENAI_API_KEY",
229        ProviderType::Anthropic => "ANTHROPIC_API_KEY",
230    };
231    
232    // First check environment variable
233    if std::env::var(env_var).is_ok() {
234        return Ok((provider, creds.default_model.clone()));
235    }
236    
237    // Then check stored credentials
238    if let Some(key) = creds.get_api_key(provider) {
239        // Set environment variable for this session
240        // SAFETY: We're setting a well-known env var with a valid string value
241        unsafe { std::env::set_var(env_var, key) };
242        return Ok((provider, creds.default_model.clone()));
243    }
244    
245    // No credentials found, run setup
246    println!("No API key found for {}.", provider);
247    run_setup_wizard()
248}