1use std::path::{Path, PathBuf};
8
9use crate::onboarding::claude_compat::{self, ClaudeSettings};
10
11#[derive(Debug, Clone)]
14pub struct MigrationResult {
15 pub tool: String,
16 pub agents: usize,
17 pub commands: usize,
18 pub skills: usize,
19 pub config_entries: usize,
20 pub api_keys: usize,
21 pub mcp_servers: usize,
22 pub summary: Vec<String>,
23}
24
25impl MigrationResult {
26 fn new(tool: &str) -> Self {
27 MigrationResult {
28 tool: tool.to_string(),
29 agents: 0,
30 commands: 0,
31 skills: 0,
32 config_entries: 0,
33 api_keys: 0,
34 mcp_servers: 0,
35 summary: Vec::new(),
36 }
37 }
38
39 fn note(&mut self, msg: &str) {
40 self.summary.push(msg.to_string());
41 }
42}
43
44fn sparrow_dir() -> PathBuf {
47 dirs::config_dir()
48 .unwrap_or_else(|| PathBuf::from("."))
49 .join("sparrow")
50}
51
52fn sparrow_agents_dir() -> PathBuf {
53 sparrow_dir().join("agents")
54}
55
56fn sparrow_commands_dir() -> PathBuf {
57 sparrow_dir().join("commands")
58}
59
60fn sparrow_config_file() -> PathBuf {
61 sparrow_dir().join("config.toml")
62}
63
64pub struct Migration;
67
68impl Migration {
69 pub fn import_claude_code(path: &Path) -> anyhow::Result<MigrationResult> {
81 let mut result = MigrationResult::new("claude-code");
82 let home = dirs::home_dir().unwrap_or_default();
83 let cwd = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
84
85 let imported = claude_compat::discover(&home, &cwd);
87
88 if let Some(user_md) = &imported.user_memory {
90 write_sparrow_instruction("claude-user.md", user_md)?;
91 result.note("Imported ~/.claude/CLAUDE.md → user instruction");
92 result.config_entries += 1;
93 }
94 if let Some(proj_md) = &imported.project_memory {
95 write_sparrow_instruction("claude-project.md", proj_md)?;
96 result.note("Imported .claude/CLAUDE.md → project instruction");
97 result.config_entries += 1;
98 }
99
100 let cmd_dir = sparrow_commands_dir();
102 std::fs::create_dir_all(&cmd_dir)?;
103 for cmd in &imported.commands {
104 let dest = cmd_dir.join(format!("{}.md", cmd.name));
105 if !dest.exists() {
106 std::fs::write(&dest, &cmd.body)?;
107 result.commands += 1;
108 }
109 }
110 if result.commands > 0 {
111 result.note(&format!(
112 "Imported {} slash commands → {}",
113 result.commands,
114 cmd_dir.display()
115 ));
116 }
117
118 let agents_dir = sparrow_agents_dir();
120 std::fs::create_dir_all(&agents_dir)?;
121 for agent in &imported.agents {
122 let dest = agents_dir.join(format!("{}.soul.toml", agent.name));
123 if !dest.exists() {
124 let soul = agent_body_to_soul(&agent.name, &agent.body);
125 std::fs::write(&dest, &soul)?;
126 result.agents += 1;
127 }
128 }
129 if result.agents > 0 {
130 result.note(&format!(
131 "Imported {} agents → {}",
132 result.agents,
133 agents_dir.display()
134 ));
135 }
136
137 if let Some(settings) = &imported.settings {
139 let merged = merge_claude_settings(settings)?;
140 if merged > 0 {
141 result.config_entries += merged;
142 result.note(&format!(
143 "Merged {} settings entries into {}",
144 merged,
145 sparrow_config_file().display()
146 ));
147 }
148 }
149
150 let mcp_sources = [home.join(".claude").join("mcp.json"), cwd.join(".mcp.json")];
152 for mcp_path in &mcp_sources {
153 if mcp_path.exists() {
154 result.mcp_servers += import_mcp_servers(mcp_path)?;
155 }
156 }
157 if result.mcp_servers > 0 {
158 result.note(&format!("Imported {} MCP servers", result.mcp_servers));
159 }
160
161 if let Some(settings) = &imported.settings {
163 result.api_keys += extract_api_keys_from_settings(settings)?;
164 }
165 if result.api_keys > 0 {
166 result.note(&format!(
167 "Detected {} API keys in settings",
168 result.api_keys
169 ));
170 result.note("Run `sparrow auth add <provider>` to register them securely.");
171 }
172
173 if result.summary.is_empty() {
174 result.note("No Claude Code configuration found.");
175 }
176
177 Ok(result)
178 }
179
180 pub fn import_codex(path: &Path) -> anyhow::Result<MigrationResult> {
188 let mut result = MigrationResult::new("codex");
189 let cwd = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
190 let home = dirs::home_dir().unwrap_or_default();
191
192 for md_path in &[cwd.join("AGENTS.md"), home.join(".codex").join("AGENTS.md")] {
194 if md_path.exists() {
195 let content = std::fs::read_to_string(md_path)?;
196 write_sparrow_instruction("codex-agents.md", &content)?;
197 result.note("Imported AGENTS.md → instruction");
198 result.config_entries += 1;
199 break;
200 }
201 }
202
203 let config_paths = [
205 cwd.join("codex.yaml"),
206 cwd.join("codex.yml"),
207 home.join(".codex").join("config.json"),
208 ];
209 for cfg_path in &config_paths {
210 if cfg_path.exists() {
211 result.config_entries += import_codex_config(cfg_path)?;
212 result.note(&format!(
213 "Imported config from {}",
214 cfg_path.file_name().unwrap_or_default().to_string_lossy()
215 ));
216 break;
217 }
218 }
219
220 if result.summary.is_empty() {
221 result.note("No Codex configuration found.");
222 }
223
224 Ok(result)
225 }
226
227 pub fn import_opencode(path: &Path) -> anyhow::Result<MigrationResult> {
235 let mut result = MigrationResult::new("opencode");
236 let cwd = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
237 let home = dirs::home_dir().unwrap_or_default();
238
239 let config_paths = [
240 cwd.join("opencode.json"),
241 home.join(".config").join("opencode").join("config.json"),
242 ];
243 for cfg_path in &config_paths {
244 if cfg_path.exists() {
245 result.config_entries += import_opencode_config(cfg_path)?;
246 result.note(&format!("Imported config from {}", cfg_path.display()));
247 break;
248 }
249 }
250
251 if result.summary.is_empty() {
252 result.note("No OpenCode configuration found.");
253 }
254
255 Ok(result)
256 }
257
258 pub fn import_openclaw(path: &PathBuf) -> anyhow::Result<MigrationResult> {
261 let mut result = MigrationResult::new("openclaw");
262
263 let agents_dir = path.join("agents");
264 if agents_dir.exists() {
265 result.agents = std::fs::read_dir(&agents_dir)?
266 .filter_map(|e| e.ok())
267 .filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
268 .count();
269 }
270
271 let skills_dir = path.join("skills");
272 if skills_dir.exists() {
273 result.skills = std::fs::read_dir(&skills_dir)?
274 .filter_map(|e| e.ok())
275 .filter(|e| e.path().is_dir())
276 .count();
277 }
278
279 let cron_file = path.join("cron.json");
280 if cron_file.exists() {
281 if let Ok(content) = std::fs::read_to_string(&cron_file) {
282 if let Ok(jobs) = serde_json::from_str::<Vec<serde_json::Value>>(&content) {
283 result.config_entries += jobs.len();
284 }
285 }
286 }
287
288 if result.agents > 0 {
289 result.note(&format!("Found {} agents", result.agents));
290 }
291 if result.skills > 0 {
292 result.note(&format!("Found {} skills", result.skills));
293 }
294
295 Ok(result)
296 }
297
298 pub fn import_hermes(path: &PathBuf) -> anyhow::Result<MigrationResult> {
301 let mut result = MigrationResult::new("hermes");
302
303 let agents_dir = path.join("agents");
304 if agents_dir.exists() {
305 result.agents = std::fs::read_dir(&agents_dir)?
306 .filter_map(|e| e.ok())
307 .filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
308 .count();
309 }
310
311 let skills_dir = path.join("skills");
312 if skills_dir.exists() {
313 result.skills = std::fs::read_dir(&skills_dir)?
314 .filter_map(|e| e.ok())
315 .filter(|e| e.path().is_dir())
316 .count();
317 }
318
319 Ok(result)
320 }
321
322 pub fn detect_installed() -> Vec<String> {
325 let mut found = Vec::new();
326 let home = dirs::home_dir().unwrap_or_default();
327
328 let tools: Vec<(&str, PathBuf)> = vec![
329 ("claude-code", home.join(".claude")),
330 ("codex", home.join(".codex")),
331 ("opencode", home.join(".config").join("opencode")),
332 ("openclaw", home.join(".openclaw")),
333 ("hermes", home.join(".hermes")),
334 ];
335
336 for (name, path) in tools {
337 if path.exists() {
338 found.push(name.to_string());
339 }
340 }
341 found
342 }
343}
344
345fn write_sparrow_instruction(name: &str, content: &str) -> anyhow::Result<()> {
348 let dir = sparrow_dir().join("instructions");
349 std::fs::create_dir_all(&dir)?;
350 let dest = dir.join(name);
351 if !dest.exists() {
352 std::fs::write(&dest, content)?;
353 }
354 Ok(())
355}
356
357fn agent_body_to_soul(name: &str, body: &str) -> String {
358 if let Some(rest) = body.strip_prefix("---") {
360 if let Some(end) = rest.find("---") {
361 let frontmatter = &rest[..end];
362 let content = &rest[end + 3..].trim();
363 return format!(
364 "# Imported from Claude Code\n\
365 name = \"{}\"\n\
366 {}\n\
367 personality = \"\"\"\n{}\n\"\"\"\n",
368 name, frontmatter, content
369 );
370 }
371 }
372 format!(
374 "# Imported from Claude Code\n\
375 name = \"{}\"\n\
376 role = \"assistant\"\n\
377 personality = \"\"\"\n{}\n\"\"\"\n",
378 name,
379 body.lines().take(80).collect::<Vec<_>>().join("\n")
380 )
381}
382
383fn merge_claude_settings(settings: &ClaudeSettings) -> anyhow::Result<usize> {
384 let mut count = 0;
385
386 let hint_dir = sparrow_dir().join("imports");
389 std::fs::create_dir_all(&hint_dir)?;
390 let hint_path = hint_dir.join("claude-settings.json");
391 if !hint_path.exists() {
392 let pretty = serde_json::to_string_pretty(settings)?;
393 std::fs::write(&hint_path, &pretty)?;
394 count += 1;
395 }
396
397 if let Some(env) = &settings.env {
399 let env_path = hint_dir.join("claude-env.txt");
400 if !env_path.exists() {
401 if let Some(obj) = env.as_object() {
402 let mut lines = Vec::new();
403 for (k, v) in obj {
404 if let Some(val) = v.as_str() {
405 lines.push(format!("{}={}", k, val));
406 }
407 }
408 if !lines.is_empty() {
409 std::fs::write(&env_path, lines.join("\n") + "\n")?;
410 count += 1;
411 }
412 }
413 }
414 }
415
416 Ok(count)
417}
418
419fn extract_api_keys_from_settings(settings: &ClaudeSettings) -> anyhow::Result<usize> {
420 let env = match &settings.env {
421 Some(e) => e,
422 None => return Ok(0),
423 };
424
425 let obj = match env.as_object() {
426 Some(o) => o,
427 None => return Ok(0),
428 };
429
430 let key_patterns = [
431 "ANTHROPIC_API_KEY",
432 "OPENAI_API_KEY",
433 "GROQ_API_KEY",
434 "OPENROUTER_API_KEY",
435 "GOOGLE_API_KEY",
436 "NVIDIA_API_KEY",
437 "DEEPSEEK_API_KEY",
438 ];
439
440 let mut found = 0;
441 for pattern in &key_patterns {
442 if obj.contains_key(*pattern) {
443 found += 1;
444 }
445 }
446
447 Ok(found)
448}
449
450fn import_mcp_servers(mcp_path: &Path) -> anyhow::Result<usize> {
451 let content = std::fs::read_to_string(mcp_path)?;
452 let config: serde_json::Value = serde_json::from_str(&content)?;
453
454 let mcp_dir = sparrow_dir().join("mcp");
455 std::fs::create_dir_all(&mcp_dir)?;
456
457 let dest = mcp_dir.join(
458 mcp_path
459 .file_name()
460 .unwrap_or_default()
461 .to_string_lossy()
462 .replace(".json", "-imported.json"),
463 );
464
465 if !dest.exists() {
466 let pretty = serde_json::to_string_pretty(&config)?;
467 std::fs::write(&dest, &pretty)?;
468 }
469
470 let count = config
472 .get("mcpServers")
473 .and_then(|s| s.as_object())
474 .map(|o| o.len())
475 .unwrap_or(0);
476
477 Ok(count)
478}
479
480fn import_codex_config(path: &Path) -> anyhow::Result<usize> {
481 let content = std::fs::read_to_string(path)?;
482 let config: serde_json::Value = serde_json::from_str(&content)?;
483
484 let import_dir = sparrow_dir().join("imports");
486 std::fs::create_dir_all(&import_dir)?;
487
488 let dest = import_dir.join("codex-config.json");
489 if !dest.exists() {
490 let pretty = serde_json::to_string_pretty(&config)?;
491 std::fs::write(&dest, &pretty)?;
492 }
493
494 let entries = config.as_object().map(|o| o.len()).unwrap_or(0);
495
496 Ok(entries)
497}
498
499fn import_opencode_config(path: &Path) -> anyhow::Result<usize> {
500 let content = std::fs::read_to_string(path)?;
501 let config: serde_json::Value = serde_json::from_str(&content)?;
502
503 let import_dir = sparrow_dir().join("imports");
504 std::fs::create_dir_all(&import_dir)?;
505
506 let dest = import_dir.join("opencode-config.json");
507 if !dest.exists() {
508 let pretty = serde_json::to_string_pretty(&config)?;
509 std::fs::write(&dest, &pretty)?;
510 }
511
512 let entries = config.as_object().map(|o| o.len()).unwrap_or(0);
513
514 Ok(entries)
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_agent_body_to_soul_with_frontmatter() {
523 let body = "---\nname: planner\nrole: architect\n---\nYou plan carefully.";
524 let soul = agent_body_to_soul("planner", body);
525 assert!(soul.contains("name = \"planner\""));
526 assert!(soul.contains("role: architect"));
527 assert!(soul.contains("You plan carefully."));
528 }
529
530 #[test]
531 fn test_agent_body_to_soul_plain_text() {
532 let body = "Be concise. Return evidence.";
533 let soul = agent_body_to_soul("helper", body);
534 assert!(soul.contains("name = \"helper\""));
535 assert!(soul.contains("role = \"assistant\""));
536 assert!(soul.contains("Be concise."));
537 }
538
539 #[test]
540 fn test_detect_installed_returns_vec() {
541 let found = Migration::detect_installed();
542 let _ = found.len();
544 }
545
546 #[test]
547 fn test_migration_result_new() {
548 let r = MigrationResult::new("test-tool");
549 assert_eq!(r.tool, "test-tool");
550 assert_eq!(r.agents, 0);
551 assert!(r.summary.is_empty());
552 }
553}