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 removed_sidecar_warning(plugin: &str, manifest_json: &str) -> Option<String> {
555 let value: serde_json::Value = serde_json::from_str(manifest_json).ok()?;
556 value.pointer("/provides/sidecar")?;
557 Some(format!(
558 "plugin '{plugin}': `provides.sidecar` is ignored; sidecars are no longer supported (removed in {})",
559 agent_core::SIDECAR_REMOVED_IN
560 ))
561}
562
563fn load_plugin(
564 plugin_root: &Path,
565 marketplace: Option<&str>,
566 plugins: &mut Vec<Plugin>,
567 skills: &mut Vec<LoadedSkill>,
568 seen: &mut std::collections::HashSet<(Option<String>, String)>,
569 budget: &mut DiscoveryBudget,
570) {
571 let Some(manifest_path) = plugin_json_for(plugin_root) else {
572 tracing::warn!("no plugin.json under {}", plugin_root.display());
573 return;
574 };
575 let Ok(content) = std::fs::read_to_string(&manifest_path) else {
576 tracing::warn!("failed to read {}", manifest_path.display());
577 return;
578 };
579 let Ok(m): Result<PluginManifest, _> = serde_json::from_str(&content) else {
580 tracing::warn!("failed to parse {}", manifest_path.display());
581 return;
582 };
583 if let Some(warning) = removed_sidecar_warning(&m.name, &content) {
584 tracing::warn!("{warning}");
585 }
586
587 let Ok(root_abs) = plugin_root.canonicalize() else {
588 return;
589 };
590 if plugins.iter().any(|p| p.root == root_abs) {
591 return;
592 }
593 plugins.push(Plugin {
594 name: m.name.clone(),
595 root: root_abs,
596 marketplace: marketplace.map(str::to_string),
597 version: m.version.clone(),
598 description: m.description.clone(),
599 extension: m.extension.clone(),
600 manifest: Some(m.clone()),
601 });
602
603 let skills_dir = plugin_root.join("skills");
604 if !skills_dir.is_dir() {
605 return;
606 }
607 for path in sorted_dir_entries(&skills_dir) {
608 if !path.is_dir() {
609 continue;
610 }
611 let skill_md = path.join("SKILL.md");
612 if !skill_md.exists() {
613 continue;
614 }
615 if let Some(s) = load_skill_file(&skill_md, Some(&m.name), Some(plugin_root)) {
616 let key = (Some(m.name.clone()), s.name.clone());
617 if seen.contains(&key) || !budget.admit(skills.len(), &s) {
618 continue;
619 }
620 seen.insert(key);
621 skills.push(s);
622 }
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use std::fs;
630
631 fn write_skill(dir: &Path, content: &str) -> PathBuf {
632 fs::create_dir_all(dir).unwrap();
633 let path = dir.join("SKILL.md");
634 fs::write(&path, content).unwrap();
635 path
636 }
637
638 #[test]
642 fn frontmatter_scan_stops_exactly_at_body_offset() {
643 let tmp = tempdir();
644 let path = write_skill(
645 &tmp.join("scan"),
646 "---\nname: s\ndescription: d\n---\nBODY_AFTER",
647 );
648 let mut file = open_skill_file(&path).unwrap();
649 let frontmatter = scan_frontmatter_bytes(&mut file).unwrap();
650 let pos = std::io::Seek::stream_position(&mut file).unwrap();
651 assert_eq!(frontmatter.len() as u64, pos, "no body byte consumed");
652 let full = fs::read(&path).unwrap();
653 assert_eq!(&full[..frontmatter.len()], &frontmatter[..]);
654 let body_pos = full.windows(10).position(|w| w == b"BODY_AFTER").unwrap();
655 assert_eq!(frontmatter.len(), body_pos, "scan ends where body begins");
656 }
657
658 #[test]
660 fn frontmatter_scan_accepts_crlf() {
661 let tmp = tempdir();
662 let path = write_skill(
663 &tmp.join("crlf"),
664 "---\r\nname: s\r\ndescription: d\r\n---\r\nBody",
665 );
666 let s = load_skill_file(&path, None, None).unwrap();
667 assert_eq!(s.name, "s");
668 assert_eq!(s.load_body().unwrap(), "Body");
669 }
670
671 #[test]
674 fn frontmatter_rejects_dash_prefix_as_delimiter() {
675 let tmp = tempdir();
676 let path = write_skill(
677 &tmp.join("dashes"),
678 "---\nname: s\ndescription: d\n----\nBody",
679 );
680 assert!(
681 load_skill_file(&path, None, None).is_none(),
682 "'----' must not close frontmatter"
683 );
684 }
685
686 #[test]
690 fn frontmatter_trailing_newline_variants_fail_closed_on_empty_body() {
691 let tmp = tempdir();
692 let with_nl = write_skill(&tmp.join("with-nl"), "---\nname: s\ndescription: d\n---\n");
693 assert!(
694 load_skill_file(&with_nl, None, None).is_none(),
695 "empty body"
696 );
697 let without_nl = write_skill(&tmp.join("no-nl"), "---\nname: s\ndescription: d\n---");
698 assert!(
699 load_skill_file(&without_nl, None, None).is_none(),
700 "unterminated closing delimiter line never closes"
701 );
702 }
703
704 #[test]
709 fn discovery_bounds_name_and_description() {
710 let tmp = tempdir();
711 let long_name = "n".repeat(SKILL_NAME_MAX_BYTES + 1);
712 let over = write_skill(
713 &tmp.join("over-name"),
714 &format!("---\nname: {long_name}\ndescription: d\n---\nBody"),
715 );
716 assert!(load_skill_file(&over, None, None).is_none());
717 let ctrl = write_skill(
718 &tmp.join("ctrl-name"),
719 "---\nname: bad\u{7}name\ndescription: d\n---\nBody",
720 );
721 assert!(load_skill_file(&ctrl, None, None).is_none());
722 let no_desc = write_skill(
723 &tmp.join("empty-desc"),
724 "---\nname: x\ndescription:\n---\nBody",
725 );
726 assert!(load_skill_file(&no_desc, None, None).is_none());
727 let ctrl_desc = write_skill(
728 &tmp.join("ctrl-desc"),
729 "---\nname: x\ndescription: bad\u{7}desc\n---\nBody",
730 );
731 assert!(
732 load_skill_file(&ctrl_desc, None, None).is_none(),
733 "control characters in description are refused"
734 );
735
736 let long_desc = "d".repeat(SKILL_DESCRIPTION_MAX_BYTES + 100);
737 let truncated = write_skill(
738 &tmp.join("long-desc"),
739 &format!("---\nname: x\ndescription: {long_desc}\n---\nBody"),
740 );
741 let s = load_skill_file(&truncated, None, None).unwrap();
742 assert!(s.description.len() <= SKILL_DESCRIPTION_MAX_BYTES);
743 assert_eq!(s.load_body().unwrap(), "Body");
745 }
746
747 #[test]
750 fn verified_body_carries_exact_snapshot_digest() {
751 use sha2::{Digest as _, Sha256};
752 let tmp = tempdir();
753 let path = write_skill(&tmp.join("ev"), "---\nname: e\ndescription: d\n---\nBody");
754 let s = load_skill_file(&path, None, None).unwrap();
755 let verified = s.load_body_with_evidence().unwrap();
756 let expected: [u8; 32] = Sha256::digest(fs::read(&path).unwrap()).into();
757 assert_eq!(verified.snapshot_sha256, expected);
758 assert_eq!(verified.body, "Body");
759 }
760
761 #[test]
764 fn frontmatter_requires_opening_delimiter_first_line() {
765 let tmp = tempdir();
766 let late = write_skill(&tmp.join("late"), "intro\n---\nname: s\n---\nBody");
767 assert!(load_skill_file(&late, None, None).is_none());
768 let none = write_skill(&tmp.join("none"), "just a body");
769 assert!(load_skill_file(&none, None, None).is_none());
770 }
771
772 #[test]
773 fn load_skill_basic() {
774 let tmp = tempdir();
775 let skill_dir = tmp.join("my-skill");
776 let path = write_skill(
777 &skill_dir,
778 "---\nname: my-skill\ndescription: desc\n---\nBody",
779 );
780 let s = load_skill_file(&path, Some("plugin-x"), None).unwrap();
781 assert_eq!(s.name, "my-skill");
782 assert_eq!(s.description, "desc");
783 assert_eq!(s.load_body().unwrap(), "Body");
784 assert_eq!(s.plugin.as_deref(), Some("plugin-x"));
785 assert!(s.base_dir.is_absolute());
786 }
787
788 #[test]
789 fn load_skill_basedir_substitution() {
790 let tmp = tempdir();
791 let skill_dir = tmp.join("skill");
792 let path = write_skill(
793 &skill_dir,
794 "---\nname: s\ndescription: d\n---\nRun {baseDir}/x.js",
795 );
796 let s = load_skill_file(&path, None, None).unwrap();
797 let expected = format!("Run {}/x.js", s.base_dir.to_str().unwrap());
798 assert_eq!(s.load_body().unwrap(), expected);
799 }
800
801 #[test]
802 fn load_skill_missing_frontmatter_returns_none() {
803 let tmp = tempdir();
804 let skill_dir = tmp.join("bad");
805 let path = write_skill(&skill_dir, "no frontmatter here");
806 assert!(load_skill_file(&path, None, None).is_none());
807 }
808
809 #[test]
810 fn load_skill_missing_description_returns_none() {
811 let tmp = tempdir();
812 let skill_dir = tmp.join("bad2");
813 let path = write_skill(&skill_dir, "---\nname: x\n---\nbody");
814 assert!(load_skill_file(&path, None, None).is_none());
815 }
816
817 #[test]
818 fn load_skill_missing_name_returns_none() {
819 let tmp = tempdir();
820 let skill_dir = tmp.join("bad3");
821 let path = write_skill(&skill_dir, "---\ndescription: d\n---\nbody");
822 assert!(load_skill_file(&path, None, None).is_none());
823 }
824
825 #[test]
826 fn load_skill_empty_body_returns_none() {
827 let tmp = tempdir();
828 let skill_dir = tmp.join("empty-body");
829 let path = write_skill(&skill_dir, "---\nname: x\ndescription: d\n---\n");
830 assert!(load_skill_file(&path, None, None).is_none());
831 }
832
833 #[test]
834 fn load_skill_unclosed_frontmatter_returns_none() {
835 let tmp = tempdir();
836 let skill_dir = tmp.join("unclosed");
837 let path = write_skill(
839 &skill_dir,
840 "---\nname: x\ndescription: d\nbody without closing fence",
841 );
842 assert!(load_skill_file(&path, None, None).is_none());
843 }
844
845 #[test]
846 fn load_skill_basedir_multiple_occurrences() {
847 let tmp = tempdir();
848 let skill_dir = tmp.join("multi");
849 let path = write_skill(
850 &skill_dir,
851 "---\nname: m\ndescription: d\n---\n{baseDir}/a and {baseDir}/b",
852 );
853 let s = load_skill_file(&path, None, None).unwrap();
854 let bd = s.base_dir.to_str().unwrap();
855 assert_eq!(s.load_body().unwrap(), format!("{}/a and {}/b", bd, bd));
856 }
857
858 #[test]
859 fn load_skill_substitutes_claude_plugin_root_braced_and_plain() {
860 let tmp = tempdir();
864 let plugin_root = tmp.join("my-plugin");
865 fs::create_dir_all(&plugin_root).unwrap();
866 let skill_dir = plugin_root.join("skills").join("exa");
867 let path = write_skill(
868 &skill_dir,
869 "---\nname: exa\ndescription: d\n---\nbash ${CLAUDE_PLUGIN_ROOT}/scripts/a.js then $CLAUDE_PLUGIN_ROOT/b.js",
870 );
871 let s = load_skill_file(&path, Some("my-plugin"), Some(&plugin_root)).unwrap();
872 let root_abs = plugin_root.canonicalize().unwrap();
873 let r = root_abs.to_str().unwrap();
874 assert_eq!(
875 s.load_body().unwrap(),
876 format!("bash {}/scripts/a.js then {}/b.js", r, r)
877 );
878 }
879
880 #[test]
881 fn load_skill_leaves_claude_plugin_root_alone_when_not_in_plugin() {
882 let tmp = tempdir();
884 let skill_dir = tmp.join("loose");
885 let path = write_skill(
886 &skill_dir,
887 "---\nname: loose\ndescription: d\n---\n${CLAUDE_PLUGIN_ROOT}/x",
888 );
889 let s = load_skill_file(&path, None, None).unwrap();
890 assert_eq!(s.load_body().unwrap(), "${CLAUDE_PLUGIN_ROOT}/x");
891 }
892
893 #[test]
894 fn load_all_loose_skill() {
895 let tmp = tempdir();
896 let skill_dir = tmp.join("skills").join("loose");
897 write_skill(&skill_dir, "---\nname: loose\ndescription: d\n---\nBody");
898
899 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
900 assert!(plugins.is_empty());
901 assert_eq!(skills.len(), 1);
902 assert_eq!(skills[0].name, "loose");
903 assert_eq!(skills[0].plugin, None);
904 }
905
906 #[test]
907 fn load_all_plugin_skill() {
908 let tmp = tempdir();
909 let plugin_dir = tmp.join("my-plugin");
910 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
911 fs::write(
912 plugin_dir.join(".synaps-plugin").join("plugin.json"),
913 r#"{"name":"my-plugin"}"#,
914 )
915 .unwrap();
916 write_skill(
917 &plugin_dir.join("skills").join("s1"),
918 "---\nname: s1\ndescription: d\n---\nBody",
919 );
920
921 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
922 assert_eq!(plugins.len(), 1);
923 assert_eq!(plugins[0].name, "my-plugin");
924 assert!(plugins[0].manifest.as_ref().unwrap().commands.is_empty());
925 assert_eq!(skills.len(), 1);
926 assert_eq!(skills[0].plugin.as_deref(), Some("my-plugin"));
927 }
928
929 #[test]
930 fn load_all_plugin_commands_are_carried_in_manifest() {
931 let tmp = tempdir();
932 let plugin_dir = tmp.join("cmd-plugin");
933 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
934 fs::write(
935 plugin_dir.join(".synaps-plugin").join("plugin.json"),
936 r#"{
937 "name": "cmd-plugin",
938 "commands": [
939 {"name":"hello","description":"Say hello","command":"printf","args":["hello"]}
940 ]
941 }"#,
942 )
943 .unwrap();
944
945 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
946
947 assert_eq!(plugins.len(), 1);
948 assert!(skills.is_empty());
949 let commands = &plugins[0].manifest.as_ref().unwrap().commands;
950 assert_eq!(commands.len(), 1);
951 match &commands[0] {
952 crate::skills::manifest::ManifestCommand::Shell(cmd) => {
953 assert_eq!(cmd.name, "hello");
954 assert_eq!(cmd.command, "printf");
955 }
956 other => panic!("expected shell command, got {other:?}"),
957 }
958 }
959
960 #[test]
961 fn load_all_marketplace() {
962 let tmp = tempdir();
963 fs::create_dir_all(tmp.join(".synaps-plugin")).unwrap();
965 fs::write(
966 tmp.join(".synaps-plugin").join("marketplace.json"),
967 r#"{"name":"pi-skills","plugins":[{"name":"web","source":"./web"}]}"#,
968 )
969 .unwrap();
970 let plugin_dir = tmp.join("web");
972 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
973 fs::write(
974 plugin_dir.join(".synaps-plugin").join("plugin.json"),
975 r#"{"name":"web"}"#,
976 )
977 .unwrap();
978 write_skill(
979 &plugin_dir.join("skills").join("search"),
980 "---\nname: search\ndescription: d\n---\nBody",
981 );
982
983 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
984 assert_eq!(plugins.len(), 1);
985 assert_eq!(plugins[0].marketplace.as_deref(), Some("pi-skills"));
986 assert_eq!(skills.len(), 1);
987 }
988
989 #[test]
990 fn load_all_dedup_priority() {
991 let tmp_local = tempdir();
992 let tmp_global = tempdir();
993 write_skill(
995 &tmp_local.join("skills").join("dup"),
996 "---\nname: dup\ndescription: local\n---\nBody",
997 );
998 write_skill(
999 &tmp_global.join("skills").join("dup"),
1000 "---\nname: dup\ndescription: global\n---\nBody",
1001 );
1002
1003 let (_p, skills) = load_all(&[tmp_local, tmp_global]);
1004 assert_eq!(skills.len(), 1);
1005 assert_eq!(skills[0].description, "local"); }
1007
1008 #[test]
1009 fn test_load_all_plugin_dedup_via_marketplace_and_subdir() {
1010 let root = tempdir();
1014
1015 fs::create_dir_all(root.join(".synaps-plugin")).unwrap();
1017 fs::write(
1018 root.join(".synaps-plugin").join("marketplace.json"),
1019 r#"{"name":"mp","plugins":[{"name":"web","source":"./web"}]}"#,
1020 )
1021 .unwrap();
1022
1023 let plugin_dir = root.join("web");
1025 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
1026 fs::write(
1027 plugin_dir.join(".synaps-plugin").join("plugin.json"),
1028 r#"{"name":"web"}"#,
1029 )
1030 .unwrap();
1031 write_skill(
1032 &plugin_dir.join("skills").join("demo"),
1033 "---\nname: demo\ndescription: d\n---\nBody",
1034 );
1035
1036 let (plugins, skills) = load_all(std::slice::from_ref(&root));
1037
1038 assert_eq!(plugins.len(), 1, "plugin should be deduplicated");
1040 assert_eq!(plugins[0].name, "web");
1041 assert_eq!(plugins[0].root, plugin_dir.canonicalize().unwrap());
1042
1043 assert_eq!(skills.len(), 1, "skill should be registered exactly once");
1045 assert_eq!(skills[0].name, "demo");
1046 assert_eq!(skills[0].plugin.as_deref(), Some("web"));
1047
1048 let _ = fs::remove_dir_all(&root);
1049 }
1050
1051 #[test]
1052 fn load_all_accepts_claude_plugin_marketplace_layout() {
1053 let tmp = tempdir();
1056 fs::create_dir_all(tmp.join(".claude-plugin")).unwrap();
1057 fs::write(
1058 tmp.join(".claude-plugin").join("marketplace.json"),
1059 r#"{"name":"cc-mp","plugins":[{"name":"web","source":"./web"}]}"#,
1060 )
1061 .unwrap();
1062 let plugin_dir = tmp.join("web");
1063 fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
1064 fs::write(
1065 plugin_dir.join(".claude-plugin").join("plugin.json"),
1066 r#"{"name":"web"}"#,
1067 )
1068 .unwrap();
1069 write_skill(
1070 &plugin_dir.join("skills").join("search"),
1071 "---\nname: search\ndescription: d\n---\nBody",
1072 );
1073
1074 let (plugins, skills) = load_all(std::slice::from_ref(&tmp));
1075 assert_eq!(plugins.len(), 1);
1076 assert_eq!(plugins[0].marketplace.as_deref(), Some("cc-mp"));
1077 assert_eq!(plugins[0].name, "web");
1078 assert_eq!(skills.len(), 1);
1079 assert_eq!(skills[0].name, "search");
1080 }
1081
1082 #[test]
1083 fn load_all_prefers_synaps_plugin_over_claude_plugin() {
1084 let tmp = tempdir();
1086 let plugin_dir = tmp.join("dual");
1087 fs::create_dir_all(plugin_dir.join(".synaps-plugin")).unwrap();
1088 fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
1089 fs::write(
1090 plugin_dir.join(".synaps-plugin").join("plugin.json"),
1091 r#"{"name":"native"}"#,
1092 )
1093 .unwrap();
1094 fs::write(
1095 plugin_dir.join(".claude-plugin").join("plugin.json"),
1096 r#"{"name":"claude"}"#,
1097 )
1098 .unwrap();
1099 write_skill(
1100 &plugin_dir.join("skills").join("s"),
1101 "---\nname: s\ndescription: d\n---\nBody",
1102 );
1103
1104 let (plugins, _skills) = load_all(std::slice::from_ref(&tmp));
1105 assert_eq!(plugins.len(), 1);
1106 assert_eq!(plugins[0].name, "native", "synaps-plugin layout must win");
1107 }
1108
1109 #[test]
1110 fn test_load_all_malformed_plugin_json_continues_walk() {
1111 let root = tempdir();
1114
1115 let broken_dir = root.join("broken");
1117 fs::create_dir_all(broken_dir.join(".synaps-plugin")).unwrap();
1118 fs::write(
1119 broken_dir.join(".synaps-plugin").join("plugin.json"),
1120 "{ this is not valid json",
1121 )
1122 .unwrap();
1123
1124 let good_dir = root.join("good");
1126 fs::create_dir_all(good_dir.join(".synaps-plugin")).unwrap();
1127 fs::write(
1128 good_dir.join(".synaps-plugin").join("plugin.json"),
1129 r#"{"name":"good"}"#,
1130 )
1131 .unwrap();
1132 write_skill(
1133 &good_dir.join("skills").join("hello"),
1134 "---\nname: hello\ndescription: d\n---\nBody",
1135 );
1136
1137 let (plugins, skills) = load_all(std::slice::from_ref(&root));
1138
1139 assert_eq!(plugins.len(), 1);
1141 assert_eq!(plugins[0].name, "good");
1142
1143 assert_eq!(skills.len(), 1);
1145 assert_eq!(skills[0].name, "hello");
1146 assert_eq!(skills[0].plugin.as_deref(), Some("good"));
1147
1148 let _ = fs::remove_dir_all(&root);
1149 }
1150
1151 const LEGACY_SIDECAR_PLUGIN_JSON: &str = r#"{
1155 "name": "local-voice",
1156 "version": "0.1.0",
1157 "extension": {
1158 "protocol_version": 1,
1159 "runtime": "process",
1160 "command": "bin/synaps-voice-plugin",
1161 "args": ["--extension-rpc"],
1162 "permissions": ["audio.input", "config.subscribe"],
1163 "hooks": [],
1164 "config": []
1165 },
1166 "commands": [
1167 {
1168 "name": "voice",
1169 "description": "Manage local voice dictation models and sidecar builds.",
1170 "interactive": true,
1171 "subcommands": ["help", "models", "download", "rebuild"]
1172 }
1173 ],
1174 "settings": {
1175 "categories": [
1176 {
1177 "id": "voice",
1178 "label": "Voice",
1179 "fields": [
1180 {"key": "backend", "label": "STT backend", "editor": "cycler",
1181 "options": ["auto", "cpu"], "default": "auto"}
1182 ]
1183 }
1184 ]
1185 },
1186 "provides": {
1187 "sidecar": {
1188 "command": "bin/synaps-voice-plugin",
1189 "setup": "scripts/setup.sh",
1190 "protocol_version": 2,
1191 "model": {"default_path": "~/.synaps-cli/models/whisper/ggml-base.en.bin", "required": true},
1192 "lifecycle": {"command": "voice", "settings_category": "voice", "display_name": "Voice", "importance": 50}
1193 }
1194 },
1195 "keybinds": [
1196 {"key": "C-Space", "action": "slash_command", "command": "voice toggle",
1197 "description": "Toggle local voice dictation on/off"}
1198 ]
1199 }"#;
1200
1201 #[test]
1202 fn legacy_provides_sidecar_manifest_still_loads_extension_and_commands() {
1203 let root = tempdir();
1204 let dir = root.join("local-voice");
1205 fs::create_dir_all(dir.join(".synaps-plugin")).unwrap();
1206 fs::write(
1207 dir.join(".synaps-plugin").join("plugin.json"),
1208 LEGACY_SIDECAR_PLUGIN_JSON,
1209 )
1210 .unwrap();
1211
1212 let (plugins, _skills) = load_all(std::slice::from_ref(&root));
1213 assert_eq!(plugins.len(), 1, "a provides.sidecar manifest must still load");
1214 let p = &plugins[0];
1215 assert_eq!(p.name, "local-voice");
1216 let ext = p.extension.as_ref().expect("extension block kept");
1217 assert_eq!(ext.command, "bin/synaps-voice-plugin");
1218 let validated = ext.validate("local-voice").expect("extension manifest validates");
1221 assert!(validated
1222 .permissions
1223 .has(crate::extensions::permissions::Permission::AudioInput));
1224 assert_eq!(
1225 crate::extensions::lifecycle::classify(ext),
1226 crate::extensions::lifecycle::ExtensionClass::LegacyEager
1227 );
1228 let m = p.manifest.as_ref().unwrap();
1229 assert_eq!(m.keybinds.len(), 1);
1230 assert_eq!(m.keybinds[0].command.as_deref(), Some("voice toggle"));
1231 assert_eq!(m.settings.as_ref().unwrap().categories[0].id, "voice");
1232
1233 let reg = crate::skills::registry::CommandRegistry::new_with_plugins(
1234 &[],
1235 vec![],
1236 plugins.clone(),
1237 );
1238 match reg.resolve("voice") {
1239 crate::skills::registry::Resolution::PluginCommand(c) => {
1240 assert_eq!(c.plugin, "local-voice");
1241 assert!(matches!(
1242 c.backend,
1243 crate::skills::registry::RegisteredPluginCommandBackend::Interactive { .. }
1244 ));
1245 }
1246 _ => panic!("`voice` must resolve to the plugin's own command"),
1247 }
1248 let cats = reg.plugin_settings_categories();
1249 assert_eq!(cats.len(), 1);
1250 assert!(
1251 cats[0].fields.iter().all(|f| f.key == "backend"),
1252 "no field is injected into the plugin's settings any more"
1253 );
1254
1255 let _ = fs::remove_dir_all(&root);
1256 }
1257
1258 #[test]
1259 fn legacy_provides_sidecar_gets_one_warning_line() {
1260 let w = removed_sidecar_warning("local-voice", LEGACY_SIDECAR_PLUGIN_JSON)
1261 .expect("provides.sidecar warns");
1262 assert!(w.contains("local-voice") && w.contains("no longer supported"), "{w}");
1263 assert!(removed_sidecar_warning("plain", r#"{"name":"plain"}"#).is_none());
1264 }
1265
1266 fn tempdir() -> PathBuf {
1268 use std::sync::atomic::{AtomicU64, Ordering};
1269 static COUNTER: AtomicU64 = AtomicU64::new(0);
1270 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1271 let base = std::env::temp_dir().join(format!("synaps-skills-test-{}", std::process::id()));
1272 let unique = base.join(format!("{}-{}", crate::epoch_millis(), n));
1273 std::fs::create_dir_all(&unique).unwrap();
1274 unique
1275 }
1276}