Skip to main content

zeroclaw_runtime/skills/
scaffold.rs

1//! Scaffold a new skill on disk: write `SKILL.md` + create optional
2//! `scripts/`, `references/`, `assets/` subdirs per the canonical layout.
3
4use std::path::{Path, PathBuf};
5
6use super::bundle::{self, BundleError};
7use super::constants::{SKILL_MANIFEST_FILENAME, SKILL_SCAFFOLD_SUBDIRS};
8use super::document::SkillDocument;
9use super::frontmatter::SkillFrontmatter;
10use super::reference::SkillRef;
11use zeroclaw_config::schema::Config;
12
13#[derive(Debug, Clone)]
14pub struct ScaffoldOptions {
15    /// When `true`, also `mkdir -p` the canonical optional subdirs
16    /// (`scripts/`, `references/`, `assets/`).
17    pub create_optional_subdirs: bool,
18    /// Initial markdown body. When empty, defaults to a single H1 heading
19    /// matching the skill name.
20    pub body: String,
21}
22
23impl Default for ScaffoldOptions {
24    fn default() -> Self {
25        Self {
26            create_optional_subdirs: true,
27            body: String::new(),
28        }
29    }
30}
31
32#[derive(Debug, thiserror::Error)]
33pub enum ScaffoldError {
34    #[error(transparent)]
35    Bundle(#[from] BundleError),
36
37    #[error("skill name '{0}' must use lowercase letters, digits, and hyphens only")]
38    InvalidName(String),
39
40    #[error("skill '{0}' already exists at {1}")]
41    AlreadyExists(String, PathBuf),
42
43    #[error("failed to write skill scaffold: {0}")]
44    Io(#[from] std::io::Error),
45}
46
47/// Validate the lowercase-hyphen rule per the agent-skills spec name field.
48pub fn validate_name(name: &str) -> Result<(), ScaffoldError> {
49    let ok = !name.is_empty()
50        && name
51            .chars()
52            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
53        && !name.starts_with('-')
54        && !name.ends_with('-');
55    if ok {
56        Ok(())
57    } else {
58        Err(ScaffoldError::InvalidName(name.to_string()))
59    }
60}
61
62/// Materialize a new skill on disk. Idempotency is **not** assumed: if the
63/// skill dir already exists, an error is returned.
64pub fn scaffold_skill(
65    config: &Config,
66    install_root: &Path,
67    target: &SkillRef,
68    frontmatter: SkillFrontmatter,
69    opts: ScaffoldOptions,
70) -> Result<PathBuf, ScaffoldError> {
71    validate_name(target.name())?;
72
73    let bundle_dir = bundle::resolve_directory(config, install_root, target.bundle())?;
74    bundle::validate_directory(&bundle_dir, install_root)?;
75
76    let skill_dir = bundle_dir.join(target.name());
77    if skill_dir.exists() {
78        return Err(ScaffoldError::AlreadyExists(target.to_string(), skill_dir));
79    }
80
81    std::fs::create_dir_all(&skill_dir)?;
82
83    let body = if opts.body.is_empty() {
84        format!("# {}\n", title_from_name(target.name()))
85    } else {
86        opts.body
87    };
88    let document = SkillDocument { frontmatter, body };
89    std::fs::write(
90        skill_dir.join(SKILL_MANIFEST_FILENAME),
91        document.serialize(),
92    )?;
93
94    if opts.create_optional_subdirs {
95        for sub in SKILL_SCAFFOLD_SUBDIRS {
96            std::fs::create_dir_all(skill_dir.join(sub))?;
97        }
98    }
99
100    Ok(skill_dir)
101}
102
103fn title_from_name(name: &str) -> String {
104    name.split('-')
105        .map(|w| {
106            let mut chars = w.chars();
107            match chars.next() {
108                Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
109                None => String::new(),
110            }
111        })
112        .collect::<Vec<_>>()
113        .join(" ")
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use tempfile::TempDir;
120    use zeroclaw_config::schema::SkillBundleConfig;
121
122    fn fixture() -> (TempDir, Config) {
123        let dir = tempfile::tempdir().unwrap();
124        let mut cfg = Config::default();
125        cfg.skill_bundles
126            .insert("alpha".to_string(), SkillBundleConfig::default());
127        (dir, cfg)
128    }
129
130    fn skill_ref(bundle: &str, name: &str) -> SkillRef {
131        // Tests bypass the resolver; service consumers always go through it.
132        SkillRef::new_unchecked(bundle.to_string(), name.to_string())
133    }
134
135    #[test]
136    fn rejects_invalid_skill_names() {
137        for bad in [
138            "",
139            "-leading",
140            "trailing-",
141            "Upper",
142            "with_underscore",
143            "has space",
144        ] {
145            assert!(
146                validate_name(bad).is_err(),
147                "expected '{bad}' to be rejected"
148            );
149        }
150    }
151
152    #[test]
153    fn accepts_valid_skill_names() {
154        for good in ["code-review", "x", "single-word-name", "version2", "abc123"] {
155            validate_name(good).unwrap();
156        }
157    }
158
159    #[test]
160    fn scaffold_creates_full_canonical_layout() {
161        let (dir, cfg) = fixture();
162        let frontmatter = SkillFrontmatter {
163            name: "code-review".into(),
164            description: "Reviews PRs.".into(),
165            ..Default::default()
166        };
167        let path = scaffold_skill(
168            &cfg,
169            dir.path(),
170            &skill_ref("alpha", "code-review"),
171            frontmatter.clone(),
172            ScaffoldOptions::default(),
173        )
174        .unwrap();
175
176        assert!(path.join(SKILL_MANIFEST_FILENAME).exists());
177        for sub in SKILL_SCAFFOLD_SUBDIRS {
178            assert!(path.join(sub).is_dir(), "missing optional subdir {sub}");
179        }
180
181        let written = std::fs::read_to_string(path.join(SKILL_MANIFEST_FILENAME)).unwrap();
182        let doc = SkillDocument::parse(&written).unwrap();
183        assert_eq!(doc.frontmatter, frontmatter);
184        assert!(doc.body.contains("# Code Review"));
185    }
186
187    #[test]
188    fn scaffold_skips_optional_subdirs_when_disabled() {
189        let (dir, cfg) = fixture();
190        let path = scaffold_skill(
191            &cfg,
192            dir.path(),
193            &skill_ref("alpha", "minimal"),
194            SkillFrontmatter {
195                name: "minimal".into(),
196                description: "d".into(),
197                ..Default::default()
198            },
199            ScaffoldOptions {
200                create_optional_subdirs: false,
201                body: String::new(),
202            },
203        )
204        .unwrap();
205        assert!(path.join(SKILL_MANIFEST_FILENAME).exists());
206        for sub in SKILL_SCAFFOLD_SUBDIRS {
207            assert!(!path.join(sub).exists());
208        }
209    }
210
211    #[test]
212    fn scaffold_errors_when_skill_already_exists() {
213        let (dir, cfg) = fixture();
214        let r = skill_ref("alpha", "dup");
215        let fm = SkillFrontmatter {
216            name: "dup".into(),
217            description: "d".into(),
218            ..Default::default()
219        };
220        scaffold_skill(&cfg, dir.path(), &r, fm.clone(), ScaffoldOptions::default()).unwrap();
221        let err = scaffold_skill(&cfg, dir.path(), &r, fm, ScaffoldOptions::default()).unwrap_err();
222        assert!(matches!(err, ScaffoldError::AlreadyExists(_, _)));
223    }
224
225    #[test]
226    fn scaffold_errors_when_bundle_unknown() {
227        let (dir, cfg) = fixture();
228        let r = skill_ref("missing-bundle", "x");
229        let fm = SkillFrontmatter {
230            name: "x".into(),
231            description: "d".into(),
232            ..Default::default()
233        };
234        let err = scaffold_skill(&cfg, dir.path(), &r, fm, ScaffoldOptions::default()).unwrap_err();
235        assert!(matches!(
236            err,
237            ScaffoldError::Bundle(BundleError::UnknownBundle(_))
238        ));
239    }
240
241    #[test]
242    fn title_from_name_capitalizes_hyphen_segments() {
243        assert_eq!(title_from_name("code-review"), "Code Review");
244        assert_eq!(title_from_name("x"), "X");
245        assert_eq!(
246            title_from_name("multi-word-skill-name"),
247            "Multi Word Skill Name"
248        );
249    }
250}