Skip to main content

agent_config/spec/
skill.rs

1//! Skill spec, builder, frontmatter, and supporting asset type.
2
3use std::path::PathBuf;
4
5use crate::error::AgentConfigError;
6
7use super::validate::{validate_identifier, IdentifierKind};
8
9// User-supplied frontmatter strings are rendered into single-line YAML at the
10// top of `SKILL.md`. Reject newlines, tabs, C0 control bytes, and DEL up
11// front so the renderer never has to choose between mangling input and
12// emitting broken YAML.
13fn validate_frontmatter_scalar(value: &str) -> Result<(), AgentConfigError> {
14    for c in value.chars() {
15        if c == '\n' || c == '\r' || c == '\t' {
16            return Err(AgentConfigError::InvalidTag {
17                tag: value.to_string(),
18                reason: "skill frontmatter must not contain newlines or tabs",
19            });
20        }
21        if (c as u32) < 0x20 && c != ' ' {
22            return Err(AgentConfigError::InvalidTag {
23                tag: value.to_string(),
24                reason: "skill frontmatter must not contain control characters",
25            });
26        }
27        if c == '\u{007F}' {
28            return Err(AgentConfigError::InvalidTag {
29                tag: value.to_string(),
30                reason: "skill frontmatter must not contain DEL (0x7F)",
31            });
32        }
33    }
34    Ok(())
35}
36
37/// Caller-supplied description of an agent skill to install.
38///
39/// Skills are directory-scoped: each one occupies a subdirectory under the
40/// harness's `skills/` root, with a required `SKILL.md` and any number of
41/// supporting files in `scripts/`, `references/`, and `assets/`.
42///
43/// Build via [`SkillSpec::builder`]. For fallible construction see
44/// [`SkillSpecBuilder::try_build`].
45#[derive(Debug, Clone)]
46pub struct SkillSpec {
47    /// Skill directory name. Becomes the folder under the harness's
48    /// `skills/` root. Must be lowercase kebab-case, max 64 chars.
49    pub name: String,
50
51    /// The consumer of this library that owns the skill. Recorded in the
52    /// sidecar ownership ledger; refusal to remove a skill owned by another
53    /// consumer matches the same `NotOwnedByCaller` model used for MCP.
54    pub owner_tag: String,
55
56    /// YAML frontmatter required by Claude / Antigravity. Written verbatim
57    /// at the head of `SKILL.md`.
58    pub frontmatter: SkillFrontmatter,
59
60    /// Markdown body of `SKILL.md` (no frontmatter — that is rendered from
61    /// [`SkillSpec::frontmatter`]).
62    pub body: String,
63
64    /// Optional supporting files under `scripts/`, `references/`, `assets/`.
65    /// The `relative_path` is interpreted under the skill directory; any
66    /// leading `scripts/`/`references/`/`assets/` prefix is honoured as-is.
67    pub assets: Vec<SkillAsset>,
68
69    /// When true, install adopts a skill directory that exists on disk but
70    /// has no recorded owner instead of refusing. Use after a crash between
71    /// skill-directory write and ledger record. Default `false`.
72    pub adopt_unowned: bool,
73}
74
75impl SkillSpec {
76    /// Start building a skill spec.
77    pub fn builder(name: impl Into<String>) -> SkillSpecBuilder {
78        let name = name.into();
79        SkillSpecBuilder {
80            name: name.clone(),
81            owner_tag: None,
82            frontmatter: SkillFrontmatter {
83                name,
84                description: String::new(),
85                allowed_tools: None,
86            },
87            body: String::new(),
88            assets: Vec::new(),
89            adopt_unowned: false,
90        }
91    }
92
93    /// Validate `name` and `owner_tag`.
94    pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
95        Self::validate_name(&self.name)?;
96        if self.frontmatter.description.trim().is_empty() {
97            return Err(AgentConfigError::MissingSpecField {
98                id: "<skill spec>",
99                field: "frontmatter.description",
100            });
101        }
102        if self.body.trim().is_empty() {
103            return Err(AgentConfigError::MissingSpecField {
104                id: "<skill spec>",
105                field: "body",
106            });
107        }
108        validate_frontmatter_scalar(&self.frontmatter.name)?;
109        validate_frontmatter_scalar(&self.frontmatter.description)?;
110        if let Some(tools) = &self.frontmatter.allowed_tools {
111            for t in tools {
112                validate_frontmatter_scalar(t)?;
113            }
114        }
115        validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)
116    }
117
118    /// Validate just the skill name (used by uninstall, which has no spec).
119    pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
120        validate_identifier(name, IdentifierKind::SkillName)
121    }
122}
123
124/// YAML frontmatter prepended to `SKILL.md`. `description` is the field
125/// harnesses use to decide when to activate the skill.
126#[derive(Debug, Clone)]
127pub struct SkillFrontmatter {
128    /// Skill identifier surfaced in tooling (typically matches the directory
129    /// name).
130    pub name: String,
131
132    /// Sentence (or short paragraph) explaining when the skill should
133    /// activate. Required by both Claude and Antigravity; the activation
134    /// model matches against this string.
135    pub description: String,
136
137    /// Optional `allowed-tools` list (Claude). When `None`, the field is
138    /// omitted from the frontmatter.
139    pub allowed_tools: Option<Vec<String>>,
140}
141
142/// One supporting file inside a skill directory.
143#[derive(Debug, Clone)]
144pub struct SkillAsset {
145    /// Path relative to the skill directory, e.g.
146    /// `PathBuf::from("scripts/run.sh")`. Must be a relative path; absolute
147    /// paths or `..` segments are rejected at install time.
148    pub relative_path: PathBuf,
149
150    /// Raw bytes of the file. Lets callers ship binary references (e.g.
151    /// images under `assets/`) as well as text scripts.
152    pub bytes: Vec<u8>,
153
154    /// On Unix, set the file mode to `0o755` after writing. No-op on
155    /// Windows. Use for shell/python scripts under `scripts/`.
156    pub executable: bool,
157}
158
159/// Builder for [`SkillSpec`].
160#[derive(Debug, Clone)]
161pub struct SkillSpecBuilder {
162    name: String,
163    owner_tag: Option<String>,
164    frontmatter: SkillFrontmatter,
165    body: String,
166    assets: Vec<SkillAsset>,
167    adopt_unowned: bool,
168}
169
170impl SkillSpecBuilder {
171    /// Set the consumer's owner tag.
172    pub fn owner(mut self, tag: impl Into<String>) -> Self {
173        self.owner_tag = Some(tag.into());
174        self
175    }
176
177    /// Adopt a skill directory that exists on disk but has no recorded owner.
178    /// See [`SkillSpec::adopt_unowned`].
179    pub fn adopt_unowned(mut self, adopt: bool) -> Self {
180        self.adopt_unowned = adopt;
181        self
182    }
183
184    /// Set the SKILL.md frontmatter `description`.
185    pub fn description(mut self, d: impl Into<String>) -> Self {
186        self.frontmatter.description = d.into();
187        self
188    }
189
190    /// Set the SKILL.md frontmatter `allowed-tools` list (Claude only).
191    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
192    where
193        I: IntoIterator<Item = S>,
194        S: Into<String>,
195    {
196        self.frontmatter.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
197        self
198    }
199
200    /// Set the markdown body of SKILL.md.
201    pub fn body(mut self, body: impl Into<String>) -> Self {
202        self.body = body.into();
203        self
204    }
205
206    /// Add one supporting file.
207    pub fn asset(mut self, asset: SkillAsset) -> Self {
208        self.assets.push(asset);
209        self
210    }
211
212    /// Finalize the spec, panicking on missing or invalid fields.
213    ///
214    /// Convenience wrapper around [`try_build()`](Self::try_build) for tests
215    /// and examples. Production code should prefer [`try_build()`](Self::try_build)
216    /// to propagate errors instead of panicking.
217    ///
218    /// # Panics
219    ///
220    /// Panics if `owner` or `description` were never set.
221    pub fn build(self) -> SkillSpec {
222        self.try_build().expect("SkillSpec missing required field")
223    }
224
225    /// Finalize the spec, returning [`Result`] on missing or invalid fields.
226    ///
227    /// This is the recommended way to build a spec in production code.
228    /// See [crate-level documentation](crate#production-usage) for a full example.
229    ///
230    /// # Errors
231    ///
232    /// - [`AgentConfigError::MissingSpecField`] with `field = "owner"` when
233    ///   [`SkillSpecBuilder::owner`] was never called,
234    ///   `field = "frontmatter.description"` when the skill frontmatter
235    ///   description is empty, or `field = "body"` when the body is empty.
236    /// - [`AgentConfigError::InvalidTag`] when `name` violates the kebab-case
237    ///   skill-name contract or `owner_tag` is malformed, or when a
238    ///   frontmatter scalar contains a control character.
239    pub fn try_build(self) -> Result<SkillSpec, AgentConfigError> {
240        let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
241            id: "<skill builder>",
242            field: "owner",
243        })?;
244        let spec = SkillSpec {
245            name: self.name,
246            owner_tag,
247            frontmatter: self.frontmatter,
248            body: self.body,
249            assets: self.assets,
250            adopt_unowned: self.adopt_unowned,
251        };
252        spec.validate()?;
253        Ok(spec)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn validate_rejects_newline_in_description() {
263        let err = SkillSpec::builder("alpha")
264            .owner("appA")
265            .description("line1\nline2")
266            .body("body")
267            .try_build()
268            .unwrap_err();
269        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
270    }
271
272    #[test]
273    fn validate_rejects_tab_in_name() {
274        // The frontmatter.name defaults to the SkillSpec name; build via
275        // try_build and inject the tab through the frontmatter directly,
276        // because the builder routes the SkillSpec name through the
277        // kebab-case validator first.
278        let mut spec = SkillSpec::builder("alpha")
279            .owner("appA")
280            .description("ok")
281            .body("body")
282            .try_build()
283            .expect("base spec valid");
284        spec.frontmatter.name = "bad\tname".into();
285        let err = spec.validate().unwrap_err();
286        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
287    }
288
289    #[test]
290    fn validate_rejects_control_char_in_allowed_tools() {
291        let err = SkillSpec::builder("alpha")
292            .owner("appA")
293            .description("ok")
294            .body("body")
295            .allowed_tools(["ed\u{0001}it"])
296            .try_build()
297            .unwrap_err();
298        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
299    }
300
301    #[test]
302    fn validate_rejects_del_in_description() {
303        let err = SkillSpec::builder("alpha")
304            .owner("appA")
305            .description("evil\u{007F}")
306            .body("body")
307            .try_build()
308            .unwrap_err();
309        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
310    }
311
312    #[test]
313    fn validate_accepts_normal_description_with_punctuation() {
314        SkillSpec::builder("alpha")
315            .owner("appA")
316            .description("Format Git commit messages: subject + body.")
317            .body("body")
318            .try_build()
319            .expect("valid");
320    }
321
322    #[test]
323    fn validate_rejects_empty_body() {
324        let err = SkillSpec::builder("alpha")
325            .owner("appA")
326            .description("Use this skill")
327            .try_build()
328            .unwrap_err();
329        assert!(matches!(
330            err,
331            AgentConfigError::MissingSpecField { field: "body", .. }
332        ));
333    }
334
335    #[test]
336    fn validate_rejects_whitespace_only_body() {
337        let err = SkillSpec::builder("alpha")
338            .owner("appA")
339            .description("Use this skill")
340            .body("   \n\t  \n")
341            .try_build()
342            .unwrap_err();
343        assert!(matches!(
344            err,
345            AgentConfigError::MissingSpecField { field: "body", .. }
346        ));
347    }
348
349    #[test]
350    fn adopt_unowned_defaults_false_and_round_trips() {
351        let default_spec = SkillSpec::builder("alpha")
352            .owner("appA")
353            .description("Use this skill")
354            .body("body")
355            .build();
356        assert!(!default_spec.adopt_unowned);
357
358        let opted = SkillSpec::builder("alpha")
359            .owner("appA")
360            .description("Use this skill")
361            .body("body")
362            .adopt_unowned(true)
363            .build();
364        assert!(opted.adopt_unowned);
365    }
366}