Skip to main content

agent_skill_installer/
name.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! What a skill may be called.
5//!
6//! The Agent Skills specification allows 1 to 64 characters of lowercase
7//! ASCII letters, digits and hyphens, with no hyphen at either end and no two
8//! in a row, and requires the name to match the skill's directory. The
9//! strictest tools enforce exactly that: VS Code's Copilot drops a skill with
10//! an invalid name without a word, and `OpenCode` matches
11//! `^[a-z0-9]+(-[a-z0-9]+)*$`. A name that passes here is a name every
12//! supported tool loads.
13//!
14//! The name also becomes a directory, which is the second reason to be strict:
15//! nothing that passes can contain a path separator, `..` or a character a
16//! file system treats specially.
17
18use std::fmt;
19
20/// The longest name the specification allows.
21const MAX_LEN: usize = 64;
22
23/// Why a skill name was refused.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum NameError {
27    /// The name is empty.
28    Empty,
29    /// The name is longer than the specification allows.
30    TooLong(usize),
31    /// The name contains a character other than `a-z`, `0-9` and `-`.
32    InvalidChar(char),
33    /// The name starts or ends with a hyphen.
34    EdgeHyphen,
35    /// The name contains two hyphens in a row.
36    DoubleHyphen,
37}
38
39impl fmt::Display for NameError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::Empty => f.write_str("a skill name cannot be empty"),
43            Self::TooLong(len) => {
44                write!(
45                    f,
46                    "a skill name has at most {MAX_LEN} characters, this one has {len}"
47                )
48            }
49            Self::InvalidChar(c) => {
50                write!(
51                    f,
52                    "a skill name may only contain a-z, 0-9 and '-', not {c:?}"
53                )
54            }
55            Self::EdgeHyphen => f.write_str("a skill name cannot start or end with '-'"),
56            Self::DoubleHyphen => f.write_str("a skill name cannot contain '--'"),
57        }
58    }
59}
60
61impl std::error::Error for NameError {}
62
63/// Check a skill name against the Agent Skills specification.
64///
65/// # Errors
66/// Returns the first rule the name breaks.
67pub fn validate_name(name: &str) -> Result<(), NameError> {
68    if name.is_empty() {
69        return Err(NameError::Empty);
70    }
71    // Counted in characters, which for a valid name are bytes; an invalid one
72    // is refused below whatever its length.
73    let len = name.chars().count();
74    if len > MAX_LEN {
75        return Err(NameError::TooLong(len));
76    }
77    if let Some(c) = name
78        .chars()
79        .find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-'))
80    {
81        return Err(NameError::InvalidChar(c));
82    }
83    if name.starts_with('-') || name.ends_with('-') {
84        return Err(NameError::EdgeHyphen);
85    }
86    if name.contains("--") {
87        return Err(NameError::DoubleHyphen);
88    }
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn names_the_specification_allows_pass() {
98        let longest = "a".repeat(64);
99        for name in [
100            "devserial",
101            "devknx",
102            "a",
103            "pdf-processing",
104            "x2",
105            longest.as_str(),
106        ] {
107            assert_eq!(validate_name(name), Ok(()), "{name}");
108        }
109    }
110
111    #[test]
112    fn each_rule_refuses_for_its_own_reason() {
113        assert_eq!(validate_name(""), Err(NameError::Empty));
114        assert_eq!(validate_name(&"a".repeat(65)), Err(NameError::TooLong(65)));
115        assert_eq!(validate_name("DevSerial"), Err(NameError::InvalidChar('D')));
116        assert_eq!(
117            validate_name("dev_serial"),
118            Err(NameError::InvalidChar('_'))
119        );
120        assert_eq!(validate_name("-dev"), Err(NameError::EdgeHyphen));
121        assert_eq!(validate_name("dev-"), Err(NameError::EdgeHyphen));
122        assert_eq!(validate_name("dev--serial"), Err(NameError::DoubleHyphen));
123    }
124
125    /// A name becomes a directory, so nothing that could leave it may pass.
126    #[test]
127    fn nothing_that_could_leave_the_directory_passes() {
128        for name in [
129            "..", "../x", "a/b", "a\\b", ".hidden", "a.b", "รค", "a b", "a\0b",
130        ] {
131            assert!(validate_name(name).is_err(), "{name:?} must be refused");
132        }
133    }
134}