1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug, Clone)]
7pub struct Skill {
8 pub name: String,
10 pub description: String,
12 pub template: String,
14 pub disable_model_invocation: bool,
16 pub user_invocable: bool,
18 pub argument_hint: Option<String>,
20 pub allowed_tools: Vec<String>,
22 pub skill_dir: PathBuf,
24 pub source_path: PathBuf,
26}
27
28impl Skill {
29 pub fn expand(&self, arguments: &str, session_id: &str) -> String {
38 let positional: Vec<&str> = arguments.split_whitespace().collect();
39 let mut result = self.template.clone();
40
41 for (i, arg) in positional.iter().enumerate() {
43 result = result.replace(&format!("$ARGUMENTS[{}]", i), arg);
44 }
45
46 for (i, arg) in positional.iter().enumerate() {
48 result = replace_positional_short(&result, i, arg);
49 }
50
51 if self.template.contains("$ARGUMENTS") {
57 result = result.replace("$ARGUMENTS", arguments);
58 } else if !arguments.trim().is_empty() {
59 result = format!("{}\n\nARGUMENTS: {}", result.trim_end(), arguments);
60 }
61
62 result = result.replace("${CLAUDE_SESSION_ID}", session_id);
64
65 result = result.replace("${CLAUDE_SKILL_DIR}", &self.skill_dir.to_string_lossy());
67
68 result = expand_shell_injections(&result);
70
71 result
72 }
73}
74
75fn replace_positional_short(s: &str, n: usize, replacement: &str) -> String {
78 let pattern = format!("${}", n);
79 let pat = pattern.as_bytes();
80 let src = s.as_bytes();
81 let mut out = Vec::with_capacity(s.len());
82 let mut i = 0;
83
84 while i < src.len() {
85 if src[i..].starts_with(pat) {
86 let after = i + pat.len();
87 let next_is_digit = src.get(after).map(|b| b.is_ascii_digit()).unwrap_or(false);
88 if !next_is_digit {
89 out.extend_from_slice(replacement.as_bytes());
90 i += pat.len();
91 continue;
92 }
93 }
94 out.push(src[i]);
95 i += 1;
96 }
97
98 String::from_utf8_lossy(&out).into_owned()
99}
100
101fn expand_shell_injections(template: &str) -> String {
104 let mut result = template.to_string();
105
106 loop {
107 let Some(start) = result.find("!`") else {
108 break;
109 };
110 let search_from = start + 2;
111 let Some(rel_end) = result[search_from..].find('`') else {
112 break; };
114 let end = search_from + rel_end;
115 let cmd = result[search_from..end].to_string();
116 let output = run_shell_command(&cmd);
117 result = format!("{}{}{}", &result[..start], output, &result[end + 1..]);
118 }
119
120 result
121}
122
123fn run_shell_command(cmd: &str) -> String {
124 let mut command = Command::new("sh");
125 command.arg("-c").arg(cmd);
126 crate::process_utils::suppress_console_window_sync(&mut command);
127 match command.output() {
128 Ok(out) => {
129 let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
130 if !out.status.success() {
131 let stderr = String::from_utf8_lossy(&out.stderr);
132 if !stderr.trim().is_empty() {
133 s.push('\n');
134 s.push_str(stderr.trim());
135 }
136 }
137 s.trim_end().to_string()
139 }
140 Err(e) => format!("[error: {}]", e),
141 }
142}
143
144struct Frontmatter {
149 name: Option<String>,
150 description: String,
151 disable_model_invocation: bool,
152 user_invocable: bool,
153 argument_hint: Option<String>,
154 allowed_tools: Vec<String>,
155}
156
157impl Frontmatter {
158 fn default() -> Self {
159 Self {
160 name: None,
161 description: String::new(),
162 disable_model_invocation: false,
163 user_invocable: true,
164 argument_hint: None,
165 allowed_tools: Vec::new(),
166 }
167 }
168}
169
170fn parse_frontmatter(content: &str) -> (Frontmatter, String) {
175 let mut fm = Frontmatter::default();
176
177 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
178 return (fm, content.to_string());
179 }
180
181 let after_open = &content[if content.starts_with("---\r\n") { 5 } else { 4 }..];
182
183 let (close_pos, skip) = match find_frontmatter_close(after_open) {
184 Some(v) => v,
185 None => return (fm, content.to_string()),
186 };
187
188 let fm_text = &after_open[..close_pos];
189 let template = after_open[close_pos + skip..].to_string();
190
191 for line in fm_text.lines() {
192 if let Some(val) = line.strip_prefix("name:") {
193 let v = val.trim().trim_matches('"').trim_matches('\'');
194 if !v.is_empty() {
195 fm.name = Some(v.to_string());
196 }
197 } else if let Some(val) = line.strip_prefix("description:") {
198 fm.description = val.trim().trim_matches('"').trim_matches('\'').to_string();
199 } else if let Some(val) = line.strip_prefix("disable-model-invocation:") {
200 fm.disable_model_invocation = val.trim() == "true";
201 } else if let Some(val) = line.strip_prefix("user-invocable:") {
202 fm.user_invocable = val.trim() != "false";
203 } else if let Some(val) = line.strip_prefix("argument-hint:") {
204 let v = val.trim().trim_matches('"').trim_matches('\'');
205 if !v.is_empty() {
206 fm.argument_hint = Some(v.to_string());
207 }
208 } else if let Some(val) = line.strip_prefix("allowed-tools:") {
209 fm.allowed_tools = val
211 .split(|c| c == ' ' || c == ',')
212 .map(|s| s.trim().to_string())
213 .filter(|s| !s.is_empty())
214 .collect();
215 }
216 }
217
218 (fm, template)
219}
220
221fn find_frontmatter_close(after_open: &str) -> Option<(usize, usize)> {
222 if after_open == "---" {
223 return Some((0, 3));
224 }
225 if after_open == "---\r" {
226 return Some((0, 4));
227 }
228 if after_open.starts_with("---\n") {
229 return Some((0, 4));
230 }
231 if after_open.starts_with("---\r\n") {
232 return Some((0, 5));
233 }
234
235 after_open
236 .find("\n---\n")
237 .map(|p| (p, 5usize))
238 .or_else(|| after_open.find("\n---\r\n").map(|p| (p, 6)))
239 .or_else(|| after_open.strip_suffix("\n---").map(|_| (after_open.len() - 4, 4)))
240 .or_else(|| {
241 after_open
242 .strip_suffix("\n---\r")
243 .map(|_| (after_open.len() - 5, 5))
244 })
245}
246
247fn first_paragraph(template: &str) -> String {
250 template
251 .lines()
252 .find(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
253 .unwrap_or("")
254 .trim()
255 .to_string()
256}
257
258fn parse_skill_file(path: &Path, namespace: Option<&str>) -> anyhow::Result<Skill> {
264 let stem = path
265 .file_stem()
266 .and_then(|s| s.to_str())
267 .ok_or_else(|| anyhow::anyhow!("filename is not valid UTF-8"))?;
268
269 validate_skill_name(stem)?;
270
271 let content = std::fs::read_to_string(path)?;
272 let (fm, template) = parse_frontmatter(&content);
273
274 let base_name = fm.name.as_deref().unwrap_or(stem);
275 let name = make_name(base_name, namespace);
276
277 let description = if fm.description.is_empty() {
278 first_paragraph(&template)
279 } else {
280 fm.description
281 };
282
283 Ok(Skill {
284 name,
285 description,
286 template,
287 disable_model_invocation: fm.disable_model_invocation,
288 user_invocable: fm.user_invocable,
289 argument_hint: fm.argument_hint,
290 allowed_tools: fm.allowed_tools,
291 skill_dir: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
292 source_path: path.to_path_buf(),
293 })
294}
295
296fn parse_skill_dir(
299 skill_dir: &Path,
300 skill_md: &Path,
301 namespace: Option<&str>,
302) -> anyhow::Result<Skill> {
303 let dir_name = skill_dir
304 .file_name()
305 .and_then(|s| s.to_str())
306 .ok_or_else(|| anyhow::anyhow!("directory name is not valid UTF-8"))?;
307
308 let content = std::fs::read_to_string(skill_md)?;
309 let (fm, template) = parse_frontmatter(&content);
310
311 let base_name = fm.name.as_deref().unwrap_or(dir_name);
312 validate_skill_name(base_name)?;
313 let name = make_name(base_name, namespace);
314
315 let description = if fm.description.is_empty() {
316 first_paragraph(&template)
317 } else {
318 fm.description
319 };
320
321 Ok(Skill {
322 name,
323 description,
324 template,
325 disable_model_invocation: fm.disable_model_invocation,
326 user_invocable: fm.user_invocable,
327 argument_hint: fm.argument_hint,
328 allowed_tools: fm.allowed_tools,
329 skill_dir: skill_dir.to_path_buf(),
330 source_path: skill_md.to_path_buf(),
331 })
332}
333
334fn validate_skill_name(name: &str) -> anyhow::Result<()> {
335 if name.is_empty() || name.len() > 64 {
336 anyhow::bail!("skill name '{}' must be 1-64 characters", name);
337 }
338 if !name
339 .chars()
340 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
341 {
342 anyhow::bail!(
343 "skill name '{}' must contain only lowercase letters, digits, hyphens, and underscores",
344 name
345 );
346 }
347 if name.starts_with('-') || name.ends_with('-') {
348 anyhow::bail!("skill name '{}' must not start or end with a hyphen", name);
349 }
350 if name.contains("--") {
351 anyhow::bail!("skill name '{}' must not contain consecutive hyphens", name);
352 }
353 Ok(())
354}
355
356fn make_name(base: &str, namespace: Option<&str>) -> String {
357 match namespace {
358 Some(ns) => format!("{}:{}", ns, base),
359 None => base.to_string(),
360 }
361}
362
363pub struct SkillRegistry {
369 skills: HashMap<String, Skill>,
370}
371
372impl SkillRegistry {
373 pub fn new() -> Self {
374 Self {
375 skills: HashMap::new(),
376 }
377 }
378
379 pub fn reload(&mut self, working_dir: &Path) -> Vec<String> {
406 self.skills.clear();
407 let mut warnings: Vec<String> = Vec::new();
408
409 let system_home = crate::tool::real_home_dir();
411
412 let atomcode_config_dir = crate::config::Config::config_dir();
417
418 const LOOSE_NS: Option<&str> = Some("skills");
425
426 if let Some(ref home) = system_home {
428 self.load_flat_commands(&home.join(".claude").join("commands"), LOOSE_NS, &mut warnings);
429 self.load_skills_dir(&home.join(".claude").join("skills"), LOOSE_NS, &mut warnings);
430 }
431
432 self.load_flat_commands(&atomcode_config_dir.join("commands"), LOOSE_NS, &mut warnings);
434 self.load_skills_dir(&atomcode_config_dir.join("skills"), LOOSE_NS, &mut warnings);
435
436 self.load_flat_commands(&working_dir.join(".claude").join("commands"), LOOSE_NS, &mut warnings);
438 self.load_flat_commands(&working_dir.join(".atomcode").join("commands"), LOOSE_NS, &mut warnings);
439 self.load_skills_dir(&working_dir.join(".claude").join("skills"), LOOSE_NS, &mut warnings);
440 self.load_skills_dir(&working_dir.join(".atomcode").join("skills"), LOOSE_NS, &mut warnings);
441
442 for assets in crate::plugin::loader::iter_installed_plugin_assets() {
444 for skills_dir in assets.skills_dirs() {
445 self.load_skills_dir(&skills_dir, Some(&assets.plugin), &mut warnings);
446 }
447 }
448 warnings
449 }
450
451 pub fn register(&mut self, skill: Skill) {
453 self.skills.insert(skill.name.clone(), skill);
454 }
455
456 pub fn get(&self, name: &str) -> Option<&Skill> {
468 if let Some(s) = self.skills.get(name) {
469 return Some(s);
470 }
471 if name.contains(':') {
475 return None;
476 }
477 let suffix = format!(":{}", name);
478 let mut hits = self.skills.iter().filter(|(k, _)| k.ends_with(&suffix));
479 let first = hits.next()?;
480 if hits.next().is_some() {
481 return None;
483 }
484 Some(first.1)
485 }
486
487 pub fn is_empty(&self) -> bool {
488 self.skills.is_empty()
489 }
490
491 pub fn all(&self) -> impl Iterator<Item = &Skill> {
493 self.skills.values()
494 }
495
496 pub fn user_invocable(&self) -> impl Iterator<Item = &Skill> {
498 self.skills.values().filter(|s| s.user_invocable)
499 }
500
501 pub fn invocable_by_llm(&self) -> impl Iterator<Item = &Skill> {
503 self.skills.values().filter(|s| !s.disable_model_invocation)
504 }
505
506 fn load_flat_commands(&mut self, dir: &Path, namespace: Option<&str>, warnings: &mut Vec<String>) {
510 if !dir.is_dir() {
511 return;
512 }
513 let entries = match std::fs::read_dir(dir) {
514 Ok(e) => e,
515 Err(_) => return,
516 };
517 for entry in entries.flatten() {
518 let path = entry.path();
519 if path.extension().and_then(|e| e.to_str()) != Some("md") {
520 continue;
521 }
522 match parse_skill_file(&path, namespace) {
523 Ok(skill) => {
524 self.skills.insert(skill.name.clone(), skill);
525 }
526 Err(e) => {
527 warnings.push(format!("[skill] skipping {}: {}", path.display(), e));
528 }
529 }
530 }
531 }
532
533 fn load_skills_dir(&mut self, dir: &Path, namespace: Option<&str>, warnings: &mut Vec<String>) {
548 if !dir.is_dir() {
549 return;
550 }
551 let self_md = dir.join("SKILL.md");
553 if self_md.exists() {
554 match parse_skill_dir(dir, &self_md, namespace) {
555 Ok(skill) => {
556 self.skills.insert(skill.name.clone(), skill);
557 }
558 Err(e) => {
559 warnings.push(format!("[skill] skipping {}: {}", dir.display(), e));
560 }
561 }
562 }
563 let entries = match std::fs::read_dir(dir) {
565 Ok(e) => e,
566 Err(_) => return,
567 };
568 for entry in entries.flatten() {
569 let skill_dir = entry.path();
570 if !skill_dir.is_dir() {
571 continue;
572 }
573 let skill_md = skill_dir.join("SKILL.md");
574 if !skill_md.exists() {
575 continue;
576 }
577 match parse_skill_dir(&skill_dir, &skill_md, namespace) {
578 Ok(skill) => {
579 self.skills.insert(skill.name.clone(), skill);
580 }
581 Err(e) => {
582 warnings.push(format!("[skill] skipping {}: {}", skill_dir.display(), e));
583 }
584 }
585 }
586 }
587}
588
589#[cfg(test)]
594mod tests {
595 use super::*;
596
597 fn make_skill(template: &str) -> Skill {
598 Skill {
599 name: "test".into(),
600 description: "".into(),
601 template: template.into(),
602 disable_model_invocation: false,
603 user_invocable: true,
604 argument_hint: None,
605 allowed_tools: vec![],
606 skill_dir: PathBuf::new(),
607 source_path: PathBuf::new(),
608 }
609 }
610
611 #[test]
614 fn test_expand_with_arguments() {
615 let s = make_skill("Do $ARGUMENTS please.");
616 assert_eq!(s.expand("foo bar", ""), "Do foo bar please.");
617 }
618
619 #[test]
620 fn test_expand_no_placeholder_with_args() {
621 let s = make_skill("Do something.");
622 assert_eq!(s.expand("extra", ""), "Do something.\n\nARGUMENTS: extra");
623 }
624
625 #[test]
626 fn test_expand_no_placeholder_no_args() {
627 let s = make_skill("Do something.");
628 assert_eq!(s.expand("", ""), "Do something.");
629 }
630
631 #[test]
634 fn test_expand_positional_brackets() {
635 let s = make_skill("Migrate $ARGUMENTS[0] from $ARGUMENTS[1] to $ARGUMENTS[2].");
637 assert_eq!(
638 s.expand("Button React Vue", ""),
639 "Migrate Button from React to Vue."
640 );
641 }
642
643 #[test]
644 fn test_expand_positional_short() {
645 let s = make_skill("Migrate $0 from $1 to $2.");
647 assert_eq!(
648 s.expand("Button React Vue", ""),
649 "Migrate Button from React to Vue.\n\nARGUMENTS: Button React Vue"
650 );
651 }
652
653 #[test]
654 fn test_expand_positional_short_no_partial_match() {
655 let s = make_skill("a=$10 b=$1.");
657 assert_eq!(s.expand("x y", ""), "a=$10 b=y.\n\nARGUMENTS: x y");
658 }
659
660 #[test]
661 fn test_expand_session_id() {
662 let s = make_skill("session=${CLAUDE_SESSION_ID}");
663 assert_eq!(s.expand("", "abc-123"), "session=abc-123");
664 }
665
666 #[test]
667 fn test_expand_skill_dir() {
668 let mut s = make_skill("dir=${CLAUDE_SKILL_DIR}");
669 s.skill_dir = PathBuf::from("/home/user/.claude/skills/my-skill");
670 assert_eq!(s.expand("", ""), "dir=/home/user/.claude/skills/my-skill");
671 }
672
673 #[test]
676 fn test_frontmatter_none() {
677 let (fm, tmpl) = parse_frontmatter("Just a template.");
678 assert_eq!(fm.description, "");
679 assert!(!fm.disable_model_invocation);
680 assert!(fm.user_invocable);
681 assert!(fm.name.is_none());
682 assert_eq!(tmpl, "Just a template.");
683 }
684
685 #[test]
686 fn test_frontmatter_full() {
687 let content = "---\nname: my-skill\ndescription: \"My skill\"\ndisable-model-invocation: true\nuser-invocable: false\nargument-hint: \"[file]\"\nallowed-tools: Read Grep\n---\nBody.\n";
688 let (fm, tmpl) = parse_frontmatter(content);
689 assert_eq!(fm.name.as_deref(), Some("my-skill"));
690 assert_eq!(fm.description, "My skill");
691 assert!(fm.disable_model_invocation);
692 assert!(!fm.user_invocable);
693 assert_eq!(fm.argument_hint.as_deref(), Some("[file]"));
694 assert_eq!(fm.allowed_tools, vec!["Read", "Grep"]);
695 assert_eq!(tmpl, "Body.\n");
696 }
697
698 #[test]
699 fn test_frontmatter_closing_delimiter_at_eof() {
700 let content = "---\nname: eof-skill\ndescription: EOF skill\n---";
701 let (fm, tmpl) = parse_frontmatter(content);
702 assert_eq!(fm.name.as_deref(), Some("eof-skill"));
703 assert_eq!(fm.description, "EOF skill");
704 assert_eq!(tmpl, "");
705 }
706
707 #[test]
708 fn test_empty_frontmatter_before_body() {
709 let content = "---\n---\nBody.\n";
710 let (fm, tmpl) = parse_frontmatter(content);
711 assert_eq!(fm.description, "");
712 assert_eq!(tmpl, "Body.\n");
713 }
714
715 #[test]
716 fn test_frontmatter_unclosed() {
717 let content = "---\ndescription: broken\nno closing delimiter";
718 let (fm, tmpl) = parse_frontmatter(content);
719 assert_eq!(fm.description, "");
720 assert_eq!(tmpl, content);
721 }
722
723 #[test]
724 fn test_description_fallback_to_first_paragraph() {
725 assert_eq!(
727 first_paragraph("# Title\n\nActual description."),
728 "Actual description."
729 );
730 assert_eq!(first_paragraph(" text "), "text");
731 assert_eq!(first_paragraph("# Heading"), ""); }
733
734 #[test]
737 fn test_replace_positional_short_boundary() {
738 assert_eq!(replace_positional_short("$10 $1", 1, "Y"), "$10 Y");
740 }
741
742 #[test]
745 fn test_load_skills_dir_applies_namespace() {
746 let tmp = tempfile::tempdir().expect("tempdir");
751 let skill_dir = tmp.path().join("brainstorming");
752 std::fs::create_dir_all(&skill_dir).unwrap();
753 std::fs::write(
754 skill_dir.join("SKILL.md"),
755 "---\ndescription: \"Test\"\n---\nTemplate body.\n",
756 )
757 .unwrap();
758
759 let mut reg = SkillRegistry::new();
760 let mut warnings = Vec::new();
761 reg.load_skills_dir(tmp.path(), Some("skills"), &mut warnings);
762
763 assert!(
764 reg.get("skills:brainstorming").is_some(),
765 "namespaced lookup must succeed"
766 );
767 assert!(
771 reg.get("brainstorming").is_some(),
772 "bare name must resolve via suffix fallback when unambiguous"
773 );
774 assert!(
778 reg.skills.contains_key("skills:brainstorming"),
779 "storage must use prefixed key"
780 );
781 assert!(
782 !reg.skills.contains_key("brainstorming"),
783 "storage must not duplicate under bare key"
784 );
785 }
786
787 #[test]
788 fn test_get_suffix_fallback_ambiguous_misses() {
789 let mut reg = SkillRegistry::new();
793 for ns in ["plugin-a", "plugin-b"] {
794 let key = format!("{}:verify", ns);
795 reg.skills.insert(
796 key.clone(),
797 Skill {
798 name: key,
799 description: "v".into(),
800 template: "body".into(),
801 disable_model_invocation: false,
802 user_invocable: true,
803 argument_hint: None,
804 allowed_tools: vec![],
805 skill_dir: PathBuf::new(),
806 source_path: PathBuf::new(),
807 },
808 );
809 }
810 assert!(
811 reg.get("verify").is_none(),
812 "ambiguous bare name must miss (forces qualified lookup)"
813 );
814 assert!(reg.get("plugin-a:verify").is_some());
815 assert!(reg.get("plugin-b:verify").is_some());
816 }
817
818 #[test]
819 fn test_get_qualified_miss_does_not_fallback() {
820 let mut reg = SkillRegistry::new();
823 reg.skills.insert(
824 "real-plugin:verify".into(),
825 Skill {
826 name: "real-plugin:verify".into(),
827 description: "v".into(),
828 template: "body".into(),
829 disable_model_invocation: false,
830 user_invocable: true,
831 argument_hint: None,
832 allowed_tools: vec![],
833 skill_dir: PathBuf::new(),
834 source_path: PathBuf::new(),
835 },
836 );
837 assert!(reg.get("typo-plugin:verify").is_none());
838 }
839
840 #[test]
841 fn test_load_flat_commands_applies_namespace() {
842 let tmp = tempfile::tempdir().expect("tempdir");
844 std::fs::write(
845 tmp.path().join("commit.md"),
846 "---\ndescription: \"Commit\"\n---\nDo a commit.\n",
847 )
848 .unwrap();
849
850 let mut reg = SkillRegistry::new();
851 let mut warnings = Vec::new();
852 reg.load_flat_commands(tmp.path(), Some("skills"), &mut warnings);
853
854 assert!(reg.get("skills:commit").is_some());
855 assert!(reg.get("commit").is_some());
857 assert!(reg.skills.contains_key("skills:commit"));
858 assert!(!reg.skills.contains_key("commit"));
859 }
860
861 #[test]
862 #[serial_test::serial]
863 fn reload_picks_up_installed_plugin_skills() {
864 let tmp = tempfile::tempdir().unwrap();
865 std::env::set_var("ATOMCODE_HOME", tmp.path());
866
867 let plugins_root = tmp.path().join("plugins");
871 let plugin_dir = plugins_root.join("marketplaces/p");
872 let skill_dir = plugin_dir.join("skills/hello");
873 std::fs::create_dir_all(&skill_dir).unwrap();
874 std::fs::write(
875 skill_dir.join("SKILL.md"),
876 "---\nname: hello\ndescription: hi\n---\nhi",
877 )
878 .unwrap();
879 std::fs::write(
880 plugins_root.join("installed_plugins.json"),
881 r#"{"version":1,"plugins":{"p@p":{"marketplace":"p","plugin":"p","plugin_dir":"marketplaces/p","installed_at":"x"}}}"#,
882 )
883 .unwrap();
884
885 let working = tempfile::tempdir().unwrap();
886 let mut reg = SkillRegistry::new();
887 reg.reload(working.path());
888 assert!(reg.get("p:hello").is_some(), "expected namespaced plugin skill");
889
890 std::env::remove_var("ATOMCODE_HOME");
891 }
892
893 #[test]
898 fn test_load_skills_dir_cc_array_layout() {
899 let tmp = tempfile::tempdir().expect("tempdir");
900 let skill_dir = tmp.path().join("skills/karpathy-guidelines");
903 std::fs::create_dir_all(&skill_dir).unwrap();
904 std::fs::write(
905 skill_dir.join("SKILL.md"),
906 "---\nname: karpathy-guidelines\ndescription: Guidelines\n---\nBe simple.",
907 )
908 .unwrap();
909
910 let mut reg = SkillRegistry::new();
911 let mut warnings = Vec::new();
912 reg.load_skills_dir(&skill_dir, Some("karpathy-skills"), &mut warnings);
914
915 assert!(
916 reg.get("karpathy-skills:karpathy-guidelines").is_some(),
917 "CC array layout: skill directory containing SKILL.md should be loaded"
918 );
919 assert!(warnings.is_empty(), "no warnings expected");
920 }
921
922 #[test]
925 fn test_load_skills_dir_hybrid_layout() {
926 let tmp = tempfile::tempdir().expect("tempdir");
927
928 std::fs::write(
930 tmp.path().join("SKILL.md"),
931 "---\nname: hybrid\ndescription: self\n---\nself body",
932 )
933 .unwrap();
934
935 let sub = tmp.path().join("sub-skill");
937 std::fs::create_dir_all(&sub).unwrap();
938 std::fs::write(
939 sub.join("SKILL.md"),
940 "---\nname: sub-skill\ndescription: sub\n---\nsub body",
941 )
942 .unwrap();
943
944 let mut reg = SkillRegistry::new();
945 let mut warnings = Vec::new();
946 reg.load_skills_dir(tmp.path(), Some("test"), &mut warnings);
947
948 assert!(reg.get("test:hybrid").is_some(), "self SKILL.md should load");
949 assert!(reg.get("test:sub-skill").is_some(), "subdirectory SKILL.md should load");
950 }
951
952 #[test]
955 #[serial_test::serial]
956 fn reload_picks_up_cc_array_plugin_skills() {
957 let tmp = tempfile::tempdir().unwrap();
958 std::env::set_var("ATOMCODE_HOME", tmp.path());
959
960 let plugins_root = tmp.path().join("plugins");
963 let plugin_dir = plugins_root.join("marketplaces/karpathy-skills");
964 let skill_dir = plugin_dir.join("skills/karpathy-guidelines");
965 std::fs::create_dir_all(&skill_dir).unwrap();
966 std::fs::write(
967 skill_dir.join("SKILL.md"),
968 "---\nname: karpathy-guidelines\ndescription: Guidelines\n---\nBe simple.",
969 )
970 .unwrap();
971 std::fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
973 std::fs::write(
974 plugin_dir.join(".claude-plugin/plugin.json"),
975 r#"{"name":"andrej-karpathy-skills","skills":["./skills/karpathy-guidelines"]}"#,
976 )
977 .unwrap();
978 std::fs::write(
979 plugins_root.join("installed_plugins.json"),
980 r#"{"version":1,"plugins":{"andrej-karpathy-skills@karpathy-skills":{"marketplace":"karpathy-skills","plugin":"andrej-karpathy-skills","plugin_dir":"marketplaces/karpathy-skills","installed_at":"x"}}}"#,
981 )
982 .unwrap();
983
984 let working = tempfile::tempdir().unwrap();
985 let mut reg = SkillRegistry::new();
986 reg.reload(working.path());
987 assert!(
988 reg.get("andrej-karpathy-skills:karpathy-guidelines").is_some(),
989 "CC array plugin: skill should be loaded from direct skill directory"
990 );
991
992 std::env::remove_var("ATOMCODE_HOME");
993 }
994}