Skip to main content

knack_core/
lib.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, anyhow, bail};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug)]
12pub struct Skill {
13    pub path: PathBuf,
14    pub name: String,
15    pub description: String,
16}
17
18/// Agent Skills frontmatter knack understands. Unknown fields are
19/// silently ignored: the SKILL.md format is shared across tooling
20/// (Anthropic's catalog, third-party agents, internal extensions),
21/// and other tools write fields like `hidden` that knack has no use
22/// for. Rejecting them broke `knack list` whenever a foreign skill
23/// landed in `.agents/skills/` — the user couldn't list any skill
24/// just because one of them had an extra field.
25///
26/// Required fields (`name`, `description`) still fail loudly on
27/// typos: serde reports "missing field `description`" rather than
28/// silently accepting `desciption`. Typos in optional fields will
29/// silently be dropped, which is the usual YAML/serde convention
30/// and the acceptable tradeoff for ecosystem interop.
31#[derive(Debug, Deserialize)]
32#[allow(dead_code)]
33struct SkillFrontmatter {
34    name: String,
35    description: String,
36    #[serde(default)]
37    license: Option<String>,
38    #[serde(default)]
39    compatibility: Option<String>,
40    #[serde(default)]
41    metadata: Option<serde_yaml::Mapping>,
42    #[serde(default, rename = "allowed-tools")]
43    allowed_tools: Option<String>,
44}
45
46pub fn read_skill(path: &Path) -> Result<Skill> {
47    if !path.is_dir() {
48        bail!("skill path is not a directory: {}", path.display());
49    }
50
51    let skill_file = path.join("SKILL.md");
52    let contents = fs::read_to_string(&skill_file)
53        .with_context(|| format!("failed to read {}", skill_file.display()))?;
54    let frontmatter = parse_frontmatter(&contents)
55        .with_context(|| format!("failed to parse {}", skill_file.display()))?;
56
57    Ok(Skill {
58        path: path.to_path_buf(),
59        name: frontmatter.name,
60        description: frontmatter.description,
61    })
62}
63
64fn parse_frontmatter(contents: &str) -> Result<SkillFrontmatter> {
65    let mut lines = contents.lines();
66    if lines.next() != Some("---") {
67        bail!("SKILL.md must start with YAML frontmatter delimited by ---");
68    }
69
70    let mut yaml = String::new();
71    for line in lines {
72        if line == "---" {
73            let frontmatter = serde_yaml::from_str(&yaml)?;
74            return Ok(frontmatter);
75        }
76        yaml.push_str(line);
77        yaml.push('\n');
78    }
79
80    bail!("SKILL.md frontmatter is missing closing ---");
81}
82
83/// Validate a skill's identity (name well-formed, description present)
84/// without requiring the on-disk directory to match the frontmatter
85/// name. Use this when reading skills from an upstream source whose
86/// layout we don't control — e.g. when materialising a registry's
87/// dynamic `[[source]]` entries against third-party repos. Vendors
88/// commonly use unprefixed directory names with brand-prefixed
89/// frontmatter names (e.g. `skills/composition-patterns/` containing
90/// `name: vercel-composition-patterns`); that's a legitimate
91/// convention and the registry's archive flow already renames on
92/// the way out using the frontmatter name.
93pub fn validate_skill_metadata(skill: &Skill) -> Result<()> {
94    validate_skill_name(&skill.name)?;
95
96    if skill.description.trim().is_empty() {
97        bail!("description must not be empty");
98    }
99
100    // We previously rejected descriptions over 1024 characters, but
101    // real-world Agent Skills (notably anthropics/skills) include
102    // multi-paragraph "use when..." prose well past that ceiling
103    // (skill-creator runs to ~5200 chars). knack should not gatekeep
104    // the format the broader ecosystem uses; UIs are free to truncate
105    // long descriptions at display time. Required fields stay strict.
106
107    Ok(())
108}
109
110/// Strict validation for skills installed locally or being authored.
111/// In addition to the metadata checks, the on-disk directory name
112/// must match the frontmatter name — this is a local-filesystem
113/// invariant for `.agents/skills/<name>/` lookup. Use this from
114/// `knack install`, `knack new`, `knack validate`, and the publish
115/// flow.
116pub fn validate_skill(skill: &Skill) -> Result<()> {
117    validate_skill_metadata(skill)?;
118
119    let dirname = skill
120        .path
121        .file_name()
122        .and_then(|name| name.to_str())
123        .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
124    if dirname != skill.name {
125        bail!(
126            "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
127            skill.name,
128            dirname
129        );
130    }
131
132    Ok(())
133}
134
135pub fn validate_skill_name(name: &str) -> Result<()> {
136    let len = name.chars().count();
137    if len == 0 || len > 64 {
138        bail!("skill name must be 1-64 characters");
139    }
140
141    if name.starts_with('-') || name.ends_with('-') {
142        bail!("skill name must not start or end with a hyphen");
143    }
144
145    if name.contains("--") {
146        bail!("skill name must not contain consecutive hyphens");
147    }
148
149    if !name
150        .chars()
151        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
152    {
153        bail!("skill name may only contain lowercase letters, numbers, and hyphens");
154    }
155
156    Ok(())
157}
158
159pub fn checksum_dir(path: &Path) -> Result<String> {
160    let mut hasher = Sha256::new();
161    for file in collect_files(path)? {
162        let relative_path = file.strip_prefix(path).with_context(|| {
163            format!(
164                "failed to make {} relative to {}",
165                file.display(),
166                path.display()
167            )
168        })?;
169        hasher.update(relative_path.to_string_lossy().as_bytes());
170        hasher.update([0]);
171        hasher
172            .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
173        hasher.update([0]);
174    }
175    Ok(format!("sha256:{:x}", hasher.finalize()))
176}
177
178pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
179    let mut files = Vec::new();
180    collect_files_inner(path, &mut files)?;
181    files.sort();
182    Ok(files)
183}
184
185fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
186    for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
187        let entry = entry?;
188        let path = entry.path();
189        let file_type = entry.file_type()?;
190
191        if file_type.is_dir() {
192            collect_files_inner(&path, files)?;
193        } else if file_type.is_file() {
194            files.push(path);
195        }
196    }
197
198    Ok(())
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct Manifest {
203    pub install: InstallConfig,
204    #[serde(default)]
205    pub skills: BTreeMap<String, String>,
206    #[serde(default)]
207    pub registries: BTreeMap<String, RegistryConfig>,
208}
209
210impl Manifest {
211    pub fn new(target: PathBuf) -> Self {
212        Self {
213            install: InstallConfig { target },
214            skills: BTreeMap::new(),
215            registries: BTreeMap::new(),
216        }
217    }
218}
219
220#[derive(Debug, Clone, Deserialize, Serialize)]
221pub struct InstallConfig {
222    pub target: PathBuf,
223}
224
225/// Current lockfile schema version. Bump when the on-disk layout
226/// changes in a way an older `knack` couldn't safely interpret.
227///
228/// Backward compatibility is one-way: a new knack reading an old
229/// lockfile should keep working (with default values for new fields).
230/// An old knack reading a new lockfile errors loudly rather than
231/// guessing — that's what `Lockfile::ensure_supported_version`
232/// enforces.
233pub const LOCKFILE_VERSION: u32 = 1;
234
235fn default_lockfile_version() -> u32 {
236    LOCKFILE_VERSION
237}
238
239#[derive(Debug, Deserialize, Serialize)]
240pub struct Lockfile {
241    /// Schema version. Older lockfiles without this field default to
242    /// version 1 since v1 is identical to the pre-versioned layout —
243    /// only the SHA-pinning convention differs, and that's transparent
244    /// to the parser.
245    #[serde(default = "default_lockfile_version")]
246    pub version: u32,
247    #[serde(default)]
248    pub skill: Vec<LockedSkill>,
249}
250
251impl Default for Lockfile {
252    fn default() -> Self {
253        Self {
254            version: LOCKFILE_VERSION,
255            skill: Vec::new(),
256        }
257    }
258}
259
260impl Lockfile {
261    /// Refuse to operate on a lockfile from a future knack version.
262    /// New schema versions may add fields or change semantics in ways
263    /// this binary can't preserve on round-trip; bailing avoids
264    /// silently corrupting the file when we write it back.
265    pub fn ensure_supported_version(&self) -> Result<(), String> {
266        if self.version > LOCKFILE_VERSION {
267            return Err(format!(
268                "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
269                self.version
270            ));
271        }
272        Ok(())
273    }
274}
275
276#[derive(Debug, Clone, Deserialize, Serialize)]
277pub struct LockedSkill {
278    pub name: String,
279    pub source: String,
280    pub resolved: String,
281    pub checksum: String,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
285pub struct RegistryConfig {
286    pub kind: RegistryKind,
287    pub url: String,
288    pub default_ref: String,
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
292#[serde(rename_all = "kebab-case")]
293pub enum RegistryKind {
294    GitHost,
295    Http,
296}
297
298#[derive(Debug, Default, Clone, Deserialize, Serialize)]
299pub struct RegistryIndex {
300    #[serde(default)]
301    pub skill: Vec<IndexedSkill>,
302    #[serde(default)]
303    pub source: Vec<IndexSource>,
304}
305
306#[derive(Debug, Clone, Deserialize, Serialize)]
307pub struct IndexedSkill {
308    pub name: String,
309    pub description: String,
310    pub source: String,
311    #[serde(default)]
312    pub tags: Vec<String>,
313}
314
315#[derive(Debug, Clone, Deserialize, Serialize)]
316pub struct IndexSource {
317    pub source: String,
318    #[serde(default)]
319    pub tags: Vec<String>,
320}
321
322impl RegistryIndex {
323    pub fn validate(&self) -> Result<()> {
324        for skill in &self.skill {
325            skill.validate()?;
326        }
327        for source in &self.source {
328            source.validate()?;
329        }
330        Ok(())
331    }
332
333    pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
334        let terms: Vec<String> = query
335            .split_whitespace()
336            .map(|term| term.to_ascii_lowercase())
337            .collect();
338        if terms.is_empty() {
339            return Vec::new();
340        }
341
342        self.skill
343            .iter()
344            .filter(|skill| {
345                let haystack = skill.search_text();
346                terms.iter().all(|term| haystack.contains(term))
347            })
348            .collect()
349    }
350}
351
352impl IndexSource {
353    pub fn validate(&self) -> Result<()> {
354        if self.source.trim().is_empty() {
355            bail!("indexed source must not be empty");
356        }
357        Ok(())
358    }
359}
360
361impl IndexedSkill {
362    pub fn validate(&self) -> Result<()> {
363        validate_skill_name(&self.name)?;
364        if self.description.trim().is_empty() {
365            bail!("indexed skill description must not be empty: {}", self.name);
366        }
367        if self.source.trim().is_empty() {
368            bail!("indexed skill source must not be empty: {}", self.name);
369        }
370        Ok(())
371    }
372
373    fn search_text(&self) -> String {
374        format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn validates_skill_names() {
384        assert!(validate_skill_name("rust-code-review").is_ok());
385        assert!(validate_skill_name("Rust-Code-Review").is_err());
386        assert!(validate_skill_name("-rust").is_err());
387        assert!(validate_skill_name("rust-").is_err());
388        assert!(validate_skill_name("rust--review").is_err());
389    }
390
391    #[test]
392    fn parses_frontmatter() {
393        let frontmatter =
394            parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
395                .expect("frontmatter should parse");
396
397        assert_eq!(frontmatter.name, "demo-skill");
398        assert_eq!(frontmatter.description, "Use for demos.");
399    }
400
401    #[test]
402    fn rejects_missing_frontmatter() {
403        assert!(parse_frontmatter("# demo\n").is_err());
404    }
405
406    #[test]
407    fn tolerates_unknown_frontmatter_fields() {
408        // Reported by a user whose `knack list` blew up on agent-browser's
409        // SKILL.md because it set `hidden: true`. Knack doesn't model that
410        // field — and shouldn't have to — so parsing must skip past it.
411        let frontmatter = parse_frontmatter(
412            "---\n\
413             name: agent-browser\n\
414             description: Browser automation.\n\
415             allowed-tools: Bash(agent-browser:*)\n\
416             hidden: true\n\
417             custom-field: arbitrary\n\
418             ---\n",
419        )
420        .expect("foreign fields must be ignored, not rejected");
421        assert_eq!(frontmatter.name, "agent-browser");
422        assert_eq!(frontmatter.description, "Browser automation.");
423        assert_eq!(
424            frontmatter.allowed_tools.as_deref(),
425            Some("Bash(agent-browser:*)")
426        );
427    }
428
429    #[test]
430    fn still_requires_name_and_description() {
431        // Removing deny_unknown_fields shouldn't make typos in REQUIRED
432        // fields silent. Missing `description` should still fail.
433        assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
434    }
435
436    #[test]
437    fn validate_skill_metadata_ignores_directory_mismatch() {
438        // Vendors like Vercel and Remotion use unprefixed dirs with
439        // brand-prefixed frontmatter names — e.g.
440        // skills/composition-patterns/SKILL.md whose frontmatter
441        // declares `name: vercel-composition-patterns`. That's a
442        // legitimate convention and a registry indexing them must
443        // not reject these. validate_skill (strict) still does.
444        let skill = Skill {
445            path: PathBuf::from("/tmp/composition-patterns"),
446            name: "vercel-composition-patterns".to_string(),
447            description: "React composition patterns.".to_string(),
448        };
449        assert!(validate_skill_metadata(&skill).is_ok());
450        assert!(validate_skill(&skill).is_err());
451    }
452
453    #[test]
454    fn accepts_long_descriptions() {
455        // Real ecosystem SKILL.md files (anthropics/skills/skill-creator
456        // is ~5200 chars) bury invocation guidance in description. The
457        // old 1024-char ceiling locked us out of indexing them. Empty
458        // descriptions still fail; length no longer does.
459        let long = "Use when the user is doing things. ".repeat(200);
460        assert!(long.len() > 1024);
461        let skill = Skill {
462            path: PathBuf::from("/tmp/example"),
463            name: "example".to_string(),
464            description: long,
465        };
466        assert!(validate_skill(&skill).is_ok());
467
468        let blank = Skill {
469            path: PathBuf::from("/tmp/example"),
470            name: "example".to_string(),
471            description: "   ".to_string(),
472        };
473        assert!(validate_skill(&blank).is_err());
474    }
475
476    #[test]
477    fn searches_registry_index() {
478        let index = RegistryIndex {
479            skill: vec![
480                IndexedSkill {
481                    name: "pdf".to_string(),
482                    description: "Work with PDF documents".to_string(),
483                    source: "gh:anthropics/skills/skills/pdf".to_string(),
484                    tags: vec!["documents".to_string(), "ocr".to_string()],
485                },
486                IndexedSkill {
487                    name: "rust-code-review".to_string(),
488                    description: "Review Rust code".to_string(),
489                    source: "tea:platform/skills/rust-code-review".to_string(),
490                    tags: vec!["rust".to_string()],
491                },
492            ],
493            source: Vec::new(),
494        };
495
496        assert_eq!(index.search("pdf").len(), 1);
497        assert_eq!(index.search("documents ocr").len(), 1);
498        assert_eq!(index.search("python").len(), 0);
499    }
500}