use anyhow::{Result, anyhow};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::Configurable;
use crate::snapshots::SnapshotScope;
use crate::templates::TemplateType;
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
pub struct ClaudeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attribution: Option<Attribution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permissions: Option<Permissions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<Hooks>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_helper: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cleanup_period_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_all_hooks: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub force_login_method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub force_login_org_uuid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_all_project_mcp_servers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_mcpjson_servers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_mcpjson_servers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aws_auth_refresh: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aws_credential_export: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_line: Option<StatusLine>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_model: Option<String>,
#[serde(rename = "effortLevel", skip_serializing_if = "Option::is_none")]
pub effort_level: Option<String>,
}
impl<'de> Deserialize<'de> for ClaudeSettings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Debug, Deserialize)]
struct ClaudeSettingsRaw {
#[serde(default, deserialize_with = "deserialize_env_opt")]
env: Option<HashMap<String, String>>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
output_style: Option<String>,
#[serde(default)]
attribution: Option<Attribution>,
#[serde(default)]
permissions: Option<Permissions>,
#[serde(default)]
hooks: Option<Hooks>,
#[serde(default)]
api_key_helper: Option<String>,
#[serde(default)]
cleanup_period_days: Option<u32>,
#[serde(default)]
disable_all_hooks: Option<bool>,
#[serde(default)]
force_login_method: Option<String>,
#[serde(default)]
force_login_org_uuid: Option<String>,
#[serde(default)]
enable_all_project_mcp_servers: Option<bool>,
#[serde(default)]
enabled_mcpjson_servers: Option<Vec<String>>,
#[serde(default)]
disabled_mcpjson_servers: Option<Vec<String>>,
#[serde(default)]
aws_auth_refresh: Option<String>,
#[serde(default)]
aws_credential_export: Option<String>,
#[serde(default)]
status_line: Option<StatusLine>,
#[serde(default)]
subagent_model: Option<String>,
#[serde(default, rename = "effortLevel")]
effort_level: Option<String>,
}
let raw = ClaudeSettingsRaw::deserialize(deserializer)?;
Ok(ClaudeSettings {
env: raw.env,
model: raw.model,
output_style: raw.output_style,
attribution: raw.attribution,
permissions: raw.permissions,
hooks: raw.hooks,
api_key_helper: raw.api_key_helper,
cleanup_period_days: raw.cleanup_period_days,
disable_all_hooks: raw.disable_all_hooks,
force_login_method: raw.force_login_method,
force_login_org_uuid: raw.force_login_org_uuid,
enable_all_project_mcp_servers: raw.enable_all_project_mcp_servers,
enabled_mcpjson_servers: raw.enabled_mcpjson_servers,
disabled_mcpjson_servers: raw.disabled_mcpjson_servers,
aws_auth_refresh: raw.aws_auth_refresh,
aws_credential_export: raw.aws_credential_export,
status_line: raw.status_line,
subagent_model: raw.subagent_model,
effort_level: raw.effort_level,
})
}
}
fn deserialize_env_opt<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
where
D: Deserializer<'de>,
{
let raw_opt: Option<Value> = Option::deserialize(deserializer)?;
match raw_opt {
Some(Value::Object(map)) => {
let mut env_map = HashMap::new();
for (key, value) in map.into_iter() {
let string_value = match value {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
_ => serde_json::to_string(&value).unwrap_or_default(),
};
env_map.insert(key, string_value);
}
Ok(Some(env_map))
}
Some(_) => Err(serde::de::Error::custom("env field must be an object")),
None => Ok(None),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub id: String,
pub name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub scope: SnapshotScope,
pub settings: ClaudeSettings,
pub description: Option<String>,
#[serde(skip)]
pub show_api_key: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SnapshotStore {
pub snapshots: Vec<Snapshot>,
}
impl SnapshotStore {
pub fn new() -> Self {
Self::default()
}
pub fn find_snapshot(&self, name: &str) -> Option<&Snapshot> {
self.snapshots.iter().find(|s| s.name == name)
}
pub fn add_snapshot(&mut self, snapshot: Snapshot) {
self.snapshots.push(snapshot);
}
pub fn delete_snapshot(&mut self, name: &str) -> Result<()> {
let index = self
.snapshots
.iter()
.position(|s| s.name == name)
.ok_or_else(|| anyhow!("Snapshot '{}' not found", name))?;
self.snapshots.remove(index);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Permissions {
#[serde(skip_serializing_if = "Option::is_none")]
pub allow: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ask: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deny: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_directories: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_bypass_permissions_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Hooks {
#[serde(skip_serializing_if = "Option::is_none")]
pub pre_command: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_command: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StatusLine {
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Attribution {
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pr: Option<String>,
}
impl ClaudeSettings {
pub fn new() -> Self {
Self::default()
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Ok(Self::new());
}
let content = fs::read_to_string(path)
.map_err(|e| anyhow!("Failed to read settings file {}: {}", path.display(), e))?;
if content.trim().is_empty() {
return Ok(Self::new());
}
serde_json::from_str(&content)
.map_err(|e| anyhow!("Failed to parse settings file {}: {}", path.display(), e))
}
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let path = path.as_ref();
let parent = path.parent().ok_or_else(|| {
anyhow!(
"Settings file path {} has no parent directory",
path.display()
)
})?;
fs::create_dir_all(parent).map_err(|e| {
anyhow!(
"Failed to create settings directory {}: {}",
parent.display(),
e
)
})?;
let content = serde_json::to_string_pretty(self)
.map_err(|e| anyhow!("Failed to serialize settings: {}", e))?;
fs::write(path, content)
.map_err(|e| anyhow!("Failed to write settings file {}: {}", path.display(), e))
}
pub fn capture_environment() -> HashMap<String, String> {
let mut env = HashMap::new();
if let Ok(value) = std::env::var("CLAUDE_CODE_API_KEY") {
env.insert("CLAUDE_CODE_API_KEY".to_string(), value);
}
if let Ok(value) = std::env::var("ANTHROPIC_API_KEY") {
env.insert("ANTHROPIC_API_KEY".to_string(), value);
}
env
}
pub fn capture_template_environment(template_type: &TemplateType) -> HashMap<String, String> {
let mut env = HashMap::new();
let env_var_names = crate::templates::get_env_var_names(template_type);
for env_var_name in env_var_names {
if let Ok(value) = std::env::var(env_var_name) {
env.insert(env_var_name.to_string(), value);
}
}
env
}
pub fn mask_api_keys(&self) -> Self {
let mut masked = self.clone();
if let Some(ref mut env) = masked.env {
let keys_to_mask: Vec<String> = env
.keys()
.filter(|key| {
key.contains("API_KEY") || key.contains("AUTH_TOKEN") || key.contains("TOKEN")
})
.cloned()
.collect();
for key in keys_to_mask {
if let Some(value) = env.get(&key) {
env.insert(key, mask_api_key(value));
}
}
}
masked
}
pub fn get_api_key(&self) -> Option<String> {
if let Some(ref env) = self.env {
if let Some(key) = env.get("ANTHROPIC_API_KEY") {
return Some(key.clone());
}
if let Some(key) = env.get("ANTHROPIC_AUTH_TOKEN") {
return Some(key.clone());
}
}
if let Ok(key) = std::env::var("CLAUDE_CODE_API_KEY") {
return Some(key);
}
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
return Some(key);
}
None
}
}
impl crate::Configurable for ClaudeSettings {
fn merge_with(self, other: Self) -> Self {
ClaudeSettings {
env: merge_hashmaps(self.env, other.env),
model: other.model.or(self.model),
output_style: other.output_style.or(self.output_style),
attribution: merge_attribution(self.attribution, other.attribution),
permissions: merge_permissions(self.permissions, other.permissions),
hooks: merge_hooks(self.hooks, other.hooks),
api_key_helper: other.api_key_helper.or(self.api_key_helper),
cleanup_period_days: other.cleanup_period_days.or(self.cleanup_period_days),
disable_all_hooks: other.disable_all_hooks.or(self.disable_all_hooks),
force_login_method: other.force_login_method.or(self.force_login_method),
force_login_org_uuid: other.force_login_org_uuid.or(self.force_login_org_uuid),
enable_all_project_mcp_servers: other
.enable_all_project_mcp_servers
.or(self.enable_all_project_mcp_servers),
enabled_mcpjson_servers: merge_vec(
self.enabled_mcpjson_servers,
other.enabled_mcpjson_servers,
),
disabled_mcpjson_servers: merge_vec(
self.disabled_mcpjson_servers,
other.disabled_mcpjson_servers,
),
aws_auth_refresh: other.aws_auth_refresh.or(self.aws_auth_refresh),
aws_credential_export: other.aws_credential_export.or(self.aws_credential_export),
effort_level: other.effort_level.or(self.effort_level),
status_line: other.status_line.or(self.status_line),
subagent_model: other.subagent_model.or(self.subagent_model),
}
}
fn filter_by_scope(self, scope: &SnapshotScope) -> Self {
match scope {
SnapshotScope::Env => ClaudeSettings {
env: self.env,
..Default::default()
},
SnapshotScope::All => self,
SnapshotScope::Common => ClaudeSettings {
env: self.env,
model: self.model,
output_style: self.output_style,
attribution: self.attribution,
permissions: self.permissions,
hooks: self.hooks,
status_line: self.status_line,
subagent_model: self.subagent_model,
effort_level: self.effort_level,
..Default::default()
},
}
}
fn mask_sensitive_data(self) -> Self {
self.mask_api_keys()
}
}
pub fn merge_settings(settings: Vec<ClaudeSettings>) -> ClaudeSettings {
settings
.into_iter()
.fold(ClaudeSettings::new(), |acc, settings| {
settings.merge_with(acc)
})
}
fn merge_hashmaps<K: Clone + Eq + std::hash::Hash, V: Clone>(
base_map: Option<HashMap<K, V>>,
other_map: Option<HashMap<K, V>>,
) -> Option<HashMap<K, V>> {
match (base_map, other_map) {
(Some(base), Some(other)) => {
let mut result = other;
for (key, value) in base {
result.insert(key, value);
}
Some(result)
}
(Some(base_map), None) => Some(base_map),
(None, Some(other_map)) => Some(other_map),
(None, None) => None,
}
}
fn merge_permissions(
base: Option<Permissions>,
override_settings: Option<Permissions>,
) -> Option<Permissions> {
match (base, override_settings) {
(Some(base_perms), Some(override_perms)) => Some(Permissions {
allow: merge_vec(base_perms.allow, override_perms.allow),
ask: merge_vec(base_perms.ask, override_perms.ask),
deny: merge_vec(base_perms.deny, override_perms.deny),
additional_directories: merge_vec(
base_perms.additional_directories,
override_perms.additional_directories,
),
default_mode: override_perms.default_mode.or(base_perms.default_mode),
disable_bypass_permissions_mode: override_perms
.disable_bypass_permissions_mode
.or(base_perms.disable_bypass_permissions_mode),
}),
(Some(base_perms), None) => Some(base_perms),
(None, Some(override_perms)) => Some(override_perms),
(None, None) => None,
}
}
fn merge_hooks(base: Option<Hooks>, override_settings: Option<Hooks>) -> Option<Hooks> {
match (base, override_settings) {
(Some(base_hooks), Some(override_hooks)) => Some(Hooks {
pre_command: merge_vec(base_hooks.pre_command, override_hooks.pre_command),
post_command: merge_vec(base_hooks.post_command, override_hooks.post_command),
}),
(Some(base_hooks), None) => Some(base_hooks),
(None, Some(override_hooks)) => Some(override_hooks),
(None, None) => None,
}
}
fn merge_attribution(base: Option<Attribution>, other: Option<Attribution>) -> Option<Attribution> {
match (base, other) {
(Some(base_attr), Some(other_attr)) => Some(Attribution {
commit: other_attr.commit.or(base_attr.commit),
pr: other_attr.pr.or(base_attr.pr),
}),
(Some(base_attr), None) => Some(base_attr),
(None, Some(other_attr)) => Some(other_attr),
(None, None) => None,
}
}
fn merge_vec<T: Clone>(base: Option<Vec<T>>, override_settings: Option<Vec<T>>) -> Option<Vec<T>> {
match (base, override_settings) {
(Some(mut base_vec), Some(override_vec)) => {
base_vec.extend(override_vec);
Some(base_vec)
}
(Some(base_vec), None) => Some(base_vec),
(None, Some(override_vec)) => Some(override_vec),
(None, None) => None,
}
}
pub fn format_settings_for_display(settings: &ClaudeSettings, verbose: bool) -> String {
let mut output = String::new();
if verbose {
output.push_str(&format!(
"{} Settings\n",
console::style("Current").bold().cyan()
));
output.push_str(&format!(
"{} {}\n",
console::style("Provider:").bold(),
settings.model.as_deref().unwrap_or("None")
));
output.push_str(&format!(
"{} {}\n",
console::style("Model:").bold(),
settings.model.as_deref().unwrap_or("None")
));
if let Some(ref env) = settings.env {
output.push_str(&format!(
"{}\n",
console::style("Environment Variables:").bold()
));
for (key, value) in env {
let display_value = if key.contains("API_KEY")
|| key.contains("AUTH_TOKEN")
|| key.contains("TOKEN")
|| key.contains("SECRET")
|| key.contains("PASSWORD")
|| key.contains("PRIVATE_KEY")
{
mask_api_key(value)
} else {
value.clone()
};
output.push_str(&format!(" {} = {}\n", key, display_value));
}
}
} else {
output.push_str(&format!(
"{}: {} | {}: {}\n",
console::style("Provider").bold(),
"default",
console::style("Model").bold(),
settings.model.as_deref().unwrap_or("default")
));
}
output
}
pub fn format_settings_comparison(current: &ClaudeSettings, new: &ClaudeSettings) -> String {
let current_provider = "default";
let new_provider = "default";
let current_model = current.model.as_deref().unwrap_or("default");
let new_model = new.model.as_deref().unwrap_or("default");
if current_provider == new_provider && current_model == new_model {
"Settings are identical.".to_string()
} else {
let mut output = String::new();
output.push_str(&format!(
"{}: {} → {}\n",
console::style("Provider").bold(),
current_provider,
new_provider
));
output.push_str(&format!(
"{}: {} → {}\n",
console::style("Model").bold(),
current_model,
new_model
));
output
}
}
fn mask_api_key(api_key: &str) -> String {
if let Some(actual_key) = api_key.strip_prefix("sk-") {
let actual_len = actual_key.len();
if actual_len <= 6 {
format!("sk-{}", "*".repeat(actual_len))
} else if actual_len <= 14 {
format!(
"sk-{}***{}",
&actual_key[..2],
&actual_key[actual_len - 3..]
)
} else {
format!(
"sk-{}{}...{} ({} chars)",
&actual_key[..3],
"*".repeat(std::cmp::min(actual_len - 7, 8)),
&actual_key[actual_len - 4..],
api_key.len()
)
}
} else if api_key.len() <= 8 {
"*".repeat(api_key.len())
} else if api_key.len() <= 16 {
format!("{}***{}", &api_key[..3], &api_key[api_key.len() - 3..])
} else {
let visible_start = &api_key[..4];
let visible_end = &api_key[api_key.len() - 4..];
let masked_length = api_key.len() - 8;
format!(
"{}{}...{} ({} chars)",
visible_start,
"*".repeat(std::cmp::min(masked_length, 8)),
visible_end,
api_key.len()
)
}
}
impl ClaudeSettings {
pub fn get_environment(&self) -> Option<&HashMap<String, String>> {
self.env.as_ref()
}
pub fn set_environment(&mut self, env: HashMap<String, String>) {
self.env = Some(env);
}
}
impl ClaudeSettings {
pub fn environment(&self) -> Option<&HashMap<String, String>> {
self.env.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn test_deserialize_env_with_integer_values() {
let json_with_int = r#"{
"env": {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1,
"MAX_OUTPUT_TOKENS": "96000",
"ENABLE_THINKING": true,
"NULL_VALUE": null
}
}"#;
let settings: ClaudeSettings =
serde_json::from_str(json_with_int).expect("Should deserialize successfully");
let env = settings.env.expect("Should have env map");
assert_eq!(
env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"),
Some(&"1".to_string())
);
assert_eq!(env.get("MAX_OUTPUT_TOKENS"), Some(&"96000".to_string()));
assert_eq!(env.get("ENABLE_THINKING"), Some(&"true".to_string()));
assert_eq!(env.get("NULL_VALUE"), Some(&"".to_string()));
}
#[test]
fn test_deserialize_env_string_values() {
let json_string_only = r#"{
"env": {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"MAX_OUTPUT_TOKENS": "96000"
}
}"#;
let settings: ClaudeSettings =
serde_json::from_str(json_string_only).expect("Should deserialize successfully");
let env = settings.env.expect("Should have env map");
assert_eq!(
env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"),
Some(&"1".to_string())
);
assert_eq!(env.get("MAX_OUTPUT_TOKENS"), Some(&"96000".to_string()));
}
#[test]
fn test_deserialize_env_no_env() {
let json_no_env = r#"{
"model": "test-model"
}"#;
let settings: ClaudeSettings =
serde_json::from_str(json_no_env).expect("Should deserialize successfully");
assert_eq!(settings.env, None);
}
}