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("KIMI_API_KEY") {
209 env.insert("WQ_API_KEY".to_string(), value);
210 }
211 }
212 TemplateType::Kimi => {
213 if let Ok(value) = std::env::var("MOONSHOT_API_KEY") {
215 env.insert("MOONSHOT_API_KEY".to_string(), value);
216 }
217 if let Ok(value) = std::env::var("KIMI_API_KEY") {
218 env.insert("KIMI_API_KEY".to_string(), value);
219 }
220 }
221 TemplateType::Longcat => {
222 if let Ok(value) = std::env::var("LONGCAT_API_KEY") {
223 env.insert("LONGCAT_API_KEY".to_string(), value);
224 }
225 }
226 TemplateType::MiniMax => {
227 if let Ok(value) = std::env::var("MINIMAX_API_KEY") {
228 env.insert("MINIMAX_API_KEY".to_string(), value);
229 }
230 }
231 TemplateType::SeedCode => {
232 if let Ok(value) = std::env::var("ARK_API_KEY") {
233 env.insert("ARK_API_KEY".to_string(), value);
234 }
235 }
236 }
237
238 env
239 }
240
241 pub fn mask_api_keys(&self) -> Self {
243 let mut masked = self.clone();
244 if let Some(ref mut env) = masked.env {
245 let keys_to_mask: Vec<String> = env
246 .keys()
247 .filter(|key| {
248 key.contains("API_KEY") || key.contains("AUTH_TOKEN") || key.contains("TOKEN")
249 })
250 .cloned()
251 .collect();
252
253 for key in keys_to_mask {
254 if let Some(value) = env.get(&key) {
255 env.insert(key, mask_api_key(value));
256 }
257 }
258 }
259 masked
260 }
261
262 pub fn get_api_key(&self) -> Option<String> {
264 if let Some(ref env) = self.env {
266 if let Some(key) = env.get("ANTHROPIC_API_KEY") {
267 return Some(key.clone());
268 }
269 if let Some(key) = env.get("ANTHROPIC_AUTH_TOKEN") {
270 return Some(key.clone());
271 }
272 }
273
274 if let Ok(key) = std::env::var("CLAUDE_CODE_API_KEY") {
276 return Some(key);
277 }
278 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
279 return Some(key);
280 }
281
282 None
283 }
284}
285
286impl crate::Configurable for ClaudeSettings {
287 fn merge_with(self, other: Self) -> Self {
288 ClaudeSettings {
290 env: merge_hashmaps(self.env, other.env),
291 model: other.model.or(self.model),
292 output_style: other.output_style.or(self.output_style),
293 include_co_authored_by: other.include_co_authored_by.or(self.include_co_authored_by),
294 permissions: merge_permissions(self.permissions, other.permissions),
295 hooks: merge_hooks(self.hooks, other.hooks),
296 api_key_helper: other.api_key_helper.or(self.api_key_helper),
297 cleanup_period_days: other.cleanup_period_days.or(self.cleanup_period_days),
298 disable_all_hooks: other.disable_all_hooks.or(self.disable_all_hooks),
299 force_login_method: other.force_login_method.or(self.force_login_method),
300 force_login_org_uuid: other.force_login_org_uuid.or(self.force_login_org_uuid),
301 enable_all_project_mcp_servers: other
302 .enable_all_project_mcp_servers
303 .or(self.enable_all_project_mcp_servers),
304 enabled_mcpjson_servers: merge_vec(
305 self.enabled_mcpjson_servers,
306 other.enabled_mcpjson_servers,
307 ),
308 disabled_mcpjson_servers: merge_vec(
309 self.disabled_mcpjson_servers,
310 other.disabled_mcpjson_servers,
311 ),
312 aws_auth_refresh: other.aws_auth_refresh.or(self.aws_auth_refresh),
313 aws_credential_export: other.aws_credential_export.or(self.aws_credential_export),
314 status_line: other.status_line.or(self.status_line),
315 subagent_model: other.subagent_model.or(self.subagent_model),
316 }
317 }
318
319 fn filter_by_scope(self, scope: &SnapshotScope) -> Self {
320 match scope {
321 SnapshotScope::Env => ClaudeSettings {
322 env: self.env,
323 ..Default::default()
324 },
325 SnapshotScope::All => self,
326 SnapshotScope::Common => ClaudeSettings {
327 env: self.env,
328 model: self.model,
329 output_style: self.output_style,
330 include_co_authored_by: self.include_co_authored_by,
331 permissions: self.permissions,
332 hooks: self.hooks,
333 status_line: self.status_line,
334 subagent_model: self.subagent_model,
335 ..Default::default()
336 },
337 }
338 }
339
340 fn mask_sensitive_data(self) -> Self {
341 self.mask_api_keys()
342 }
343}
344
345pub fn merge_settings(settings: Vec<ClaudeSettings>) -> ClaudeSettings {
347 settings
348 .into_iter()
349 .fold(ClaudeSettings::new(), |acc, settings| {
350 settings.merge_with(acc)
351 })
352}
353
354fn merge_hashmaps<K: Clone + Eq + std::hash::Hash, V: Clone>(
357 base_map: Option<HashMap<K, V>>,
358 other_map: Option<HashMap<K, V>>,
359) -> Option<HashMap<K, V>> {
360 match (base_map, other_map) {
361 (Some(base), Some(other)) => {
362 let mut result = other;
363 for (key, value) in base {
365 result.insert(key, value);
366 }
367 Some(result)
368 }
369 (Some(base_map), None) => Some(base_map),
370 (None, Some(other_map)) => Some(other_map),
371 (None, None) => None,
372 }
373}
374
375fn merge_permissions(
377 base: Option<Permissions>,
378 override_settings: Option<Permissions>,
379) -> Option<Permissions> {
380 match (base, override_settings) {
381 (Some(base_perms), Some(override_perms)) => Some(Permissions {
382 allow: merge_vec(base_perms.allow, override_perms.allow),
383 ask: merge_vec(base_perms.ask, override_perms.ask),
384 deny: merge_vec(base_perms.deny, override_perms.deny),
385 additional_directories: merge_vec(
386 base_perms.additional_directories,
387 override_perms.additional_directories,
388 ),
389 default_mode: override_perms.default_mode.or(base_perms.default_mode),
390 disable_bypass_permissions_mode: override_perms
391 .disable_bypass_permissions_mode
392 .or(base_perms.disable_bypass_permissions_mode),
393 }),
394 (Some(base_perms), None) => Some(base_perms),
395 (None, Some(override_perms)) => Some(override_perms),
396 (None, None) => None,
397 }
398}
399
400fn merge_hooks(base: Option<Hooks>, override_settings: Option<Hooks>) -> Option<Hooks> {
402 match (base, override_settings) {
403 (Some(base_hooks), Some(override_hooks)) => Some(Hooks {
404 pre_command: merge_vec(base_hooks.pre_command, override_hooks.pre_command),
405 post_command: merge_vec(base_hooks.post_command, override_hooks.post_command),
406 }),
407 (Some(base_hooks), None) => Some(base_hooks),
408 (None, Some(override_hooks)) => Some(override_hooks),
409 (None, None) => None,
410 }
411}
412
413fn merge_vec<T: Clone>(base: Option<Vec<T>>, override_settings: Option<Vec<T>>) -> Option<Vec<T>> {
415 match (base, override_settings) {
416 (Some(mut base_vec), Some(override_vec)) => {
417 base_vec.extend(override_vec);
418 Some(base_vec)
419 }
420 (Some(base_vec), None) => Some(base_vec),
421 (None, Some(override_vec)) => Some(override_vec),
422 (None, None) => None,
423 }
424}
425
426pub fn format_settings_for_display(settings: &ClaudeSettings, verbose: bool) -> String {
428 let mut output = String::new();
429
430 if verbose {
431 output.push_str(&format!(
432 "{} Settings\n",
433 console::style("Current").bold().cyan()
434 ));
435 output.push_str(&format!(
436 "{} {}\n",
437 console::style("Provider:").bold(),
438 settings.model.as_deref().unwrap_or("None")
439 ));
440 output.push_str(&format!(
441 "{} {}\n",
442 console::style("Model:").bold(),
443 settings.model.as_deref().unwrap_or("None")
444 ));
445
446 if let Some(ref env) = settings.env {
447 output.push_str(&format!(
448 "{}\n",
449 console::style("Environment Variables:").bold()
450 ));
451 for (key, value) in env {
452 let display_value = if key.contains("API_KEY")
453 || key.contains("AUTH_TOKEN")
454 || key.contains("TOKEN")
455 || key.contains("SECRET")
456 || key.contains("PASSWORD")
457 || key.contains("PRIVATE_KEY")
458 {
459 mask_api_key(value)
460 } else {
461 value.clone()
462 };
463 output.push_str(&format!(" {} = {}\n", key, display_value));
464 }
465 }
466 } else {
467 output.push_str(&format!(
468 "{}: {} | {}: {}\n",
469 console::style("Provider").bold(),
470 "default",
471 console::style("Model").bold(),
472 settings.model.as_deref().unwrap_or("default")
473 ));
474 }
475
476 output
477}
478
479pub fn format_settings_comparison(current: &ClaudeSettings, new: &ClaudeSettings) -> String {
481 let current_provider = "default";
482 let new_provider = "default";
483 let current_model = current.model.as_deref().unwrap_or("default");
484 let new_model = new.model.as_deref().unwrap_or("default");
485
486 if current_provider == new_provider && current_model == new_model {
488 "Settings are identical.".to_string()
489 } else {
490 let mut output = String::new();
491
492 output.push_str(&format!(
493 "{}: {} → {}\n",
494 console::style("Provider").bold(),
495 current_provider,
496 new_provider
497 ));
498
499 output.push_str(&format!(
500 "{}: {} → {}\n",
501 console::style("Model").bold(),
502 current_model,
503 new_model
504 ));
505
506 output
507 }
508}
509
510fn mask_api_key(api_key: &str) -> String {
512 if let Some(actual_key) = api_key.strip_prefix("sk-") {
513 let actual_len = actual_key.len();
514
515 if actual_len <= 6 {
516 format!("sk-{}", "*".repeat(actual_len))
517 } else if actual_len <= 14 {
518 format!(
519 "sk-{}***{}",
520 &actual_key[..2],
521 &actual_key[actual_len - 3..]
522 )
523 } else {
524 format!(
525 "sk-{}{}...{} ({} chars)",
526 &actual_key[..3],
527 "*".repeat(std::cmp::min(actual_len - 7, 8)),
528 &actual_key[actual_len - 4..],
529 api_key.len()
530 )
531 }
532 } else if api_key.len() <= 8 {
533 "*".repeat(api_key.len())
534 } else if api_key.len() <= 16 {
535 format!("{}***{}", &api_key[..3], &api_key[api_key.len() - 3..])
536 } else {
537 let visible_start = &api_key[..4];
538 let visible_end = &api_key[api_key.len() - 4..];
539 let masked_length = api_key.len() - 8;
540 format!(
541 "{}{}...{} ({} chars)",
542 visible_start,
543 "*".repeat(std::cmp::min(masked_length, 8)),
544 visible_end,
545 api_key.len()
546 )
547 }
548}
549
550impl ClaudeSettings {
552 pub fn get_environment(&self) -> Option<&HashMap<String, String>> {
554 self.env.as_ref()
555 }
556
557 pub fn set_environment(&mut self, env: HashMap<String, String>) {
559 self.env = Some(env);
560 }
561}
562
563impl ClaudeSettings {
564 pub fn environment(&self) -> Option<&HashMap<String, String>> {
566 self.env.as_ref()
567 }
568}