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
551/// The sidecar system is gone. A manifest that still has a
552/// `provides.sidecar` block loads; the block is ignored. Returns the one
553/// warning line for such a manifest.
554fn 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    /// The bounded scan consumes EXACTLY through the closing delimiter
639    /// line: the stream position equals the frontmatter byte length, so
640    /// zero body bytes were pulled from the handle.
641    #[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    /// CRLF line endings scan and parse identically to LF.
659    #[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    /// A closing delimiter line must be exactly `---`: a `----` (or any
672    /// prefixed spelling) does NOT close the frontmatter.
673    #[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    /// A file that ends right at the closing delimiter (with the trailing
687    /// newline, empty body) is skipped; without the trailing newline the
688    /// frontmatter never closes and is also skipped.
689    #[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    /// Bounded identity metadata: oversized/control names and empty or
705    /// control descriptions are refused; long descriptions are retained
706    /// TRUNCATED while the exact frontmatter digest stays authoritative
707    /// (the skill still loads and verifies).
708    #[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        // Exact digest verification is unaffected by the truncated copy.
744        assert_eq!(s.load_body().unwrap(), "Body");
745    }
746
747    /// Selection evidence: the typed snapshot digest equals the SHA-256
748    /// of the exact on-disk bytes, and the struct exposes no Debug.
749    #[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    /// Malformed openings fail closed: content before the first `---`,
762    /// or no frontmatter at all.
763    #[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        // No closing `---`; parse_frontmatter returns ([], full_text) so name/description missing → None.
838        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        // Regression: Claude-Code-style skills reference ${CLAUDE_PLUGIN_ROOT}
861        // (and the bare $CLAUDE_PLUGIN_ROOT form) which must be substituted
862        // to the plugin's canonical root before the body is handed to the model.
863        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        // Loose skills (plugin_root = None) should not receive the substitution.
883        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        // marketplace.json at root
964        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        // plugin at ./web
971        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        // same skill name in both
994        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"); // local wins
1006    }
1007
1008    #[test]
1009    fn test_load_all_plugin_dedup_via_marketplace_and_subdir() {
1010        // Regression: when a plugin is discovered both through marketplace.json
1011        // and through the plugin-subdir walk, load_plugin's root-based dedup guard
1012        // must prevent a duplicate Plugin entry and duplicate skill registration.
1013        let root = tempdir();
1014
1015        // marketplace.json at root pointing to ./web
1016        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        // Plugin at ./web — also discoverable via the plugin-subdir pass
1024        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        // Exactly one plugin registered, not two.
1039        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        // Skill registered exactly once.
1044        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        // Claude-Code-style: marketplace.json under .claude-plugin/, plugin.json
1054        // also under .claude-plugin/.
1055        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        // When both layouts are present, .synaps-plugin/ wins.
1085        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        // Regression: a malformed plugin.json should be skipped with a warning,
1112        // and the walk must continue so other valid plugins still register.
1113        let root = tempdir();
1114
1115        // Broken plugin: invalid JSON in plugin.json
1116        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        // Good plugin alongside it
1125        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        // Only the good plugin registered.
1140        assert_eq!(plugins.len(), 1);
1141        assert_eq!(plugins[0].name, "good");
1142
1143        // Its skill is present.
1144        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    /// The `local-voice` manifest shape (a `provides.sidecar` block next to
1152    /// an extension, commands, settings and keybinds), copied from the
1153    /// installed plugin. The sidecar system is gone; the rest must load.
1154    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        // The extension manager's pre-spawn step accepts it: permissions and
1219        // hooks validate, and with no `deferred` block it is an eager extension.
1220        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    /// Create a unique tempdir under /tmp for tests.
1267    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}