Skip to main content

claude_code/
home.rs

1use std::{
2    fs, io,
3    path::{Path, PathBuf},
4};
5
6use crate::ClaudeCodeError;
7
8/// Wrapper-managed "home" layout for Claude Code CLI state.
9///
10/// This is similar in spirit to Codex's `CODEX_HOME`: callers can point the wrapper at an
11/// application-scoped directory and have the Claude CLI write config/cache/data beneath it
12/// by overriding environment variables (`HOME` + `XDG_*`, and Windows equivalents).
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct ClaudeHomeLayout {
15    root: PathBuf,
16}
17
18impl ClaudeHomeLayout {
19    pub fn new(root: impl Into<PathBuf>) -> Self {
20        Self { root: root.into() }
21    }
22
23    pub fn root(&self) -> &Path {
24        self.root.as_path()
25    }
26
27    pub fn xdg_config_home(&self) -> PathBuf {
28        self.root.join(".config")
29    }
30
31    pub fn xdg_data_home(&self) -> PathBuf {
32        self.root.join(".local").join("share")
33    }
34
35    pub fn xdg_cache_home(&self) -> PathBuf {
36        self.root.join(".cache")
37    }
38
39    #[cfg(windows)]
40    pub fn userprofile_dir(&self) -> PathBuf {
41        self.root.clone()
42    }
43
44    #[cfg(windows)]
45    pub fn appdata_dir(&self) -> PathBuf {
46        self.root.join("AppData").join("Roaming")
47    }
48
49    #[cfg(windows)]
50    pub fn localappdata_dir(&self) -> PathBuf {
51        self.root.join("AppData").join("Local")
52    }
53
54    pub fn materialize(&self, create_dirs: bool) -> Result<(), ClaudeCodeError> {
55        if !create_dirs {
56            return Ok(());
57        }
58
59        for path in [
60            self.root.as_path(),
61            self.xdg_config_home().as_path(),
62            self.xdg_data_home().as_path(),
63            self.xdg_cache_home().as_path(),
64        ] {
65            fs::create_dir_all(path).map_err(|source| ClaudeCodeError::PrepareClaudeHome {
66                path: path.to_path_buf(),
67                source,
68            })?;
69        }
70
71        #[cfg(windows)]
72        for path in [self.appdata_dir(), self.localappdata_dir()] {
73            fs::create_dir_all(&path).map_err(|source| ClaudeCodeError::PrepareClaudeHome {
74                path: path.to_path_buf(),
75                source,
76            })?;
77        }
78
79        Ok(())
80    }
81
82    pub fn seed_from_user_home(
83        &self,
84        seed_home: &Path,
85        level: ClaudeHomeSeedLevel,
86    ) -> Result<ClaudeHomeSeedOutcome, ClaudeCodeError> {
87        let mut outcome = ClaudeHomeSeedOutcome::default();
88
89        match level {
90            ClaudeHomeSeedLevel::MinimalAuth => {
91                seed_minimal(seed_home, self.root(), &mut outcome)?;
92            }
93            ClaudeHomeSeedLevel::FullProfile => {
94                seed_minimal(seed_home, self.root(), &mut outcome)?;
95                seed_full_profile(seed_home, self.root(), &mut outcome)?;
96            }
97        }
98
99        Ok(outcome)
100    }
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ClaudeHomeSeedLevel {
105    MinimalAuth,
106    FullProfile,
107}
108
109#[derive(Clone, Debug, Default, Eq, PartialEq)]
110pub struct ClaudeHomeSeedOutcome {
111    pub copied_paths: Vec<PathBuf>,
112    pub skipped_paths: Vec<PathBuf>,
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct ClaudeHomeSeedRequest {
117    pub seed_user_home: PathBuf,
118    pub level: ClaudeHomeSeedLevel,
119}
120
121fn seed_minimal(
122    seed_home: &Path,
123    target_home: &Path,
124    outcome: &mut ClaudeHomeSeedOutcome,
125) -> Result<(), ClaudeCodeError> {
126    let mappings = [
127        (
128            seed_home.join(".claude.json"),
129            target_home.join(".claude.json"),
130        ),
131        (
132            seed_home.join(".claude").join("settings.json"),
133            target_home.join(".claude").join("settings.json"),
134        ),
135        (
136            seed_home.join(".claude").join("settings.local.json"),
137            target_home.join(".claude").join("settings.local.json"),
138        ),
139    ];
140
141    for (src, dst) in mappings {
142        copy_if_exists(&src, &dst, outcome)?;
143    }
144
145    copy_dir_if_exists(
146        &seed_home.join(".claude").join("plugins"),
147        &target_home.join(".claude").join("plugins"),
148        outcome,
149    )?;
150
151    Ok(())
152}
153
154#[cfg(target_os = "macos")]
155fn seed_full_profile(
156    seed_home: &Path,
157    target_home: &Path,
158    outcome: &mut ClaudeHomeSeedOutcome,
159) -> Result<(), ClaudeCodeError> {
160    copy_dir_if_exists(
161        &seed_home
162            .join("Library")
163            .join("Application Support")
164            .join("Claude"),
165        &target_home
166            .join("Library")
167            .join("Application Support")
168            .join("Claude"),
169        outcome,
170    )?;
171    Ok(())
172}
173
174#[cfg(windows)]
175fn seed_full_profile(
176    seed_home: &Path,
177    target_home: &Path,
178    outcome: &mut ClaudeHomeSeedOutcome,
179) -> Result<(), ClaudeCodeError> {
180    copy_dir_if_exists(
181        &seed_home.join("AppData").join("Roaming").join("Claude"),
182        &target_home.join("AppData").join("Roaming").join("Claude"),
183        outcome,
184    )?;
185    copy_dir_if_exists(
186        &seed_home.join("AppData").join("Local").join("Claude"),
187        &target_home.join("AppData").join("Local").join("Claude"),
188        outcome,
189    )?;
190    Ok(())
191}
192
193#[cfg(all(unix, not(target_os = "macos")))]
194fn seed_full_profile(
195    seed_home: &Path,
196    target_home: &Path,
197    outcome: &mut ClaudeHomeSeedOutcome,
198) -> Result<(), ClaudeCodeError> {
199    copy_dir_if_exists(
200        &seed_home.join(".config").join("claude"),
201        &target_home.join(".config").join("claude"),
202        outcome,
203    )?;
204    copy_dir_if_exists(
205        &seed_home.join(".local").join("share").join("claude"),
206        &target_home.join(".local").join("share").join("claude"),
207        outcome,
208    )?;
209    Ok(())
210}
211
212#[cfg(not(any(target_os = "macos", windows, all(unix, not(target_os = "macos")))))]
213fn seed_full_profile(
214    seed_home: &Path,
215    target_home: &Path,
216    outcome: &mut ClaudeHomeSeedOutcome,
217) -> Result<(), ClaudeCodeError> {
218    let _ = (seed_home, target_home, outcome);
219    Ok(())
220}
221
222fn copy_if_exists(
223    src: &Path,
224    dst: &Path,
225    outcome: &mut ClaudeHomeSeedOutcome,
226) -> Result<(), ClaudeCodeError> {
227    match fs::metadata(src) {
228        Ok(meta) => {
229            if !meta.is_file() {
230                outcome.skipped_paths.push(src.to_path_buf());
231                Ok(())
232            } else {
233                copy_file(src, dst)?;
234                outcome.copied_paths.push(dst.to_path_buf());
235                Ok(())
236            }
237        }
238        Err(err) if err.kind() == io::ErrorKind::NotFound => {
239            outcome.skipped_paths.push(src.to_path_buf());
240            Ok(())
241        }
242        Err(source) => Err(ClaudeCodeError::ClaudeHomeSeedIo {
243            path: src.to_path_buf(),
244            source,
245        }),
246    }
247}
248
249fn copy_dir_if_exists(
250    src: &Path,
251    dst: &Path,
252    outcome: &mut ClaudeHomeSeedOutcome,
253) -> Result<(), ClaudeCodeError> {
254    match fs::metadata(src) {
255        Ok(meta) => {
256            if !meta.is_dir() {
257                outcome.skipped_paths.push(src.to_path_buf());
258                Ok(())
259            } else {
260                copy_dir_recursive(src, dst)?;
261                outcome.copied_paths.push(dst.to_path_buf());
262                Ok(())
263            }
264        }
265        Err(err) if err.kind() == io::ErrorKind::NotFound => {
266            outcome.skipped_paths.push(src.to_path_buf());
267            Ok(())
268        }
269        Err(source) => Err(ClaudeCodeError::ClaudeHomeSeedIo {
270            path: src.to_path_buf(),
271            source,
272        }),
273    }
274}
275
276fn copy_file(src: &Path, dst: &Path) -> Result<(), ClaudeCodeError> {
277    if let Some(parent) = dst.parent() {
278        fs::create_dir_all(parent).map_err(|source| ClaudeCodeError::ClaudeHomeSeedIo {
279            path: parent.to_path_buf(),
280            source,
281        })?;
282    }
283    fs::copy(src, dst).map_err(|source| ClaudeCodeError::ClaudeHomeSeedCopy {
284        from: src.to_path_buf(),
285        to: dst.to_path_buf(),
286        error: source,
287    })?;
288    Ok(())
289}
290
291fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ClaudeCodeError> {
292    fs::create_dir_all(dst).map_err(|source| ClaudeCodeError::ClaudeHomeSeedIo {
293        path: dst.to_path_buf(),
294        source,
295    })?;
296
297    for entry in fs::read_dir(src).map_err(|source| ClaudeCodeError::ClaudeHomeSeedIo {
298        path: src.to_path_buf(),
299        source,
300    })? {
301        let entry = entry.map_err(|source| ClaudeCodeError::ClaudeHomeSeedIo {
302            path: src.to_path_buf(),
303            source,
304        })?;
305        let path = entry.path();
306        let file_name = entry.file_name();
307        let target_path = dst.join(file_name);
308
309        let meta =
310            fs::symlink_metadata(&path).map_err(|source| ClaudeCodeError::ClaudeHomeSeedIo {
311                path: path.clone(),
312                source,
313            })?;
314
315        if meta.is_dir() {
316            copy_dir_recursive(&path, &target_path)?;
317            continue;
318        }
319
320        if meta.is_file() {
321            copy_file(&path, &target_path)?;
322            continue;
323        }
324
325        if meta.file_type().is_symlink() {
326            // Best-effort: resolve link and copy target contents. If unreadable, skip.
327            if let Ok(link_target) = fs::read_link(&path) {
328                let resolved = if link_target.is_absolute() {
329                    link_target
330                } else {
331                    path.parent()
332                        .unwrap_or_else(|| Path::new("/"))
333                        .join(link_target)
334                };
335                if let Ok(target_meta) = fs::metadata(&resolved) {
336                    if target_meta.is_dir() {
337                        copy_dir_recursive(&resolved, &target_path)?;
338                    } else if target_meta.is_file() {
339                        copy_file(&resolved, &target_path)?;
340                    }
341                }
342            }
343            continue;
344        }
345    }
346
347    Ok(())
348}