Skip to main content

sparrow/onboarding/
migration.rs

1// ─── Migration paths from competitor tools (§4) ───────────────────────────────
2//
3// Sparrow import <tool> — zero-friction migration.
4// Reads the competitor's config directory, extracts everything we understand,
5// and writes the Sparrow equivalents. Never overwrites user data without asking.
6
7use std::path::{Path, PathBuf};
8
9use crate::onboarding::claude_compat::{self, ClaudeSettings};
10
11// ─── Migration result ──────────────────────────────────────────────────────────
12
13#[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
44// ─── Target directories ────────────────────────────────────────────────────────
45
46fn 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
64// ─── Public API ────────────────────────────────────────────────────────────────
65
66pub struct Migration;
67
68impl Migration {
69    // ── Claude Code ─────────────────────────────────────────────────────────
70
71    /// Import from Claude Code.
72    ///
73    /// Reads:
74    ///   - `~/.claude/CLAUDE.md`          → user-level Sparrow instruction
75    ///   - `~/.claude/commands/*.md`      → Sparrow slash commands
76    ///   - `~/.claude/agents/*.md`        → Sparrow SOUL agents
77    ///   - `~/.claude/settings.json`      → Sparrow config (permissions, env)
78    ///   - `<cwd>/.claude/CLAUDE.md`      → project-level instruction
79    ///   - `~/.claude/mcp.json` or `.mcp.json` → Sparrow MCP servers
80    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        // Discover everything Claude Code has on disk
86        let imported = claude_compat::discover(&home, &cwd);
87
88        // ── CLAUDE.md → memory / instruction ───────────────────────────────
89        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        // ── Commands → Sparrow slash commands ──────────────────────────────
101        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        // ── Agents → Sparrow SOUL agents ───────────────────────────────────
119        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        // ── settings.json → Sparrow config ─────────────────────────────────
138        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        // ── MCP servers ────────────────────────────────────────────────────
151        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        // ── API keys from env ──────────────────────────────────────────────
162        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    // ── Codex ──────────────────────────────────────────────────────────────
181
182    /// Import from OpenAI Codex CLI.
183    ///
184    /// Reads:
185    ///   - `~/.codex/config.json` / `codex.yaml`      → provider/model config
186    ///   - `AGENTS.md`                                  → agent instructions
187    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        // AGENTS.md → instruction
193        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        // codex.yaml / codex.yml / ~/.codex/config.json → provider config
204        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    // ── OpenCode ───────────────────────────────────────────────────────────
228
229    /// Import from OpenCode.
230    ///
231    /// Reads:
232    ///   - `opencode.json`              → provider/models/routing config
233    ///   - `~/.config/opencode/`        → user-level config
234    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    // ── OpenClaw (existing) ────────────────────────────────────────────────
259
260    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    // ── Hermes ─────────────────────────────────────────────────────────────
299
300    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    // ── Auto-detect ────────────────────────────────────────────────────────
323
324    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
345// ─── Helpers ───────────────────────────────────────────────────────────────────
346
347fn 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    // Try YAML frontmatter first (Claude Code agent format)
359    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    // Plain text: entire body is the personality
373    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    // Only write a hint file; actual config merge is done interactively
387    // to avoid breaking the user's existing Sparrow config.
388    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    // Also check for env vars in settings
398    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    // Count MCP servers
471    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    // Extract model/provider info
485    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        // May be empty on CI, but must not panic
543        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}