Skip to main content

synaps_cli/skills/
loader.rs

1//! SKILL.md parsing, {baseDir}/${CLAUDE_PLUGIN_ROOT} substitution, and discovery.
2
3use std::path::{Path, PathBuf};
4use crate::skills::LoadedSkill;
5
6/// Parse YAML frontmatter from a markdown file.
7/// Returns (frontmatter_fields, body).
8pub(super) fn parse_frontmatter(text: &str) -> (Vec<(String, String)>, String) {
9    if !text.starts_with("---") {
10        return (vec![], text.to_string());
11    }
12    if let Some(end) = text[3..].find("\n---") {
13        let frontmatter_str = &text[3..3 + end];
14        let body_start = 3 + end + 4;
15        let body = if body_start <= text.len() && text.is_char_boundary(body_start) {
16            text[body_start..].trim().to_string()
17        } else {
18            String::new()
19        };
20        let fields: Vec<(String, String)> = frontmatter_str
21            .lines()
22            .filter_map(|line| {
23                let line = line.trim();
24                if line.is_empty() { return None; }
25                let (k, v) = line.split_once(':')?;
26                Some((k.trim().to_string(), v.trim().trim_matches('"').to_string()))
27            })
28            .collect();
29        (fields, body)
30    } else {
31        (vec![], text.to_string())
32    }
33}
34
35/// Load a SKILL.md file into a `LoadedSkill`. Applies two substitutions:
36/// - `{baseDir}` → the skill's own directory (native SynapsCLI form).
37/// - `${CLAUDE_PLUGIN_ROOT}` / `$CLAUDE_PLUGIN_ROOT` → the plugin's root
38///   directory (Claude Code compat), when the skill belongs to a plugin.
39///
40/// Returns None if required frontmatter is missing or body is empty.
41pub fn load_skill_file(
42    skill_md: &Path,
43    plugin: Option<&str>,
44    plugin_root: Option<&Path>,
45) -> Option<LoadedSkill> {
46    let content = std::fs::read_to_string(skill_md).ok()?;
47    let (fields, body) = parse_frontmatter(&content);
48
49    let name = fields.iter().find(|(k, _)| k == "name").map(|(_, v)| v.clone())?;
50    let description = fields.iter().find(|(k, _)| k == "description").map(|(_, v)| v.clone())?;
51
52    if body.is_empty() {
53        return None;
54    }
55
56    let base_dir = skill_md.parent()?.canonicalize().ok()?;
57    let mut body = body.replace("{baseDir}", base_dir.to_str()?);
58    if let Some(root) = plugin_root.and_then(|p| p.canonicalize().ok()) {
59        let root_str = root.to_str()?;
60        // Replace braced form first so the plain-$ pass doesn't see a partial match.
61        body = body.replace("${CLAUDE_PLUGIN_ROOT}", root_str);
62        body = body.replace("$CLAUDE_PLUGIN_ROOT", root_str);
63    }
64
65    Some(LoadedSkill {
66        name,
67        description,
68        body,
69        plugin: plugin.map(str::to_string),
70        base_dir,
71        source_path: skill_md.canonicalize().ok()?,
72    })
73}
74
75use crate::skills::{Plugin, manifest::{PluginManifest, MarketplaceManifest}};
76
77/// The four default discovery roots, in priority order (local first, global second).
78pub fn default_roots() -> Vec<PathBuf> {
79    let mut roots = vec![
80        PathBuf::from(".synaps-cli/plugins"),
81        PathBuf::from(".synaps-cli/skills"),
82    ];
83    let home_plugins = crate::config::resolve_read_path_extended("plugins");
84    let home_skills = crate::config::resolve_read_path_extended("skills");
85    roots.push(home_plugins);
86    roots.push(home_skills);
87    roots
88}
89
90/// Walk the given roots and discover all plugins and skills.
91/// Deduplicates on (plugin_name, skill_name); first occurrence wins.
92pub fn load_all(roots: &[PathBuf]) -> (Vec<Plugin>, Vec<LoadedSkill>) {
93    let mut plugins: Vec<Plugin> = Vec::new();
94    let mut skills: Vec<LoadedSkill> = Vec::new();
95    let mut seen: std::collections::HashSet<(Option<String>, String)> =
96        std::collections::HashSet::new();
97
98    for root in roots {
99        walk_root(root, &mut plugins, &mut skills, &mut seen);
100    }
101    (plugins, skills)
102}
103
104/// Return the first existing path from a list of candidates, or None.
105/// Used to accept both `.synaps-plugin/` (native) and `.claude-plugin/`
106/// (Claude Code compat) manifest directories.
107fn first_existing(candidates: &[PathBuf]) -> Option<PathBuf> {
108    candidates.iter().find(|p| p.exists()).cloned()
109}
110
111fn marketplace_json_for(root: &Path) -> Option<PathBuf> {
112    first_existing(&[
113        root.join(".synaps-plugin").join("marketplace.json"),
114        root.join(".claude-plugin").join("marketplace.json"),
115    ])
116}
117
118fn plugin_json_for(plugin_root: &Path) -> Option<PathBuf> {
119    first_existing(&[
120        plugin_root.join(".synaps-plugin").join("plugin.json"),
121        plugin_root.join(".claude-plugin").join("plugin.json"),
122    ])
123}
124
125fn walk_root(
126    root: &Path,
127    plugins: &mut Vec<Plugin>,
128    skills: &mut Vec<LoadedSkill>,
129    seen: &mut std::collections::HashSet<(Option<String>, String)>,
130) {
131    if !root.exists() { return; }
132
133    // 1. Marketplace pass
134    let marketplace_name = if let Some(marketplace_json) = marketplace_json_for(root) {
135        match std::fs::read_to_string(&marketplace_json)
136            .ok()
137            .and_then(|c| serde_json::from_str::<MarketplaceManifest>(&c).ok())
138        {
139            Some(m) => {
140                for entry in &m.plugins {
141                    let Some(source) = entry.source.as_ref() else { continue; };
142                    let plugin_root = root.join(source);
143                    load_plugin(&plugin_root, Some(&m.name), plugins, skills, seen);
144                }
145                Some(m.name)
146            }
147            None => {
148                tracing::warn!("failed to parse {}", marketplace_json.display());
149                None
150            }
151        }
152    } else {
153        None
154    };
155
156    // 2. Plugin pass (subdirs with .synaps-plugin/plugin.json or .claude-plugin/plugin.json)
157    //    Additionally, if a subdir contains a marketplace.json, treat it as a
158    //    nested discovery root and recurse once. This supports the common
159    //    "clone marketplace repo into plugins/" install pattern.
160    if let Ok(entries) = std::fs::read_dir(root) {
161        for entry in entries.flatten() {
162            let path = entry.path();
163            if !path.is_dir() { continue; }
164            if marketplace_json_for(&path).is_some() {
165                walk_root(&path, plugins, skills, seen);
166            } else if plugin_json_for(&path).is_some() {
167                load_plugin(&path, marketplace_name.as_deref(), plugins, skills, seen);
168            }
169        }
170    }
171
172    // 3. Loose-skill pass — scan both root/ and root/skills/ for <name>/SKILL.md
173    for loose_dir in [root.to_path_buf(), root.join("skills")] {
174        if !loose_dir.is_dir() { continue; }
175        if let Ok(entries) = std::fs::read_dir(&loose_dir) {
176            for entry in entries.flatten() {
177                let path = entry.path();
178                if !path.is_dir() { continue; }
179                let skill_md = path.join("SKILL.md");
180                if skill_md.exists() {
181                    if let Some(s) = load_skill_file(&skill_md, None, None) {
182                        let key = (None, s.name.clone());
183                        if seen.insert(key) { skills.push(s); }
184                    }
185                }
186            }
187        }
188    }
189}
190
191fn load_plugin(
192    plugin_root: &Path,
193    marketplace: Option<&str>,
194    plugins: &mut Vec<Plugin>,
195    skills: &mut Vec<LoadedSkill>,
196    seen: &mut std::collections::HashSet<(Option<String>, String)>,
197) {
198    let Some(manifest_path) = plugin_json_for(plugin_root) else {
199        tracing::warn!("no plugin.json under {}", plugin_root.display());
200        return;
201    };
202    let Ok(content) = std::fs::read_to_string(&manifest_path) else {
203        tracing::warn!("failed to read {}", manifest_path.display());
204        return;
205    };
206    let Ok(m): Result<PluginManifest, _> = serde_json::from_str(&content) else {
207        tracing::warn!("failed to parse {}", manifest_path.display());
208        return;
209    };
210
211    let Ok(root_abs) = plugin_root.canonicalize() else { return; };
212    if plugins.iter().any(|p| p.root == root_abs) {
213        return;
214    }
215        plugins.push(Plugin {
216        name: m.name.clone(),
217        root: root_abs,
218        marketplace: marketplace.map(str::to_string),
219        version: m.version.clone(),
220        description: m.description.clone(),
221        extension: m.extension.clone(),
222        manifest: Some(m.clone()),
223    });
224
225    let skills_dir = plugin_root.join("skills");
226    if !skills_dir.is_dir() { return; }
227    let Ok(entries) = std::fs::read_dir(&skills_dir) else { return; };
228    for entry in entries.flatten() {
229        let path = entry.path();
230        if !path.is_dir() { continue; }
231        let skill_md = path.join("SKILL.md");
232        if !skill_md.exists() { continue; }
233        if let Some(s) = load_skill_file(&skill_md, Some(&m.name), Some(plugin_root)) {
234            let key = (Some(m.name.clone()), s.name.clone());
235            if seen.insert(key) { skills.push(s); }
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::fs;
244
245    #[test]
246    fn frontmatter_valid() {
247        let t = "---\nname: x\ndescription: y\n---\nBody text";
248        let (fields, body) = parse_frontmatter(t);
249        assert_eq!(fields.len(), 2);
250        assert_eq!(body, "Body text");
251    }
252
253    #[test]
254    fn frontmatter_absent() {
255        let t = "Just body";
256        let (fields, body) = parse_frontmatter(t);
257        assert!(fields.is_empty());
258        assert_eq!(body, "Just body");
259    }
260
261    fn write_skill(dir: &Path, content: &str) -> PathBuf {
262        fs::create_dir_all(dir).unwrap();
263        let path = dir.join("SKILL.md");
264        fs::write(&path, content).unwrap();
265        path
266    }
267
268    #[test]
269    fn load_skill_basic() {
270        let tmp = tempdir();
271        let skill_dir = tmp.join("my-skill");
272        let path = write_skill(&skill_dir, "---\nname: my-skill\ndescription: desc\n---\nBody");
273        let s = load_skill_file(&path, Some("plugin-x"), None).unwrap();
274        assert_eq!(s.name, "my-skill");
275        assert_eq!(s.description, "desc");
276        assert_eq!(s.body, "Body");
277        assert_eq!(s.plugin.as_deref(), Some("plugin-x"));
278        assert!(s.base_dir.is_absolute());
279    }
280
281    #[test]
282    fn load_skill_basedir_substitution() {
283        let tmp = tempdir();
284        let skill_dir = tmp.join("skill");
285        let path = write_skill(&skill_dir, "---\nname: s\ndescription: d\n---\nRun {baseDir}/x.js");
286        let s = load_skill_file(&path, None, None).unwrap();
287        let expected = format!("Run {}/x.js", s.base_dir.to_str().unwrap());
288        assert_eq!(s.body, expected);
289    }
290
291    #[test]
292    fn load_skill_missing_frontmatter_returns_none() {
293        let tmp = tempdir();
294        let skill_dir = tmp.join("bad");
295        let path = write_skill(&skill_dir, "no frontmatter here");
296        assert!(load_skill_file(&path, None, None).is_none());
297    }
298
299    #[test]
300    fn load_skill_missing_description_returns_none() {
301        let tmp = tempdir();
302        let skill_dir = tmp.join("bad2");
303        let path = write_skill(&skill_dir, "---\nname: x\n---\nbody");
304        assert!(load_skill_file(&path, None, None).is_none());
305    }
306
307    #[test]
308    fn load_skill_missing_name_returns_none() {
309        let tmp = tempdir();
310        let skill_dir = tmp.join("bad3");
311        let path = write_skill(&skill_dir, "---\ndescription: d\n---\nbody");
312        assert!(load_skill_file(&path, None, None).is_none());
313    }
314
315    #[test]
316    fn load_skill_empty_body_returns_none() {
317        let tmp = tempdir();
318        let skill_dir = tmp.join("empty-body");
319        let path = write_skill(&skill_dir, "---\nname: x\ndescription: d\n---\n");
320        assert!(load_skill_file(&path, None, None).is_none());
321    }
322
323    #[test]
324    fn load_skill_unclosed_frontmatter_returns_none() {
325        let tmp = tempdir();
326        let skill_dir = tmp.join("unclosed");
327        // No closing `---`; parse_frontmatter returns ([], full_text) so name/description missing → None.
328        let path = write_skill(&skill_dir, "---\nname: x\ndescription: d\nbody without closing fence");
329        assert!(load_skill_file(&path, None, None).is_none());
330    }
331
332    #[test]
333    fn load_skill_basedir_multiple_occurrences() {
334        let tmp = tempdir();
335        let skill_dir = tmp.join("multi");
336        let path = write_skill(
337            &skill_dir,
338            "---\nname: m\ndescription: d\n---\n{baseDir}/a and {baseDir}/b",
339        );
340        let s = load_skill_file(&path, None, None).unwrap();
341        let bd = s.base_dir.to_str().unwrap();
342        assert_eq!(s.body, format!("{}/a and {}/b", bd, bd));
343    }
344
345    #[test]
346    fn load_skill_substitutes_claude_plugin_root_braced_and_plain() {
347        // Regression: Claude-Code-style skills reference ${CLAUDE_PLUGIN_ROOT}
348        // (and the bare $CLAUDE_PLUGIN_ROOT form) which must be substituted
349        // to the plugin's canonical root before the body is handed to the model.
350        let tmp = tempdir();
351        let plugin_root = tmp.join("my-plugin");
352        fs::create_dir_all(&plugin_root).unwrap();
353        let skill_dir = plugin_root.join("skills").join("exa");
354        let path = write_skill(
355            &skill_dir,
356            "---\nname: exa\ndescription: d\n---\nbash ${CLAUDE_PLUGIN_ROOT}/scripts/a.js then $CLAUDE_PLUGIN_ROOT/b.js",
357        );
358        let s = load_skill_file(&path, Some("my-plugin"), Some(&plugin_root)).unwrap();
359        let root_abs = plugin_root.canonicalize().unwrap();
360        let r = root_abs.to_str().unwrap();
361        assert_eq!(s.body, format!("bash {}/scripts/a.js then {}/b.js", r, r));
362    }
363
364    #[test]
365    fn load_skill_leaves_claude_plugin_root_alone_when_not_in_plugin() {
366        // Loose skills (plugin_root = None) should not receive the substitution.
367        let tmp = tempdir();
368        let skill_dir = tmp.join("loose");
369        let path = write_skill(
370            &skill_dir,
371            "---\nname: loose\ndescription: d\n---\n${CLAUDE_PLUGIN_ROOT}/x",
372        );
373        let s = load_skill_file(&path, None, None).unwrap();
374        assert_eq!(s.body, "${CLAUDE_PLUGIN_ROOT}/x");
375    }
376
377    #[test]
378    fn load_all_loose_skill() {
379        let tmp = tempdir();
380        let skill_dir = tmp.join("skills").join("loose");
381        write_skill(&skill_dir, "---\nname: loose\ndescription: d\n---\nBody");
382
383        let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
384        assert!(plugins.is_empty());
385        assert_eq!(skills.len(), 1);
386        assert_eq!(skills[0].name, "loose");
387        assert_eq!(skills[0].plugin, None);
388    }
389
390    #[test]
391    fn load_all_plugin_skill() {
392        let tmp = tempdir();
393        let plugin_dir = tmp.join("my-plugin");
394        fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
395        fs::write(
396            plugin_dir.join(".synaps-plugin").join("plugin.json"),
397            r#"{"name":"my-plugin"}"#,
398        ).unwrap();
399        write_skill(&plugin_dir.join("skills").join("s1"),
400            "---\nname: s1\ndescription: d\n---\nBody");
401
402        let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
403        assert_eq!(plugins.len(), 1);
404        assert_eq!(plugins[0].name, "my-plugin");
405        assert!(plugins[0].manifest.as_ref().unwrap().commands.is_empty());
406        assert_eq!(skills.len(), 1);
407        assert_eq!(skills[0].plugin.as_deref(), Some("my-plugin"));
408    }
409
410    #[test]
411    fn load_all_plugin_commands_are_carried_in_manifest() {
412        let tmp = tempdir();
413        let plugin_dir = tmp.join("cmd-plugin");
414        fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
415        fs::write(
416            plugin_dir.join(".synaps-plugin").join("plugin.json"),
417            r#"{
418                "name": "cmd-plugin",
419                "commands": [
420                    {"name":"hello","description":"Say hello","command":"printf","args":["hello"]}
421                ]
422            }"#,
423        ).unwrap();
424
425        let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
426
427        assert_eq!(plugins.len(), 1);
428        assert!(skills.is_empty());
429        let commands = &plugins[0].manifest.as_ref().unwrap().commands;
430        assert_eq!(commands.len(), 1);
431        match &commands[0] {
432            crate::skills::manifest::ManifestCommand::Shell(cmd) => {
433                assert_eq!(cmd.name, "hello");
434                assert_eq!(cmd.command, "printf");
435            }
436            other => panic!("expected shell command, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn load_all_marketplace() {
442        let tmp = tempdir();
443        // marketplace.json at root
444        fs::create_dir_all(tmp.join(".synaps-plugin")).unwrap();
445        fs::write(tmp.join(".synaps-plugin").join("marketplace.json"),
446            r#"{"name":"pi-skills","plugins":[{"name":"web","source":"./web"}]}"#).unwrap();
447        // plugin at ./web
448        let plugin_dir = tmp.join("web");
449        fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
450        fs::write(plugin_dir.join(".synaps-plugin").join("plugin.json"),
451            r#"{"name":"web"}"#).unwrap();
452        write_skill(&plugin_dir.join("skills").join("search"),
453            "---\nname: search\ndescription: d\n---\nBody");
454
455        let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
456        assert_eq!(plugins.len(), 1);
457        assert_eq!(plugins[0].marketplace.as_deref(), Some("pi-skills"));
458        assert_eq!(skills.len(), 1);
459    }
460
461    #[test]
462    fn load_all_dedup_priority() {
463        let tmp_local = tempdir();
464        let tmp_global = tempdir();
465        // same skill name in both
466        write_skill(&tmp_local.join("skills").join("dup"),
467            "---\nname: dup\ndescription: local\n---\nBody");
468        write_skill(&tmp_global.join("skills").join("dup"),
469            "---\nname: dup\ndescription: global\n---\nBody");
470
471        let (_p, skills) = load_all(&[tmp_local, tmp_global]);
472        assert_eq!(skills.len(), 1);
473        assert_eq!(skills[0].description, "local"); // local wins
474    }
475
476    #[test]
477    fn test_load_all_plugin_dedup_via_marketplace_and_subdir() {
478        // Regression: when a plugin is discovered both through marketplace.json
479        // and through the plugin-subdir walk, load_plugin's root-based dedup guard
480        // must prevent a duplicate Plugin entry and duplicate skill registration.
481        let root = tempdir();
482
483        // marketplace.json at root pointing to ./web
484        fs::create_dir_all(root.join(".synaps-plugin")).unwrap();
485        fs::write(
486            root.join(".synaps-plugin").join("marketplace.json"),
487            r#"{"name":"mp","plugins":[{"name":"web","source":"./web"}]}"#,
488        )
489        .unwrap();
490
491        // Plugin at ./web — also discoverable via the plugin-subdir pass
492        let plugin_dir = root.join("web");
493        fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
494        fs::write(
495            plugin_dir.join(".synaps-plugin").join("plugin.json"),
496            r#"{"name":"web"}"#,
497        )
498        .unwrap();
499        write_skill(
500            &plugin_dir.join("skills").join("demo"),
501            "---\nname: demo\ndescription: d\n---\nBody",
502        );
503
504        let (plugins, skills) = load_all(std::slice::from_ref(&root));
505
506        // Exactly one plugin registered, not two.
507        assert_eq!(plugins.len(), 1, "plugin should be deduplicated");
508        assert_eq!(plugins[0].name, "web");
509        assert_eq!(plugins[0].root, plugin_dir.canonicalize().unwrap());
510
511        // Skill registered exactly once.
512        assert_eq!(skills.len(), 1, "skill should be registered exactly once");
513        assert_eq!(skills[0].name, "demo");
514        assert_eq!(skills[0].plugin.as_deref(), Some("web"));
515
516        let _ = fs::remove_dir_all(&root);
517    }
518
519    #[test]
520    fn load_all_accepts_claude_plugin_marketplace_layout() {
521        // Claude-Code-style: marketplace.json under .claude-plugin/, plugin.json
522        // also under .claude-plugin/.
523        let tmp = tempdir();
524        fs::create_dir_all(tmp.join(".claude-plugin")).unwrap();
525        fs::write(tmp.join(".claude-plugin").join("marketplace.json"),
526            r#"{"name":"cc-mp","plugins":[{"name":"web","source":"./web"}]}"#).unwrap();
527        let plugin_dir = tmp.join("web");
528        fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
529        fs::write(plugin_dir.join(".claude-plugin").join("plugin.json"),
530            r#"{"name":"web"}"#).unwrap();
531        write_skill(&plugin_dir.join("skills").join("search"),
532            "---\nname: search\ndescription: d\n---\nBody");
533
534        let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
535        assert_eq!(plugins.len(), 1);
536        assert_eq!(plugins[0].marketplace.as_deref(), Some("cc-mp"));
537        assert_eq!(plugins[0].name, "web");
538        assert_eq!(skills.len(), 1);
539        assert_eq!(skills[0].name, "search");
540    }
541
542    #[test]
543    fn load_all_prefers_synaps_plugin_over_claude_plugin() {
544        // When both layouts are present, .synaps-plugin/ wins.
545        let tmp = tempdir();
546        let plugin_dir = tmp.join("dual");
547        fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
548        fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
549        fs::write(plugin_dir.join(".synaps-plugin").join("plugin.json"),
550            r#"{"name":"native"}"#).unwrap();
551        fs::write(plugin_dir.join(".claude-plugin").join("plugin.json"),
552            r#"{"name":"claude"}"#).unwrap();
553        write_skill(&plugin_dir.join("skills").join("s"),
554            "---\nname: s\ndescription: d\n---\nBody");
555
556        let (plugins, _skills) = load_all(std::slice::from_ref(&tmp));
557        assert_eq!(plugins.len(), 1);
558        assert_eq!(plugins[0].name, "native", "synaps-plugin layout must win");
559    }
560
561    #[test]
562    fn test_load_all_malformed_plugin_json_continues_walk() {
563        // Regression: a malformed plugin.json should be skipped with a warning,
564        // and the walk must continue so other valid plugins still register.
565        let root = tempdir();
566
567        // Broken plugin: invalid JSON in plugin.json
568        let broken_dir = root.join("broken");
569        fs::create_dir_all(broken_dir.join(".synaps-plugin")).unwrap();
570        fs::write(
571            broken_dir.join(".synaps-plugin").join("plugin.json"),
572            "{ this is not valid json",
573        )
574        .unwrap();
575
576        // Good plugin alongside it
577        let good_dir = root.join("good");
578        fs::create_dir_all(good_dir.join(".synaps-plugin")).unwrap();
579        fs::write(
580            good_dir.join(".synaps-plugin").join("plugin.json"),
581            r#"{"name":"good"}"#,
582        )
583        .unwrap();
584        write_skill(
585            &good_dir.join("skills").join("hello"),
586            "---\nname: hello\ndescription: d\n---\nBody",
587        );
588
589        let (plugins, skills) = load_all(std::slice::from_ref(&root));
590
591        // Only the good plugin registered.
592        assert_eq!(plugins.len(), 1);
593        assert_eq!(plugins[0].name, "good");
594
595        // Its skill is present.
596        assert_eq!(skills.len(), 1);
597        assert_eq!(skills[0].name, "hello");
598        assert_eq!(skills[0].plugin.as_deref(), Some("good"));
599
600        let _ = fs::remove_dir_all(&root);
601    }
602
603    /// Create a unique tempdir under /tmp for tests.
604    fn tempdir() -> PathBuf {
605        use std::sync::atomic::{AtomicU64, Ordering};
606        static COUNTER: AtomicU64 = AtomicU64::new(0);
607        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
608        let base = std::env::temp_dir().join(format!(
609            "synaps-skills-test-{}", std::process::id()
610        ));
611        let unique = base.join(format!("{}-{}", crate::epoch_millis(), n));
612        std::fs::create_dir_all(&unique).unwrap();
613        unique
614    }
615}