Skip to main content

molo_skills/skill/
mod.rs

1//! Skill: capability packages following the Agent Skills open protocol
2//! (SKILL.md format).
3//!
4//! A skill is a directory containing `SKILL.md` (YAML frontmatter +
5//! Markdown body) and optional `references/` / `scripts/` / `assets/`
6//! resources; the frontmatter `name` / `description` decides when the
7//! model triggers it. This module provides the full mechanism the protocol
8//! defines: **format parsing → directory discovery → progressive
9//! disclosure → activation** (the execution environment after activation
10//! belongs to the application layer; this library does not run scripts).
11//!
12//! The core mechanism is **progressive disclosure**: the model initially
13//! sees only a one-line `name + description` per skill (~100 tokens,
14//! `menu()`); when a task matches the description, [`LoadSkillTool`] reads
15//! the SKILL.md body by name and execution begins. Skills do not bundle
16//! tools — tools stay in [`ToolRegistry`](crate::tool::ToolRegistry), and skills
17//! declare dependencies with `allowed-tools`.
18//!
19//! Companion assembly: [`SkillLayer`] returns the prompt fragment and optional
20//! [`LoadSkillTool`]. Hosts append the fragment to their system prompt and
21//! register the tool explicitly, keeping skill policy outside the agent loop.
22//!
23//! # Example
24//!
25//! Parse a SKILL.md text (a self-contained skill, no resource directory):
26//!
27//! ```
28//! # extern crate molo_skills as molo;
29//! # fn main() -> Result<(), molo::skill::SkillError> {
30//! use molo::skill::Skill;
31//!
32//! let skill = Skill::parse(
33//!     "---\n\
34//!      name: code-review\n\
35//!      description: Review code changes against team conventions, find bugs and style issues\n\
36//!      allowed-tools: Bash(git:*)\n\
37//!      ---\n\
38//!      Review steps: read the diff first, then check each file.",
39//! )?;
40//!
41//! assert_eq!(skill.name(), "code-review");
42//! assert_eq!(skill.description(), "Review code changes against team conventions, find bugs and style issues");
43//! assert_eq!(skill.body(), "Review steps: read the diff first, then check each file.");
44//! assert_eq!(skill.allowed_tools().first().map(|tool| tool.name.as_str()), Some("Bash"));
45//! # Ok(())
46//! # }
47//! ```
48
49use indexmap::IndexMap;
50use serde::{Deserialize, Serialize};
51use std::collections::HashSet;
52use std::path::{Component, Path, PathBuf};
53use std::sync::{Arc, RwLock};
54use tokio::io::AsyncReadExt;
55
56use crate::tool::{
57    Tool, ToolContext, ToolError, ToolMemoryPolicy, ToolNamespace, ToolOutput, ToolPolicy,
58    ToolResult, ToolSchema, ToolSource, ToolTrustLevel,
59};
60
61/// SKILL.md read limit (bytes): prevents a malicious skill package from
62/// blowing up memory at once.
63const MAX_SKILL_FILE_BYTES: u64 = 1024 * 1024;
64
65/// SKILL.md body limit (characters): the body is recorded as resident
66/// context via protected tool output, so a single skill must not grow
67/// unbounded.
68const MAX_SKILL_BODY_CHARS: usize = 256 * 1024;
69
70/// Skill assembly mode.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub enum SkillMode {
74    /// Progressive disclosure: inject a menu and expose `load_skill`.
75    Progressive,
76    /// Inline skill bodies directly into the prompt.
77    Inline,
78    /// Do not automatically inject prompt text or tools.
79    Manual,
80}
81
82/// Trust assigned by the host to skill packages.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[non_exhaustive]
85pub enum SkillSourceTrust {
86    /// Project-local skill.
87    Project,
88    /// User-installed skill.
89    UserInstalled,
90    /// Untrusted skill source.
91    Untrusted,
92}
93
94impl From<SkillSourceTrust> for ToolTrustLevel {
95    fn from(value: SkillSourceTrust) -> Self {
96        match value {
97            SkillSourceTrust::Project => ToolTrustLevel::Project,
98            SkillSourceTrust::UserInstalled => ToolTrustLevel::UserInstalled,
99            SkillSourceTrust::Untrusted => ToolTrustLevel::Untrusted,
100        }
101    }
102}
103
104/// Configuration for [`SkillLayer`] assembly.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(default)]
107#[non_exhaustive]
108pub struct SkillLayerConfig {
109    /// Maximum menu characters emitted into the prompt.
110    pub(crate) max_menu_chars: usize,
111    /// Maximum body characters emitted into the prompt.
112    pub(crate) max_body_chars: usize,
113    /// Maximum reference bytes returned by reference loading tools.
114    pub(crate) max_reference_bytes: usize,
115    /// Whether allowed-tools should be treated as a strict policy hint.
116    pub(crate) strict_allowed_tools: bool,
117    /// Trust assigned to tools exposed by this skill layer.
118    pub(crate) source_trust: SkillSourceTrust,
119}
120
121impl Default for SkillLayerConfig {
122    fn default() -> Self {
123        Self {
124            max_menu_chars: 32 * 1024,
125            max_body_chars: MAX_SKILL_BODY_CHARS,
126            max_reference_bytes: 256 * 1024,
127            strict_allowed_tools: false,
128            source_trust: SkillSourceTrust::Project,
129        }
130    }
131}
132
133impl SkillLayerConfig {
134    /// Constructs a config with default values.
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Maximum menu characters emitted into the prompt.
140    pub fn max_menu_chars(&self) -> usize {
141        self.max_menu_chars
142    }
143
144    /// Returns a config with an updated menu character cap.
145    pub fn with_max_menu_chars(mut self, max_menu_chars: usize) -> Self {
146        self.max_menu_chars = max_menu_chars;
147        self
148    }
149
150    /// Maximum body characters emitted into the prompt.
151    pub fn max_body_chars(&self) -> usize {
152        self.max_body_chars
153    }
154
155    /// Returns a config with an updated body character cap.
156    pub fn with_max_body_chars(mut self, max_body_chars: usize) -> Self {
157        self.max_body_chars = max_body_chars;
158        self
159    }
160
161    /// Maximum reference bytes returned by reference loading tools.
162    pub fn max_reference_bytes(&self) -> usize {
163        self.max_reference_bytes
164    }
165
166    /// Returns a config with an updated reference byte cap.
167    pub fn with_max_reference_bytes(mut self, max_reference_bytes: usize) -> Self {
168        self.max_reference_bytes = max_reference_bytes;
169        self
170    }
171
172    /// Whether allowed-tools should be treated as a strict policy hint.
173    pub fn strict_allowed_tools(&self) -> bool {
174        self.strict_allowed_tools
175    }
176
177    /// Returns a config with updated allowed-tools strictness.
178    pub fn with_strict_allowed_tools(mut self, strict_allowed_tools: bool) -> Self {
179        self.strict_allowed_tools = strict_allowed_tools;
180        self
181    }
182
183    /// Trust assigned to tools exposed by this skill layer.
184    pub fn source_trust(&self) -> SkillSourceTrust {
185        self.source_trust
186    }
187
188    /// Returns a config with updated skill source trust.
189    pub fn with_source_trust(mut self, source_trust: SkillSourceTrust) -> Self {
190        self.source_trust = source_trust;
191        self
192    }
193}
194
195/// Session activation state for a skill layer.
196///
197/// Loaded skills are remembered to avoid duplicate body disclosure. Pinned
198/// skills are explicit host activations whose bodies are injected into the
199/// prompt by [`SkillLayer`].
200#[derive(Clone, Default)]
201pub struct SkillActivationState {
202    loaded: Arc<RwLock<HashSet<String>>>,
203    pinned: Arc<RwLock<Vec<String>>>,
204}
205
206impl SkillActivationState {
207    /// Constructs empty activation state.
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    /// Marks a skill as loaded through progressive disclosure.
213    ///
214    /// Returns `true` when this call newly loaded the skill.
215    pub fn mark_loaded(&self, name: &str) -> bool {
216        self.loaded
217            .write()
218            .expect("SkillActivationState loaded lock poisoned")
219            .insert(name.to_string())
220    }
221
222    /// Pins a skill for prompt injection.
223    ///
224    /// Returns `true` when the skill was newly pinned.
225    pub fn pin(&self, name: &str) -> bool {
226        let mut pinned = self
227            .pinned
228            .write()
229            .expect("SkillActivationState pinned lock poisoned");
230        if pinned.iter().any(|existing| existing == name) {
231            false
232        } else {
233            pinned.push(name.to_string());
234            true
235        }
236    }
237
238    /// Unpins a skill from prompt injection.
239    pub fn unpin(&self, name: &str) -> bool {
240        let mut pinned = self
241            .pinned
242            .write()
243            .expect("SkillActivationState pinned lock poisoned");
244        if let Some(pos) = pinned.iter().position(|existing| existing == name) {
245            pinned.remove(pos);
246            true
247        } else {
248            false
249        }
250    }
251
252    /// Whether the skill has been loaded or pinned in this session.
253    pub fn is_active(&self, name: &str) -> bool {
254        self.loaded
255            .read()
256            .expect("SkillActivationState loaded lock poisoned")
257            .contains(name)
258            || self
259                .pinned
260                .read()
261                .expect("SkillActivationState pinned lock poisoned")
262                .iter()
263                .any(|existing| existing == name)
264    }
265
266    /// Loaded skill names.
267    pub fn loaded(&self) -> Vec<String> {
268        self.loaded
269            .read()
270            .expect("SkillActivationState loaded lock poisoned")
271            .iter()
272            .cloned()
273            .collect()
274    }
275
276    /// Pinned skill names in activation order.
277    pub fn pinned(&self) -> Vec<String> {
278        self.pinned
279            .read()
280            .expect("SkillActivationState pinned lock poisoned")
281            .clone()
282    }
283}
284
285impl std::fmt::Debug for SkillActivationState {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.debug_struct("SkillActivationState")
288            .field("loaded", &self.loaded())
289            .field("pinned", &self.pinned())
290            .finish()
291    }
292}
293
294/// Tool dependencies declared by a skill: tool name + optional scope
295/// (execution belongs to the application layer; this struct only parses
296/// and matches).
297///
298/// Corresponds to the frontmatter `allowed-tools` field (experimental):
299/// entries look like `Bash(git:*)` or `Python` — `name` is the tool name,
300/// `scope` is the argument scope (e.g. `git:*` means any command under the
301/// git namespace).
302///
303/// # Example
304///
305/// ```
306/// # extern crate molo_skills as molo;
307/// use molo::skill::AllowedTool;
308///
309/// let bash_git = AllowedTool {
310///     name: "Bash".into(),
311///     scope: Some("git:*".into()),
312/// };
313/// // exact name match + scope prefix match (after stripping the trailing
314/// // * wildcard)
315/// assert!(bash_git.permits("Bash", "git:diff --stat"));
316/// assert!(!bash_git.permits("Bash", "rm -rf /"));
317/// assert!(!bash_git.permits("Python", "git:log"));
318///
319/// let python = AllowedTool { name: "Python".into(), scope: None };
320/// // no scope: any arguments for the tool are allowed
321/// assert!(python.permits("Python", "print('hello')"));
322/// ```
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct AllowedTool {
325    /// Tool name (same as the tool registered in ToolRegistry).
326    pub name: String,
327    /// Argument scope: prefix-matching rule — when ending with `*`, the
328    /// `*` stands for any suffix and the rest is prefix-matched against
329    /// the argument text; `None` means arguments are unrestricted.
330    pub scope: Option<String>,
331}
332
333impl AllowedTool {
334    /// Whether a tool call falls within the scope this declaration allows.
335    ///
336    /// Pure function: the application layer (execution approval) plugs it
337    /// into permission checks; this library does not reject on its own.
338    ///
339    /// # Panics
340    ///
341    /// Never panics.
342    pub fn permits(&self, tool: &str, args: &str) -> bool {
343        if self.name != tool {
344            return false;
345        }
346        match &self.scope {
347            None => true,
348            Some(scope) => {
349                // Scopes ending in `*` match by prefix after stripping the
350                // wildcard.
351                let prefix = scope.strip_suffix('*').unwrap_or(scope);
352                args.starts_with(prefix)
353            }
354        }
355    }
356}
357
358/// A parsed SKILL.md (a data packet, immutable; `Clone` copies by value).
359///
360/// Constructed by [`Skill::parse`] or [`Skill::from_dir`]; the difference
361/// between the two sources is `base_dir` — the directory source carries
362/// the skill root and supports reading resources via
363/// [`load_reference`](Skill::load_reference); the text source has no
364/// resource directory and returns [`SkillError::NotFound`] when reading.
365///
366/// Unknown top-level frontmatter fields (e.g. `user-invocable` carried by
367/// ecosystem skills) do not error: they are stringified into
368/// [`metadata`](Skill::metadata) for the reader to interpret.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Skill {
371    name: String,
372    description: String,
373    body: String,
374    license: Option<String>,
375    compatibility: Option<String>,
376    metadata: Vec<(String, String)>,
377    allowed_tools: Vec<AllowedTool>,
378    resources: Vec<PathBuf>,
379    base_dir: Option<PathBuf>,
380}
381
382impl Skill {
383    /// Parse SKILL.md text (pure in-memory operation, synchronous).
384    ///
385    /// frontmatter validation:
386    /// - `name` required: 1-64 characters, kebab-case (lowercase letters /
387    ///   digits / hyphens, not starting or ending with a hyphen, no
388    ///   consecutive hyphens);
389    /// - `description` required: non-empty, at most 1024 characters;
390    /// - `compatibility` optional: at most 500 characters;
391    /// - `allowed-tools` optional: supports a space-separated string form
392    ///   (`Bash(git:*) Python`) and a YAML list form (`- Bash(git:*)`);
393    /// - `metadata` optional: a key-value block with values stringified
394    ///   (quotes stripped, numbers / booleans converted as-is to text);
395    ///   nested structures are not supported and report
396    ///   [`SkillError::InvalidFrontmatter`];
397    /// - **unknown top-level fields are tolerated**: merged into metadata
398    ///   (e.g. `user-invocable: true` → `("user-invocable", "true")`), so
399    ///   ecosystem skills are not rejected.
400    ///
401    /// Body = everything after the end delimiter (a single newline right
402    /// after the delimiter is stripped).
403    ///
404    /// # Errors
405    ///
406    /// All validation failures are described by [`SkillError`]; a missing
407    /// or malformed `name` → [`SkillError::InvalidName`], a missing /
408    /// empty / too-long `description` →
409    /// [`SkillError::InvalidDescription`], any other frontmatter
410    /// structural issue → [`SkillError::InvalidFrontmatter`].
411    ///
412    /// # Example
413    ///
414    /// ```
415    /// # extern crate molo_skills as molo;
416    /// use molo::skill::Skill;
417    ///
418    /// let skill = Skill::parse("---\nname: greet\ndescription: Say hello\n---\nHello!").unwrap();
419    /// assert_eq!(skill.name(), "greet");
420    ///
421    /// // invalid name (uppercase letter): parsing fails
422    /// assert!(Skill::parse("---\nname: Greet\ndescription: Say hello\n---\n").is_err());
423    /// ```
424    pub fn parse(content: &str) -> Result<Skill, SkillError> {
425        let (fm, body) = parse_frontmatter(content)?;
426
427        let name = fm
428            .name
429            .ok_or_else(|| SkillError::InvalidName("missing name field".into()))?;
430        validate_name(&name)?;
431
432        let description = fm
433            .description
434            .ok_or_else(|| SkillError::InvalidDescription("missing description field".into()))?;
435        validate_description(&description)?;
436
437        // Body limit: the body is recorded as protected resident context,
438        // so reject when over the limit.
439        if body.chars().count() > MAX_SKILL_BODY_CHARS {
440            return Err(SkillError::InvalidBody(format!(
441                "body exceeds size limit ({MAX_SKILL_BODY_CHARS} chars)"
442            )));
443        }
444
445        Ok(Skill {
446            name,
447            description,
448            body,
449            license: fm.license,
450            compatibility: fm.compatibility,
451            metadata: fm.metadata,
452            allowed_tools: fm.allowed_tools,
453            resources: Vec::new(),
454            base_dir: None,
455        })
456    }
457
458    /// Load from a skill directory: read `SKILL.md` + verify the directory
459    /// name matches the skill name + collect the resource list.
460    ///
461    /// Directory layout follows the protocol: the directory contains
462    /// `SKILL.md`, and optional `references/` / `scripts/` / `assets/`
463    /// subdirectories are collected recursively into
464    /// [`resources`](Skill::resources) (relative paths, stable ordering).
465    ///
466    /// # Errors
467    ///
468    /// - no `SKILL.md` in the directory → [`SkillError::NotFound`];
469    /// - parsing failures (see [`Skill::parse`]) propagate as-is;
470    /// - directory name differs from the skill `name` →
471    ///   [`SkillError::NameMismatch`];
472    /// - filesystem errors → [`SkillError::Io`].
473    pub async fn from_dir(path: &Path) -> Result<Skill, SkillError> {
474        // Symlink defense: after resolution, SKILL.md must still be inside
475        // the skill directory. The directory itself may be a symlink (e.g.
476        // ~/skills → /mnt/skills); but if SKILL.md is a link to a file
477        // outside the directory, its content would be read into the model
478        // context, so it is rejected just like [`load_reference`].
479        let dir = tokio::fs::canonicalize(path)
480            .await
481            .map_err(|e| SkillError::Io(format!("cannot resolve skill root: {e}")))?;
482        let content = {
483            let skill_md = match tokio::fs::canonicalize(dir.join("SKILL.md")).await {
484                Ok(c) => c,
485                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
486                    // Include the directory path so batch discovery
487                    // failures can be located.
488                    return Err(SkillError::NotFound(format!(
489                        "no SKILL.md in directory: {}",
490                        dir.display()
491                    )));
492                }
493                Err(e) => return Err(e.into()),
494            };
495            if !skill_md.starts_with(&dir) {
496                return Err(SkillError::NotFound("SKILL.md escapes skill root".into()));
497            }
498            // Read limit: reject when over the limit instead of reading
499            // it all into memory (prevents malicious skill packages from
500            // blowing up memory).
501            let mut buf = String::new();
502            let file = tokio::fs::File::open(&skill_md).await?;
503            file.take(MAX_SKILL_FILE_BYTES + 1)
504                .read_to_string(&mut buf)
505                .await?;
506            if buf.len() > MAX_SKILL_FILE_BYTES as usize {
507                return Err(SkillError::InvalidBody(format!(
508                    "SKILL.md exceeds size limit ({MAX_SKILL_FILE_BYTES} bytes)"
509                )));
510            }
511            buf
512        };
513
514        let mut skill = Skill::parse(&content)?;
515        let dir_name = dir
516            .file_name()
517            .and_then(|n| n.to_str())
518            .unwrap_or_default()
519            .to_string();
520        if skill.name != dir_name {
521            return Err(SkillError::NameMismatch {
522                name: skill.name.clone(),
523                dir: dir_name,
524            });
525        }
526        skill.resources = collect_resources(&dir).await;
527        skill.base_dir = Some(dir);
528        Ok(skill)
529    }
530
531    /// Skill name (kebab-case).
532    pub fn name(&self) -> &str {
533        &self.name
534    }
535
536    /// Skill description: one sentence of "what it does + when to use it",
537    /// the basis on which the model decides whether to trigger it.
538    pub fn description(&self) -> &str {
539        &self.description
540    }
541
542    /// SKILL.md body (the content shown to the model once triggered).
543    pub fn body(&self) -> &str {
544        &self.body
545    }
546
547    /// License (SPDX identifier, optional).
548    pub fn license(&self) -> Option<&str> {
549        self.license.as_deref()
550    }
551
552    /// Compatibility note (optional, e.g. applicable frameworks and
553    /// versions).
554    pub fn compatibility(&self) -> Option<&str> {
555        self.compatibility.as_deref()
556    }
557
558    /// Arbitrary metadata key-values (including unknown top-level fields,
559    /// stringified; keeps frontmatter order).
560    pub fn metadata(&self) -> &[(String, String)] {
561        &self.metadata
562    }
563
564    /// Declared tool dependencies (`allowed-tools`), which the application
565    /// layer uses for execution approval.
566    pub fn allowed_tools(&self) -> &[AllowedTool] {
567        &self.allowed_tools
568    }
569
570    /// Resource file list (paths relative to the skill root; empty for
571    /// text-parsed skills).
572    pub fn resources(&self) -> &[PathBuf] {
573        &self.resources
574    }
575
576    /// Skill root directory (`Some` for directory-loaded skills; `None`
577    /// for text-parsed skills, which have no resource directory).
578    pub fn base_dir(&self) -> Option<&Path> {
579        self.base_dir.as_deref()
580    }
581
582    /// Read the content of a resource file (decoded as UTF-8 text).
583    ///
584    /// `name` is a relative resource path (e.g. `references/style.md`);
585    /// absolute paths and paths containing `..` are rejected. **Symlink
586    /// defense**: after `canonicalize`, the target must still be inside
587    /// the skill root; links pointing outside the directory (which
588    /// malicious skill packages could use to read arbitrary files) return
589    /// [`SkillError::NotFound`].
590    ///
591    /// # Errors
592    ///
593    /// - the skill comes from text parsing and has no resource directory,
594    ///   or the resource does not exist / the path is invalid / the link
595    ///   escapes the skill directory → [`SkillError::NotFound`];
596    /// - filesystem errors while reading → [`SkillError::Io`].
597    pub async fn load_reference(&self, name: &str) -> Result<String, SkillError> {
598        let Some(base) = &self.base_dir else {
599            return Err(SkillError::NotFound(
600                "no resource directory: skill parsed from text".into(),
601            ));
602        };
603        let name_path = Path::new(name);
604        if name_path.is_absolute()
605            || name_path.components().any(|c| {
606                matches!(
607                    c,
608                    Component::ParentDir | Component::RootDir | Component::Prefix(_)
609                )
610            })
611        {
612            return Err(SkillError::NotFound(format!(
613                "invalid resource path: {name}"
614            )));
615        }
616        let base = tokio::fs::canonicalize(base)
617            .await
618            .map_err(|e| SkillError::Io(format!("cannot resolve skill root: {e}")))?;
619        let canonical = tokio::fs::canonicalize(base.join(name_path))
620            .await
621            .map_err(|e| match e.kind() {
622                std::io::ErrorKind::NotFound => {
623                    SkillError::NotFound(format!("resource not found: {name}"))
624                }
625                _ => SkillError::Io(e.to_string()),
626            })?;
627        if !canonical.starts_with(&base) {
628            return Err(SkillError::NotFound(format!(
629                "resource escapes skill root: {name}"
630            )));
631        }
632        tokio::fs::read_to_string(canonical)
633            .await
634            .map_err(|e| match e.kind() {
635                std::io::ErrorKind::NotFound => {
636                    SkillError::NotFound(format!("resource not found: {name}"))
637                }
638                _ => SkillError::Io(e.to_string()),
639            })
640    }
641}
642
643/// Skill registry: holds a collection of skills, responsible for lookup
644/// and disclosure by name.
645///
646/// Internally an ordered name → skill map guarded by a read-write lock
647/// (read-heavy, O(1) lookup by name, registration order preserved);
648/// `add` / `remove` take `&self`, so the application can hold the registry
649/// handle and **hot-swap** — add/remove does not depend on
650/// construction-time ordering, and the next read (menu / lookup) takes
651/// effect immediately.
652///
653/// Cloning is a deep copy: each registry holds its own independent skill
654/// collection, so add/remove do not affect each other.
655///
656/// # Panics
657///
658/// If the internal read-write lock gets poisoned (a method panics while
659/// still holding it), subsequent calls panic. Normal operation (no public
660/// method enters a panic path) does not trigger this.
661///
662/// # Example
663///
664/// ```
665/// # extern crate molo_skills as molo;
666/// # fn main() -> Result<(), molo::skill::SkillError> {
667/// use molo::skill::{Skill, SkillRegistry};
668///
669/// let registry = SkillRegistry::new();
670/// let skill = Skill::parse("---\nname: greet\ndescription: Say hello\n---\nHello!")?;
671/// registry.add(skill);
672///
673/// assert_eq!(registry.menu(), "- greet: Say hello");
674/// assert_eq!(
675///     registry.get("greet").map(|skill| skill.body().to_string()),
676///     Some("Hello!".to_string())
677/// );
678/// // re-registering the same name: replaces the original skill, position
679/// // unchanged
680/// let v2 = Skill::parse("---\nname: greet\ndescription: Say hello\n---\nGood morning!")?;
681/// registry.add(v2);
682/// assert_eq!(
683///     registry.get("greet").map(|skill| skill.body().to_string()),
684///     Some("Good morning!".to_string())
685/// );
686/// assert_eq!(registry.skills().len(), 1);
687/// # Ok(())
688/// # }
689/// ```
690#[derive(Default)]
691pub struct SkillRegistry {
692    /// Ordered name → skill map: O(1) lookup by name while preserving
693    /// registration order (order affects the menu and static assembly
694    /// presentation).
695    skills: RwLock<IndexMap<String, Skill>>,
696}
697
698impl Clone for SkillRegistry {
699    /// std RwLock has no Clone: copy the contents under the lock and
700    /// rebuild.
701    fn clone(&self) -> Self {
702        let skills = self
703            .skills
704            .read()
705            .expect("SkillRegistry internal lock poisoned")
706            .clone();
707        Self {
708            skills: RwLock::new(skills),
709        }
710    }
711}
712
713impl SkillRegistry {
714    /// Create an empty registry.
715    pub fn new() -> Self {
716        Self::default()
717    }
718
719    /// Register a skill; a same-named skill **replaces** the original
720    /// (position unchanged), returning `self` for chaining.
721    ///
722    /// Registration is an explicit operation: a skill must first pass
723    /// validation via [`Skill::parse`] / [`Skill::from_dir`]; invalid
724    /// skills cannot enter the registry.
725    pub fn add(&self, skill: Skill) -> &Self {
726        let mut guard = self
727            .skills
728            .write()
729            .expect("SkillRegistry internal lock poisoned");
730        guard.insert(skill.name.clone(), skill);
731        self
732    }
733
734    /// Remove a skill (by name); returns `true` when removed, `false`
735    /// when the skill does not exist.
736    ///
737    /// This is the developer's physical management interface (upgrading /
738    /// retiring skills); session-level "invisibility" filtering uses
739    /// [`SkillLayer::with_enabled_skills`], and metadata can stay in the
740    /// registry.
741    pub fn remove(&self, name: &str) -> bool {
742        let mut guard = self
743            .skills
744            .write()
745            .expect("SkillRegistry internal lock poisoned");
746        guard.shift_remove(name).is_some()
747    }
748
749    /// Get a skill by name (**cloned**, so the lock-held reference does
750    /// not escape; O(1) lookup by name); returns `None` when missing.
751    pub fn get(&self, name: &str) -> Option<Skill> {
752        let guard = self
753            .skills
754            .read()
755            .expect("SkillRegistry internal lock poisoned");
756        guard.get(name).cloned()
757    }
758
759    /// Scan a directory and discover all skill directories within it
760    /// (reading `SKILL.md` from each subdirectory).
761    ///
762    /// **Lenient discovery**: bad skills (parse failure / directory name
763    /// mismatch / no SKILL.md) are skipped with the reason logged via
764    /// `tracing::warn`, without taking down the whole registry; only an
765    /// unreadable root directory itself returns an error.
766    ///
767    /// # Errors
768    ///
769    /// An unreadable root directory → [`SkillError::Io`].
770    pub async fn from_dir(path: &Path) -> Result<Self, SkillError> {
771        let registry = SkillRegistry::new();
772        let mut entries = tokio::fs::read_dir(path).await?;
773        let mut dirs = Vec::new();
774        // Directory iteration / type probing failures: skip the entry
775        // without taking down the whole discovery.
776        loop {
777            match entries.next_entry().await {
778                Ok(Some(entry)) => {
779                    let is_dir = match entry.file_type().await {
780                        Ok(ft) => ft.is_dir(),
781                        Err(_) => false,
782                    };
783                    if is_dir {
784                        dirs.push(entry.path());
785                    }
786                }
787                Ok(None) => break,
788                Err(e) => {
789                    // Single-entry read failure: warn and skip, do not
790                    // interrupt other directories.
791                    #[cfg(feature = "tracing")]
792                    tracing::warn!("failed to read skill directory entry: {e}");
793                    #[cfg(not(feature = "tracing"))]
794                    let _ = e;
795                    continue;
796                }
797            }
798        }
799        for dir in dirs {
800            match Skill::from_dir(&dir).await {
801                Ok(skill) => {
802                    registry.add(skill);
803                }
804                Err(err) => {
805                    #[cfg(feature = "tracing")]
806                    tracing::warn!("skipping skill directory {}: {err}", dir.display());
807                    #[cfg(not(feature = "tracing"))]
808                    let _ = err;
809                }
810            }
811        }
812        Ok(registry)
813    }
814    /// Discover skills from multiple directories and merge (multi-source
815    /// loading).
816    ///
817    /// Typical scenario: user-level and project-level skill directories as
818    /// two sources (concrete paths are the caller's decision; this library
819    /// does not hardcode directory conventions). Directories are scanned
820    /// in argument order, and **later-loaded skills with the same name
821    /// override earlier ones** (argument order is priority: put the
822    /// project level last so it overrides the user level).
823    ///
824    /// **Lenient discovery**: missing directories, unreadable directories
825    /// and bad skills are all skipped with the reason logged via
826    /// `tracing::warn`, without taking down other sources; if all sources
827    /// are unusable, an empty registry is returned.
828    ///
829    /// # Example
830    ///
831    /// ```
832    /// # extern crate molo_skills as molo;
833    /// # #[tokio::main]
834    /// # async fn main() {
835    /// use molo::skill::SkillRegistry;
836    ///
837    /// // neither source exists: skipped leniently, resulting in an empty
838    /// // registry
839    /// let skills =
840    ///     SkillRegistry::from_dirs(&["molo-nonexistent-a", "molo-nonexistent-b"]).await;
841    /// assert!(skills.skills().is_empty());
842    /// # }
843    /// ```
844    pub async fn from_dirs<P: AsRef<Path>>(paths: &[P]) -> Self {
845        let registry = SkillRegistry::new();
846        for path in paths {
847            let path = path.as_ref();
848            match Self::from_dir(path).await {
849                Ok(found) => {
850                    for skill in found.skills() {
851                        registry.add(skill);
852                    }
853                }
854                Err(err) => {
855                    #[cfg(feature = "tracing")]
856                    tracing::warn!("skipping skill source directory {}: {err}", path.display());
857                    #[cfg(not(feature = "tracing"))]
858                    let _ = err;
859                }
860            }
861        }
862        registry
863    }
864
865    /// Disclosure block: one line `- {name}: {description}` per skill, in
866    /// registration order.
867    ///
868    /// This is the first step of progressive disclosure — the model uses
869    /// it to decide whether to load a skill by name; one line per skill
870    /// keeps the resident system-prompt cost fixed and negligible.
871    pub fn menu(&self) -> String {
872        let guard = self
873            .skills
874            .read()
875            .expect("SkillRegistry internal lock poisoned");
876        let mut out = String::new();
877        for (i, skill) in guard.values().enumerate() {
878            if i > 0 {
879                out.push('\n');
880            }
881            out.push_str(&format!("- {}: {}", skill.name, skill.description));
882        }
883        out
884    }
885
886    /// A cloned snapshot of all skills (in registration order).
887    ///
888    /// For static assembly scenarios, take the snapshot and build the
889    /// system prompt yourself, bypassing the disclosure flow.
890    pub fn skills(&self) -> Vec<Skill> {
891        let guard = self
892            .skills
893            .read()
894            .expect("SkillRegistry internal lock poisoned");
895        guard.values().cloned().collect()
896    }
897}
898
899impl std::fmt::Debug for SkillRegistry {
900    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
901        // Do not block when the lock is held (try_read fails): Debug is
902        // only for display.
903        match self.skills.try_read() {
904            Ok(guard) => f
905                .debug_list()
906                .entries(guard.values().map(|s| s.name.as_str()))
907                .finish(),
908            Err(_) => f.write_str("<locked>"),
909        }
910    }
911}
912
913impl Extend<Skill> for SkillRegistry {
914    fn extend<I>(&mut self, iter: I)
915    where
916        I: IntoIterator<Item = Skill>,
917    {
918        for skill in iter {
919            self.add(skill);
920        }
921    }
922}
923
924impl FromIterator<Skill> for SkillRegistry {
925    fn from_iter<I>(iter: I) -> Self
926    where
927        I: IntoIterator<Item = Skill>,
928    {
929        let mut registry = Self::new();
930        registry.extend(iter);
931        registry
932    }
933}
934
935/// Assembled output of a [`SkillLayer`].
936#[derive(Debug, Clone)]
937pub struct SkillLayerAssembly {
938    /// Prompt fragment to append to the host's system prompt.
939    pub prompt_fragment: String,
940    /// Progressive disclosure loader tool, when this mode exposes one.
941    pub load_skill_tool: Option<LoadSkillTool>,
942    /// Assembly manifest for transcript/debug use.
943    pub manifest: SkillLayerManifest,
944}
945
946/// Skill layer manifest for transcript/debug use.
947#[derive(Debug, Clone, PartialEq, Eq)]
948pub struct SkillLayerManifest {
949    /// Layer id.
950    pub layer_id: String,
951    /// Assembly mode.
952    pub mode: SkillMode,
953    /// Visible skill names.
954    pub visible_skills: Vec<String>,
955    /// Active skill names.
956    pub active_skills: Vec<String>,
957}
958
959/// Optional Agent Skills extension layer.
960///
961/// The layer assembles progressive-disclosure prompt text and tools without
962/// making `ReActAgent` own skill policy or execution.
963/// Skill scripts are never executed by this layer.
964#[derive(Debug, Clone)]
965pub struct SkillLayer {
966    registry: Arc<SkillRegistry>,
967    enabled: Option<Arc<HashSet<String>>>,
968    mode: SkillMode,
969    activation: SkillActivationState,
970    config: SkillLayerConfig,
971    layer_id: String,
972}
973
974impl SkillLayer {
975    /// Constructs a progressive skill layer over a registry.
976    pub fn new(registry: Arc<SkillRegistry>) -> Self {
977        Self {
978            registry,
979            enabled: None,
980            mode: SkillMode::Progressive,
981            activation: SkillActivationState::new(),
982            config: SkillLayerConfig::default(),
983            layer_id: "skills".to_string(),
984        }
985    }
986
987    /// Restricts visible skills by name.
988    pub fn with_enabled_skills(mut self, names: &[&str]) -> Self {
989        self.enabled = Some(Arc::new(
990            names.iter().map(|name| name.to_string()).collect(),
991        ));
992        self
993    }
994
995    /// Restricts visible skills using a shared allowlist.
996    pub fn with_enabled_set(mut self, enabled: Option<Arc<HashSet<String>>>) -> Self {
997        self.enabled = enabled;
998        self
999    }
1000
1001    /// Sets assembly mode.
1002    pub fn with_mode(mut self, mode: SkillMode) -> Self {
1003        self.mode = mode;
1004        self
1005    }
1006
1007    /// Sets assembly configuration.
1008    pub fn with_config(mut self, config: SkillLayerConfig) -> Self {
1009        self.config = config;
1010        self
1011    }
1012
1013    /// Sets the layer id used in source metadata.
1014    pub fn with_layer_id(mut self, layer_id: impl Into<String>) -> Self {
1015        self.layer_id = layer_id.into();
1016        self
1017    }
1018
1019    /// Returns the shared activation state.
1020    pub fn activation_state(&self) -> SkillActivationState {
1021        self.activation.clone()
1022    }
1023
1024    /// Returns the registry backing this layer.
1025    pub fn registry(&self) -> Arc<SkillRegistry> {
1026        Arc::clone(&self.registry)
1027    }
1028
1029    /// Returns the assembly mode.
1030    pub fn mode(&self) -> SkillMode {
1031        self.mode
1032    }
1033
1034    /// Whether a skill is visible in this layer.
1035    pub fn is_enabled(&self, name: &str) -> bool {
1036        match &self.enabled {
1037            None => true,
1038            Some(enabled) => enabled.contains(name),
1039        }
1040    }
1041
1042    /// Pins a skill into the prompt.
1043    pub fn activate_skill(&self, name: &str) -> bool {
1044        if self.mode != SkillMode::Progressive {
1045            return false;
1046        }
1047        if !self.is_enabled(name) || self.registry.get(name).is_none() {
1048            return false;
1049        }
1050        self.activation.pin(name);
1051        true
1052    }
1053
1054    /// Removes a pinned skill from the prompt.
1055    pub fn deactivate_skill(&self, name: &str) -> bool {
1056        if self.mode != SkillMode::Progressive {
1057            return false;
1058        }
1059        self.activation.unpin(name)
1060    }
1061
1062    /// Assembles prompt text and tools for the current registry snapshot.
1063    pub fn assemble(&self) -> SkillLayerAssembly {
1064        let visible = self.visible_skills();
1065        let prompt_fragment = match self.mode {
1066            SkillMode::Manual => String::new(),
1067            SkillMode::Progressive => self.progressive_prompt(&visible),
1068            SkillMode::Inline => self.inline_prompt(&visible),
1069        };
1070        let load_skill_tool = if self.mode == SkillMode::Progressive {
1071            Some(LoadSkillTool::with_activation(
1072                Arc::clone(&self.registry),
1073                self.enabled.clone(),
1074                self.activation.clone(),
1075            ))
1076        } else {
1077            None
1078        };
1079        SkillLayerAssembly {
1080            prompt_fragment,
1081            load_skill_tool,
1082            manifest: SkillLayerManifest {
1083                layer_id: self.layer_id.clone(),
1084                mode: self.mode,
1085                visible_skills: visible
1086                    .iter()
1087                    .map(|skill| skill.name().to_string())
1088                    .collect(),
1089                active_skills: {
1090                    let mut active = self.activation.loaded();
1091                    for pinned in self.activation.pinned() {
1092                        if !active.contains(&pinned) {
1093                            active.push(pinned);
1094                        }
1095                    }
1096                    active
1097                },
1098            },
1099        }
1100    }
1101
1102    fn visible_skills(&self) -> Vec<Skill> {
1103        self.registry
1104            .skills()
1105            .into_iter()
1106            .filter(|skill| self.is_enabled(skill.name()))
1107            .collect()
1108    }
1109
1110    fn progressive_prompt(&self, visible: &[Skill]) -> String {
1111        let menu: Vec<String> = visible
1112            .iter()
1113            .filter(|skill| !self.activation.is_active(skill.name()))
1114            .map(|skill| format!("- {}: {}", skill.name(), skill.description()))
1115            .collect();
1116        let mut out = join_limited_sections(menu, self.config.max_menu_chars);
1117        let pinned: Vec<String> = self
1118            .activation
1119            .pinned()
1120            .into_iter()
1121            .filter_map(|name| self.registry.get(&name))
1122            .map(|skill| {
1123                format!(
1124                    "[Skill {}]\n{}",
1125                    skill.name(),
1126                    limit_chars(skill.body(), self.config.max_body_chars)
1127                )
1128            })
1129            .collect();
1130        append_sections(&mut out, &pinned);
1131        out
1132    }
1133
1134    fn inline_prompt(&self, visible: &[Skill]) -> String {
1135        let bodies = visible.iter().map(|skill| {
1136            format!(
1137                "[Skill {}]\n{}",
1138                skill.name(),
1139                limit_chars(skill.body(), self.config.max_body_chars)
1140            )
1141        });
1142        join_limited_sections(
1143            bodies,
1144            self.config.max_body_chars.saturating_mul(visible.len()),
1145        )
1146    }
1147
1148    /// Source metadata for the `load_skill` tool produced by this layer.
1149    pub fn load_skill_source(&self) -> ToolSource {
1150        LoadSkillTool::source(self.layer_id.clone(), self.config.source_trust.into())
1151    }
1152}
1153
1154fn append_sections(out: &mut String, sections: &[String]) {
1155    for section in sections {
1156        if section.is_empty() {
1157            continue;
1158        }
1159        if !out.is_empty() {
1160            out.push_str("\n\n");
1161        }
1162        out.push_str(section);
1163    }
1164}
1165
1166fn join_limited_sections(sections: impl IntoIterator<Item = String>, max_chars: usize) -> String {
1167    let mut out = String::new();
1168    for section in sections {
1169        if section.is_empty() {
1170            continue;
1171        }
1172        let separator = if out.is_empty() { "" } else { "\n" };
1173        let next_len = out.chars().count() + separator.chars().count() + section.chars().count();
1174        if next_len > max_chars {
1175            if !out.is_empty() {
1176                out.push_str("\n[truncated]");
1177            }
1178            break;
1179        }
1180        out.push_str(separator);
1181        out.push_str(&section);
1182    }
1183    out
1184}
1185
1186fn limit_chars(text: &str, max_chars: usize) -> String {
1187    let mut out = String::new();
1188    for (idx, ch) in text.chars().enumerate() {
1189        if idx >= max_chars {
1190            out.push_str("\n[truncated]");
1191            return out;
1192        }
1193        out.push(ch);
1194    }
1195    out
1196}
1197
1198/// Skill loading tool: reads the SKILL.md body by name; the second step of
1199/// progressive disclosure.
1200///
1201/// The model picks a skill from the system-prompt menu and calls this tool
1202/// (argument `name`), which returns the skill body as the tool result —
1203/// the body then enters the conversation ledger as protected tool output
1204/// ([`ToolMemoryPolicy::Protected`]), exempt from window trimming, so skill
1205/// instructions persist in long conversations.
1206///
1207/// The returned content is wrapped in structured tags (so the model can
1208/// distinguish skill instructions from ordinary conversation content, and
1209/// context compression can recognize it), and lists the resource files
1210/// (not pre-read; the model reads them on demand via
1211/// [`Skill::load_reference`]). **In-session deduplication**: re-loading an
1212/// already activated skill returns a notice without re-injecting the body
1213/// (the body is already resident; re-injection would be pure waste).
1214///
1215/// The body length should stay within 5000 tokens / 500 lines; anything
1216/// beyond goes into `references/` resources read via
1217/// [`Skill::load_reference`] (protocol recommendation, not enforced).
1218///
1219/// [`SkillLayer`] returns this tool in progressive mode; hosts register it
1220/// into the ToolRegistry with [`SkillLayer::load_skill_source`]. `enabled` is
1221/// the session allowlist view (`None` = all visible), and skills outside the
1222/// allowlist are refused. The `name` argument is constrained by enum to
1223/// allowlisted skill names (queried fresh from the registry each turn, so
1224/// hot-swaps are reflected per turn), preventing the model from hallucinating
1225/// nonexistent skills.
1226///
1227/// # Errors
1228///
1229/// - missing `name` argument → [`ToolError::InvalidArguments`];
1230/// - the skill exists but is not in the allowlist → [`ToolError::Execution`]("skill not enabled");
1231/// - the skill does not exist → [`ToolError::Execution`]("skill not found").
1232///
1233/// Errors are passed back to the model by the agent loop as ToolResult
1234/// text, and the model decides what to do next.
1235#[derive(Debug, Clone)]
1236pub struct LoadSkillTool {
1237    registry: Arc<SkillRegistry>,
1238    enabled: Option<Arc<HashSet<String>>>,
1239    /// Skills activated in this session (dedup).
1240    activated: SkillActivationState,
1241}
1242
1243impl LoadSkillTool {
1244    /// Construct: holds a registry handle and an optional allowlist
1245    /// (`None` = all skills visible).
1246    pub fn new(registry: Arc<SkillRegistry>, enabled: Option<Arc<HashSet<String>>>) -> Self {
1247        Self {
1248            registry,
1249            enabled,
1250            activated: SkillActivationState::new(),
1251        }
1252    }
1253
1254    /// Construct with shared activation state.
1255    pub fn with_activation(
1256        registry: Arc<SkillRegistry>,
1257        enabled: Option<Arc<HashSet<String>>>,
1258        activated: SkillActivationState,
1259    ) -> Self {
1260        Self {
1261            registry,
1262            enabled,
1263            activated,
1264        }
1265    }
1266
1267    /// Source metadata for source-aware tool registration.
1268    pub fn source(layer_id: impl Into<String>, trust: ToolTrustLevel) -> ToolSource {
1269        ToolSource::new(
1270            ToolNamespace::skill_layer(layer_id),
1271            "load_skill",
1272            "load_skill",
1273        )
1274        .with_trust(trust)
1275    }
1276}
1277
1278#[async_trait::async_trait]
1279impl Tool for LoadSkillTool {
1280    fn schema(&self) -> ToolSchema {
1281        // Base schema generated by schemars from the argument type (same
1282        // as tools generated by the `#[molo::tool]` macro); the name enum
1283        // is a runtime constraint — query the registry for allowlisted
1284        // skill names, updated per turn as hot-swaps happen (empty = no
1285        // usable skills, so the model will not call).
1286        let mut parameters = serde_json::to_value(schemars::schema_for!(LoadSkillArgs))
1287            .expect("LoadSkillArgs JSON Schema serialization must not fail");
1288        let available: Vec<String> = self
1289            .registry
1290            .skills()
1291            .iter()
1292            .filter(|s| self.is_enabled(s.name()))
1293            .map(|s| s.name().to_string())
1294            .collect();
1295        parameters["properties"]["name"]["enum"] = serde_json::json!(available);
1296        ToolSchema::new(
1297            "load_skill",
1298            "Load and activate a skill: the name argument is the skill name, and the skill body is returned. The available skills are listed in the system prompt.",
1299            parameters,
1300        )
1301        .with_policy(ToolPolicy {
1302            memory_policy: ToolMemoryPolicy::Protected,
1303            ..Default::default()
1304        })
1305    }
1306
1307    async fn call(
1308        &self,
1309        arguments: serde_json::Value,
1310        _context: ToolContext<'_>,
1311    ) -> Result<ToolResult, ToolError> {
1312        // Argument parsing is the same as for tools generated by
1313        // `#[molo::tool]`: serde deserialization, failures classified as
1314        // InvalidArguments (error text passed back to the model).
1315        let name = serde_json::from_value::<LoadSkillArgs>(arguments)
1316            .map_err(ToolError::from)?
1317            .name;
1318        if !self.is_enabled(&name) {
1319            return Err(ToolError::Execution(format!(
1320                "skill '{name}' is not enabled"
1321            )));
1322        }
1323        let skill = match self.registry.get(&name) {
1324            Some(skill) => skill,
1325            None => return Err(ToolError::Execution(format!("skill '{name}' not found"))),
1326        };
1327        // In-session dedup: the body is protected and resident, so
1328        // already activated skills are not re-injected (the notice lets
1329        // the model know it is "already loaded" and not to call again).
1330        if !self.activated.mark_loaded(&name) {
1331            return Ok(ToolOutput::text(format!(
1332                "skill '{name}' is already active in this conversation"
1333            ))
1334            .with_memory_policy(ToolMemoryPolicy::Protected)
1335            .into());
1336        }
1337        Ok(ToolOutput::text(format_skill_content(&skill))
1338            .with_memory_policy(ToolMemoryPolicy::Protected)
1339            .into())
1340    }
1341}
1342
1343/// Arguments for load_skill (defined the same way as tools generated by
1344/// `#[molo::tool]`: serde deserialization + schemars-generated JSON
1345/// Schema).
1346#[derive(serde::Deserialize, schemars::JsonSchema)]
1347struct LoadSkillArgs {
1348    /// Skill name (the available skills are listed in the system-prompt
1349    /// menu).
1350    name: String,
1351}
1352
1353impl LoadSkillTool {
1354    /// Whether the skill is in the session allowlist (no allowlist = all
1355    /// visible).
1356    fn is_enabled(&self, name: &str) -> bool {
1357        match &self.enabled {
1358            None => true,
1359            Some(enabled) => enabled.contains(name),
1360        }
1361    }
1362}
1363
1364/// Skill resource loading limits.
1365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1366#[serde(default)]
1367#[non_exhaustive]
1368pub struct SkillResourceStore {
1369    /// Maximum bytes returned from a reference resource.
1370    pub(crate) max_reference_bytes: usize,
1371}
1372
1373impl Default for SkillResourceStore {
1374    fn default() -> Self {
1375        Self {
1376            max_reference_bytes: 256 * 1024,
1377        }
1378    }
1379}
1380
1381impl SkillResourceStore {
1382    /// Constructs a store config with default limits.
1383    pub fn new() -> Self {
1384        Self::default()
1385    }
1386
1387    /// Maximum bytes returned from a reference resource.
1388    pub fn max_reference_bytes(&self) -> usize {
1389        self.max_reference_bytes
1390    }
1391
1392    /// Returns a store config with an updated reference byte cap.
1393    pub fn with_max_reference_bytes(mut self, max_reference_bytes: usize) -> Self {
1394        self.max_reference_bytes = max_reference_bytes;
1395        self
1396    }
1397}
1398
1399/// Tool that loads text references for already active skills.
1400///
1401/// Only `references/` paths are accepted. Absolute paths, parent traversal,
1402/// symlink escape, and inactive skills are rejected.
1403#[derive(Debug, Clone)]
1404pub struct LoadSkillReferenceTool {
1405    registry: Arc<SkillRegistry>,
1406    enabled: Option<Arc<HashSet<String>>>,
1407    activated: SkillActivationState,
1408    store: SkillResourceStore,
1409}
1410
1411impl LoadSkillReferenceTool {
1412    /// Constructs a reference loader.
1413    pub fn new(
1414        registry: Arc<SkillRegistry>,
1415        enabled: Option<Arc<HashSet<String>>>,
1416        activated: SkillActivationState,
1417        store: SkillResourceStore,
1418    ) -> Self {
1419        Self {
1420            registry,
1421            enabled,
1422            activated,
1423            store,
1424        }
1425    }
1426
1427    /// Source metadata for source-aware tool registration.
1428    pub fn source(layer_id: impl Into<String>, trust: ToolTrustLevel) -> ToolSource {
1429        ToolSource::new(
1430            ToolNamespace::skill_layer(layer_id),
1431            "load_skill_reference",
1432            "load_skill_reference",
1433        )
1434        .with_trust(trust)
1435    }
1436
1437    fn is_enabled(&self, name: &str) -> bool {
1438        match &self.enabled {
1439            None => true,
1440            Some(enabled) => enabled.contains(name),
1441        }
1442    }
1443}
1444
1445#[derive(serde::Deserialize, schemars::JsonSchema)]
1446struct LoadSkillReferenceArgs {
1447    /// Skill name.
1448    skill: String,
1449    /// Root-relative path under references/.
1450    path: String,
1451}
1452
1453#[async_trait::async_trait]
1454impl Tool for LoadSkillReferenceTool {
1455    fn schema(&self) -> ToolSchema {
1456        let parameters = serde_json::to_value(schemars::schema_for!(LoadSkillReferenceArgs))
1457            .expect("LoadSkillReferenceArgs JSON Schema serialization must not fail");
1458        ToolSchema::new(
1459            "load_skill_reference",
1460            "Load a text reference file for an already active skill. The path must be under references/.",
1461            parameters,
1462        )
1463        .with_policy(ToolPolicy {
1464            memory_policy: ToolMemoryPolicy::Protected,
1465            ..Default::default()
1466        })
1467    }
1468
1469    async fn call(
1470        &self,
1471        arguments: serde_json::Value,
1472        _context: ToolContext<'_>,
1473    ) -> Result<ToolResult, ToolError> {
1474        let args =
1475            serde_json::from_value::<LoadSkillReferenceArgs>(arguments).map_err(ToolError::from)?;
1476        if !self.is_enabled(&args.skill) {
1477            return Err(ToolError::Execution(format!(
1478                "skill '{}' is not enabled",
1479                args.skill
1480            )));
1481        }
1482        if !self.activated.is_active(&args.skill) {
1483            return Err(ToolError::Execution(format!(
1484                "skill '{}' is not active",
1485                args.skill
1486            )));
1487        }
1488        if !args.path.starts_with("references/") {
1489            return Err(ToolError::InvalidArguments(
1490                "skill reference path must be under references/".into(),
1491            ));
1492        }
1493        let skill = self
1494            .registry
1495            .get(&args.skill)
1496            .ok_or_else(|| ToolError::Execution(format!("skill '{}' not found", args.skill)))?;
1497        let content = skill
1498            .load_reference(&args.path)
1499            .await
1500            .map_err(|e| ToolError::Execution(e.to_string()))?;
1501        if content.len() > self.store.max_reference_bytes {
1502            return Err(ToolError::Execution(format!(
1503                "skill reference exceeds size limit ({} bytes)",
1504                self.store.max_reference_bytes
1505            )));
1506        }
1507        Ok(ToolOutput::text(content)
1508            .with_memory_policy(ToolMemoryPolicy::Protected)
1509            .into())
1510    }
1511}
1512
1513/// Assemble the load result: structured tags wrapping the body + resource
1514/// list (not pre-read).
1515fn format_skill_content(skill: &Skill) -> String {
1516    let mut out = String::new();
1517    out.push_str(&format!("<skill_content name=\"{}\">\n", skill.name()));
1518    out.push_str(skill.body());
1519    if skill.base_dir().is_some() {
1520        // Declare only relative semantics, no absolute paths: filesystem
1521        // layout must not enter the model context; concrete relative
1522        // paths are resolved tool-side against the skill root.
1523        out.push_str("\n\nRelative paths in this skill are relative to the skill directory.");
1524    }
1525    if !skill.resources().is_empty() {
1526        out.push_str("\n\n<skill_resources>");
1527        for resource in skill.resources() {
1528            out.push_str(&format!("\n  <file>{}</file>", resource.display()));
1529        }
1530        out.push_str("\n</skill_resources>");
1531    }
1532    out.push_str("\n</skill_content>");
1533    out
1534}
1535
1536/// Reasons a skill parse / load fails.
1537///
1538/// `#[non_exhaustive]` ensures future error categories are not breaking
1539/// changes; external crates should match with a wildcard arm. The error
1540/// type is well-behaved: the `Io` variant carries stringified error text
1541/// (io::Error itself is not Clone; stringifying keeps Clone + PartialEq).
1542///
1543/// # Example
1544///
1545/// ```
1546/// # extern crate molo_skills as molo;
1547/// use molo::skill::SkillError;
1548///
1549/// let err = SkillError::InvalidName("name may only contain lowercase letters, digits, and hyphens".into());
1550/// assert!(err.to_string().contains("name"));
1551/// ```
1552#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1553#[non_exhaustive]
1554pub enum SkillError {
1555    /// frontmatter structural errors: missing delimiters, syntax errors,
1556    /// unsupported nested structures, an over-long `compatibility`, etc.
1557    #[error("invalid frontmatter: {0}")]
1558    InvalidFrontmatter(String),
1559    /// `name` missing or not conforming to kebab-case rules.
1560    #[error("invalid skill name: {0}")]
1561    InvalidName(String),
1562    /// `description` missing, empty, or over 1024 characters.
1563    #[error("invalid skill description: {0}")]
1564    InvalidDescription(String),
1565    /// During directory loading, the skill name does not match the
1566    /// directory name.
1567    #[error("skill name '{name}' does not match directory name '{dir}'")]
1568    NameMismatch {
1569        /// The name in the skill frontmatter.
1570        name: String,
1571        /// The directory name.
1572        dir: String,
1573    },
1574    /// Target not found: no SKILL.md in the directory, resource missing,
1575    /// or a text-parsed skill without a resource directory.
1576    #[error("skill not found: {0}")]
1577    NotFound(String),
1578    /// Body too long (limit in `MAX_SKILL_BODY_CHARS`): the body is
1579    /// recorded as protected resident context and must not grow unbounded.
1580    #[error("invalid skill body: {0}")]
1581    InvalidBody(String),
1582    /// Filesystem error (stringified; the error is already fully expressed
1583    /// as text).
1584    #[error("io error: {0}")]
1585    Io(String),
1586}
1587
1588impl From<std::io::Error> for SkillError {
1589    fn from(err: std::io::Error) -> Self {
1590        SkillError::Io(err.to_string())
1591    }
1592}
1593
1594/// Validate a skill name: kebab-case (1-64 characters, lowercase letters /
1595/// digits / hyphens, not starting or ending with a hyphen, no consecutive
1596/// hyphens).
1597fn validate_name(name: &str) -> Result<(), SkillError> {
1598    if name.is_empty() {
1599        return Err(SkillError::InvalidName("name must not be empty".into()));
1600    }
1601    if name.chars().count() > 64 {
1602        return Err(SkillError::InvalidName("name exceeds 64 characters".into()));
1603    }
1604    if !name
1605        .chars()
1606        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1607    {
1608        return Err(SkillError::InvalidName(
1609            "name may only contain lowercase letters, digits, and hyphens".into(),
1610        ));
1611    }
1612    if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
1613        return Err(SkillError::InvalidName(
1614            "name is not kebab-case: must not start or end with a hyphen, and must not contain consecutive hyphens".into(),
1615        ));
1616    }
1617    Ok(())
1618}
1619
1620/// Validate a skill description: non-empty, at most 1024 characters.
1621fn validate_description(description: &str) -> Result<(), SkillError> {
1622    if description.is_empty() {
1623        return Err(SkillError::InvalidDescription(
1624            "description must not be empty".into(),
1625        ));
1626    }
1627    if description.chars().count() > 1024 {
1628        return Err(SkillError::InvalidDescription(
1629            "description exceeds 1024 characters".into(),
1630        ));
1631    }
1632    Ok(())
1633}
1634
1635/// frontmatter parsing result (raw fields; validation is deferred to
1636/// `Skill::parse`).
1637struct Frontmatter {
1638    name: Option<String>,
1639    description: Option<String>,
1640    license: Option<String>,
1641    compatibility: Option<String>,
1642    metadata: Vec<(String, String)>,
1643    allowed_tools: Vec<AllowedTool>,
1644}
1645
1646/// The block context while parsing lines: decides where indented lines
1647/// belong.
1648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1649enum Block {
1650    /// Top level (no block).
1651    None,
1652    /// Inside a `metadata:` block: indented `key: value` lines belong to
1653    /// metadata.
1654    Metadata,
1655    /// Inside an `allowed-tools:` block: indented `- item` lines belong to
1656    /// allowed-tools.
1657    AllowedTools,
1658}
1659
1660/// A hand-written minimal frontmatter parser (zero dependencies)
1661/// supporting a protocol field subset and both string / list
1662/// `allowed-tools` forms; unknown top-level scalar fields are tolerated
1663/// and merged into metadata; nested structures error out.
1664fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), SkillError> {
1665    // Lenient prefix: allow a BOM and leading blank lines (common in real
1666    // skill files).
1667    let content = content.strip_prefix('\u{feff}').unwrap_or(content);
1668    let content = content.trim_start_matches(['\n', '\r']);
1669
1670    let rest = content.strip_prefix("---").ok_or_else(|| {
1671        SkillError::InvalidFrontmatter("missing frontmatter start delimiter ---".into())
1672    })?;
1673    let (first_line, mut rest) = match rest.split_once('\n') {
1674        Some((line, tail)) => (line, tail),
1675        None => (rest, ""),
1676    };
1677    if !first_line.trim().is_empty() {
1678        return Err(SkillError::InvalidFrontmatter(
1679            "start delimiter --- must be followed by a newline".into(),
1680        ));
1681    }
1682
1683    let mut fm = Frontmatter {
1684        name: None,
1685        description: None,
1686        license: None,
1687        compatibility: None,
1688        metadata: Vec::new(),
1689        allowed_tools: Vec::new(),
1690    };
1691    let mut block = Block::None;
1692
1693    loop {
1694        let (line, tail) = match rest.split_once('\n') {
1695            Some((line, tail)) => (line, tail),
1696            None => (rest, ""),
1697        };
1698        if line.trim_end().trim() == "---" {
1699            // End delimiter: everything after it is the body (a single
1700            // blank line right after the delimiter is stripped).
1701            let body = tail.strip_prefix('\n').unwrap_or(tail);
1702            return Ok((fm, body.to_string()));
1703        }
1704        if tail.is_empty() {
1705            // Reached end of file without seeing the end delimiter.
1706            return Err(SkillError::InvalidFrontmatter(
1707                "missing frontmatter end delimiter ---".into(),
1708            ));
1709        }
1710
1711        let trimmed = line.trim_end_matches('\r').trim();
1712        if trimmed.is_empty() || trimmed.starts_with('#') {
1713            // Blank lines and comments: do not change the current block
1714            // context.
1715        } else if line.starts_with(' ') || line.starts_with('\t') {
1716            // Indented line: belongs to the current block.
1717            if let Some(item) = trimmed.strip_prefix("- ") {
1718                let item = item.trim();
1719                if block == Block::Metadata {
1720                    return Err(SkillError::InvalidFrontmatter(
1721                        "metadata does not support nested lists".into(),
1722                    ));
1723                }
1724                fm.allowed_tools.push(parse_allowed_tool(item)?);
1725            } else if block == Block::Metadata {
1726                let (key, value) = split_kv(trimmed)?;
1727                fm.metadata
1728                    .push((key.to_string(), stringify(value.unwrap_or_default())));
1729            } else {
1730                return Err(SkillError::InvalidFrontmatter(format!(
1731                    "unsupported nested structure: {trimmed}"
1732                )));
1733            }
1734        } else {
1735            // Top-level line: end the current block.
1736            block = Block::None;
1737            let (key, value) = split_kv(trimmed)?;
1738            // Values are uniformly stringified (quotes stripped) before
1739            // dispatch.
1740            let value = value.map(stringify);
1741            match key {
1742                "name" => fm.name = Some(value.unwrap_or_default().to_string()),
1743                "description" => fm.description = Some(value.unwrap_or_default().to_string()),
1744                "license" => fm.license = Some(value.unwrap_or_default().to_string()),
1745                "compatibility" => {
1746                    let v = value.unwrap_or_default().to_string();
1747                    if v.chars().count() > 500 {
1748                        return Err(SkillError::InvalidFrontmatter(
1749                            "compatibility exceeds 500 characters".into(),
1750                        ));
1751                    }
1752                    fm.compatibility = Some(v);
1753                }
1754                "allowed-tools" => {
1755                    block = Block::AllowedTools;
1756                    if let Some(v) = value {
1757                        // String form: space-separated entries; flow list
1758                        // form (`[Bash, Python]`) is split on commas.
1759                        let v = v.trim();
1760                        if v.starts_with('[') {
1761                            let inner = v
1762                                .strip_prefix('[')
1763                                .and_then(|s| s.strip_suffix(']'))
1764                                .ok_or_else(|| {
1765                                    SkillError::InvalidFrontmatter(format!(
1766                                        "allowed-tools flow list has unbalanced brackets: {v}"
1767                                    ))
1768                                })?;
1769                            for item in inner.split(',') {
1770                                fm.allowed_tools.push(parse_allowed_tool(item.trim())?);
1771                            }
1772                        } else {
1773                            for item in v.split_whitespace() {
1774                                fm.allowed_tools.push(parse_allowed_tool(item)?);
1775                            }
1776                        }
1777                    }
1778                }
1779                "metadata" => {
1780                    block = Block::Metadata;
1781                    if value.is_some() {
1782                        return Err(SkillError::InvalidFrontmatter(
1783                            "metadata value must be a key-value block (inline form is not supported)".into(),
1784                        ));
1785                    }
1786                }
1787                _ => {
1788                    // Unknown top-level fields are tolerated: merged into
1789                    // metadata (values already stringified).
1790                    fm.metadata
1791                        .push((key.to_string(), value.unwrap_or_default()));
1792                }
1793            }
1794        }
1795        rest = tail;
1796    }
1797}
1798
1799/// Split a `key: value` line; the key must be non-empty and contain only
1800/// alphanumerics, hyphens and underscores; the value may be empty.
1801fn split_kv(line: &str) -> Result<(&str, Option<&str>), SkillError> {
1802    let Some((key, value)) = line.split_once(':') else {
1803        return Err(SkillError::InvalidFrontmatter(format!(
1804            "frontmatter line missing colon: {line}"
1805        )));
1806    };
1807    let key = key.trim();
1808    if key.is_empty() {
1809        return Err(SkillError::InvalidFrontmatter(
1810            "frontmatter line missing field name".into(),
1811        ));
1812    }
1813    if !key
1814        .chars()
1815        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1816    {
1817        return Err(SkillError::InvalidFrontmatter(format!(
1818            "invalid field name: {key}"
1819        )));
1820    }
1821    let value = value.trim();
1822    Ok((key, if value.is_empty() { None } else { Some(value) }))
1823}
1824
1825/// Stringify a value: strip matching surrounding quotes; everything else
1826/// (numbers / booleans / text) is kept as-is.
1827fn stringify(value: &str) -> String {
1828    let value = value.trim();
1829    let stripped = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2)
1830        || (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2)
1831    {
1832        &value[1..value.len() - 1]
1833    } else {
1834        value
1835    };
1836    stripped.to_string()
1837}
1838
1839/// Parse an allowed-tools entry: `name(scope)` or `name`.
1840fn parse_allowed_tool(item: &str) -> Result<AllowedTool, SkillError> {
1841    if let Some(open) = item.find('(') {
1842        if !item.ends_with(')') || item[open + 1..].contains('(') {
1843            return Err(SkillError::InvalidFrontmatter(format!(
1844                "invalid allowed-tools entry: {item}"
1845            )));
1846        }
1847        let name = item[..open].trim();
1848        if name.is_empty() {
1849            return Err(SkillError::InvalidFrontmatter(format!(
1850                "allowed-tools entry missing tool name: {item}"
1851            )));
1852        }
1853        let scope = item[open + 1..item.len() - 1].trim();
1854        Ok(AllowedTool {
1855            name: name.to_string(),
1856            scope: (!scope.is_empty()).then(|| scope.to_string()),
1857        })
1858    } else if item.contains(')') {
1859        // Closing parenthesis without an opening one: malformed entry
1860        // (e.g. Bash)git:*).
1861        Err(SkillError::InvalidFrontmatter(format!(
1862            "invalid allowed-tools entry: {item}"
1863        )))
1864    } else if item.contains(['[', ']', ',']) {
1865        // Residual list syntax (e.g. "[Bash," / "Python]" split out by
1866        // spaces): error out explicitly instead of silently accepting it
1867        // as a tool name.
1868        Err(SkillError::InvalidFrontmatter(format!(
1869            "invalid allowed-tools entry: {item}"
1870        )))
1871    } else if item.is_empty() {
1872        Err(SkillError::InvalidFrontmatter(
1873            "empty allowed-tools entry".into(),
1874        ))
1875    } else {
1876        Ok(AllowedTool {
1877            name: item.to_string(),
1878            scope: None,
1879        })
1880    }
1881}
1882
1883/// Recursively collect all files under the resource directories
1884/// (references / scripts / assets), returned as paths relative to the
1885/// skill root, sorted for determinism.
1886async fn collect_resources(base: &Path) -> Vec<PathBuf> {
1887    let mut resources = Vec::new();
1888    for dir in ["references", "scripts", "assets"] {
1889        walk_dir(base.join(dir), base, &mut resources).await;
1890    }
1891    resources.sort();
1892    resources
1893}
1894
1895async fn walk_dir(dir: PathBuf, base: &Path, out: &mut Vec<PathBuf>) {
1896    let mut entries = match tokio::fs::read_dir(&dir).await {
1897        Ok(e) => e,
1898        Err(_) => return, // directory missing / unreadable: ignore
1899    };
1900    loop {
1901        match entries.next_entry().await {
1902            Ok(Some(entry)) => {
1903                let path = entry.path();
1904                let is_dir = match entry.file_type().await {
1905                    Ok(ft) => ft.is_dir(),
1906                    Err(_) => false,
1907                };
1908                if is_dir {
1909                    Box::pin(walk_dir(path, base, out)).await;
1910                } else if let Ok(rel) = path.strip_prefix(base) {
1911                    out.push(rel.to_path_buf());
1912                }
1913            }
1914            Ok(None) => break,
1915            Err(_) => return,
1916        }
1917    }
1918}
1919
1920#[cfg(test)]
1921mod tests {
1922    // Skill component tests: parse validation (incl. unknown-field
1923    // tolerance) / directory discovery (incl. bad-skill skipping) / both
1924    // allowed-tools forms / same-name replacement / hot-swap add-remove /
1925    // menu format / resource reading.
1926
1927    use std::path::{Path, PathBuf};
1928    use std::sync::Arc;
1929
1930    use super::{
1931        AllowedTool, LoadSkillReferenceTool, LoadSkillTool, Skill, SkillActivationState,
1932        SkillError, SkillLayer, SkillMode, SkillRegistry, SkillResourceStore,
1933    };
1934    use crate::tool::Tool;
1935    use std::collections::HashSet;
1936
1937    /// Create a temp directory (unique per process id + tag, so tests do
1938    /// not conflict).
1939    /// Test temp directory: cleaned up automatically on drop, leaving no
1940    /// residue in /tmp.
1941    fn temp_dir(tag: &str) -> TempDir {
1942        TempDir::new(tag)
1943    }
1944
1945    /// Test temp directory handle: `Deref`s to `PathBuf`, deletes the
1946    /// whole directory on drop.
1947    struct TempDir(PathBuf);
1948
1949    impl TempDir {
1950        fn new(tag: &str) -> Self {
1951            let dir =
1952                std::env::temp_dir().join(format!("molo-skill-test-{}-{tag}", std::process::id()));
1953            let _ = std::fs::remove_dir_all(&dir);
1954            std::fs::create_dir_all(&dir).unwrap();
1955            TempDir(dir)
1956        }
1957    }
1958
1959    impl std::ops::Deref for TempDir {
1960        type Target = PathBuf;
1961        fn deref(&self) -> &PathBuf {
1962            &self.0
1963        }
1964    }
1965
1966    impl Drop for TempDir {
1967        fn drop(&mut self) {
1968            let _ = std::fs::remove_dir_all(&self.0);
1969        }
1970    }
1971
1972    /// Write a skill directory (SKILL.md plus optional resource files),
1973    /// returning the directory path.
1974    fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
1975        let skill_dir = dir.join(name);
1976        std::fs::create_dir_all(&skill_dir).unwrap();
1977        std::fs::write(
1978            skill_dir.join("SKILL.md"),
1979            format!("---\nname: {name}\ndescription: {description}\n---\n{body}"),
1980        )
1981        .unwrap();
1982        skill_dir
1983    }
1984
1985    fn minimal(name: &str) -> Skill {
1986        Skill::parse(&format!(
1987            "---\nname: {name}\ndescription: description\n---\nbody"
1988        ))
1989        .unwrap()
1990    }
1991
1992    // ---------- parsing: valid input ----------
1993
1994    #[test]
1995    fn parse_minimal() {
1996        let skill = Skill::parse("---\nname: greet\ndescription: Say hello\n---\nHello!").unwrap();
1997        assert_eq!(skill.name(), "greet");
1998        assert_eq!(skill.description(), "Say hello");
1999        assert_eq!(skill.body(), "Hello!");
2000        assert!(skill.license().is_none());
2001        assert!(skill.metadata().is_empty());
2002        assert!(skill.allowed_tools().is_empty());
2003        assert!(skill.resources().is_empty());
2004    }
2005
2006    #[test]
2007    fn parse_full_fields() {
2008        let content = r#"---
2009name: code-review
2010description: Review code changes
2011license: MIT
2012compatibility: rust-1.80+
2013metadata:
2014  author: team
2015  public: true
2016allowed-tools:
2017  - Bash(git:*)
2018  - Python
2019user-invocable: true
2020---
2021Review steps.
2022"#;
2023        let skill = Skill::parse(content).unwrap();
2024        assert_eq!(skill.name(), "code-review");
2025        assert_eq!(skill.license(), Some("MIT"));
2026        assert_eq!(skill.compatibility(), Some("rust-1.80+"));
2027        // metadata: explicit block + unknown top-level fields
2028        // (stringified).
2029        assert_eq!(
2030            skill.metadata(),
2031            &[
2032                ("author".to_string(), "team".to_string()),
2033                ("public".to_string(), "true".to_string()),
2034                ("user-invocable".to_string(), "true".to_string()),
2035            ]
2036        );
2037        // allowed-tools: list form (scoped + unscoped).
2038        assert_eq!(
2039            skill.allowed_tools(),
2040            &[
2041                AllowedTool {
2042                    name: "Bash".into(),
2043                    scope: Some("git:*".into())
2044                },
2045                AllowedTool {
2046                    name: "Python".into(),
2047                    scope: None
2048                },
2049            ]
2050        );
2051    }
2052
2053    #[test]
2054    fn parse_allowed_tools_string_form() {
2055        let skill = Skill::parse(
2056            "---\nname: a\ndescription: description\nallowed-tools: Bash(git:*) Python\n---\nbody",
2057        )
2058        .unwrap();
2059        assert_eq!(skill.allowed_tools().len(), 2);
2060        assert_eq!(skill.allowed_tools()[0].name, "Bash");
2061        assert_eq!(skill.allowed_tools()[0].scope.as_deref(), Some("git:*"));
2062        assert_eq!(skill.allowed_tools()[1].name, "Python");
2063    }
2064
2065    #[test]
2066    fn parse_allowed_tools_flow_list_form() {
2067        // flow style `[Bash, Python]`: parsed on commas.
2068        let skill = Skill::parse(
2069            "---\nname: a\ndescription: description\nallowed-tools: [Bash, Python]\n---\nbody",
2070        )
2071        .unwrap();
2072        assert_eq!(skill.allowed_tools().len(), 2);
2073        assert_eq!(skill.allowed_tools()[0].name, "Bash");
2074        assert_eq!(skill.allowed_tools()[1].name, "Python");
2075    }
2076
2077    #[test]
2078    fn parse_allowed_tools_flow_list_unbalanced_brackets_rejected() {
2079        // Unbalanced brackets: explicit error instead of silently
2080        // producing garbage tool names.
2081        let err =
2082            Skill::parse("---\nname: a\ndescription: description\nallowed-tools: [Bash\n---\nbody")
2083                .unwrap_err();
2084        assert!(err.to_string().contains("unbalanced brackets"));
2085    }
2086
2087    #[test]
2088    fn frontmatter_underscore_keys_tolerated_into_metadata() {
2089        // Underscore keys (e.g. user_invocable) do not error; unknown keys
2090        // are tolerated and merged into metadata.
2091        let skill =
2092            Skill::parse("---\nname: a\ndescription: description\nuser_invocable: true\n---\nbody")
2093                .unwrap();
2094        assert_eq!(
2095            skill.metadata(),
2096            &[("user_invocable".to_string(), "true".to_string())]
2097        );
2098    }
2099
2100    #[test]
2101    fn parse_body_with_blank_line_after_delimiter() {
2102        // A blank line immediately after the end delimiter is formatting;
2103        // strip it.
2104        let skill = Skill::parse(
2105            "---\nname: a\ndescription: description\n---\n\nfirst body line\n\nsecond body line",
2106        )
2107        .unwrap();
2108        assert_eq!(skill.body(), "first body line\n\nsecond body line");
2109    }
2110
2111    #[test]
2112    fn parse_empty_body_allowed() {
2113        // The body may be empty: a skill with only frontmatter.
2114        let skill = Skill::parse("---\nname: a\ndescription: description\n---").unwrap();
2115        assert_eq!(skill.body(), "");
2116    }
2117
2118    #[test]
2119    fn parse_bom_and_leading_blank_lines_tolerated() {
2120        let content = "\u{feff}\n\n---\nname: a\ndescription: description\n---\nbody";
2121        let skill = Skill::parse(content).unwrap();
2122        assert_eq!(skill.name(), "a");
2123    }
2124
2125    #[test]
2126    fn parse_quoted_values_stripped() {
2127        let skill =
2128            Skill::parse("---\nname: a\ndescription: \"quoted description\"\n---\n").unwrap();
2129        assert_eq!(skill.description(), "quoted description");
2130    }
2131
2132    #[test]
2133    fn parse_comments_and_blank_lines_ignored() {
2134        let content = "---\n# this is a comment\n\nname: a\ndescription: description\n---\nbody";
2135        let skill = Skill::parse(content).unwrap();
2136        assert_eq!(skill.name(), "a");
2137    }
2138
2139    #[test]
2140    fn parse_duplicate_field_last_wins() {
2141        let content = "---\nname: a\ndescription: first\ndescription: second\n---\n";
2142        let skill = Skill::parse(content).unwrap();
2143        assert_eq!(skill.description(), "second");
2144    }
2145
2146    // ---------- parsing: invalid input (explicit operations error
2147    // strictly) ----------
2148
2149    #[test]
2150    fn parse_missing_frontmatter() {
2151        let err = Skill::parse("plain text, no frontmatter").unwrap_err();
2152        assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2153    }
2154
2155    #[test]
2156    fn parse_missing_end_delimiter() {
2157        let err =
2158            Skill::parse("---\nname: a\ndescription: description\nbody without end delimiter")
2159                .unwrap_err();
2160        assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2161    }
2162
2163    #[test]
2164    fn parse_missing_name() {
2165        let err = Skill::parse("---\ndescription: description\n---\n").unwrap_err();
2166        assert!(matches!(err, SkillError::InvalidName(_)));
2167    }
2168
2169    #[test]
2170    fn parse_invalid_names() {
2171        // uppercase letters / illegal characters / too long / leading or
2172        // trailing hyphens / consecutive hyphens.
2173        for bad in [
2174            "Bad-name",
2175            "bad_name",
2176            "bad name",
2177            &"a".repeat(65),
2178            "-bad",
2179            "bad-",
2180            "ba--d",
2181        ] {
2182            let content = format!("---\nname: {bad}\ndescription: description\n---\n");
2183            assert!(
2184                matches!(Skill::parse(&content), Err(SkillError::InvalidName(_))),
2185                "name should be rejected: {bad}"
2186            );
2187        }
2188    }
2189
2190    #[test]
2191    fn parse_invalid_descriptions() {
2192        let missing = Skill::parse("---\nname: a\n---\n").unwrap_err();
2193        assert!(matches!(missing, SkillError::InvalidDescription(_)));
2194
2195        let long = Skill::parse(&format!(
2196            "---\nname: a\ndescription: {}\n---\n",
2197            "x".repeat(1025)
2198        ))
2199        .unwrap_err();
2200        assert!(matches!(long, SkillError::InvalidDescription(_)));
2201    }
2202
2203    #[test]
2204    fn parse_compatibility_too_long() {
2205        let err = Skill::parse(&format!(
2206            "---\nname: a\ndescription: description\ncompatibility: {}\n---\n",
2207            "x".repeat(501)
2208        ))
2209        .unwrap_err();
2210        assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2211    }
2212
2213    #[test]
2214    fn parse_metadata_nested_rejected() {
2215        // Nested list inside metadata: unsupported, error out.
2216        let content = "---\nname: a\ndescription: description\nmetadata:\n  tags:\n    - x\n---\n";
2217        let err = Skill::parse(content).unwrap_err();
2218        assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2219    }
2220
2221    #[test]
2222    fn parse_metadata_inline_value_rejected() {
2223        let err = Skill::parse("---\nname: a\ndescription: description\nmetadata: foo\n---\n")
2224            .unwrap_err();
2225        assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2226    }
2227
2228    #[test]
2229    fn parse_unknown_field_empty_value() {
2230        // Unknown top-level field with empty value: tolerated, merged into
2231        // metadata as an empty string.
2232        let skill =
2233            Skill::parse("---\nname: a\ndescription: description\nuser-invocable:\n---\n").unwrap();
2234        assert_eq!(
2235            skill.metadata(),
2236            &[("user-invocable".to_string(), String::new())]
2237        );
2238    }
2239
2240    #[test]
2241    fn parse_invalid_allowed_tool_entries() {
2242        // Unclosed parens / trailing content after parens / empty entries.
2243        for bad in ["Bash(git:*", "Bash)git:*", "()", "(x)"] {
2244            let content =
2245                format!("---\nname: a\ndescription: description\nallowed-tools: {bad}\n---\n");
2246            assert!(
2247                matches!(
2248                    Skill::parse(&content),
2249                    Err(SkillError::InvalidFrontmatter(_))
2250                ),
2251                "entry should be rejected: {bad}"
2252            );
2253        }
2254    }
2255
2256    // ---------- directory loading ----------
2257
2258    #[tokio::test]
2259    async fn from_dir_ok_with_resources() {
2260        let dir = temp_dir("from-dir-ok");
2261        let skill_dir = write_skill(&dir, "code-review", "Review code", "Step one");
2262        // Resources: a nested references file + a scripts file + one
2263        // unrelated file (not collected).
2264        std::fs::create_dir_all(skill_dir.join("references/nested")).unwrap();
2265        std::fs::write(skill_dir.join("references/style.md"), "# style").unwrap();
2266        std::fs::write(skill_dir.join("references/nested/check.md"), "# checklist").unwrap();
2267        std::fs::create_dir_all(skill_dir.join("scripts")).unwrap();
2268        std::fs::write(skill_dir.join("scripts/run.sh"), "#!/bin/sh").unwrap();
2269        std::fs::write(skill_dir.join("README.md"), "not a resource").unwrap();
2270
2271        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2272        assert_eq!(skill.name(), "code-review");
2273        assert_eq!(skill.body(), "Step one");
2274        // Resources: three files, relative paths, stable ordering.
2275        assert_eq!(
2276            skill.resources(),
2277            &[
2278                PathBuf::from("references/nested/check.md"),
2279                PathBuf::from("references/style.md"),
2280                PathBuf::from("scripts/run.sh"),
2281            ]
2282        );
2283    }
2284
2285    #[tokio::test]
2286    async fn from_dir_name_mismatch() {
2287        let dir = temp_dir("from-dir-mismatch");
2288        // Directory named wrong-dir, skill named right-name → NameMismatch.
2289        let skill_dir = dir.join("wrong-dir");
2290        std::fs::create_dir_all(&skill_dir).unwrap();
2291        std::fs::write(
2292            skill_dir.join("SKILL.md"),
2293            "---\nname: right-name\ndescription: description\n---\nbody",
2294        )
2295        .unwrap();
2296
2297        let err = Skill::from_dir(&skill_dir).await.unwrap_err();
2298        assert!(matches!(
2299            err,
2300            SkillError::NameMismatch { name, dir: _ } if name == "right-name"
2301        ));
2302    }
2303
2304    #[tokio::test]
2305    async fn from_dir_missing_skill_md() {
2306        let dir = temp_dir("from-dir-missing");
2307        let empty = dir.join("empty-skill");
2308        std::fs::create_dir_all(&empty).unwrap();
2309
2310        let err = Skill::from_dir(&empty).await.unwrap_err();
2311        assert!(matches!(err, SkillError::NotFound(_)));
2312    }
2313
2314    #[tokio::test]
2315    async fn load_reference_ok() {
2316        let dir = temp_dir("load-ref");
2317        let skill_dir = write_skill(&dir, "a", "description", "body");
2318        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2319        std::fs::write(skill_dir.join("references/style.md"), "style content").unwrap();
2320        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2321
2322        assert_eq!(
2323            skill.load_reference("references/style.md").await.unwrap(),
2324            "style content"
2325        );
2326    }
2327
2328    #[tokio::test]
2329    async fn load_reference_missing_or_invalid() {
2330        let dir = temp_dir("load-ref-missing");
2331        let skill_dir = write_skill(&dir, "a", "description", "body");
2332        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2333
2334        // Missing.
2335        let err = skill.load_reference("nope.md").await.unwrap_err();
2336        assert!(matches!(err, SkillError::NotFound(_)));
2337        // Path traversal rejected.
2338        let err = skill.load_reference("../SKILL.md").await.unwrap_err();
2339        assert!(matches!(err, SkillError::NotFound(_)));
2340        // Absolute path rejected.
2341        let err = skill.load_reference("/etc/passwd").await.unwrap_err();
2342        assert!(matches!(err, SkillError::NotFound(_)));
2343
2344        // Text-parsed skill has no resource directory.
2345        let parsed = Skill::parse("---\nname: a\ndescription: description\n---\nbody").unwrap();
2346        let err = parsed.load_reference("x.md").await.unwrap_err();
2347        assert!(matches!(err, SkillError::NotFound(_)));
2348    }
2349
2350    /// Symlink defense: links pointing outside the skill directory must be
2351    /// rejected (arbitrary file read surface).
2352    #[cfg(unix)]
2353    #[tokio::test]
2354    async fn load_reference_rejects_symlink_escape() {
2355        let dir = temp_dir("load-ref-symlink");
2356        let skill_dir = write_skill(&dir, "a", "description", "body");
2357        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2358        // A secret file outside the directory + a symlink pointing at it.
2359        let secret = dir.join("secret.txt");
2360        std::fs::write(&secret, "secret content").unwrap();
2361        std::os::unix::fs::symlink(&secret, skill_dir.join("references/leak")).unwrap();
2362        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2363
2364        let err = skill.load_reference("references/leak").await.unwrap_err();
2365        assert!(
2366            matches!(err, SkillError::NotFound(_)),
2367            "symlink escape must be rejected, got: {err:?}"
2368        );
2369    }
2370
2371    /// Symlink defense: links inside the directory (pointing at
2372    /// in-directory resources) still work.
2373    #[cfg(unix)]
2374    #[tokio::test]
2375    async fn load_reference_allows_internal_symlink() {
2376        let dir = temp_dir("load-ref-symlink-in");
2377        let skill_dir = write_skill(&dir, "a", "description", "body");
2378        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2379        std::fs::write(skill_dir.join("references/real.md"), "real content").unwrap();
2380        std::os::unix::fs::symlink("real.md", skill_dir.join("references/alias.md")).unwrap();
2381        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2382
2383        assert_eq!(
2384            skill.load_reference("references/alias.md").await.unwrap(),
2385            "real content"
2386        );
2387    }
2388
2389    /// Symlink defense: from_dir rejects when SKILL.md itself is a link to
2390    /// a file outside the directory.
2391    #[cfg(unix)]
2392    #[tokio::test]
2393    async fn from_dir_rejects_symlinked_skill_md() {
2394        let dir = temp_dir("from-dir-symlink");
2395        let skill_dir = dir.join("a");
2396        std::fs::create_dir_all(&skill_dir).unwrap();
2397        let secret = dir.join("secret.md");
2398        std::fs::write(
2399            &secret,
2400            "---\nname: a\ndescription: description\n---\nsecret body",
2401        )
2402        .unwrap();
2403        std::os::unix::fs::symlink(&secret, skill_dir.join("SKILL.md")).unwrap();
2404
2405        let err = Skill::from_dir(&skill_dir).await.unwrap_err();
2406        assert!(
2407            matches!(err, SkillError::NotFound(_)),
2408            "SKILL.md symlink escape must be rejected, got: {err:?}"
2409        );
2410    }
2411
2412    // ---------- registry ----------
2413
2414    #[test]
2415    fn registry_add_get_remove() {
2416        let registry = SkillRegistry::new();
2417        assert!(registry.get("a").is_none());
2418
2419        registry.add(minimal("a"));
2420        assert_eq!(registry.get("a").unwrap().name(), "a");
2421        assert!(registry.remove("a"));
2422        assert!(registry.get("a").is_none());
2423        // Removing again: false.
2424        assert!(!registry.remove("a"));
2425    }
2426
2427    #[test]
2428    fn registry_add_duplicate_replaces_in_place() {
2429        let registry = SkillRegistry::new();
2430        registry
2431            .add(minimal("a"))
2432            .add(minimal("b"))
2433            .add(minimal("c"));
2434
2435        // Replace b with a same-named skill: position unchanged (order a,
2436        // b', c).
2437        let v2 = Skill::parse("---\nname: b\ndescription: new description\n---\nnew body").unwrap();
2438        registry.add(v2);
2439        let names: Vec<String> = registry
2440            .skills()
2441            .iter()
2442            .map(|s| s.name().to_string())
2443            .collect();
2444        assert_eq!(names, vec!["a", "b", "c"]);
2445        assert_eq!(registry.get("b").unwrap().body(), "new body");
2446    }
2447
2448    #[test]
2449    fn registry_from_iter_and_extend_keep_add_semantics() {
2450        let mut registry: SkillRegistry = [minimal("a"), minimal("b"), minimal("a")]
2451            .into_iter()
2452            .collect();
2453        let names: Vec<String> = registry
2454            .skills()
2455            .iter()
2456            .map(|skill| skill.name().to_string())
2457            .collect();
2458        assert_eq!(names, vec!["a", "b"]);
2459
2460        registry.extend([minimal("c")]);
2461        assert_eq!(
2462            registry
2463                .skills()
2464                .iter()
2465                .map(|skill| skill.name().to_string())
2466                .collect::<Vec<_>>(),
2467            vec!["a", "b", "c"]
2468        );
2469    }
2470
2471    #[test]
2472    fn registry_menu_format() {
2473        let registry = SkillRegistry::new();
2474        registry.add(minimal("a")).add(minimal("b"));
2475        assert_eq!(registry.menu(), "- a: description\n- b: description");
2476        // Empty registry: empty disclosure block.
2477        assert_eq!(SkillRegistry::new().menu(), "");
2478    }
2479
2480    #[tokio::test]
2481    async fn registry_from_dir_skips_bad_skills() {
2482        let dir = temp_dir("registry-from-dir");
2483        write_skill(&dir, "good-one", "good skill", "body");
2484        // Bad skill 1: directory name does not match the skill name.
2485        let bad = dir.join("bad-one");
2486        std::fs::create_dir_all(&bad).unwrap();
2487        std::fs::write(
2488            bad.join("SKILL.md"),
2489            "---\nname: other-name\ndescription: description\n---\nbody",
2490        )
2491        .unwrap();
2492        // Bad skill 2: no SKILL.md.
2493        std::fs::create_dir_all(dir.join("empty-dir")).unwrap();
2494        // Unrelated file (not a directory): skipped.
2495        std::fs::write(dir.join("notes.md"), "not a skill").unwrap();
2496
2497        let registry = SkillRegistry::from_dir(&dir).await.unwrap();
2498        let names: Vec<String> = registry
2499            .skills()
2500            .iter()
2501            .map(|s| s.name().to_string())
2502            .collect();
2503        assert_eq!(names, vec!["good-one"]);
2504        assert!(registry.get("bad-one").is_none());
2505    }
2506
2507    #[tokio::test]
2508    async fn from_dirs_merges_with_later_override() {
2509        let dir = temp_dir("from-dirs-merge");
2510        let user = dir.join("user");
2511        let project = dir.join("project");
2512        std::fs::create_dir_all(&user).unwrap();
2513        std::fs::create_dir_all(&project).unwrap();
2514        // User level: two skills.
2515        write_skill(&user, "greet", "user version", "user body");
2516        write_skill(&user, "user-only", "user only", "body");
2517        // Project level: same-named greet (new body) + a new skill — the
2518        // project level comes later in the arguments and overrides the
2519        // user level.
2520        write_skill(&project, "greet", "project version", "project body");
2521        write_skill(&project, "project-only", "project only", "body");
2522
2523        let registry = SkillRegistry::from_dirs(&[user, project]).await;
2524        let names: Vec<String> = registry
2525            .skills()
2526            .iter()
2527            .map(|s| s.name().to_string())
2528            .collect();
2529        // Same-name replacement keeps the first registration position:
2530        // greet first, body is the project version.
2531        assert_eq!(names, vec!["greet", "user-only", "project-only"]);
2532        assert_eq!(registry.get("greet").unwrap().body(), "project body");
2533        assert_eq!(registry.get("user-only").unwrap().body(), "body");
2534    }
2535
2536    #[tokio::test]
2537    async fn from_dirs_skips_missing_sources() {
2538        let dir = temp_dir("from-dirs-missing");
2539        let exists = dir.join("exists");
2540        std::fs::create_dir_all(&exists).unwrap();
2541        write_skill(&exists, "a", "description", "body");
2542
2543        // A missing directory in the middle: skipped, other sources
2544        // unaffected.
2545        let registry =
2546            SkillRegistry::from_dirs(&[dir.join("missing-a"), exists, dir.join("missing-b")]).await;
2547        assert_eq!(registry.skills().len(), 1);
2548
2549        // All sources missing: empty registry (lenient, no error).
2550        let empty = SkillRegistry::from_dirs(&[dir.join("missing-a"), dir.join("missing-b")]).await;
2551        assert!(empty.skills().is_empty());
2552    }
2553
2554    #[tokio::test]
2555    async fn from_dirs_empty_list() {
2556        let registry = SkillRegistry::from_dirs::<&str>(&[]).await;
2557        assert!(registry.skills().is_empty());
2558    }
2559
2560    #[tokio::test]
2561    async fn registry_from_dir_root_io_error() {
2562        let missing = temp_dir("registry-root").join("does-not-exist");
2563        let err = SkillRegistry::from_dir(&missing).await.unwrap_err();
2564        assert!(matches!(err, SkillError::Io(_)));
2565    }
2566
2567    #[test]
2568    fn registry_hot_swap_add_remove() {
2569        // Hot-swap basics: add / remove take &self; a shared handle (Arc)
2570        // can read/write concurrently.
2571        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2572        let handle = Arc::clone(&registry);
2573
2574        handle.add(minimal("a"));
2575        assert!(registry.get("a").is_some());
2576        handle.remove("a");
2577        assert!(registry.get("a").is_none());
2578    }
2579
2580    #[test]
2581    fn allowed_tool_permits_rules() {
2582        let bash_git = AllowedTool {
2583            name: "Bash".into(),
2584            scope: Some("git:*".into()),
2585        };
2586        // Exact name + scope prefix (trailing wildcard stripped).
2587        assert!(bash_git.permits("Bash", "git:diff --stat"));
2588        assert!(bash_git.permits("Bash", "git:log"));
2589        assert!(!bash_git.permits("Bash", "rm -rf /"));
2590        assert!(!bash_git.permits("Python", "git:log"));
2591
2592        // Scope without wildcard: pure prefix.
2593        let exact = AllowedTool {
2594            name: "Bash".into(),
2595            scope: Some("git:status".into()),
2596        };
2597        assert!(exact.permits("Bash", "git:status"));
2598        assert!(!exact.permits("Bash", "git:log"));
2599
2600        // No scope: any arguments for the tool.
2601        let python = AllowedTool {
2602            name: "Python".into(),
2603            scope: None,
2604        };
2605        assert!(python.permits("Python", "print('hello')"));
2606        assert!(!python.permits("Bash", "echo hi"));
2607    }
2608
2609    // ---------- LoadSkillTool ----------
2610
2611    async fn call_load_skill(
2612        tool: &LoadSkillTool,
2613        arguments: serde_json::Value,
2614        state: &crate::SharedState,
2615    ) -> Result<String, crate::tool::ToolError> {
2616        let run = crate::RunContext::new("load-skill-test");
2617        let result = tool
2618            .call(
2619                arguments,
2620                crate::ToolContext::new(&run, state, "call-load-skill", "load_skill"),
2621            )
2622            .await?;
2623        Ok(result.to_string())
2624    }
2625
2626    #[tokio::test]
2627    async fn load_skill_returns_wrapped_body() {
2628        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2629        registry.add(minimal("a"));
2630        let tool = LoadSkillTool::new(Arc::clone(&registry), None);
2631        let state = crate::SharedState::new();
2632
2633        let result = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2634            .await
2635            .unwrap();
2636        // Structured tags wrapping + body (text-parsed skill has no
2637        // resource list).
2638        assert_eq!(result, "<skill_content name=\"a\">\nbody\n</skill_content>");
2639    }
2640
2641    #[tokio::test]
2642    async fn load_skill_deduplicates_activations() {
2643        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2644        registry.add(minimal("a"));
2645        let tool = LoadSkillTool::new(Arc::clone(&registry), None);
2646        let state = crate::SharedState::new();
2647
2648        // First call: returns the body and records it in the session
2649        // activation set.
2650        let first = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2651            .await
2652            .unwrap();
2653        assert!(first.contains("body"));
2654        // Second call: already in context, returns the notice without
2655        // re-injecting the body.
2656        let second = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2657            .await
2658            .unwrap();
2659        assert!(second.contains("already active"));
2660        assert!(!second.contains("body"));
2661        // A new instance (new session) has an independent set: can reload.
2662        let fresh = LoadSkillTool::new(Arc::clone(&registry), None);
2663        let again = call_load_skill(&fresh, serde_json::json!({ "name": "a" }), &state)
2664            .await
2665            .unwrap();
2666        assert!(again.contains("body"));
2667    }
2668
2669    #[tokio::test]
2670    async fn load_skill_schema_enum_lists_enabled_skills() {
2671        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2672        registry
2673            .add(minimal("a"))
2674            .add(minimal("b"))
2675            .add(minimal("c"));
2676        let enabled: Arc<HashSet<String>> =
2677            Arc::new(["a".to_string(), "b".to_string()].into_iter().collect());
2678        let tool = LoadSkillTool::new(registry, Some(enabled));
2679
2680        let schema = tool.schema();
2681        let names = schema.parameters["properties"]["name"]["enum"]
2682            .as_array()
2683            .expect("name should be an enum")
2684            .iter()
2685            .map(|v| v.as_str().unwrap())
2686            .collect::<Vec<_>>();
2687        // Allowlist filter (c excluded); updates after hot-swap are
2688        // guaranteed by fresh lookups.
2689        assert_eq!(names, vec!["a", "b"]);
2690    }
2691
2692    #[tokio::test]
2693    async fn load_skill_content_lists_resources() {
2694        let dir = temp_dir("load-skill-resources");
2695        let skill_dir = write_skill(&dir, "a", "description", "body");
2696        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2697        std::fs::write(skill_dir.join("references/style.md"), "# style").unwrap();
2698        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2699
2700        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2701        registry.add(skill);
2702        let tool = LoadSkillTool::new(registry, None);
2703        let state = crate::SharedState::new();
2704
2705        let result = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2706            .await
2707            .unwrap();
2708        assert!(result.contains("<skill_content name=\"a\">"));
2709        // Only relative semantics are declared, no absolute paths
2710        // (filesystem layout does not enter the model context).
2711        assert!(
2712            result.contains("Relative paths in this skill are relative to the skill directory.")
2713        );
2714        assert!(!result.contains(&skill_dir.display().to_string()));
2715        assert!(result.contains("<skill_resources>"));
2716        assert!(result.contains("<file>references/style.md</file>"));
2717        assert!(result.ends_with("</skill_content>"));
2718    }
2719
2720    #[tokio::test]
2721    async fn load_skill_not_found() {
2722        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2723        let tool = LoadSkillTool::new(registry, None);
2724        let state = crate::SharedState::new();
2725
2726        let err = call_load_skill(&tool, serde_json::json!({ "name": "ghost" }), &state)
2727            .await
2728            .unwrap_err();
2729        assert!(err.to_string().contains("not found"));
2730    }
2731
2732    #[tokio::test]
2733    async fn load_skill_not_enabled() {
2734        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2735        registry.add(minimal("a")).add(minimal("b"));
2736        let enabled: Arc<std::collections::HashSet<String>> =
2737            Arc::new(["a".to_string()].into_iter().collect());
2738        let tool = LoadSkillTool::new(registry, Some(enabled));
2739        let state = crate::SharedState::new();
2740
2741        // A skill outside the allowlist: not enabled (even though it
2742        // exists).
2743        let err = call_load_skill(&tool, serde_json::json!({ "name": "b" }), &state)
2744            .await
2745            .unwrap_err();
2746        assert!(err.to_string().contains("not enabled"));
2747        // Inside the allowlist: normal (tags wrapping the body).
2748        let ok = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2749            .await
2750            .unwrap();
2751        assert!(ok.contains("body"));
2752    }
2753
2754    #[tokio::test]
2755    async fn load_skill_missing_name_argument() {
2756        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2757        let tool = LoadSkillTool::new(registry, None);
2758        let state = crate::SharedState::new();
2759        let err = call_load_skill(&tool, serde_json::json!({}), &state)
2760            .await
2761            .unwrap_err();
2762        assert!(matches!(err, crate::tool::ToolError::InvalidArguments(_)));
2763    }
2764
2765    #[test]
2766    fn skill_layer_progressive_assembles_menu_and_loader() {
2767        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2768        registry.add(minimal("a")).add(minimal("b"));
2769        let layer = SkillLayer::new(Arc::clone(&registry)).with_enabled_skills(&["a"]);
2770
2771        let assembly = layer.assemble();
2772        assert!(assembly.prompt_fragment.contains("- a: description"));
2773        assert!(!assembly.prompt_fragment.contains("- b:"));
2774        assert!(assembly.load_skill_tool.is_some());
2775        assert_eq!(assembly.manifest.visible_skills, vec!["a"]);
2776        assert_eq!(layer.load_skill_source().display_name, "load_skill");
2777    }
2778
2779    #[test]
2780    fn skill_layer_inline_embeds_bodies_without_loader() {
2781        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2782        registry.add(minimal("a"));
2783        let layer = SkillLayer::new(registry).with_mode(SkillMode::Inline);
2784
2785        let assembly = layer.assemble();
2786        assert!(assembly.prompt_fragment.contains("[Skill a]\nbody"));
2787        assert!(assembly.load_skill_tool.is_none());
2788    }
2789
2790    #[tokio::test]
2791    async fn skill_layer_shared_activation_deduplicates_loader_and_menu() {
2792        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2793        registry.add(minimal("a"));
2794        let layer = SkillLayer::new(Arc::clone(&registry));
2795        let tool = layer.assemble().load_skill_tool.unwrap();
2796        let state = crate::SharedState::new();
2797
2798        let body = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2799            .await
2800            .unwrap();
2801        assert!(body.contains("body"));
2802        assert!(!layer.assemble().prompt_fragment.contains("- a:"));
2803        assert!(layer.activation_state().is_active("a"));
2804    }
2805
2806    async fn call_reference_tool(
2807        tool: &LoadSkillReferenceTool,
2808        arguments: serde_json::Value,
2809    ) -> Result<String, crate::tool::ToolError> {
2810        let run = crate::RunContext::new("load-skill-reference-test");
2811        let state = crate::SharedState::new();
2812        let result = tool
2813            .call(
2814                arguments,
2815                crate::ToolContext::new(&run, &state, "call-ref", "load_skill_reference"),
2816            )
2817            .await?;
2818        Ok(result.to_string())
2819    }
2820
2821    #[tokio::test]
2822    async fn load_skill_reference_requires_active_skill_and_references_path() {
2823        let dir = temp_dir("load-skill-reference");
2824        let skill_dir = write_skill(&dir, "a", "description", "body");
2825        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2826        std::fs::write(skill_dir.join("references/style.md"), "style").unwrap();
2827        let skill = Skill::from_dir(&skill_dir).await.unwrap();
2828
2829        let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2830        registry.add(skill);
2831        let activation = SkillActivationState::new();
2832        let tool = LoadSkillReferenceTool::new(
2833            Arc::clone(&registry),
2834            None,
2835            activation.clone(),
2836            SkillResourceStore::default(),
2837        );
2838
2839        let inactive = call_reference_tool(
2840            &tool,
2841            serde_json::json!({ "skill": "a", "path": "references/style.md" }),
2842        )
2843        .await
2844        .unwrap_err();
2845        assert!(inactive.to_string().contains("not active"));
2846
2847        activation.mark_loaded("a");
2848        let invalid = call_reference_tool(
2849            &tool,
2850            serde_json::json!({ "skill": "a", "path": "scripts/run.sh" }),
2851        )
2852        .await
2853        .unwrap_err();
2854        assert!(matches!(
2855            invalid,
2856            crate::tool::ToolError::InvalidArguments(_)
2857        ));
2858
2859        let content = call_reference_tool(
2860            &tool,
2861            serde_json::json!({ "skill": "a", "path": "references/style.md" }),
2862        )
2863        .await
2864        .unwrap();
2865        assert_eq!(content, "style");
2866    }
2867}