Skip to main content

imp_core/
import.rs

1use std::path::{Path, PathBuf};
2
3/// A detected external agent source that imp can import from.
4#[derive(Debug, Clone)]
5pub struct DetectedSource {
6    pub agent: AgentSource,
7    pub skills: Vec<DetectedSkill>,
8    pub agents_md: Vec<DetectedAgentsMd>,
9}
10
11/// Which agent tool the source comes from.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum AgentSource {
14    Pi,
15    ClaudeCode,
16    Codex,
17}
18
19impl AgentSource {
20    pub fn label(&self) -> &'static str {
21        match self {
22            Self::Pi => "pi",
23            Self::ClaudeCode => "Claude Code",
24            Self::Codex => "Codex",
25        }
26    }
27
28    pub fn import_namespace(&self) -> &'static str {
29        match self {
30            Self::Pi => "pi",
31            Self::ClaudeCode => "claude-code",
32            Self::Codex => "codex",
33        }
34    }
35}
36
37/// A skill discovered in another agent's config.
38#[derive(Debug, Clone)]
39pub struct DetectedSkill {
40    pub name: String,
41    pub description: String,
42    pub source_path: PathBuf,
43}
44
45/// An AGENTS.md or CLAUDE.md discovered in another agent's config.
46#[derive(Debug, Clone)]
47pub struct DetectedAgentsMd {
48    pub path: PathBuf,
49    pub kind: AgentsMdKind,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum AgentsMdKind {
54    AgentsMd,
55    ClaudeMd,
56}
57
58impl AgentsMdKind {
59    pub fn label(&self) -> &'static str {
60        match self {
61            Self::AgentsMd => "AGENTS.md",
62            Self::ClaudeMd => "CLAUDE.md",
63        }
64    }
65}
66
67/// Scan all known agent sources and return what was found.
68pub fn detect_sources(home: &Path) -> Vec<DetectedSource> {
69    let mut sources = Vec::new();
70
71    if let Some(pi) = detect_pi(home) {
72        sources.push(pi);
73    }
74    if let Some(claude) = detect_claude_code(home) {
75        sources.push(claude);
76    }
77    if let Some(codex) = detect_codex(home) {
78        sources.push(codex);
79    }
80
81    sources
82}
83
84fn detect_pi(home: &Path) -> Option<DetectedSource> {
85    let pi_dir = home.join(".pi").join("agent");
86    if !pi_dir.exists() {
87        return None;
88    }
89
90    let skills = discover_skills_in_dir(&pi_dir.join("skills"));
91    let mut agents_md = Vec::new();
92    let agents_path = pi_dir.join("AGENTS.md");
93    if agents_path.exists() {
94        agents_md.push(DetectedAgentsMd {
95            path: agents_path,
96            kind: AgentsMdKind::AgentsMd,
97        });
98    }
99
100    if skills.is_empty() && agents_md.is_empty() {
101        return None;
102    }
103
104    Some(DetectedSource {
105        agent: AgentSource::Pi,
106        skills,
107        agents_md,
108    })
109}
110
111fn detect_claude_code(home: &Path) -> Option<DetectedSource> {
112    let claude_dir = home.join(".claude");
113    if !claude_dir.exists() {
114        return None;
115    }
116
117    let mut agents_md = Vec::new();
118
119    // ~/.claude/CLAUDE.md
120    let claude_md = claude_dir.join("CLAUDE.md");
121    if claude_md.exists() {
122        agents_md.push(DetectedAgentsMd {
123            path: claude_md,
124            kind: AgentsMdKind::ClaudeMd,
125        });
126    }
127
128    if agents_md.is_empty() {
129        return None;
130    }
131
132    Some(DetectedSource {
133        agent: AgentSource::ClaudeCode,
134        skills: Vec::new(),
135        agents_md,
136    })
137}
138
139fn detect_codex(home: &Path) -> Option<DetectedSource> {
140    // Codex uses ~/.codex/ or project-level AGENTS.md (which imp already reads).
141    // Check for user-level codex config.
142    let codex_dir = home.join(".codex");
143    if !codex_dir.exists() {
144        return None;
145    }
146
147    let mut agents_md = Vec::new();
148
149    let instructions = codex_dir.join("instructions.md");
150    if instructions.exists() {
151        agents_md.push(DetectedAgentsMd {
152            path: instructions,
153            kind: AgentsMdKind::AgentsMd,
154        });
155    }
156
157    if agents_md.is_empty() {
158        return None;
159    }
160
161    Some(DetectedSource {
162        agent: AgentSource::Codex,
163        skills: Vec::new(),
164        agents_md,
165    })
166}
167
168fn discover_skills_in_dir(dir: &Path) -> Vec<DetectedSkill> {
169    let mut skills = Vec::new();
170
171    let entries = match std::fs::read_dir(dir) {
172        Ok(entries) => entries,
173        Err(_) => return skills,
174    };
175
176    for entry in entries.flatten() {
177        let skill_dir = entry.path();
178        let skill_file = skill_dir.join("SKILL.md");
179        if !skill_file.exists() {
180            continue;
181        }
182
183        let content = match std::fs::read_to_string(&skill_file) {
184            Ok(c) => c,
185            Err(_) => continue,
186        };
187
188        let name = skill_dir
189            .file_name()
190            .map(|n| n.to_string_lossy().to_string())
191            .unwrap_or_default();
192
193        let description = extract_skill_description(&content);
194
195        skills.push(DetectedSkill {
196            name,
197            description,
198            source_path: skill_file,
199        });
200    }
201
202    skills.sort_by(|a, b| a.name.cmp(&b.name));
203    skills
204}
205
206fn extract_skill_description(content: &str) -> String {
207    // Parse YAML frontmatter for description field.
208    // Simple string extraction — no YAML parser dependency.
209    let lines: Vec<&str> = content.lines().collect();
210    if lines.first().copied() != Some("---") {
211        return crate::resources::extract_description(content);
212    }
213
214    let end = lines
215        .iter()
216        .enumerate()
217        .skip(1)
218        .find_map(|(i, l)| (*l == "---").then_some(i));
219
220    let Some(end) = end else {
221        return String::new();
222    };
223
224    // Look for "description:" in frontmatter lines
225    let mut description = String::new();
226    let mut in_description = false;
227
228    for line in &lines[1..end] {
229        if let Some(rest) = line.strip_prefix("description:") {
230            // Inline value: "description: Some text" or "description: >"
231            let value = rest.trim();
232            if value == ">" || value == "|" {
233                // Multi-line scalar follows
234                in_description = true;
235                continue;
236            }
237            // Single-line value (may be quoted)
238            let value = value.trim_matches('\'').trim_matches('"');
239            return value.to_string();
240        } else if in_description {
241            let trimmed = line.trim();
242            if trimmed.is_empty() {
243                // End of multi-line block
244                break;
245            }
246            if !line.starts_with(' ') && !line.starts_with('\t') {
247                // New key — description block is over
248                break;
249            }
250            if !description.is_empty() {
251                description.push(' ');
252            }
253            description.push_str(trimmed);
254        }
255    }
256
257    description
258}
259
260/// Result of importing skills.
261#[derive(Debug)]
262pub struct ImportResult {
263    pub copied: Vec<String>,
264    pub skipped: Vec<(String, SkipReason)>,
265}
266
267#[derive(Debug)]
268pub enum SkipReason {
269    AlreadyExists,
270    CopyFailed(String),
271}
272
273/// Copy skills from a detected source into imp's skill directory.
274pub fn import_skills(
275    skills: &[DetectedSkill],
276    imp_skills_dir: &Path,
277) -> std::io::Result<ImportResult> {
278    std::fs::create_dir_all(imp_skills_dir)?;
279
280    let mut result = ImportResult {
281        copied: Vec::new(),
282        skipped: Vec::new(),
283    };
284
285    for skill in skills {
286        let dest_dir = imp_skills_dir.join(&skill.name);
287
288        if dest_dir.exists() {
289            result
290                .skipped
291                .push((skill.name.clone(), SkipReason::AlreadyExists));
292            continue;
293        }
294
295        // Copy the entire skill directory
296        let source_dir = skill.source_path.parent().unwrap_or(Path::new("."));
297        match copy_dir_recursive(source_dir, &dest_dir) {
298            Ok(()) => result.copied.push(skill.name.clone()),
299            Err(e) => result
300                .skipped
301                .push((skill.name.clone(), SkipReason::CopyFailed(e.to_string()))),
302        }
303    }
304
305    Ok(result)
306}
307
308/// Copy an AGENTS.md/CLAUDE.md file into imp's config as AGENTS.md.
309///
310/// Returns the destination path, or None if it already exists.
311pub fn import_agents_md(
312    source: &DetectedAgentsMd,
313    imp_config_dir: &Path,
314) -> std::io::Result<Option<PathBuf>> {
315    let dest = imp_config_dir.join("AGENTS.md");
316    if dest.exists() {
317        return Ok(None);
318    }
319
320    std::fs::create_dir_all(imp_config_dir)?;
321    std::fs::copy(&source.path, &dest)?;
322    Ok(Some(dest))
323}
324
325fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
326    std::fs::create_dir_all(dst)?;
327
328    for entry in std::fs::read_dir(src)? {
329        let entry = entry?;
330        let entry_path = entry.path();
331        let dest_path = dst.join(entry.file_name());
332
333        if entry_path.is_dir() {
334            copy_dir_recursive(&entry_path, &dest_path)?;
335        } else {
336            std::fs::copy(&entry_path, &dest_path)?;
337        }
338    }
339
340    Ok(())
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use tempfile::TempDir;
347
348    fn write_skill(dir: &Path, name: &str, description: &str) {
349        let skill_dir = dir.join(name);
350        std::fs::create_dir_all(&skill_dir).unwrap();
351        std::fs::write(
352            skill_dir.join("SKILL.md"),
353            format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
354        )
355        .unwrap();
356    }
357
358    fn write_pi_fallback_package(dir: &Path, name: &str) {
359        let package_dir = dir.join(name);
360        std::fs::create_dir_all(&package_dir).unwrap();
361        std::fs::write(
362            package_dir.join("index.ts"),
363            "export default function(pi) {}\n",
364        )
365        .unwrap();
366    }
367
368    #[test]
369    fn detect_pi_skills() {
370        let home = TempDir::new().unwrap();
371        let skills_dir = home.path().join(".pi").join("agent").join("skills");
372        std::fs::create_dir_all(&skills_dir).unwrap();
373        write_skill(&skills_dir, "rust", "Rust conventions");
374        write_skill(&skills_dir, "testing", "Write tests");
375
376        let sources = detect_sources(home.path());
377        assert_eq!(sources.len(), 1);
378        assert_eq!(sources[0].agent, AgentSource::Pi);
379        assert_eq!(sources[0].skills.len(), 2);
380
381        let names: Vec<&str> = sources[0].skills.iter().map(|s| s.name.as_str()).collect();
382        assert!(names.contains(&"rust"));
383        assert!(names.contains(&"testing"));
384    }
385
386    #[test]
387    #[test]
388    fn detect_pi_agents_md() {
389        let home = TempDir::new().unwrap();
390        let agent_dir = home.path().join(".pi").join("agent");
391        std::fs::create_dir_all(&agent_dir).unwrap();
392        std::fs::write(agent_dir.join("AGENTS.md"), "# Global rules").unwrap();
393
394        let sources = detect_sources(home.path());
395        assert_eq!(sources.len(), 1);
396        assert_eq!(sources[0].agents_md.len(), 1);
397        assert_eq!(sources[0].agents_md[0].kind, AgentsMdKind::AgentsMd);
398    }
399
400    #[test]
401    fn detect_claude_code() {
402        let home = TempDir::new().unwrap();
403        let claude_dir = home.path().join(".claude");
404        std::fs::create_dir_all(&claude_dir).unwrap();
405        std::fs::write(claude_dir.join("CLAUDE.md"), "# Claude config").unwrap();
406
407        let sources = detect_sources(home.path());
408        assert_eq!(sources.len(), 1);
409        assert_eq!(sources[0].agent, AgentSource::ClaudeCode);
410        assert_eq!(sources[0].agents_md.len(), 1);
411        assert_eq!(sources[0].agents_md[0].kind, AgentsMdKind::ClaudeMd);
412    }
413
414    #[test]
415    fn detect_codex_instructions() {
416        let home = TempDir::new().unwrap();
417        let codex_dir = home.path().join(".codex");
418        std::fs::create_dir_all(&codex_dir).unwrap();
419        std::fs::write(codex_dir.join("instructions.md"), "# Codex rules").unwrap();
420
421        let sources = detect_sources(home.path());
422        assert_eq!(sources.len(), 1);
423        assert_eq!(sources[0].agent, AgentSource::Codex);
424    }
425
426    #[test]
427    fn detect_nothing_when_no_agents_installed() {
428        let home = TempDir::new().unwrap();
429        let sources = detect_sources(home.path());
430        assert!(sources.is_empty());
431    }
432
433    #[test]
434    fn detect_multiple_sources() {
435        let home = TempDir::new().unwrap();
436
437        // pi
438        let pi_skills = home.path().join(".pi").join("agent").join("skills");
439        std::fs::create_dir_all(&pi_skills).unwrap();
440        write_skill(&pi_skills, "rust", "Rust");
441
442        // claude
443        let claude_dir = home.path().join(".claude");
444        std::fs::create_dir_all(&claude_dir).unwrap();
445        std::fs::write(claude_dir.join("CLAUDE.md"), "config").unwrap();
446
447        let sources = detect_sources(home.path());
448        assert_eq!(sources.len(), 2);
449    }
450
451    #[test]
452    fn import_copies_skills() {
453        let home = TempDir::new().unwrap();
454        let source_dir = home.path().join("source");
455        std::fs::create_dir_all(&source_dir).unwrap();
456        write_skill(&source_dir, "rust", "Rust conventions");
457        write_skill(&source_dir, "testing", "Write tests");
458
459        let skills = discover_skills_in_dir(&source_dir);
460        let dest = home.path().join("imp_skills");
461
462        let result = import_skills(&skills, &dest).unwrap();
463        assert_eq!(result.copied.len(), 2);
464        assert!(result.skipped.is_empty());
465
466        // Verify files exist
467        assert!(dest.join("rust").join("SKILL.md").exists());
468        assert!(dest.join("testing").join("SKILL.md").exists());
469    }
470
471    #[test]
472    fn import_skips_existing() {
473        let home = TempDir::new().unwrap();
474        let source_dir = home.path().join("source");
475        std::fs::create_dir_all(&source_dir).unwrap();
476        write_skill(&source_dir, "rust", "Rust conventions");
477
478        let dest = home.path().join("imp_skills");
479        // Pre-create the destination
480        std::fs::create_dir_all(dest.join("rust")).unwrap();
481        std::fs::write(dest.join("rust").join("SKILL.md"), "existing").unwrap();
482
483        let skills = discover_skills_in_dir(&source_dir);
484        let result = import_skills(&skills, &dest).unwrap();
485
486        assert!(result.copied.is_empty());
487        assert_eq!(result.skipped.len(), 1);
488        assert!(matches!(result.skipped[0].1, SkipReason::AlreadyExists));
489
490        // Original content preserved
491        let content = std::fs::read_to_string(dest.join("rust").join("SKILL.md")).unwrap();
492        assert_eq!(content, "existing");
493    }
494
495    #[test]
496    #[test]
497    #[test]
498    fn import_agents_md_copies_file() {
499        let home = TempDir::new().unwrap();
500        let source = home.path().join("source.md");
501        std::fs::write(&source, "# Global rules").unwrap();
502
503        let detected = DetectedAgentsMd {
504            path: source,
505            kind: AgentsMdKind::AgentsMd,
506        };
507
508        let imp_config = home.path().join("config");
509        let result = import_agents_md(&detected, &imp_config).unwrap();
510        assert!(result.is_some());
511
512        let dest = imp_config.join("AGENTS.md");
513        assert!(dest.exists());
514        assert_eq!(std::fs::read_to_string(dest).unwrap(), "# Global rules");
515    }
516
517    #[test]
518    fn import_agents_md_skips_existing() {
519        let home = TempDir::new().unwrap();
520        let source = home.path().join("source.md");
521        std::fs::write(&source, "# New rules").unwrap();
522
523        let imp_config = home.path().join("config");
524        std::fs::create_dir_all(&imp_config).unwrap();
525        std::fs::write(imp_config.join("AGENTS.md"), "# Existing rules").unwrap();
526
527        let detected = DetectedAgentsMd {
528            path: source,
529            kind: AgentsMdKind::AgentsMd,
530        };
531
532        let result = import_agents_md(&detected, &imp_config).unwrap();
533        assert!(result.is_none());
534
535        // Original preserved
536        let content = std::fs::read_to_string(imp_config.join("AGENTS.md")).unwrap();
537        assert_eq!(content, "# Existing rules");
538    }
539
540    #[test]
541    fn extract_description_from_frontmatter() {
542        let content = "---\nname: test\ndescription: A test skill\n---\n\n# Body\n";
543        assert_eq!(extract_skill_description(content), "A test skill");
544    }
545
546    #[test]
547    fn extract_description_multiline() {
548        let content = "---\nname: test\ndescription: >\n  Line one\n  line two\n---\n";
549        let desc = extract_skill_description(content);
550        assert!(desc.contains("Line one"));
551    }
552
553    #[test]
554    fn extract_description_no_frontmatter() {
555        let content = "# Just a heading\nSome body text.";
556        assert_eq!(extract_skill_description(content), "Some body text.");
557    }
558
559    #[test]
560    fn copy_dir_recursive_works() {
561        let tmp = TempDir::new().unwrap();
562        let src = tmp.path().join("src");
563        let dst = tmp.path().join("dst");
564
565        std::fs::create_dir_all(src.join("sub")).unwrap();
566        std::fs::write(src.join("a.txt"), "hello").unwrap();
567        std::fs::write(src.join("sub").join("b.txt"), "world").unwrap();
568
569        copy_dir_recursive(&src, &dst).unwrap();
570
571        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
572        assert_eq!(
573            std::fs::read_to_string(dst.join("sub").join("b.txt")).unwrap(),
574            "world"
575        );
576    }
577}