Skip to main content

agent_engine/skills/
loader.rs

1//! SKILL.md parsing, {baseDir}/${CLAUDE_PLUGIN_ROOT} substitution, and discovery.
2//!
3//! Task 21 (spec §7.6) — LAZY skill bodies: boot discovery reads ONLY the
4//! bounded frontmatter (never one body byte), records an immutable source
5//! fingerprint, and defers body read/substitution/validation to exact
6//! selection, which re-verifies the fingerprint before returning content.
7
8use crate::skills::{LoadedSkill, SkillSource};
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12/// Hard bound on the boot-time frontmatter scan: the scan reads through
13/// the closing `---` delimiter and never past this many bytes.
14pub const SKILL_FRONTMATTER_MAX_BYTES: usize = 8 * 1024;
15/// Hard cap on a SKILL.md regular file; larger files are skipped at
16/// discovery (stat only) and refused at selection.
17pub const SKILL_FILE_MAX_BYTES: usize = 1024 * 1024;
18/// Bounded retained skill name.
19pub const SKILL_NAME_MAX_BYTES: usize = 128;
20/// Bounded retained skill description: the retained copy is a BOUNDED
21/// PROJECTION (UTF-8-safe truncation via `BoundedText`) used only for
22/// display/search; selection verification always uses the exact
23/// frontmatter byte digest, never this projection.
24pub const SKILL_DESCRIPTION_MAX_BYTES: usize = 1024;
25/// Deterministic cap on discovered skills (first-wins across roots).
26pub const DISCOVERY_MAX_SKILLS: usize = 2048;
27/// Deterministic cap on total retained metadata bytes (names +
28/// descriptions + paths) across the whole discovery pass.
29pub const DISCOVERY_MAX_METADATA_BYTES: usize = 4 * 1024 * 1024;
30
31/// Immutable identity of the exact regular file the frontmatter was
32/// scanned from, captured via fstat on the OPEN handle (no TOCTOU window
33/// against the scanned bytes). Selection re-opens, re-stats, re-scans,
34/// and requires every recorded value to match before any body byte is
35/// trusted. Never rendered into errors or logs.
36#[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    /// SHA-256 of the exact frontmatter bytes (through the closing
44    /// delimiter line, i.e. bytes `[0, body_start)`).
45    pub(crate) frontmatter_sha256: [u8; 32],
46    /// Byte offset where the body begins (== bytes read by the scan).
47    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
82/// Open a SKILL.md for bounded reading: regular files only, symlinks
83/// refused (O_NOFOLLOW), non-blocking so a FIFO swapped in place can
84/// never hang the caller (O_NONBLOCK; regular-file reads ignore it).
85fn 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
110/// Bytewise bounded scan of the frontmatter block on an OPEN file:
111/// reads exactly through the closing `---` delimiter line (never past it,
112/// never more than [`SKILL_FRONTMATTER_MAX_BYTES`]) and returns the raw
113/// frontmatter bytes (including both delimiter lines). Zero body bytes
114/// are consumed from the handle.
115fn 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                    // Opening delimiter must be the very first line.
146                    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                // First line was not `---`: no frontmatter.
156                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
166/// Parse `key: value` fields from raw frontmatter bytes (both delimiter
167/// lines included). UTF-8 required for the frontmatter itself.
168fn 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    // Drop the delimiter lines.
172    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
198/// Discover a SKILL.md into a LAZY `LoadedSkill` (Task 21): bounded
199/// frontmatter scan ONLY — zero body bytes read, no substitution. The
200/// open-handle fstat fingerprint (device/inode/size/mtime/ctime +
201/// frontmatter digest + body offset) is recorded so selection can verify
202/// the exact same content before trusting one body byte.
203///
204/// Returns None if the file is not a safe regular file within bounds, the
205/// frontmatter is missing/unclosed/malformed, name/description are
206/// absent, or there is no body byte after the closing delimiter.
207pub 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; // empty body
218    }
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    // Bounded identity metadata (Task 21): non-empty, control-free name up
229    // to SKILL_NAME_MAX_BYTES; non-empty description bounded to
230    // SKILL_DESCRIPTION_MAX_BYTES (retained truncated; verification uses
231    // the exact frontmatter digest, not this bounded copy).
232    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
259/// Static selection-time failures. Deliberately free of paths, hashes,
260/// and file content.
261const 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
267/// Selection result: the verified, substituted body plus the SHA-256 of
268/// the exact verified on-disk snapshot. No Debug — the digest and body
269/// are never rendered by accident.
270pub(crate) struct VerifiedSkillBody {
271    pub(crate) body: String,
272    /// Typed local evidence only: consumed by verification unit tests;
273    /// production callers deliberately never render or persist it.
274    #[cfg_attr(not(test), allow(dead_code))]
275    pub(crate) snapshot_sha256: [u8; 32],
276}
277
278impl LoadedSkill {
279    /// Read, VERIFY, substitute, and return the body of this exact skill.
280    /// Called only at selection (`load_skill` tool or slash command) —
281    /// never during boot, search, or diagnostics.
282    ///
283    /// Verification: reopen (regular file only, O_NOFOLLOW|O_NONBLOCK),
284    /// fstat must equal the recorded fingerprint, the re-scanned
285    /// frontmatter bytes must digest to the recorded SHA-256, and a second
286    /// fstat AFTER the body read must still match — any drift fails
287    /// closed with a static, path-free reason.
288    pub fn load_body(&self) -> Result<String, &'static str> {
289        self.load_body_with_evidence().map(|verified| verified.body)
290    }
291
292    /// As [`Self::load_body`], plus the typed SHA-256 of the exact
293    /// verified snapshot (frontmatter + body bytes) as local evidence.
294    /// Crate-internal; the digest is never logged or exposed and never
295    /// substitutes for the immutable fingerprint authority.
296    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        // Bounded body read: the pre-verified length is under the file
317        // cap; read exactly the remaining bytes.
318        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        // No trailing bytes may exist beyond the recorded length.
322        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        // Post-read fstat: the inode must not have changed underneath us.
328        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        // Honest evidence: SHA-256 over the exact verified snapshot
333        // (frontmatter + body bytes). Typed local value only — never
334        // logged, never exposed, never a substitute for the immutable
335        // fingerprint authority above.
336        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        // Substitutions happen HERE, at selection time only.
349        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
368/// The four default discovery roots, in priority order (local first, global second).
369pub 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
381/// Walk the given roots and discover all plugins and skills.
382/// Deduplicates on (plugin_name, skill_name); first occurrence wins.
383///
384/// Task 21: discovery is deterministically CAPPED — at most
385/// [`DISCOVERY_MAX_SKILLS`] skills and [`DISCOVERY_MAX_METADATA_BYTES`]
386/// of retained metadata across the whole pass. Beyond a cap, later
387/// candidates are skipped (first-wins order preserved) and only COUNTS
388/// are reported.
389pub 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/// Running discovery caps. Metadata cost counts the bounded retained
409/// strings (name, description, paths).
410#[derive(Default)]
411struct DiscoveryBudget {
412    metadata_bytes: usize,
413    skipped: usize,
414}
415
416impl DiscoveryBudget {
417    /// Admit one discovered skill if both caps hold; otherwise count it
418    /// as skipped. `current` is the number of skills already retained.
419    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
436/// Return the first existing path from a list of candidates, or None.
437/// Used to accept both `.synaps-plugin/` (native) and `.claude-plugin/`
438/// (Claude Code compat) manifest directories.
439fn 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
457/// Deterministic directory listing: `read_dir` order is filesystem-
458/// dependent, so every first-wins/cap decision sorts entries by file
459/// name first (Task 21 determinism).
460fn 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    // 1. Marketplace pass
481    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    // 2. Plugin pass (subdirs with .synaps-plugin/plugin.json or .claude-plugin/plugin.json)
506    //    Additionally, if a subdir contains a marketplace.json, treat it as a
507    //    nested discovery root and recurse once. This supports the common
508    //    "clone marketplace repo into plugins/" install pattern.
509    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    // 3. Loose-skill pass — scan both root/ and root/skills/ for <name>/SKILL.md
528    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    /// The bounded scan consumes EXACTLY through the closing delimiter
624    /// line: the stream position equals the frontmatter byte length, so
625    /// zero body bytes were pulled from the handle.
626    #[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    /// CRLF line endings scan and parse identically to LF.
644    #[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    /// A closing delimiter line must be exactly `---`: a `----` (or any
657    /// prefixed spelling) does NOT close the frontmatter.
658    #[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    /// A file that ends right at the closing delimiter (with the trailing
672    /// newline, empty body) is skipped; without the trailing newline the
673    /// frontmatter never closes and is also skipped.
674    #[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    /// Bounded identity metadata: oversized/control names and empty or
690    /// control descriptions are refused; long descriptions are retained
691    /// TRUNCATED while the exact frontmatter digest stays authoritative
692    /// (the skill still loads and verifies).
693    #[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        // Exact digest verification is unaffected by the truncated copy.
729        assert_eq!(s.load_body().unwrap(), "Body");
730    }
731
732    /// Selection evidence: the typed snapshot digest equals the SHA-256
733    /// of the exact on-disk bytes, and the struct exposes no Debug.
734    #[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    /// Malformed openings fail closed: content before the first `---`,
747    /// or no frontmatter at all.
748    #[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        // No closing `---`; parse_frontmatter returns ([], full_text) so name/description missing → None.
823        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        // Regression: Claude-Code-style skills reference ${CLAUDE_PLUGIN_ROOT}
846        // (and the bare $CLAUDE_PLUGIN_ROOT form) which must be substituted
847        // to the plugin's canonical root before the body is handed to the model.
848        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        // Loose skills (plugin_root = None) should not receive the substitution.
868        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        // marketplace.json at root
949        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        // plugin at ./web
956        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        // same skill name in both
979        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"); // local wins
991    }
992
993    #[test]
994    fn test_load_all_plugin_dedup_via_marketplace_and_subdir() {
995        // Regression: when a plugin is discovered both through marketplace.json
996        // and through the plugin-subdir walk, load_plugin's root-based dedup guard
997        // must prevent a duplicate Plugin entry and duplicate skill registration.
998        let root = tempdir();
999
1000        // marketplace.json at root pointing to ./web
1001        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        // Plugin at ./web — also discoverable via the plugin-subdir pass
1009        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        // Exactly one plugin registered, not two.
1024        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        // Skill registered exactly once.
1029        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        // Claude-Code-style: marketplace.json under .claude-plugin/, plugin.json
1039        // also under .claude-plugin/.
1040        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        // When both layouts are present, .synaps-plugin/ wins.
1070        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        // Regression: a malformed plugin.json should be skipped with a warning,
1097        // and the walk must continue so other valid plugins still register.
1098        let root = tempdir();
1099
1100        // Broken plugin: invalid JSON in plugin.json
1101        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        // Good plugin alongside it
1110        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        // Only the good plugin registered.
1125        assert_eq!(plugins.len(), 1);
1126        assert_eq!(plugins[0].name, "good");
1127
1128        // Its skill is present.
1129        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    /// Create a unique tempdir under /tmp for tests.
1137    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}