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
37fn validate_optional_frontmatter_scalar(value: Option<&String>) -> Result<(), AgentConfigError> {
38    if let Some(value) = value {
39        validate_frontmatter_scalar(value)?;
40    }
41    Ok(())
42}
43
44fn validate_optional_frontmatter_list(
45    values: Option<&Vec<String>>,
46) -> Result<(), AgentConfigError> {
47    if let Some(values) = values {
48        for value in values {
49            validate_frontmatter_scalar(value)?;
50        }
51    }
52    Ok(())
53}
54
55/// Claude skill effort override.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum SkillEffort {
59    /// Low reasoning effort.
60    Low,
61    /// Medium reasoning effort.
62    Medium,
63    /// High reasoning effort.
64    High,
65    /// Extra-high reasoning effort.
66    XHigh,
67    /// Maximum reasoning effort.
68    Max,
69}
70
71impl SkillEffort {
72    pub(crate) const fn as_yaml(self) -> &'static str {
73        match self {
74            Self::Low => "low",
75            Self::Medium => "medium",
76            Self::High => "high",
77            Self::XHigh => "xhigh",
78            Self::Max => "max",
79        }
80    }
81}
82
83/// Claude skill context mode.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum SkillContext {
87    /// Run the skill in a forked subagent context.
88    Fork,
89}
90
91impl SkillContext {
92    pub(crate) const fn as_yaml(self) -> &'static str {
93        match self {
94            Self::Fork => "fork",
95        }
96    }
97}
98
99/// Claude skill shell for inline shell snippets.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum SkillShell {
103    /// Use Bash for inline shell commands.
104    Bash,
105    /// Use PowerShell for inline shell commands.
106    PowerShell,
107}
108
109impl SkillShell {
110    pub(crate) const fn as_yaml(self) -> &'static str {
111        match self {
112            Self::Bash => "bash",
113            Self::PowerShell => "powershell",
114        }
115    }
116}
117
118/// Caller-supplied description of an agent skill to install.
119///
120/// Skills are directory-scoped: each one occupies a subdirectory under the
121/// harness's `skills/` root, with a required `SKILL.md` and any number of
122/// supporting files in `scripts/`, `references/`, and `assets/`.
123///
124/// Build via [`SkillSpec::builder`]. For fallible construction see
125/// [`SkillSpecBuilder::try_build`].
126#[derive(Debug, Clone)]
127pub struct SkillSpec {
128    /// Skill directory name. Becomes the folder under the harness's
129    /// `skills/` root. Must be lowercase kebab-case, max 64 chars.
130    pub name: String,
131
132    /// The consumer of this library that owns the skill. Recorded in the
133    /// sidecar ownership ledger; refusal to remove a skill owned by another
134    /// consumer matches the same `NotOwnedByCaller` model used for MCP.
135    pub owner_tag: String,
136
137    /// YAML frontmatter required by Claude / Antigravity. Written verbatim
138    /// at the head of `SKILL.md`.
139    pub frontmatter: SkillFrontmatter,
140
141    /// Markdown body of `SKILL.md` (no frontmatter — that is rendered from
142    /// [`SkillSpec::frontmatter`]).
143    pub body: String,
144
145    /// Optional supporting files under `scripts/`, `references/`, `assets/`.
146    /// The `relative_path` is interpreted under the skill directory; any
147    /// leading `scripts/`/`references/`/`assets/` prefix is honoured as-is.
148    pub assets: Vec<SkillAsset>,
149
150    /// When true, install adopts a skill directory that exists on disk but
151    /// has no recorded owner instead of refusing. Use after a crash between
152    /// skill-directory write and ledger record. Default `false`.
153    pub adopt_unowned: bool,
154}
155
156impl SkillSpec {
157    /// Start building a skill spec.
158    pub fn builder(name: impl Into<String>) -> SkillSpecBuilder {
159        let name = name.into();
160        SkillSpecBuilder {
161            name: name.clone(),
162            owner_tag: None,
163            frontmatter: SkillFrontmatter {
164                name,
165                description: String::new(),
166                when_to_use: None,
167                argument_hint: None,
168                arguments: None,
169                disable_model_invocation: None,
170                user_invocable: None,
171                allowed_tools: None,
172                disallowed_tools: None,
173                model: None,
174                effort: None,
175                context: None,
176                agent: None,
177                paths: None,
178                shell: None,
179            },
180            body: String::new(),
181            assets: Vec::new(),
182            adopt_unowned: false,
183        }
184    }
185
186    /// Validate `name` and `owner_tag`.
187    pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
188        Self::validate_name(&self.name)?;
189        if self.frontmatter.description.trim().is_empty() {
190            return Err(AgentConfigError::MissingSpecField {
191                id: "<skill spec>",
192                field: "frontmatter.description",
193            });
194        }
195        if self.body.trim().is_empty() {
196            return Err(AgentConfigError::MissingSpecField {
197                id: "<skill spec>",
198                field: "body",
199            });
200        }
201        validate_frontmatter_scalar(&self.frontmatter.name)?;
202        validate_frontmatter_scalar(&self.frontmatter.description)?;
203        validate_optional_frontmatter_scalar(self.frontmatter.when_to_use.as_ref())?;
204        validate_optional_frontmatter_scalar(self.frontmatter.argument_hint.as_ref())?;
205        validate_optional_frontmatter_list(self.frontmatter.arguments.as_ref())?;
206        validate_optional_frontmatter_list(self.frontmatter.allowed_tools.as_ref())?;
207        validate_optional_frontmatter_list(self.frontmatter.disallowed_tools.as_ref())?;
208        validate_optional_frontmatter_scalar(self.frontmatter.model.as_ref())?;
209        validate_optional_frontmatter_scalar(self.frontmatter.agent.as_ref())?;
210        validate_optional_frontmatter_list(self.frontmatter.paths.as_ref())?;
211        validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)
212    }
213
214    /// Validate just the skill name (used by uninstall, which has no spec).
215    pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
216        validate_identifier(name, IdentifierKind::SkillName)
217    }
218}
219
220/// YAML frontmatter prepended to `SKILL.md`.
221///
222/// `description` remains required by this crate for cross-harness safety,
223/// even though Claude can infer it in some cases.
224#[derive(Debug, Clone)]
225pub struct SkillFrontmatter {
226    /// Skill identifier surfaced in tooling (typically matches the directory
227    /// name).
228    pub name: String,
229
230    /// Sentence (or short paragraph) explaining when the skill should
231    /// activate. Required by both Claude and Antigravity; the activation
232    /// model matches against this string.
233    pub description: String,
234
235    /// Optional Claude `when_to_use` field.
236    pub when_to_use: Option<String>,
237
238    /// Optional Claude `argument-hint` field.
239    pub argument_hint: Option<String>,
240
241    /// Optional Claude `arguments` list.
242    pub arguments: Option<Vec<String>>,
243
244    /// Optional Claude `disable-model-invocation` field.
245    pub disable_model_invocation: Option<bool>,
246
247    /// Optional Claude `user-invocable` field.
248    pub user_invocable: Option<bool>,
249
250    /// Optional `allowed-tools` list (Claude). When `None`, the field is
251    /// omitted from the frontmatter.
252    pub allowed_tools: Option<Vec<String>>,
253
254    /// Optional Claude `disallowed-tools` list.
255    pub disallowed_tools: Option<Vec<String>>,
256
257    /// Optional Claude `model` override.
258    pub model: Option<String>,
259
260    /// Optional Claude `effort` override.
261    pub effort: Option<SkillEffort>,
262
263    /// Optional Claude `context` mode.
264    pub context: Option<SkillContext>,
265
266    /// Optional Claude `agent` to use with forked context.
267    pub agent: Option<String>,
268
269    /// Optional Claude `paths` list.
270    pub paths: Option<Vec<String>>,
271
272    /// Optional Claude `shell` override.
273    pub shell: Option<SkillShell>,
274}
275
276/// One supporting file inside a skill directory.
277#[derive(Debug, Clone)]
278pub struct SkillAsset {
279    /// Path relative to the skill directory, e.g.
280    /// `PathBuf::from("scripts/run.sh")`. Must be a relative path; absolute
281    /// paths or `..` segments are rejected at install time.
282    pub relative_path: PathBuf,
283
284    /// Raw bytes of the file. Lets callers ship binary references (e.g.
285    /// images under `assets/`) as well as text scripts.
286    pub bytes: Vec<u8>,
287
288    /// On Unix, set the file mode to `0o755` after writing. No-op on
289    /// Windows. Use for shell/python scripts under `scripts/`.
290    pub executable: bool,
291}
292
293/// Builder for [`SkillSpec`].
294#[derive(Debug, Clone)]
295pub struct SkillSpecBuilder {
296    name: String,
297    owner_tag: Option<String>,
298    frontmatter: SkillFrontmatter,
299    body: String,
300    assets: Vec<SkillAsset>,
301    adopt_unowned: bool,
302}
303
304impl SkillSpecBuilder {
305    /// Set the consumer's owner tag.
306    pub fn owner(mut self, tag: impl Into<String>) -> Self {
307        self.owner_tag = Some(tag.into());
308        self
309    }
310
311    /// Adopt a skill directory that exists on disk but has no recorded owner.
312    /// See [`SkillSpec::adopt_unowned`].
313    pub fn adopt_unowned(mut self, adopt: bool) -> Self {
314        self.adopt_unowned = adopt;
315        self
316    }
317
318    /// Set the SKILL.md frontmatter `description`.
319    pub fn description(mut self, d: impl Into<String>) -> Self {
320        self.frontmatter.description = d.into();
321        self
322    }
323
324    /// Set the SKILL.md frontmatter `when_to_use` field.
325    pub fn when_to_use(mut self, value: impl Into<String>) -> Self {
326        self.frontmatter.when_to_use = Some(value.into());
327        self
328    }
329
330    /// Set the SKILL.md frontmatter `argument-hint` field.
331    pub fn argument_hint(mut self, value: impl Into<String>) -> Self {
332        self.frontmatter.argument_hint = Some(value.into());
333        self
334    }
335
336    /// Set the SKILL.md frontmatter `arguments` list.
337    pub fn arguments<I, S>(mut self, arguments: I) -> Self
338    where
339        I: IntoIterator<Item = S>,
340        S: Into<String>,
341    {
342        self.frontmatter.arguments = Some(arguments.into_iter().map(Into::into).collect());
343        self
344    }
345
346    /// Set the SKILL.md frontmatter `disable-model-invocation` field.
347    pub fn disable_model_invocation(mut self, disable: bool) -> Self {
348        self.frontmatter.disable_model_invocation = Some(disable);
349        self
350    }
351
352    /// Set the SKILL.md frontmatter `user-invocable` field.
353    pub fn user_invocable(mut self, invocable: bool) -> Self {
354        self.frontmatter.user_invocable = Some(invocable);
355        self
356    }
357
358    /// Set the SKILL.md frontmatter `allowed-tools` list (Claude only).
359    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
360    where
361        I: IntoIterator<Item = S>,
362        S: Into<String>,
363    {
364        self.frontmatter.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
365        self
366    }
367
368    /// Set the SKILL.md frontmatter `disallowed-tools` list.
369    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
370    where
371        I: IntoIterator<Item = S>,
372        S: Into<String>,
373    {
374        self.frontmatter.disallowed_tools = Some(tools.into_iter().map(Into::into).collect());
375        self
376    }
377
378    /// Set the SKILL.md frontmatter `model` override.
379    pub fn model(mut self, model: impl Into<String>) -> Self {
380        self.frontmatter.model = Some(model.into());
381        self
382    }
383
384    /// Set the SKILL.md frontmatter `effort` override.
385    pub fn effort(mut self, effort: SkillEffort) -> Self {
386        self.frontmatter.effort = Some(effort);
387        self
388    }
389
390    /// Set the SKILL.md frontmatter `context` mode.
391    pub fn context(mut self, context: SkillContext) -> Self {
392        self.frontmatter.context = Some(context);
393        self
394    }
395
396    /// Set the SKILL.md frontmatter `agent` field.
397    pub fn agent(mut self, agent: impl Into<String>) -> Self {
398        self.frontmatter.agent = Some(agent.into());
399        self
400    }
401
402    /// Set the SKILL.md frontmatter `paths` list.
403    pub fn paths<I, S>(mut self, paths: I) -> Self
404    where
405        I: IntoIterator<Item = S>,
406        S: Into<String>,
407    {
408        self.frontmatter.paths = Some(paths.into_iter().map(Into::into).collect());
409        self
410    }
411
412    /// Set the SKILL.md frontmatter `shell` override.
413    pub fn shell(mut self, shell: SkillShell) -> Self {
414        self.frontmatter.shell = Some(shell);
415        self
416    }
417
418    /// Set the markdown body of SKILL.md.
419    pub fn body(mut self, body: impl Into<String>) -> Self {
420        self.body = body.into();
421        self
422    }
423
424    /// Add one supporting file.
425    pub fn asset(mut self, asset: SkillAsset) -> Self {
426        self.assets.push(asset);
427        self
428    }
429
430    /// Finalize the spec, panicking on missing or invalid fields.
431    ///
432    /// Convenience wrapper around [`try_build()`](Self::try_build) for tests
433    /// and examples. Production code should prefer [`try_build()`](Self::try_build)
434    /// to propagate errors instead of panicking.
435    ///
436    /// # Panics
437    ///
438    /// Panics if `owner` or `description` were never set.
439    pub fn build(self) -> SkillSpec {
440        self.try_build().expect("SkillSpec missing required field")
441    }
442
443    /// Finalize the spec, returning [`Result`] on missing or invalid fields.
444    ///
445    /// This is the recommended way to build a spec in production code.
446    /// See [crate-level documentation](crate#production-usage) for a full example.
447    ///
448    /// # Errors
449    ///
450    /// - [`AgentConfigError::MissingSpecField`] with `field = "owner"` when
451    ///   [`SkillSpecBuilder::owner`] was never called,
452    ///   `field = "frontmatter.description"` when the skill frontmatter
453    ///   description is empty, or `field = "body"` when the body is empty.
454    /// - [`AgentConfigError::InvalidTag`] when `name` violates the kebab-case
455    ///   skill-name contract or `owner_tag` is malformed, or when a
456    ///   frontmatter scalar contains a control character.
457    pub fn try_build(self) -> Result<SkillSpec, AgentConfigError> {
458        let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
459            id: "<skill builder>",
460            field: "owner",
461        })?;
462        let spec = SkillSpec {
463            name: self.name,
464            owner_tag,
465            frontmatter: self.frontmatter,
466            body: self.body,
467            assets: self.assets,
468            adopt_unowned: self.adopt_unowned,
469        };
470        spec.validate()?;
471        Ok(spec)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn validate_rejects_newline_in_description() {
481        let err = SkillSpec::builder("alpha")
482            .owner("appA")
483            .description("line1\nline2")
484            .body("body")
485            .try_build()
486            .unwrap_err();
487        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
488    }
489
490    #[test]
491    fn validate_rejects_tab_in_name() {
492        // The frontmatter.name defaults to the SkillSpec name; build via
493        // try_build and inject the tab through the frontmatter directly,
494        // because the builder routes the SkillSpec name through the
495        // kebab-case validator first.
496        let mut spec = SkillSpec::builder("alpha")
497            .owner("appA")
498            .description("ok")
499            .body("body")
500            .try_build()
501            .expect("base spec valid");
502        spec.frontmatter.name = "bad\tname".into();
503        let err = spec.validate().unwrap_err();
504        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
505    }
506
507    #[test]
508    fn validate_rejects_control_char_in_allowed_tools() {
509        let err = SkillSpec::builder("alpha")
510            .owner("appA")
511            .description("ok")
512            .body("body")
513            .allowed_tools(["ed\u{0001}it"])
514            .try_build()
515            .unwrap_err();
516        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
517    }
518
519    #[test]
520    fn validate_rejects_control_char_in_current_frontmatter_fields() {
521        let newline = SkillSpec::builder("alpha")
522            .owner("appA")
523            .description("ok")
524            .when_to_use("line1\nline2")
525            .body("body")
526            .try_build()
527            .unwrap_err();
528        assert!(matches!(newline, AgentConfigError::InvalidTag { .. }));
529
530        let tab = SkillSpec::builder("alpha")
531            .owner("appA")
532            .description("ok")
533            .disallowed_tools(["Write\tFile"])
534            .body("body")
535            .try_build()
536            .unwrap_err();
537        assert!(matches!(tab, AgentConfigError::InvalidTag { .. }));
538    }
539
540    #[test]
541    fn validate_rejects_del_in_description() {
542        let err = SkillSpec::builder("alpha")
543            .owner("appA")
544            .description("evil\u{007F}")
545            .body("body")
546            .try_build()
547            .unwrap_err();
548        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
549    }
550
551    #[test]
552    fn validate_accepts_normal_description_with_punctuation() {
553        SkillSpec::builder("alpha")
554            .owner("appA")
555            .description("Format Git commit messages: subject + body.")
556            .body("body")
557            .try_build()
558            .expect("valid");
559    }
560
561    #[test]
562    fn validate_rejects_empty_body() {
563        let err = SkillSpec::builder("alpha")
564            .owner("appA")
565            .description("Use this skill")
566            .try_build()
567            .unwrap_err();
568        assert!(matches!(
569            err,
570            AgentConfigError::MissingSpecField { field: "body", .. }
571        ));
572    }
573
574    #[test]
575    fn validate_rejects_whitespace_only_body() {
576        let err = SkillSpec::builder("alpha")
577            .owner("appA")
578            .description("Use this skill")
579            .body("   \n\t  \n")
580            .try_build()
581            .unwrap_err();
582        assert!(matches!(
583            err,
584            AgentConfigError::MissingSpecField { field: "body", .. }
585        ));
586    }
587
588    #[test]
589    fn adopt_unowned_defaults_false_and_round_trips() {
590        let default_spec = SkillSpec::builder("alpha")
591            .owner("appA")
592            .description("Use this skill")
593            .body("body")
594            .build();
595        assert!(!default_spec.adopt_unowned);
596
597        let opted = SkillSpec::builder("alpha")
598            .owner("appA")
599            .description("Use this skill")
600            .body("body")
601            .adopt_unowned(true)
602            .build();
603        assert!(opted.adopt_unowned);
604    }
605}