Skip to main content

sparrow/onboarding/
claude_compat.rs

1//! Claude Code drop-in compatibility.
2//!
3//! Reads existing Claude Code config so users can migrate to Sparrow without
4//! moving files. Specifically:
5//!
6//! - `~/.claude/CLAUDE.md`             → imported as user-level memory
7//! - `~/.claude/commands/*.md`         → registered as slash commands
8//! - `~/.claude/agents/*.md`           → loaded as SOUL agents
9//! - `~/.claude/settings.json`         → mapped to `Sparrow` config (permissions, hooks)
10//! - `.claude/CLAUDE.md` in cwd        → imported as project-level memory
11//! - `.claude/commands/*.md` in cwd    → registered as project slash commands
12//!
13//! All reads are best-effort and never overwrite the user's Sparrow config.
14
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct ClaudeImport {
20    pub user_memory: Option<String>,
21    pub project_memory: Option<String>,
22    pub commands: Vec<ClaudeCommand>,
23    pub agents: Vec<ClaudeAgentFile>,
24    pub settings: Option<ClaudeSettings>,
25    pub source_paths: Vec<PathBuf>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ClaudeCommand {
30    pub name: String,
31    pub body: String,
32    pub source: PathBuf,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ClaudeAgentFile {
37    pub name: String,
38    pub body: String,
39    pub source: PathBuf,
40}
41
42/// Subset of Claude Code settings.json we honor when importing.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct ClaudeSettings {
45    #[serde(default)]
46    pub permissions: Option<serde_json::Value>,
47    #[serde(default)]
48    pub hooks: Option<serde_json::Value>,
49    #[serde(default)]
50    pub env: Option<serde_json::Value>,
51}
52
53/// Discover Claude Code config on disk and return what we can import.
54///
55/// Looks in:
56///   - `$HOME/.claude/` for user-level config
57///   - `<cwd>/.claude/` for project-level config
58pub fn discover(home: &Path, cwd: &Path) -> ClaudeImport {
59    let mut out = ClaudeImport::default();
60
61    let user_root = home.join(".claude");
62    if user_root.is_dir() {
63        out.source_paths.push(user_root.clone());
64        out.user_memory = read_optional(user_root.join("CLAUDE.md"));
65        out.commands
66            .extend(load_dir(&user_root.join("commands"), "command"));
67        out.agents
68            .extend(load_dir_as_agents(&user_root.join("agents")));
69        out.settings = read_optional(user_root.join("settings.json"))
70            .and_then(|s| serde_json::from_str(&s).ok());
71    }
72
73    let project_root = cwd.join(".claude");
74    if project_root.is_dir() {
75        out.source_paths.push(project_root.clone());
76        out.project_memory = read_optional(project_root.join("CLAUDE.md"));
77        out.commands
78            .extend(load_dir(&project_root.join("commands"), "command"));
79        out.agents
80            .extend(load_dir_as_agents(&project_root.join("agents")));
81    }
82
83    out
84}
85
86fn read_optional(p: PathBuf) -> Option<String> {
87    if p.is_file() {
88        std::fs::read_to_string(&p).ok()
89    } else {
90        None
91    }
92}
93
94fn load_dir(dir: &Path, _kind: &str) -> Vec<ClaudeCommand> {
95    let mut out = Vec::new();
96    let Ok(rd) = std::fs::read_dir(dir) else {
97        return out;
98    };
99    for entry in rd.flatten() {
100        let path = entry.path();
101        if path.extension().and_then(|s| s.to_str()) != Some("md") {
102            continue;
103        }
104        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
105            continue;
106        };
107        let Ok(body) = std::fs::read_to_string(&path) else {
108            continue;
109        };
110        out.push(ClaudeCommand {
111            name: stem.to_string(),
112            body,
113            source: path,
114        });
115    }
116    out
117}
118
119fn load_dir_as_agents(dir: &Path) -> Vec<ClaudeAgentFile> {
120    let mut out = Vec::new();
121    let Ok(rd) = std::fs::read_dir(dir) else {
122        return out;
123    };
124    for entry in rd.flatten() {
125        let path = entry.path();
126        if path.extension().and_then(|s| s.to_str()) != Some("md") {
127            continue;
128        }
129        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
130            continue;
131        };
132        let Ok(body) = std::fs::read_to_string(&path) else {
133            continue;
134        };
135        out.push(ClaudeAgentFile {
136            name: stem.to_string(),
137            body,
138            source: path,
139        });
140    }
141    out
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::fs;
148
149    #[test]
150    fn discover_picks_up_user_claude_md() {
151        let tmp = tempfile::tempdir().unwrap();
152        let home = tmp.path();
153        let cdir = home.join(".claude");
154        fs::create_dir_all(&cdir).unwrap();
155        fs::write(cdir.join("CLAUDE.md"), "# rules\nbe concise").unwrap();
156
157        let cwd = tempfile::tempdir().unwrap();
158        let imported = discover(home, cwd.path());
159        assert!(imported.user_memory.as_deref().unwrap().contains("rules"));
160        assert!(imported.project_memory.is_none());
161    }
162
163    #[test]
164    fn discover_loads_commands_and_agents() {
165        let tmp = tempfile::tempdir().unwrap();
166        let home = tmp.path();
167        let cmds = home.join(".claude").join("commands");
168        let agents = home.join(".claude").join("agents");
169        fs::create_dir_all(&cmds).unwrap();
170        fs::create_dir_all(&agents).unwrap();
171        fs::write(cmds.join("hello.md"), "/hello body").unwrap();
172        fs::write(agents.join("planner.md"), "---\nname: planner\n---\nbody").unwrap();
173
174        let cwd = tempfile::tempdir().unwrap();
175        let imported = discover(home, cwd.path());
176        assert_eq!(imported.commands.len(), 1);
177        assert_eq!(imported.commands[0].name, "hello");
178        assert_eq!(imported.agents.len(), 1);
179        assert_eq!(imported.agents[0].name, "planner");
180    }
181
182    #[test]
183    fn discover_returns_empty_when_no_claude_dir() {
184        let tmp = tempfile::tempdir().unwrap();
185        let imported = discover(tmp.path(), tmp.path());
186        assert!(imported.user_memory.is_none());
187        assert!(imported.commands.is_empty());
188        assert!(imported.agents.is_empty());
189    }
190}