Skip to main content

claude_wrapper/
settings.rs

1//! Read-side access to Claude Code's on-disk **settings** files.
2//!
3//! Claude Code reads up to four JSON files in increasing order of
4//! precedence (later layers override earlier ones):
5//!
6//! 1. `~/.claude/settings.json` -- user defaults
7//! 2. `~/.claude/settings.local.json` -- user-private overrides
8//! 3. `<project>/.claude/settings.json` -- project-shared (checked
9//!    into version control)
10//! 4. `<project>/.claude/settings.local.json` -- project-private
11//!    overrides (typically gitignored)
12//!
13//! Plus a managed (enterprise) layer that lives outside these paths
14//! and isn't covered here.
15//!
16//! This module reads each layer as opaque [`serde_json::Value`] and
17//! returns them side-by-side, **without merging**. Claude Code's
18//! merge semantics are non-trivial and not fully documented (some
19//! object fields deep-merge, some arrays concatenate, some replace),
20//! and reproducing them here would risk diverging from the binary's
21//! actual behavior in subtle ways. Callers who want an "effective"
22//! view can apply their own merge with full knowledge of which
23//! source produced which value -- the per-layer split makes that
24//! attribution possible.
25//!
26//! # What's in a settings file
27//!
28//! Field names worth knowing (not exhaustive; survives unknown keys
29//! since values are kept as raw JSON):
30//!
31//! - `env` -- environment variables passed to spawned `claude`
32//!   subprocesses. **Often contains secrets** (`ANTHROPIC_API_KEY`,
33//!   tokens, etc.). Use [`redact_env_values`] before forwarding to
34//!   a less-trusted consumer.
35//! - `permissions` -- `{ allow: [...], deny: [...] }` Bash-pattern
36//!   and tool-name allow/deny lists.
37//! - `hooks` -- map keyed by hook event (`PreToolUse`,
38//!   `PostToolUse`, `UserPromptSubmit`, etc.); values are arrays of
39//!   shell-command hook configs.
40//! - `statusLine` -- statusline command config.
41//! - `enabledPlugins` -- map of plugin keys to enabled state.
42//! - `enableAllProjectMcpServers`, `enabledMcpjsonServers` -- MCP
43//!   server gates.
44//!
45//! # Example
46//!
47//! ```no_run
48//! use claude_wrapper::settings::SettingsLoader;
49//!
50//! # fn example() -> claude_wrapper::Result<()> {
51//! let layers = SettingsLoader::home()?
52//!     .project_root("/path/to/repo")
53//!     .load()?;
54//!
55//! if let Some(user) = &layers.user {
56//!     println!("user settings present: {} top-level keys",
57//!         user.as_object().map(|o| o.len()).unwrap_or(0));
58//! }
59//! # Ok(()) }
60//! ```
61
62use std::fs;
63use std::path::{Path, PathBuf};
64
65use serde::Serialize;
66use serde_json::Value;
67
68use crate::error::{Error, Result};
69
70/// One of the four settings layers Claude Code can read.
71///
72/// Ordering reflects Claude Code's precedence (variants later in
73/// the enum take precedence over earlier ones), but this module
74/// does not apply that precedence -- it just reports what's on disk.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SettingsLayer {
78    /// `~/.claude/settings.json` -- user defaults.
79    User,
80    /// `~/.claude/settings.local.json` -- user-private overrides.
81    UserLocal,
82    /// `<project>/.claude/settings.json` -- project-shared.
83    Project,
84    /// `<project>/.claude/settings.local.json` -- project-private.
85    ProjectLocal,
86}
87
88impl SettingsLayer {
89    /// The filename component of this layer (the part after
90    /// `.claude/`). User and project layers share filenames; the
91    /// directory determines which root they came from.
92    pub fn filename(self) -> &'static str {
93        match self {
94            Self::User | Self::Project => "settings.json",
95            Self::UserLocal | Self::ProjectLocal => "settings.local.json",
96        }
97    }
98
99    /// All four layers, low-to-high precedence (User → ProjectLocal).
100    pub fn all() -> [Self; 4] {
101        [
102            Self::User,
103            Self::UserLocal,
104            Self::Project,
105            Self::ProjectLocal,
106        ]
107    }
108}
109
110/// Builder/loader for the settings layers.
111///
112/// Defaults to reading from `~/.claude/`. If
113/// [`Self::project_root`] is set, also reads from
114/// `<project_root>/.claude/`. Missing files are not errors -- the
115/// corresponding [`Settings`] field stays `None`.
116#[derive(Debug, Clone)]
117pub struct SettingsLoader {
118    user_root: PathBuf,
119    project_root: Option<PathBuf>,
120}
121
122impl SettingsLoader {
123    /// Construct with the default user root `~/.claude/` and no
124    /// project root. Add a project root with [`Self::project_root`].
125    pub fn home() -> Result<Self> {
126        let home = home_dir().ok_or_else(|| Error::Artifacts {
127            message: "could not determine user home directory".to_string(),
128        })?;
129        Ok(Self {
130            user_root: home.join(".claude"),
131            project_root: None,
132        })
133    }
134
135    /// Construct with explicit roots. Useful for tests (point both
136    /// at tempdirs) and non-default installs. `project_root` may be
137    /// `None` if no project context applies.
138    pub fn at(user_root: impl Into<PathBuf>, project_root: Option<PathBuf>) -> Self {
139        Self {
140            user_root: user_root.into(),
141            project_root,
142        }
143    }
144
145    /// Set or override the project root. Note this is the `.claude`
146    /// *parent* -- i.e. the project directory itself, not
147    /// `<project>/.claude`. The loader appends `/.claude` internally.
148    #[must_use]
149    pub fn project_root(mut self, dir: impl Into<PathBuf>) -> Self {
150        self.project_root = Some(dir.into());
151        self
152    }
153
154    /// The configured user root (typically `~/.claude`).
155    pub fn user_root_path(&self) -> &Path {
156        &self.user_root
157    }
158
159    /// The configured project root, if any.
160    pub fn project_root_path(&self) -> Option<&Path> {
161        self.project_root.as_deref()
162    }
163
164    /// Read all four layers. Missing files become `None`; malformed
165    /// JSON files raise an error so callers don't silently lose data.
166    pub fn load(&self) -> Result<Settings> {
167        Ok(Settings {
168            user: read_layer(&self.user_root.join("settings.json"))?,
169            user_local: read_layer(&self.user_root.join("settings.local.json"))?,
170            project: match &self.project_root {
171                Some(root) => read_layer(&root.join(".claude").join("settings.json"))?,
172                None => None,
173            },
174            project_local: match &self.project_root {
175                Some(root) => read_layer(&root.join(".claude").join("settings.local.json"))?,
176                None => None,
177            },
178            paths: SettingsPaths {
179                user: self.user_root.join("settings.json"),
180                user_local: self.user_root.join("settings.local.json"),
181                project: self
182                    .project_root
183                    .as_ref()
184                    .map(|r| r.join(".claude").join("settings.json")),
185                project_local: self
186                    .project_root
187                    .as_ref()
188                    .map(|r| r.join(".claude").join("settings.local.json")),
189            },
190        })
191    }
192}
193
194/// Parsed settings layers as returned by [`SettingsLoader::load`].
195///
196/// Each field is the raw JSON of one layer, or `None` if the file
197/// doesn't exist. No merging is performed -- precedence is a
198/// caller-side concern.
199#[derive(Debug, Clone, Serialize)]
200pub struct Settings {
201    /// `<user_root>/settings.json` content, or None if absent.
202    pub user: Option<Value>,
203    /// `<user_root>/settings.local.json` content, or None if absent.
204    pub user_local: Option<Value>,
205    /// `<project_root>/.claude/settings.json` content, or None if
206    /// the project root wasn't configured or the file doesn't exist.
207    pub project: Option<Value>,
208    /// `<project_root>/.claude/settings.local.json` content, or
209    /// None similarly.
210    pub project_local: Option<Value>,
211    /// Absolute paths the loader checked, regardless of whether the
212    /// files existed. Useful for diagnostics ("where would the
213    /// project layer come from?").
214    pub paths: SettingsPaths,
215}
216
217impl Settings {
218    /// Return the loaded value for one layer.
219    pub fn get(&self, layer: SettingsLayer) -> Option<&Value> {
220        match layer {
221            SettingsLayer::User => self.user.as_ref(),
222            SettingsLayer::UserLocal => self.user_local.as_ref(),
223            SettingsLayer::Project => self.project.as_ref(),
224            SettingsLayer::ProjectLocal => self.project_local.as_ref(),
225        }
226    }
227}
228
229/// Absolute paths the loader would read, regardless of which files
230/// exist. Returned alongside [`Settings`] so diagnostics can show
231/// "the project layer *would* come from `<path>`" even when the file
232/// isn't there.
233#[derive(Debug, Clone, Serialize)]
234pub struct SettingsPaths {
235    pub user: PathBuf,
236    pub user_local: PathBuf,
237    pub project: Option<PathBuf>,
238    pub project_local: Option<PathBuf>,
239}
240
241/// Replace every value under the top-level `env` object with
242/// `"<redacted>"`, keeping the keys visible. Safe to call on any
243/// JSON value, including non-objects and objects without an `env`
244/// field. Other top-level keys (`permissions`, `hooks`, etc.) are
245/// left untouched.
246///
247/// Callers that forward settings to less-trusted consumers should
248/// apply this. Hosts that want the raw values must explicitly
249/// opt in.
250pub fn redact_env_values(value: &mut Value) {
251    let Some(obj) = value.as_object_mut() else {
252        return;
253    };
254    let Some(env) = obj.get_mut("env") else {
255        return;
256    };
257    let Some(env_obj) = env.as_object_mut() else {
258        return;
259    };
260    for (_, v) in env_obj.iter_mut() {
261        *v = Value::String("<redacted>".to_string());
262    }
263}
264
265fn read_layer(path: &Path) -> Result<Option<Value>> {
266    match fs::read_to_string(path) {
267        Ok(raw) => {
268            let parsed: Value = serde_json::from_str(&raw).map_err(|e| Error::Artifacts {
269                message: format!("settings file {} is not valid JSON: {e}", path.display()),
270            })?;
271            Ok(Some(parsed))
272        }
273        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
274        Err(e) => Err(e.into()),
275    }
276}
277
278fn home_dir() -> Option<PathBuf> {
279    if let Ok(h) = std::env::var("HOME")
280        && !h.is_empty()
281    {
282        return Some(PathBuf::from(h));
283    }
284    if let Ok(h) = std::env::var("USERPROFILE")
285        && !h.is_empty()
286    {
287        return Some(PathBuf::from(h));
288    }
289    None
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use serde_json::json;
296
297    fn write_layer(path: &Path, value: &Value) {
298        if let Some(parent) = path.parent() {
299            fs::create_dir_all(parent).expect("mkdir parent");
300        }
301        fs::write(path, serde_json::to_string_pretty(value).expect("ser")).expect("write");
302    }
303
304    #[test]
305    fn load_with_only_user_layer_present() {
306        let user_root = tempfile::tempdir().expect("tempdir");
307        write_layer(
308            &user_root.path().join("settings.json"),
309            &json!({"env": {"FOO": "bar"}, "permissions": {"allow": ["Bash(ls *)"]}}),
310        );
311        let loader = SettingsLoader::at(user_root.path(), None);
312        let layers = loader.load().expect("load");
313        assert!(layers.user.is_some());
314        assert!(layers.user_local.is_none());
315        assert!(layers.project.is_none());
316        assert!(layers.project_local.is_none());
317        assert_eq!(
318            layers.user.as_ref().unwrap()["env"]["FOO"].as_str(),
319            Some("bar")
320        );
321    }
322
323    #[test]
324    fn load_with_all_four_layers() {
325        let user_root = tempfile::tempdir().expect("user");
326        let project_root = tempfile::tempdir().expect("project");
327        write_layer(
328            &user_root.path().join("settings.json"),
329            &json!({"layer": "user"}),
330        );
331        write_layer(
332            &user_root.path().join("settings.local.json"),
333            &json!({"layer": "user_local"}),
334        );
335        write_layer(
336            &project_root.path().join(".claude").join("settings.json"),
337            &json!({"layer": "project"}),
338        );
339        write_layer(
340            &project_root
341                .path()
342                .join(".claude")
343                .join("settings.local.json"),
344            &json!({"layer": "project_local"}),
345        );
346        let layers = SettingsLoader::at(user_root.path(), Some(project_root.path().to_path_buf()))
347            .load()
348            .expect("load");
349        assert_eq!(
350            layers.user.as_ref().unwrap()["layer"].as_str(),
351            Some("user")
352        );
353        assert_eq!(
354            layers.user_local.as_ref().unwrap()["layer"].as_str(),
355            Some("user_local")
356        );
357        assert_eq!(
358            layers.project.as_ref().unwrap()["layer"].as_str(),
359            Some("project")
360        );
361        assert_eq!(
362            layers.project_local.as_ref().unwrap()["layer"].as_str(),
363            Some("project_local")
364        );
365    }
366
367    #[test]
368    fn missing_root_dir_treated_as_empty_not_error() {
369        let user_root = tempfile::tempdir().expect("user");
370        let layers = SettingsLoader::at(user_root.path(), None)
371            .load()
372            .expect("load");
373        assert!(layers.user.is_none());
374        assert!(layers.user_local.is_none());
375    }
376
377    #[test]
378    fn project_root_unset_means_no_project_layers() {
379        let user_root = tempfile::tempdir().expect("user");
380        write_layer(&user_root.path().join("settings.json"), &json!({"x": 1}));
381        let layers = SettingsLoader::at(user_root.path(), None)
382            .load()
383            .expect("load");
384        assert!(layers.user.is_some());
385        assert!(layers.project.is_none());
386        assert!(layers.project_local.is_none());
387        assert!(layers.paths.project.is_none());
388    }
389
390    #[test]
391    fn malformed_json_returns_error() {
392        let user_root = tempfile::tempdir().expect("user");
393        fs::write(user_root.path().join("settings.json"), "{not json").expect("write");
394        let err = SettingsLoader::at(user_root.path(), None)
395            .load()
396            .unwrap_err();
397        assert!(err.to_string().contains("not valid JSON"), "got: {err}");
398    }
399
400    #[test]
401    fn paths_reflect_configured_roots() {
402        let user_root = tempfile::tempdir().expect("user");
403        let project_root = tempfile::tempdir().expect("project");
404        let layers = SettingsLoader::at(user_root.path(), Some(project_root.path().to_path_buf()))
405            .load()
406            .expect("load");
407        assert_eq!(layers.paths.user, user_root.path().join("settings.json"));
408        assert_eq!(
409            layers.paths.project,
410            Some(project_root.path().join(".claude").join("settings.json"))
411        );
412    }
413
414    #[test]
415    fn get_indexes_by_layer() {
416        let user_root = tempfile::tempdir().expect("user");
417        write_layer(
418            &user_root.path().join("settings.json"),
419            &json!({"k": "user"}),
420        );
421        write_layer(
422            &user_root.path().join("settings.local.json"),
423            &json!({"k": "user_local"}),
424        );
425        let layers = SettingsLoader::at(user_root.path(), None)
426            .load()
427            .expect("load");
428        assert_eq!(
429            layers.get(SettingsLayer::User).unwrap()["k"].as_str(),
430            Some("user")
431        );
432        assert_eq!(
433            layers.get(SettingsLayer::UserLocal).unwrap()["k"].as_str(),
434            Some("user_local")
435        );
436        assert!(layers.get(SettingsLayer::Project).is_none());
437    }
438
439    // -- env redaction ------------------------------------------------
440
441    #[test]
442    fn redact_env_replaces_values_keeps_keys() {
443        let mut v = json!({
444            "env": {"ANTHROPIC_API_KEY": "sk-xxx", "DEBUG": "1"},
445            "permissions": {"allow": ["Bash(ls *)"]},
446        });
447        redact_env_values(&mut v);
448        assert_eq!(v["env"]["ANTHROPIC_API_KEY"].as_str(), Some("<redacted>"));
449        assert_eq!(v["env"]["DEBUG"].as_str(), Some("<redacted>"));
450        // Permissions untouched.
451        assert_eq!(v["permissions"]["allow"][0].as_str(), Some("Bash(ls *)"));
452    }
453
454    #[test]
455    fn redact_env_noop_when_no_env_field() {
456        let mut v = json!({"permissions": {"allow": ["Bash(ls *)"]}});
457        let before = v.clone();
458        redact_env_values(&mut v);
459        assert_eq!(v, before);
460    }
461
462    #[test]
463    fn redact_env_noop_on_non_object_root() {
464        let mut v = json!(["not", "an", "object"]);
465        let before = v.clone();
466        redact_env_values(&mut v);
467        assert_eq!(v, before);
468    }
469
470    #[test]
471    fn redact_env_noop_when_env_not_object() {
472        let mut v = json!({"env": "weird-but-tolerated"});
473        let before = v.clone();
474        redact_env_values(&mut v);
475        assert_eq!(v, before);
476    }
477}