1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone)]
5pub struct DetectedSource {
6 pub agent: AgentSource,
7 pub skills: Vec<DetectedSkill>,
8 pub agents_md: Vec<DetectedAgentsMd>,
9}
10
11#[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#[derive(Debug, Clone)]
39pub struct DetectedSkill {
40 pub name: String,
41 pub description: String,
42 pub source_path: PathBuf,
43}
44
45#[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
67pub 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 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 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 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 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 let value = rest.trim();
232 if value == ">" || value == "|" {
233 in_description = true;
235 continue;
236 }
237 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 break;
245 }
246 if !line.starts_with(' ') && !line.starts_with('\t') {
247 break;
249 }
250 if !description.is_empty() {
251 description.push(' ');
252 }
253 description.push_str(trimmed);
254 }
255 }
256
257 description
258}
259
260#[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
273pub 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 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
308pub 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 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 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 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 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 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 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}