1use crate::skills::{LoadedSkill, SkillSource};
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12pub const SKILL_FRONTMATTER_MAX_BYTES: usize = 8 * 1024;
15pub const SKILL_FILE_MAX_BYTES: usize = 1024 * 1024;
18pub const SKILL_NAME_MAX_BYTES: usize = 128;
20pub const SKILL_DESCRIPTION_MAX_BYTES: usize = 1024;
25pub const DISCOVERY_MAX_SKILLS: usize = 2048;
27pub const DISCOVERY_MAX_METADATA_BYTES: usize = 4 * 1024 * 1024;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub(crate) struct SkillFingerprint {
38 pub(crate) dev: u64,
39 pub(crate) ino: u64,
40 pub(crate) len: u64,
41 pub(crate) mtime: (i64, i64),
42 pub(crate) ctime: (i64, i64),
43 pub(crate) frontmatter_sha256: [u8; 32],
46 pub(crate) body_start: u64,
48}
49
50fn fingerprint_of(
51 meta: &std::fs::Metadata,
52 frontmatter: &[u8],
53 body_start: u64,
54) -> SkillFingerprint {
55 use sha2::{Digest as _, Sha256};
56 #[cfg(unix)]
57 let (dev, ino, mtime, ctime) = {
58 use std::os::unix::fs::MetadataExt as _;
59 (
60 meta.dev(),
61 meta.ino(),
62 (meta.mtime(), meta.mtime_nsec()),
63 (meta.ctime(), meta.ctime_nsec()),
64 )
65 };
66 #[cfg(not(unix))]
67 let (dev, ino, mtime, ctime) = (0u64, 0u64, (0i64, 0i64), (0i64, 0i64));
68 let digest = Sha256::digest(frontmatter);
69 let mut frontmatter_sha256 = [0u8; 32];
70 frontmatter_sha256.copy_from_slice(&digest);
71 SkillFingerprint {
72 dev,
73 ino,
74 len: meta.len(),
75 mtime,
76 ctime,
77 frontmatter_sha256,
78 body_start,
79 }
80}
81
82fn open_skill_file(path: &Path) -> std::io::Result<std::fs::File> {
86 let mut options = std::fs::OpenOptions::new();
87 options.read(true);
88 #[cfg(unix)]
89 {
90 use std::os::unix::fs::OpenOptionsExt as _;
91 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
92 }
93 let file = options.open(path)?;
94 let meta = file.metadata()?;
95 if !meta.is_file() {
96 return Err(std::io::Error::new(
97 std::io::ErrorKind::InvalidInput,
98 "not a regular file",
99 ));
100 }
101 if meta.len() > SKILL_FILE_MAX_BYTES as u64 {
102 return Err(std::io::Error::new(
103 std::io::ErrorKind::InvalidData,
104 "file exceeds the skill size cap",
105 ));
106 }
107 Ok(file)
108}
109
110fn scan_frontmatter_bytes(file: &mut std::fs::File) -> std::io::Result<Vec<u8>> {
116 let mut buf: Vec<u8> = Vec::with_capacity(256);
117 let mut byte = [0u8; 1];
118 let mut line_start = 0usize;
119 let mut delimiters = 0usize;
120 loop {
121 if buf.len() >= SKILL_FRONTMATTER_MAX_BYTES {
122 return Err(std::io::Error::new(
123 std::io::ErrorKind::InvalidData,
124 "frontmatter exceeds the scan bound",
125 ));
126 }
127 let n = file.read(&mut byte)?;
128 if n == 0 {
129 return Err(std::io::Error::new(
130 std::io::ErrorKind::UnexpectedEof,
131 "frontmatter not closed",
132 ));
133 }
134 buf.push(byte[0]);
135 if byte[0] == b'\n' {
136 let line = &buf[line_start..buf.len() - 1];
137 let line = if line.last() == Some(&b'\r') {
138 &line[..line.len() - 1]
139 } else {
140 line
141 };
142 if line == b"---" {
143 delimiters += 1;
144 if delimiters == 1 && line_start != 0 {
145 return Err(std::io::Error::new(
147 std::io::ErrorKind::InvalidData,
148 "missing opening frontmatter delimiter",
149 ));
150 }
151 if delimiters == 2 {
152 return Ok(buf);
153 }
154 } else if delimiters == 0 {
155 return Err(std::io::Error::new(
157 std::io::ErrorKind::InvalidData,
158 "missing opening frontmatter delimiter",
159 ));
160 }
161 line_start = buf.len();
162 }
163 }
164}
165
166fn parse_frontmatter_fields(frontmatter: &[u8]) -> Option<Vec<(String, String)>> {
169 let text = std::str::from_utf8(frontmatter).ok()?;
170 let mut lines: Vec<&str> = text.lines().collect();
171 if lines.first().map(|l| l.trim()) != Some("---") {
173 return None;
174 }
175 lines.remove(0);
176 while let Some(last) = lines.last() {
177 if last.trim() == "---" {
178 lines.pop();
179 break;
180 }
181 lines.pop();
182 }
183 Some(
184 lines
185 .into_iter()
186 .filter_map(|line| {
187 let line = line.trim();
188 if line.is_empty() {
189 return None;
190 }
191 let (k, v) = line.split_once(':')?;
192 Some((k.trim().to_string(), v.trim().trim_matches('"').to_string()))
193 })
194 .collect(),
195 )
196}
197
198pub fn load_skill_file(
208 skill_md: &Path,
209 plugin: Option<&str>,
210 plugin_root: Option<&Path>,
211) -> Option<LoadedSkill> {
212 let mut file = open_skill_file(skill_md).ok()?;
213 let frontmatter = scan_frontmatter_bytes(&mut file).ok()?;
214 let body_start = frontmatter.len() as u64;
215 let meta = file.metadata().ok()?;
216 if meta.len() <= body_start {
217 return None; }
219 let fields = parse_frontmatter_fields(&frontmatter)?;
220 let name = fields
221 .iter()
222 .find(|(k, _)| k == "name")
223 .map(|(_, v)| v.clone())?;
224 let description = fields
225 .iter()
226 .find(|(k, _)| k == "description")
227 .map(|(_, v)| v.clone())?;
228 if name.is_empty()
233 || name.len() > SKILL_NAME_MAX_BYTES
234 || name.chars().any(char::is_control)
235 || description.is_empty()
236 || description.chars().any(char::is_control)
237 {
238 return None;
239 }
240 let description = agent_core::BoundedText::new(&description, SKILL_DESCRIPTION_MAX_BYTES).text;
241 let base_dir = skill_md.parent()?.canonicalize().ok()?;
242 let plugin_root = match plugin_root {
243 Some(root) => Some(root.canonicalize().ok()?),
244 None => None,
245 };
246 Some(LoadedSkill {
247 name,
248 description,
249 source: SkillSource::Lazy {
250 fingerprint: fingerprint_of(&meta, &frontmatter, body_start),
251 plugin_root,
252 },
253 plugin: plugin.map(str::to_string),
254 base_dir,
255 source_path: skill_md.canonicalize().ok()?,
256 })
257}
258
259const ERR_SOURCE_CHANGED: &str =
262 "skill source changed since discovery (fingerprint verification failed); reload skills and retry";
263const ERR_SOURCE_UNREADABLE: &str =
264 "skill source is no longer readable as a bounded regular file; reload skills and retry";
265const ERR_BODY_NOT_UTF8: &str = "skill body is not valid UTF-8";
266
267pub(crate) struct VerifiedSkillBody {
271 pub(crate) body: String,
272 #[cfg_attr(not(test), allow(dead_code))]
275 pub(crate) snapshot_sha256: [u8; 32],
276}
277
278impl LoadedSkill {
279 pub fn load_body(&self) -> Result<String, &'static str> {
289 self.load_body_with_evidence().map(|verified| verified.body)
290 }
291
292 pub(crate) fn load_body_with_evidence(&self) -> Result<VerifiedSkillBody, &'static str> {
297 let (fingerprint, plugin_root) = match &self.source {
298 SkillSource::Inline(body) => {
299 use sha2::{Digest as _, Sha256};
300 return Ok(VerifiedSkillBody {
301 snapshot_sha256: Sha256::digest(body.as_bytes()).into(),
302 body: body.clone(),
303 });
304 }
305 SkillSource::Lazy {
306 fingerprint,
307 plugin_root,
308 } => (fingerprint, plugin_root.as_deref()),
309 };
310 let mut file = open_skill_file(&self.source_path).map_err(|_| ERR_SOURCE_UNREADABLE)?;
311 let meta = file.metadata().map_err(|_| ERR_SOURCE_UNREADABLE)?;
312 let frontmatter = scan_frontmatter_bytes(&mut file).map_err(|_| ERR_SOURCE_UNREADABLE)?;
313 if fingerprint_of(&meta, &frontmatter, frontmatter.len() as u64) != *fingerprint {
314 return Err(ERR_SOURCE_CHANGED);
315 }
316 let remaining = (fingerprint.len - fingerprint.body_start) as usize;
319 let mut body_bytes = vec![0u8; remaining];
320 std::io::Read::read_exact(&mut file, &mut body_bytes).map_err(|_| ERR_SOURCE_CHANGED)?;
321 let mut probe = [0u8; 1];
323 match std::io::Read::read(&mut file, &mut probe) {
324 Ok(0) => {}
325 _ => return Err(ERR_SOURCE_CHANGED),
326 }
327 let post = file.metadata().map_err(|_| ERR_SOURCE_UNREADABLE)?;
329 if fingerprint_of(&post, &frontmatter, fingerprint.body_start) != *fingerprint {
330 return Err(ERR_SOURCE_CHANGED);
331 }
332 let snapshot_sha256: [u8; 32] = {
337 use sha2::{Digest as _, Sha256};
338 let mut hasher = Sha256::new();
339 hasher.update(&frontmatter);
340 hasher.update(&body_bytes);
341 hasher.finalize().into()
342 };
343 let body = String::from_utf8(body_bytes).map_err(|_| ERR_BODY_NOT_UTF8)?;
344 let body = body.trim().to_string();
345 if body.is_empty() {
346 return Err(ERR_SOURCE_CHANGED);
347 }
348 let base = self.base_dir.to_str().ok_or(ERR_SOURCE_UNREADABLE)?;
350 let mut body = body.replace("{baseDir}", base);
351 if let Some(root) = plugin_root {
352 let root = root.to_str().ok_or(ERR_SOURCE_UNREADABLE)?;
353 body = body.replace("${CLAUDE_PLUGIN_ROOT}", root);
354 body = body.replace("$CLAUDE_PLUGIN_ROOT", root);
355 }
356 Ok(VerifiedSkillBody {
357 body,
358 snapshot_sha256,
359 })
360 }
361}
362
363use crate::skills::{
364 manifest::{MarketplaceManifest, PluginManifest},
365 Plugin,
366};
367
368pub fn default_roots() -> Vec<PathBuf> {
370 let mut roots = vec![
371 PathBuf::from(".synaps-cli/plugins"),
372 PathBuf::from(".synaps-cli/skills"),
373 ];
374 let home_plugins = crate::config::resolve_read_path_extended("plugins");
375 let home_skills = crate::config::resolve_read_path_extended("skills");
376 roots.push(home_plugins);
377 roots.push(home_skills);
378 roots
379}
380
381pub fn load_all(roots: &[PathBuf]) -> (Vec<Plugin>, Vec<LoadedSkill>) {
390 let mut plugins: Vec<Plugin> = Vec::new();
391 let mut skills: Vec<LoadedSkill> = Vec::new();
392 let mut seen: std::collections::HashSet<(Option<String>, String)> =
393 std::collections::HashSet::new();
394 let mut budget = DiscoveryBudget::default();
395
396 for root in roots {
397 walk_root(root, &mut plugins, &mut skills, &mut seen, &mut budget);
398 }
399 if budget.skipped > 0 {
400 tracing::warn!(
401 skipped = budget.skipped,
402 "skill discovery caps reached; later candidates skipped (count only)"
403 );
404 }
405 (plugins, skills)
406}
407
408#[derive(Default)]
411struct DiscoveryBudget {
412 metadata_bytes: usize,
413 skipped: usize,
414}
415
416impl DiscoveryBudget {
417 fn admit(&mut self, current: usize, skill: &LoadedSkill) -> bool {
420 let cost = skill.name.len()
421 + skill.description.len()
422 + skill.plugin.as_ref().map(String::len).unwrap_or(0)
423 + skill.base_dir.as_os_str().len()
424 + skill.source_path.as_os_str().len();
425 if current >= DISCOVERY_MAX_SKILLS
426 || self.metadata_bytes.saturating_add(cost) > DISCOVERY_MAX_METADATA_BYTES
427 {
428 self.skipped += 1;
429 return false;
430 }
431 self.metadata_bytes += cost;
432 true
433 }
434}
435
436fn first_existing(candidates: &[PathBuf]) -> Option<PathBuf> {
440 candidates.iter().find(|p| p.exists()).cloned()
441}
442
443fn marketplace_json_for(root: &Path) -> Option<PathBuf> {
444 first_existing(&[
445 root.join(".synaps-plugin").join("marketplace.json"),
446 root.join(".claude-plugin").join("marketplace.json"),
447 ])
448}
449
450fn plugin_json_for(plugin_root: &Path) -> Option<PathBuf> {
451 first_existing(&[
452 plugin_root.join(".synaps-plugin").join("plugin.json"),
453 plugin_root.join(".claude-plugin").join("plugin.json"),
454 ])
455}
456
457fn sorted_dir_entries(dir: &Path) -> Vec<PathBuf> {
461 let Ok(entries) = std::fs::read_dir(dir) else {
462 return Vec::new();
463 };
464 let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
465 paths.sort();
466 paths
467}
468
469fn walk_root(
470 root: &Path,
471 plugins: &mut Vec<Plugin>,
472 skills: &mut Vec<LoadedSkill>,
473 seen: &mut std::collections::HashSet<(Option<String>, String)>,
474 budget: &mut DiscoveryBudget,
475) {
476 if !root.exists() {
477 return;
478 }
479
480 let marketplace_name = if let Some(marketplace_json) = marketplace_json_for(root) {
482 match std::fs::read_to_string(&marketplace_json)
483 .ok()
484 .and_then(|c| serde_json::from_str::<MarketplaceManifest>(&c).ok())
485 {
486 Some(m) => {
487 for entry in &m.plugins {
488 let Some(source) = entry.source.as_ref() else {
489 continue;
490 };
491 let plugin_root = root.join(source);
492 load_plugin(&plugin_root, Some(&m.name), plugins, skills, seen, budget);
493 }
494 Some(m.name)
495 }
496 None => {
497 tracing::warn!("failed to parse {}", marketplace_json.display());
498 None
499 }
500 }
501 } else {
502 None
503 };
504
505 for path in sorted_dir_entries(root) {
510 if !path.is_dir() {
511 continue;
512 }
513 if marketplace_json_for(&path).is_some() {
514 walk_root(&path, plugins, skills, seen, budget);
515 } else if plugin_json_for(&path).is_some() {
516 load_plugin(
517 &path,
518 marketplace_name.as_deref(),
519 plugins,
520 skills,
521 seen,
522 budget,
523 );
524 }
525 }
526
527 for loose_dir in [root.to_path_buf(), root.join("skills")] {
529 if !loose_dir.is_dir() {
530 continue;
531 }
532 for path in sorted_dir_entries(&loose_dir) {
533 if !path.is_dir() {
534 continue;
535 }
536 let skill_md = path.join("SKILL.md");
537 if skill_md.exists() {
538 if let Some(s) = load_skill_file(&skill_md, None, None) {
539 let key = (None, s.name.clone());
540 if seen.contains(&key) || !budget.admit(skills.len(), &s) {
541 continue;
542 }
543 seen.insert(key);
544 skills.push(s);
545 }
546 }
547 }
548 }
549}
550
551fn load_plugin(
552 plugin_root: &Path,
553 marketplace: Option<&str>,
554 plugins: &mut Vec<Plugin>,
555 skills: &mut Vec<LoadedSkill>,
556 seen: &mut std::collections::HashSet<(Option<String>, String)>,
557 budget: &mut DiscoveryBudget,
558) {
559 let Some(manifest_path) = plugin_json_for(plugin_root) else {
560 tracing::warn!("no plugin.json under {}", plugin_root.display());
561 return;
562 };
563 let Ok(content) = std::fs::read_to_string(&manifest_path) else {
564 tracing::warn!("failed to read {}", manifest_path.display());
565 return;
566 };
567 let Ok(m): Result<PluginManifest, _> = serde_json::from_str(&content) else {
568 tracing::warn!("failed to parse {}", manifest_path.display());
569 return;
570 };
571
572 let Ok(root_abs) = plugin_root.canonicalize() else {
573 return;
574 };
575 if plugins.iter().any(|p| p.root == root_abs) {
576 return;
577 }
578 plugins.push(Plugin {
579 name: m.name.clone(),
580 root: root_abs,
581 marketplace: marketplace.map(str::to_string),
582 version: m.version.clone(),
583 description: m.description.clone(),
584 extension: m.extension.clone(),
585 manifest: Some(m.clone()),
586 });
587
588 let skills_dir = plugin_root.join("skills");
589 if !skills_dir.is_dir() {
590 return;
591 }
592 for path in sorted_dir_entries(&skills_dir) {
593 if !path.is_dir() {
594 continue;
595 }
596 let skill_md = path.join("SKILL.md");
597 if !skill_md.exists() {
598 continue;
599 }
600 if let Some(s) = load_skill_file(&skill_md, Some(&m.name), Some(plugin_root)) {
601 let key = (Some(m.name.clone()), s.name.clone());
602 if seen.contains(&key) || !budget.admit(skills.len(), &s) {
603 continue;
604 }
605 seen.insert(key);
606 skills.push(s);
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use std::fs;
615
616 fn write_skill(dir: &Path, content: &str) -> PathBuf {
617 fs::create_dir_all(dir).unwrap();
618 let path = dir.join("SKILL.md");
619 fs::write(&path, content).unwrap();
620 path
621 }
622
623 #[test]
627 fn frontmatter_scan_stops_exactly_at_body_offset() {
628 let tmp = tempdir();
629 let path = write_skill(
630 &tmp.join("scan"),
631 "---\nname: s\ndescription: d\n---\nBODY_AFTER",
632 );
633 let mut file = open_skill_file(&path).unwrap();
634 let frontmatter = scan_frontmatter_bytes(&mut file).unwrap();
635 let pos = std::io::Seek::stream_position(&mut file).unwrap();
636 assert_eq!(frontmatter.len() as u64, pos, "no body byte consumed");
637 let full = fs::read(&path).unwrap();
638 assert_eq!(&full[..frontmatter.len()], &frontmatter[..]);
639 let body_pos = full.windows(10).position(|w| w == b"BODY_AFTER").unwrap();
640 assert_eq!(frontmatter.len(), body_pos, "scan ends where body begins");
641 }
642
643 #[test]
645 fn frontmatter_scan_accepts_crlf() {
646 let tmp = tempdir();
647 let path = write_skill(
648 &tmp.join("crlf"),
649 "---\r\nname: s\r\ndescription: d\r\n---\r\nBody",
650 );
651 let s = load_skill_file(&path, None, None).unwrap();
652 assert_eq!(s.name, "s");
653 assert_eq!(s.load_body().unwrap(), "Body");
654 }
655
656 #[test]
659 fn frontmatter_rejects_dash_prefix_as_delimiter() {
660 let tmp = tempdir();
661 let path = write_skill(
662 &tmp.join("dashes"),
663 "---\nname: s\ndescription: d\n----\nBody",
664 );
665 assert!(
666 load_skill_file(&path, None, None).is_none(),
667 "'----' must not close frontmatter"
668 );
669 }
670
671 #[test]
675 fn frontmatter_trailing_newline_variants_fail_closed_on_empty_body() {
676 let tmp = tempdir();
677 let with_nl = write_skill(&tmp.join("with-nl"), "---\nname: s\ndescription: d\n---\n");
678 assert!(
679 load_skill_file(&with_nl, None, None).is_none(),
680 "empty body"
681 );
682 let without_nl = write_skill(&tmp.join("no-nl"), "---\nname: s\ndescription: d\n---");
683 assert!(
684 load_skill_file(&without_nl, None, None).is_none(),
685 "unterminated closing delimiter line never closes"
686 );
687 }
688
689 #[test]
694 fn discovery_bounds_name_and_description() {
695 let tmp = tempdir();
696 let long_name = "n".repeat(SKILL_NAME_MAX_BYTES + 1);
697 let over = write_skill(
698 &tmp.join("over-name"),
699 &format!("---\nname: {long_name}\ndescription: d\n---\nBody"),
700 );
701 assert!(load_skill_file(&over, None, None).is_none());
702 let ctrl = write_skill(
703 &tmp.join("ctrl-name"),
704 "---\nname: bad\u{7}name\ndescription: d\n---\nBody",
705 );
706 assert!(load_skill_file(&ctrl, None, None).is_none());
707 let no_desc = write_skill(
708 &tmp.join("empty-desc"),
709 "---\nname: x\ndescription:\n---\nBody",
710 );
711 assert!(load_skill_file(&no_desc, None, None).is_none());
712 let ctrl_desc = write_skill(
713 &tmp.join("ctrl-desc"),
714 "---\nname: x\ndescription: bad\u{7}desc\n---\nBody",
715 );
716 assert!(
717 load_skill_file(&ctrl_desc, None, None).is_none(),
718 "control characters in description are refused"
719 );
720
721 let long_desc = "d".repeat(SKILL_DESCRIPTION_MAX_BYTES + 100);
722 let truncated = write_skill(
723 &tmp.join("long-desc"),
724 &format!("---\nname: x\ndescription: {long_desc}\n---\nBody"),
725 );
726 let s = load_skill_file(&truncated, None, None).unwrap();
727 assert!(s.description.len() <= SKILL_DESCRIPTION_MAX_BYTES);
728 assert_eq!(s.load_body().unwrap(), "Body");
730 }
731
732 #[test]
735 fn verified_body_carries_exact_snapshot_digest() {
736 use sha2::{Digest as _, Sha256};
737 let tmp = tempdir();
738 let path = write_skill(&tmp.join("ev"), "---\nname: e\ndescription: d\n---\nBody");
739 let s = load_skill_file(&path, None, None).unwrap();
740 let verified = s.load_body_with_evidence().unwrap();
741 let expected: [u8; 32] = Sha256::digest(fs::read(&path).unwrap()).into();
742 assert_eq!(verified.snapshot_sha256, expected);
743 assert_eq!(verified.body, "Body");
744 }
745
746 #[test]
749 fn frontmatter_requires_opening_delimiter_first_line() {
750 let tmp = tempdir();
751 let late = write_skill(&tmp.join("late"), "intro\n---\nname: s\n---\nBody");
752 assert!(load_skill_file(&late, None, None).is_none());
753 let none = write_skill(&tmp.join("none"), "just a body");
754 assert!(load_skill_file(&none, None, None).is_none());
755 }
756
757 #[test]
758 fn load_skill_basic() {
759 let tmp = tempdir();
760 let skill_dir = tmp.join("my-skill");
761 let path = write_skill(
762 &skill_dir,
763 "---\nname: my-skill\ndescription: desc\n---\nBody",
764 );
765 let s = load_skill_file(&path, Some("plugin-x"), None).unwrap();
766 assert_eq!(s.name, "my-skill");
767 assert_eq!(s.description, "desc");
768 assert_eq!(s.load_body().unwrap(), "Body");
769 assert_eq!(s.plugin.as_deref(), Some("plugin-x"));
770 assert!(s.base_dir.is_absolute());
771 }
772
773 #[test]
774 fn load_skill_basedir_substitution() {
775 let tmp = tempdir();
776 let skill_dir = tmp.join("skill");
777 let path = write_skill(
778 &skill_dir,
779 "---\nname: s\ndescription: d\n---\nRun {baseDir}/x.js",
780 );
781 let s = load_skill_file(&path, None, None).unwrap();
782 let expected = format!("Run {}/x.js", s.base_dir.to_str().unwrap());
783 assert_eq!(s.load_body().unwrap(), expected);
784 }
785
786 #[test]
787 fn load_skill_missing_frontmatter_returns_none() {
788 let tmp = tempdir();
789 let skill_dir = tmp.join("bad");
790 let path = write_skill(&skill_dir, "no frontmatter here");
791 assert!(load_skill_file(&path, None, None).is_none());
792 }
793
794 #[test]
795 fn load_skill_missing_description_returns_none() {
796 let tmp = tempdir();
797 let skill_dir = tmp.join("bad2");
798 let path = write_skill(&skill_dir, "---\nname: x\n---\nbody");
799 assert!(load_skill_file(&path, None, None).is_none());
800 }
801
802 #[test]
803 fn load_skill_missing_name_returns_none() {
804 let tmp = tempdir();
805 let skill_dir = tmp.join("bad3");
806 let path = write_skill(&skill_dir, "---\ndescription: d\n---\nbody");
807 assert!(load_skill_file(&path, None, None).is_none());
808 }
809
810 #[test]
811 fn load_skill_empty_body_returns_none() {
812 let tmp = tempdir();
813 let skill_dir = tmp.join("empty-body");
814 let path = write_skill(&skill_dir, "---\nname: x\ndescription: d\n---\n");
815 assert!(load_skill_file(&path, None, None).is_none());
816 }
817
818 #[test]
819 fn load_skill_unclosed_frontmatter_returns_none() {
820 let tmp = tempdir();
821 let skill_dir = tmp.join("unclosed");
822 let path = write_skill(
824 &skill_dir,
825 "---\nname: x\ndescription: d\nbody without closing fence",
826 );
827 assert!(load_skill_file(&path, None, None).is_none());
828 }
829
830 #[test]
831 fn load_skill_basedir_multiple_occurrences() {
832 let tmp = tempdir();
833 let skill_dir = tmp.join("multi");
834 let path = write_skill(
835 &skill_dir,
836 "---\nname: m\ndescription: d\n---\n{baseDir}/a and {baseDir}/b",
837 );
838 let s = load_skill_file(&path, None, None).unwrap();
839 let bd = s.base_dir.to_str().unwrap();
840 assert_eq!(s.load_body().unwrap(), format!("{}/a and {}/b", bd, bd));
841 }
842
843 #[test]
844 fn load_skill_substitutes_claude_plugin_root_braced_and_plain() {
845 let tmp = tempdir();
849 let plugin_root = tmp.join("my-plugin");
850 fs::create_dir_all(&plugin_root).unwrap();
851 let skill_dir = plugin_root.join("skills").join("exa");
852 let path = write_skill(
853 &skill_dir,
854 "---\nname: exa\ndescription: d\n---\nbash ${CLAUDE_PLUGIN_ROOT}/scripts/a.js then $CLAUDE_PLUGIN_ROOT/b.js",
855 );
856 let s = load_skill_file(&path, Some("my-plugin"), Some(&plugin_root)).unwrap();
857 let root_abs = plugin_root.canonicalize().unwrap();
858 let r = root_abs.to_str().unwrap();
859 assert_eq!(
860 s.load_body().unwrap(),
861 format!("bash {}/scripts/a.js then {}/b.js", r, r)
862 );
863 }
864
865 #[test]
866 fn load_skill_leaves_claude_plugin_root_alone_when_not_in_plugin() {
867 let tmp = tempdir();
869 let skill_dir = tmp.join("loose");
870 let path = write_skill(
871 &skill_dir,
872 "---\nname: loose\ndescription: d\n---\n${CLAUDE_PLUGIN_ROOT}/x",
873 );
874 let s = load_skill_file(&path, None, None).unwrap();
875 assert_eq!(s.load_body().unwrap(), "${CLAUDE_PLUGIN_ROOT}/x");
876 }
877
878 #[test]
879 fn load_all_loose_skill() {
880 let tmp = tempdir();
881 let skill_dir = tmp.join("skills").join("loose");
882 write_skill(&skill_dir, "---\nname: loose\ndescription: d\n---\nBody");
883
884 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
885 assert!(plugins.is_empty());
886 assert_eq!(skills.len(), 1);
887 assert_eq!(skills[0].name, "loose");
888 assert_eq!(skills[0].plugin, None);
889 }
890
891 #[test]
892 fn load_all_plugin_skill() {
893 let tmp = tempdir();
894 let plugin_dir = tmp.join("my-plugin");
895 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
896 fs::write(
897 plugin_dir.join(".synaps-plugin").join("plugin.json"),
898 r#"{"name":"my-plugin"}"#,
899 )
900 .unwrap();
901 write_skill(
902 &plugin_dir.join("skills").join("s1"),
903 "---\nname: s1\ndescription: d\n---\nBody",
904 );
905
906 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
907 assert_eq!(plugins.len(), 1);
908 assert_eq!(plugins[0].name, "my-plugin");
909 assert!(plugins[0].manifest.as_ref().unwrap().commands.is_empty());
910 assert_eq!(skills.len(), 1);
911 assert_eq!(skills[0].plugin.as_deref(), Some("my-plugin"));
912 }
913
914 #[test]
915 fn load_all_plugin_commands_are_carried_in_manifest() {
916 let tmp = tempdir();
917 let plugin_dir = tmp.join("cmd-plugin");
918 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
919 fs::write(
920 plugin_dir.join(".synaps-plugin").join("plugin.json"),
921 r#"{
922 "name": "cmd-plugin",
923 "commands": [
924 {"name":"hello","description":"Say hello","command":"printf","args":["hello"]}
925 ]
926 }"#,
927 )
928 .unwrap();
929
930 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
931
932 assert_eq!(plugins.len(), 1);
933 assert!(skills.is_empty());
934 let commands = &plugins[0].manifest.as_ref().unwrap().commands;
935 assert_eq!(commands.len(), 1);
936 match &commands[0] {
937 crate::skills::manifest::ManifestCommand::Shell(cmd) => {
938 assert_eq!(cmd.name, "hello");
939 assert_eq!(cmd.command, "printf");
940 }
941 other => panic!("expected shell command, got {other:?}"),
942 }
943 }
944
945 #[test]
946 fn load_all_marketplace() {
947 let tmp = tempdir();
948 fs::create_dir_all(tmp.join(".synaps-plugin")).unwrap();
950 fs::write(
951 tmp.join(".synaps-plugin").join("marketplace.json"),
952 r#"{"name":"pi-skills","plugins":[{"name":"web","source":"./web"}]}"#,
953 )
954 .unwrap();
955 let plugin_dir = tmp.join("web");
957 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
958 fs::write(
959 plugin_dir.join(".synaps-plugin").join("plugin.json"),
960 r#"{"name":"web"}"#,
961 )
962 .unwrap();
963 write_skill(
964 &plugin_dir.join("skills").join("search"),
965 "---\nname: search\ndescription: d\n---\nBody",
966 );
967
968 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
969 assert_eq!(plugins.len(), 1);
970 assert_eq!(plugins[0].marketplace.as_deref(), Some("pi-skills"));
971 assert_eq!(skills.len(), 1);
972 }
973
974 #[test]
975 fn load_all_dedup_priority() {
976 let tmp_local = tempdir();
977 let tmp_global = tempdir();
978 write_skill(
980 &tmp_local.join("skills").join("dup"),
981 "---\nname: dup\ndescription: local\n---\nBody",
982 );
983 write_skill(
984 &tmp_global.join("skills").join("dup"),
985 "---\nname: dup\ndescription: global\n---\nBody",
986 );
987
988 let (_p, skills) = load_all(&[tmp_local, tmp_global]);
989 assert_eq!(skills.len(), 1);
990 assert_eq!(skills[0].description, "local"); }
992
993 #[test]
994 fn test_load_all_plugin_dedup_via_marketplace_and_subdir() {
995 let root = tempdir();
999
1000 fs::create_dir_all(root.join(".synaps-plugin")).unwrap();
1002 fs::write(
1003 root.join(".synaps-plugin").join("marketplace.json"),
1004 r#"{"name":"mp","plugins":[{"name":"web","source":"./web"}]}"#,
1005 )
1006 .unwrap();
1007
1008 let plugin_dir = root.join("web");
1010 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
1011 fs::write(
1012 plugin_dir.join(".synaps-plugin").join("plugin.json"),
1013 r#"{"name":"web"}"#,
1014 )
1015 .unwrap();
1016 write_skill(
1017 &plugin_dir.join("skills").join("demo"),
1018 "---\nname: demo\ndescription: d\n---\nBody",
1019 );
1020
1021 let (plugins, skills) = load_all(std::slice::from_ref(&root));
1022
1023 assert_eq!(plugins.len(), 1, "plugin should be deduplicated");
1025 assert_eq!(plugins[0].name, "web");
1026 assert_eq!(plugins[0].root, plugin_dir.canonicalize().unwrap());
1027
1028 assert_eq!(skills.len(), 1, "skill should be registered exactly once");
1030 assert_eq!(skills[0].name, "demo");
1031 assert_eq!(skills[0].plugin.as_deref(), Some("web"));
1032
1033 let _ = fs::remove_dir_all(&root);
1034 }
1035
1036 #[test]
1037 fn load_all_accepts_claude_plugin_marketplace_layout() {
1038 let tmp = tempdir();
1041 fs::create_dir_all(tmp.join(".claude-plugin")).unwrap();
1042 fs::write(
1043 tmp.join(".claude-plugin").join("marketplace.json"),
1044 r#"{"name":"cc-mp","plugins":[{"name":"web","source":"./web"}]}"#,
1045 )
1046 .unwrap();
1047 let plugin_dir = tmp.join("web");
1048 fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
1049 fs::write(
1050 plugin_dir.join(".claude-plugin").join("plugin.json"),
1051 r#"{"name":"web"}"#,
1052 )
1053 .unwrap();
1054 write_skill(
1055 &plugin_dir.join("skills").join("search"),
1056 "---\nname: search\ndescription: d\n---\nBody",
1057 );
1058
1059 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
1060 assert_eq!(plugins.len(), 1);
1061 assert_eq!(plugins[0].marketplace.as_deref(), Some("cc-mp"));
1062 assert_eq!(plugins[0].name, "web");
1063 assert_eq!(skills.len(), 1);
1064 assert_eq!(skills[0].name, "search");
1065 }
1066
1067 #[test]
1068 fn load_all_prefers_synaps_plugin_over_claude_plugin() {
1069 let tmp = tempdir();
1071 let plugin_dir = tmp.join("dual");
1072 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
1073 fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
1074 fs::write(
1075 plugin_dir.join(".synaps-plugin").join("plugin.json"),
1076 r#"{"name":"native"}"#,
1077 )
1078 .unwrap();
1079 fs::write(
1080 plugin_dir.join(".claude-plugin").join("plugin.json"),
1081 r#"{"name":"claude"}"#,
1082 )
1083 .unwrap();
1084 write_skill(
1085 &plugin_dir.join("skills").join("s"),
1086 "---\nname: s\ndescription: d\n---\nBody",
1087 );
1088
1089 let (plugins, _skills) = load_all(std::slice::from_ref(&tmp));
1090 assert_eq!(plugins.len(), 1);
1091 assert_eq!(plugins[0].name, "native", "synaps-plugin layout must win");
1092 }
1093
1094 #[test]
1095 fn test_load_all_malformed_plugin_json_continues_walk() {
1096 let root = tempdir();
1099
1100 let broken_dir = root.join("broken");
1102 fs::create_dir_all(broken_dir.join(".synaps-plugin")).unwrap();
1103 fs::write(
1104 broken_dir.join(".synaps-plugin").join("plugin.json"),
1105 "{ this is not valid json",
1106 )
1107 .unwrap();
1108
1109 let good_dir = root.join("good");
1111 fs::create_dir_all(good_dir.join(".synaps-plugin")).unwrap();
1112 fs::write(
1113 good_dir.join(".synaps-plugin").join("plugin.json"),
1114 r#"{"name":"good"}"#,
1115 )
1116 .unwrap();
1117 write_skill(
1118 &good_dir.join("skills").join("hello"),
1119 "---\nname: hello\ndescription: d\n---\nBody",
1120 );
1121
1122 let (plugins, skills) = load_all(std::slice::from_ref(&root));
1123
1124 assert_eq!(plugins.len(), 1);
1126 assert_eq!(plugins[0].name, "good");
1127
1128 assert_eq!(skills.len(), 1);
1130 assert_eq!(skills[0].name, "hello");
1131 assert_eq!(skills[0].plugin.as_deref(), Some("good"));
1132
1133 let _ = fs::remove_dir_all(&root);
1134 }
1135
1136 fn tempdir() -> PathBuf {
1138 use std::sync::atomic::{AtomicU64, Ordering};
1139 static COUNTER: AtomicU64 = AtomicU64::new(0);
1140 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1141 let base = std::env::temp_dir().join(format!("synaps-skills-test-{}", std::process::id()));
1142 let unique = base.join(format!("{}-{}", crate::epoch_millis(), n));
1143 std::fs::create_dir_all(&unique).unwrap();
1144 unique
1145 }
1146}