syncable_cli/agent/
config.rs1use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10use super::{AgentError, AgentResult, ProviderType};
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct AgentCredentials {
15 #[serde(default)]
17 pub default_provider: Option<String>,
18
19 #[serde(default)]
21 pub default_model: Option<String>,
22
23 #[serde(default)]
25 pub openai_api_key: Option<String>,
26
27 #[serde(default)]
29 pub anthropic_api_key: Option<String>,
30}
31
32impl AgentCredentials {
33 pub fn config_dir() -> Option<PathBuf> {
35 dirs::home_dir().map(|h| h.join(".syncable"))
36 }
37
38 pub fn credentials_path() -> Option<PathBuf> {
40 Self::config_dir().map(|d| d.join("credentials.toml"))
41 }
42
43 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 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 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 #[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 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 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 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 pub fn get_default_provider(&self) -> Option<ProviderType> {
114 self.default_provider.as_ref().and_then(|p| p.parse().ok())
115 }
116
117 pub fn set_default_provider(&mut self, provider: ProviderType) {
119 self.default_provider = Some(provider.to_string());
120 }
121}
122
123pub 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 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 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 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 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 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
217pub fn ensure_credentials(provider: Option<ProviderType>) -> AgentResult<(ProviderType, Option<String>)> {
219 let creds = AgentCredentials::load().unwrap_or_default();
220
221 let provider = provider
223 .or_else(|| creds.get_default_provider())
224 .unwrap_or(ProviderType::OpenAI);
225
226 let env_var = match provider {
228 ProviderType::OpenAI => "OPENAI_API_KEY",
229 ProviderType::Anthropic => "ANTHROPIC_API_KEY",
230 };
231
232 if std::env::var(env_var).is_ok() {
234 return Ok((provider, creds.default_model.clone()));
235 }
236
237 if let Some(key) = creds.get_api_key(provider) {
239 unsafe { std::env::set_var(env_var, key) };
242 return Ok((provider, creds.default_model.clone()));
243 }
244
245 println!("No API key found for {}.", provider);
247 run_setup_wizard()
248}