1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use crate::{Configurable, SnapshotScope, TemplateType};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
11pub struct ClaudeSettings {
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub env: Option<std::collections::HashMap<String, String>>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub model: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub output_style: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub include_co_authored_by: Option<bool>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub permissions: Option<Permissions>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub hooks: Option<Hooks>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub api_key_helper: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub cleanup_period_days: Option<u32>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub disable_all_hooks: Option<bool>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub force_login_method: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub force_login_org_uuid: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub enable_all_project_mcp_servers: Option<bool>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub enabled_mcpjson_servers: Option<Vec<String>>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub disabled_mcpjson_servers: Option<Vec<String>>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub aws_auth_refresh: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub aws_credential_export: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub status_line: Option<StatusLine>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub subagent_model: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Snapshot {
53 pub id: String,
54 pub name: String,
55 pub created_at: chrono::DateTime<chrono::Utc>,
56 pub scope: SnapshotScope,
57 pub settings: ClaudeSettings,
58 pub description: Option<String>,
59 #[serde(skip)]
60 pub show_api_key: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct SnapshotStore {
66 pub snapshots: Vec<Snapshot>,
67}
68
69impl SnapshotStore {
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn find_snapshot(&self, name: &str) -> Option<&Snapshot> {
75 self.snapshots.iter().find(|s| s.name == name)
76 }
77
78 pub fn add_snapshot(&mut self, snapshot: Snapshot) {
79 self.snapshots.push(snapshot);
80 }
81
82 pub fn delete_snapshot(&mut self, name: &str) -> Result<()> {
83 let index = self
84 .snapshots
85 .iter()
86 .position(|s| s.name == name)
87 .ok_or_else(|| anyhow!("Snapshot '{}' not found", name))?;
88 self.snapshots.remove(index);
89 Ok(())
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct Permissions {
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub allow: Option<Vec<String>>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub ask: Option<Vec<String>>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub deny: Option<Vec<String>>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub additional_directories: Option<Vec<String>>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub default_mode: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub disable_bypass_permissions_mode: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct Hooks {
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub pre_command: Option<Vec<String>>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub post_command: Option<Vec<String>>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct StatusLine {
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub r#type: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub command: Option<String>,
126}
127
128impl ClaudeSettings {
129 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
136 let path = path.as_ref();
137 if !path.exists() {
138 return Ok(Self::new());
139 }
140
141 let content = fs::read_to_string(path)
142 .map_err(|e| anyhow!("Failed to read settings file {}: {}", path.display(), e))?;
143
144 if content.trim().is_empty() {
145 return Ok(Self::new());
146 }
147
148 serde_json::from_str(&content)
149 .map_err(|e| anyhow!("Failed to parse settings file {}: {}", path.display(), e))
150 }
151
152 pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
154 let path = path.as_ref();
155 let parent = path.parent().ok_or_else(|| {
156 anyhow!(
157 "Settings file path {} has no parent directory",
158 path.display()
159 )
160 })?;
161
162 fs::create_dir_all(parent).map_err(|e| {
163 anyhow!(
164 "Failed to create settings directory {}: {}",
165 parent.display(),
166 e
167 )
168 })?;
169
170 let content = serde_json::to_string_pretty(self)
171 .map_err(|e| anyhow!("Failed to serialize settings: {}", e))?;
172
173 fs::write(path, content)
174 .map_err(|e| anyhow!("Failed to write settings file {}: {}", path.display(), e))
175 }
176
177 pub fn capture_environment() -> HashMap<String, String> {
179 let mut env = HashMap::new();
180
181 if let Ok(value) = std::env::var("CLAUDE_CODE_API_KEY") {
183 env.insert("CLAUDE_CODE_API_KEY".to_string(), value);
184 }
185 if let Ok(value) = std::env::var("ANTHROPIC_API_KEY") {
186 env.insert("ANTHROPIC_API_KEY".to_string(), value);
187 }
188
189 env
190 }
191
192 pub fn capture_template_environment(template_type: &TemplateType) -> HashMap<String, String> {
194 let mut env = HashMap::new();
195
196 match template_type {
197 TemplateType::DeepSeek => {
198 if let Ok(value) = std::env::var("DEEPSEEK_API_KEY") {
199 env.insert("DEEPSEEK_API_KEY".to_string(), value);
200 }
201 }
202 TemplateType::Zai => {
203 if let Ok(value) = std::env::var("Z_AI_API_KEY") {
204 env.insert("Z_AI_API_KEY".to_string(), value);
205 }
206 }
207 TemplateType::KatCoder => {
208 if let Ok(value) = std::env::var("MOONSHOT_API_KEY") {
210 env.insert("MOONSHOT_API_KEY".to_string(), value);
211 }
212 if let Ok(value) = std::env::var("KIMI_API_KEY") {
213 env.insert("KIMI_API_KEY".to_string(), value);
214 }
215 }
216 TemplateType::Kimi => {
217 if let Ok(value) = std::env::var("MOONSHOT_API_KEY") {
219 env.insert("MOONSHOT_API_KEY".to_string(), value);
220 }
221 if let Ok(value) = std::env::var("KIMI_API_KEY") {
222 env.insert("KIMI_API_KEY".to_string(), value);
223 }
224 }
225 TemplateType::Longcat => {
226 if let Ok(value) = std::env::var("LONGCAT_API_KEY") {
227 env.insert("LONGCAT_API_KEY".to_string(), value);
228 }
229 }
230 TemplateType::MiniMax => {
231 if let Ok(value) = std::env::var("MINIMAX_API_KEY") {
232 env.insert("MINIMAX_API_KEY".to_string(), value);
233 }
234 }
235 TemplateType::SeedCode => {
236 if let Ok(value) = std::env::var("ARK_API_KEY") {
237 env.insert("ARK_API_KEY".to_string(), value);
238 }
239 }
240 }
241
242 env
243 }
244
245 pub fn mask_api_keys(&self) -> Self {
247 let mut masked = self.clone();
248 if let Some(ref mut env) = masked.env {
249 let keys_to_mask: Vec<String> = env
250 .keys()
251 .filter(|key| {
252 key.contains("API_KEY") || key.contains("AUTH_TOKEN") || key.contains("TOKEN")
253 })
254 .cloned()
255 .collect();
256
257 for key in keys_to_mask {
258 if let Some(value) = env.get(&key) {
259 env.insert(key, mask_api_key(value));
260 }
261 }
262 }
263 masked
264 }
265
266 pub fn get_api_key(&self) -> Option<String> {
268 if let Some(ref env) = self.env {
270 if let Some(key) = env.get("ANTHROPIC_API_KEY") {
271 return Some(key.clone());
272 }
273 if let Some(key) = env.get("ANTHROPIC_AUTH_TOKEN") {
274 return Some(key.clone());
275 }
276 }
277
278 if let Ok(key) = std::env::var("CLAUDE_CODE_API_KEY") {
280 return Some(key);
281 }
282 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
283 return Some(key);
284 }
285
286 None
287 }
288}
289
290impl crate::Configurable for ClaudeSettings {
291 fn merge_with(self, other: Self) -> Self {
292 ClaudeSettings {
294 env: merge_hashmaps(self.env, other.env),
295 model: other.model.or(self.model),
296 output_style: other.output_style.or(self.output_style),
297 include_co_authored_by: other.include_co_authored_by.or(self.include_co_authored_by),
298 permissions: merge_permissions(self.permissions, other.permissions),
299 hooks: merge_hooks(self.hooks, other.hooks),
300 api_key_helper: other.api_key_helper.or(self.api_key_helper),
301 cleanup_period_days: other.cleanup_period_days.or(self.cleanup_period_days),
302 disable_all_hooks: other.disable_all_hooks.or(self.disable_all_hooks),
303 force_login_method: other.force_login_method.or(self.force_login_method),
304 force_login_org_uuid: other.force_login_org_uuid.or(self.force_login_org_uuid),
305 enable_all_project_mcp_servers: other
306 .enable_all_project_mcp_servers
307 .or(self.enable_all_project_mcp_servers),
308 enabled_mcpjson_servers: merge_vec(
309 self.enabled_mcpjson_servers,
310 other.enabled_mcpjson_servers,
311 ),
312 disabled_mcpjson_servers: merge_vec(
313 self.disabled_mcpjson_servers,
314 other.disabled_mcpjson_servers,
315 ),
316 aws_auth_refresh: other.aws_auth_refresh.or(self.aws_auth_refresh),
317 aws_credential_export: other.aws_credential_export.or(self.aws_credential_export),
318 status_line: other.status_line.or(self.status_line),
319 subagent_model: other.subagent_model.or(self.subagent_model),
320 }
321 }
322
323 fn filter_by_scope(self, scope: &SnapshotScope) -> Self {
324 match scope {
325 SnapshotScope::Env => ClaudeSettings {
326 env: self.env,
327 ..Default::default()
328 },
329 SnapshotScope::All => self,
330 SnapshotScope::Common => ClaudeSettings {
331 env: self.env,
332 model: self.model,
333 output_style: self.output_style,
334 include_co_authored_by: self.include_co_authored_by,
335 permissions: self.permissions,
336 hooks: self.hooks,
337 status_line: self.status_line,
338 subagent_model: self.subagent_model,
339 ..Default::default()
340 },
341 }
342 }
343
344 fn mask_sensitive_data(self) -> Self {
345 self.mask_api_keys()
346 }
347}
348
349pub fn merge_settings(settings: Vec<ClaudeSettings>) -> ClaudeSettings {
351 settings
352 .into_iter()
353 .fold(ClaudeSettings::new(), |acc, settings| {
354 settings.merge_with(acc)
355 })
356}
357
358fn merge_hashmaps<K: Clone + Eq + std::hash::Hash, V: Clone>(
360 base: Option<HashMap<K, V>>,
361 override_settings: Option<HashMap<K, V>>,
362) -> Option<HashMap<K, V>> {
363 match (base, override_settings) {
364 (Some(mut base_map), Some(override_map)) => {
365 base_map.extend(override_map);
366 Some(base_map)
367 }
368 (Some(base_map), None) => Some(base_map),
369 (None, Some(override_map)) => Some(override_map),
370 (None, None) => None,
371 }
372}
373
374fn merge_permissions(
376 base: Option<Permissions>,
377 override_settings: Option<Permissions>,
378) -> Option<Permissions> {
379 match (base, override_settings) {
380 (Some(base_perms), Some(override_perms)) => Some(Permissions {
381 allow: merge_vec(base_perms.allow, override_perms.allow),
382 ask: merge_vec(base_perms.ask, override_perms.ask),
383 deny: merge_vec(base_perms.deny, override_perms.deny),
384 additional_directories: merge_vec(
385 base_perms.additional_directories,
386 override_perms.additional_directories,
387 ),
388 default_mode: override_perms.default_mode.or(base_perms.default_mode),
389 disable_bypass_permissions_mode: override_perms
390 .disable_bypass_permissions_mode
391 .or(base_perms.disable_bypass_permissions_mode),
392 }),
393 (Some(base_perms), None) => Some(base_perms),
394 (None, Some(override_perms)) => Some(override_perms),
395 (None, None) => None,
396 }
397}
398
399fn merge_hooks(base: Option<Hooks>, override_settings: Option<Hooks>) -> Option<Hooks> {
401 match (base, override_settings) {
402 (Some(base_hooks), Some(override_hooks)) => Some(Hooks {
403 pre_command: merge_vec(base_hooks.pre_command, override_hooks.pre_command),
404 post_command: merge_vec(base_hooks.post_command, override_hooks.post_command),
405 }),
406 (Some(base_hooks), None) => Some(base_hooks),
407 (None, Some(override_hooks)) => Some(override_hooks),
408 (None, None) => None,
409 }
410}
411
412fn merge_vec<T: Clone>(base: Option<Vec<T>>, override_settings: Option<Vec<T>>) -> Option<Vec<T>> {
414 match (base, override_settings) {
415 (Some(mut base_vec), Some(override_vec)) => {
416 base_vec.extend(override_vec);
417 Some(base_vec)
418 }
419 (Some(base_vec), None) => Some(base_vec),
420 (None, Some(override_vec)) => Some(override_vec),
421 (None, None) => None,
422 }
423}
424
425pub fn format_settings_for_display(settings: &ClaudeSettings, verbose: bool) -> String {
427 let mut output = String::new();
428
429 if verbose {
430 output.push_str(&format!(
431 "{} Settings\n",
432 console::style("Current").bold().cyan()
433 ));
434 output.push_str(&format!(
435 "{} {}\n",
436 console::style("Provider:").bold(),
437 settings.model.as_deref().unwrap_or("None")
438 ));
439 output.push_str(&format!(
440 "{} {}\n",
441 console::style("Model:").bold(),
442 settings.model.as_deref().unwrap_or("None")
443 ));
444
445 if let Some(ref env) = settings.env {
446 output.push_str(&format!(
447 "{}\n",
448 console::style("Environment Variables:").bold()
449 ));
450 for (key, value) in env {
451 let display_value = if key.contains("API_KEY")
452 || key.contains("AUTH_TOKEN")
453 || key.contains("TOKEN")
454 || key.contains("SECRET")
455 || key.contains("PASSWORD")
456 || key.contains("PRIVATE_KEY")
457 {
458 mask_api_key(value)
459 } else {
460 value.clone()
461 };
462 output.push_str(&format!(" {} = {}\n", key, display_value));
463 }
464 }
465 } else {
466 output.push_str(&format!(
467 "{}: {} | {}: {}\n",
468 console::style("Provider").bold(),
469 "default",
470 console::style("Model").bold(),
471 settings.model.as_deref().unwrap_or("default")
472 ));
473 }
474
475 output
476}
477
478pub fn format_settings_comparison(current: &ClaudeSettings, new: &ClaudeSettings) -> String {
480 let current_provider = "default";
481 let new_provider = "default";
482 let current_model = current.model.as_deref().unwrap_or("default");
483 let new_model = new.model.as_deref().unwrap_or("default");
484
485 if current_provider == new_provider && current_model == new_model {
487 "Settings are identical.".to_string()
488 } else {
489 let mut output = String::new();
490
491 output.push_str(&format!(
492 "{}: {} → {}\n",
493 console::style("Provider").bold(),
494 current_provider,
495 new_provider
496 ));
497
498 output.push_str(&format!(
499 "{}: {} → {}\n",
500 console::style("Model").bold(),
501 current_model,
502 new_model
503 ));
504
505 output
506 }
507}
508
509fn mask_api_key(api_key: &str) -> String {
511 if let Some(actual_key) = api_key.strip_prefix("sk-") {
512 let actual_len = actual_key.len();
513
514 if actual_len <= 6 {
515 format!("sk-{}", "*".repeat(actual_len))
516 } else if actual_len <= 14 {
517 format!(
518 "sk-{}***{}",
519 &actual_key[..2],
520 &actual_key[actual_len - 3..]
521 )
522 } else {
523 format!(
524 "sk-{}{}...{} ({} chars)",
525 &actual_key[..3],
526 "*".repeat(std::cmp::min(actual_len - 7, 8)),
527 &actual_key[actual_len - 4..],
528 api_key.len()
529 )
530 }
531 } else if api_key.len() <= 8 {
532 "*".repeat(api_key.len())
533 } else if api_key.len() <= 16 {
534 format!("{}***{}", &api_key[..3], &api_key[api_key.len() - 3..])
535 } else {
536 let visible_start = &api_key[..4];
537 let visible_end = &api_key[api_key.len() - 4..];
538 let masked_length = api_key.len() - 8;
539 format!(
540 "{}{}...{} ({} chars)",
541 visible_start,
542 "*".repeat(std::cmp::min(masked_length, 8)),
543 visible_end,
544 api_key.len()
545 )
546 }
547}
548
549impl ClaudeSettings {
551 pub fn get_environment(&self) -> Option<&HashMap<String, String>> {
553 self.env.as_ref()
554 }
555
556 pub fn set_environment(&mut self, env: HashMap<String, String>) {
558 self.env = Some(env);
559 }
560}
561
562impl ClaudeSettings {
563 pub fn environment(&self) -> Option<&HashMap<String, String>> {
565 self.env.as_ref()
566 }
567}