1use std::path::{Path, PathBuf};
4use crate::skills::LoadedSkill;
5
6pub(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
35pub 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 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
77pub 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
90pub 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
104fn 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 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 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 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 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 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 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 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 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 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"); }
475
476 #[test]
477 fn test_load_all_plugin_dedup_via_marketplace_and_subdir() {
478 let root = tempdir();
482
483 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 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 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 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 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 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 let root = tempdir();
566
567 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 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 assert_eq!(plugins.len(), 1);
593 assert_eq!(plugins[0].name, "good");
594
595 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 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}