sparrow/onboarding/
claude_compat.rs1use 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#[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
53pub 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}