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
83pub fn validate_skill(skill: &Skill) -> Result<()> {
84    validate_skill_name(&skill.name)?;
85
86    let dirname = skill
87        .path
88        .file_name()
89        .and_then(|name| name.to_str())
90        .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
91    if dirname != skill.name {
92        bail!(
93            "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
94            skill.name,
95            dirname
96        );
97    }
98
99    if skill.description.trim().is_empty() {
100        bail!("description must not be empty");
101    }
102
103    if skill.description.chars().count() > 1024 {
104        bail!("description must be at most 1024 characters");
105    }
106
107    Ok(())
108}
109
110pub fn validate_skill_name(name: &str) -> Result<()> {
111    let len = name.chars().count();
112    if len == 0 || len > 64 {
113        bail!("skill name must be 1-64 characters");
114    }
115
116    if name.starts_with('-') || name.ends_with('-') {
117        bail!("skill name must not start or end with a hyphen");
118    }
119
120    if name.contains("--") {
121        bail!("skill name must not contain consecutive hyphens");
122    }
123
124    if !name
125        .chars()
126        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
127    {
128        bail!("skill name may only contain lowercase letters, numbers, and hyphens");
129    }
130
131    Ok(())
132}
133
134pub fn checksum_dir(path: &Path) -> Result<String> {
135    let mut hasher = Sha256::new();
136    for file in collect_files(path)? {
137        let relative_path = file.strip_prefix(path).with_context(|| {
138            format!(
139                "failed to make {} relative to {}",
140                file.display(),
141                path.display()
142            )
143        })?;
144        hasher.update(relative_path.to_string_lossy().as_bytes());
145        hasher.update([0]);
146        hasher
147            .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
148        hasher.update([0]);
149    }
150    Ok(format!("sha256:{:x}", hasher.finalize()))
151}
152
153pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
154    let mut files = Vec::new();
155    collect_files_inner(path, &mut files)?;
156    files.sort();
157    Ok(files)
158}
159
160fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
161    for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
162        let entry = entry?;
163        let path = entry.path();
164        let file_type = entry.file_type()?;
165
166        if file_type.is_dir() {
167            collect_files_inner(&path, files)?;
168        } else if file_type.is_file() {
169            files.push(path);
170        }
171    }
172
173    Ok(())
174}
175
176#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct Manifest {
178    pub install: InstallConfig,
179    #[serde(default)]
180    pub skills: BTreeMap<String, String>,
181    #[serde(default)]
182    pub registries: BTreeMap<String, RegistryConfig>,
183}
184
185impl Manifest {
186    pub fn new(target: PathBuf) -> Self {
187        Self {
188            install: InstallConfig { target },
189            skills: BTreeMap::new(),
190            registries: BTreeMap::new(),
191        }
192    }
193}
194
195#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct InstallConfig {
197    pub target: PathBuf,
198}
199
200/// Current lockfile schema version. Bump when the on-disk layout
201/// changes in a way an older `knack` couldn't safely interpret.
202///
203/// Backward compatibility is one-way: a new knack reading an old
204/// lockfile should keep working (with default values for new fields).
205/// An old knack reading a new lockfile errors loudly rather than
206/// guessing — that's what `Lockfile::ensure_supported_version`
207/// enforces.
208pub const LOCKFILE_VERSION: u32 = 1;
209
210fn default_lockfile_version() -> u32 {
211    LOCKFILE_VERSION
212}
213
214#[derive(Debug, Deserialize, Serialize)]
215pub struct Lockfile {
216    /// Schema version. Older lockfiles without this field default to
217    /// version 1 since v1 is identical to the pre-versioned layout —
218    /// only the SHA-pinning convention differs, and that's transparent
219    /// to the parser.
220    #[serde(default = "default_lockfile_version")]
221    pub version: u32,
222    #[serde(default)]
223    pub skill: Vec<LockedSkill>,
224}
225
226impl Default for Lockfile {
227    fn default() -> Self {
228        Self {
229            version: LOCKFILE_VERSION,
230            skill: Vec::new(),
231        }
232    }
233}
234
235impl Lockfile {
236    /// Refuse to operate on a lockfile from a future knack version.
237    /// New schema versions may add fields or change semantics in ways
238    /// this binary can't preserve on round-trip; bailing avoids
239    /// silently corrupting the file when we write it back.
240    pub fn ensure_supported_version(&self) -> Result<(), String> {
241        if self.version > LOCKFILE_VERSION {
242            return Err(format!(
243                "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
244                self.version
245            ));
246        }
247        Ok(())
248    }
249}
250
251#[derive(Debug, Clone, Deserialize, Serialize)]
252pub struct LockedSkill {
253    pub name: String,
254    pub source: String,
255    pub resolved: String,
256    pub checksum: String,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
260pub struct RegistryConfig {
261    pub kind: RegistryKind,
262    pub url: String,
263    pub default_ref: String,
264}
265
266#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
267#[serde(rename_all = "kebab-case")]
268pub enum RegistryKind {
269    GitHost,
270    Http,
271}
272
273#[derive(Debug, Default, Clone, Deserialize, Serialize)]
274pub struct RegistryIndex {
275    #[serde(default)]
276    pub skill: Vec<IndexedSkill>,
277    #[serde(default)]
278    pub source: Vec<IndexSource>,
279}
280
281#[derive(Debug, Clone, Deserialize, Serialize)]
282pub struct IndexedSkill {
283    pub name: String,
284    pub description: String,
285    pub source: String,
286    #[serde(default)]
287    pub tags: Vec<String>,
288}
289
290#[derive(Debug, Clone, Deserialize, Serialize)]
291pub struct IndexSource {
292    pub source: String,
293    #[serde(default)]
294    pub tags: Vec<String>,
295}
296
297impl RegistryIndex {
298    pub fn validate(&self) -> Result<()> {
299        for skill in &self.skill {
300            skill.validate()?;
301        }
302        for source in &self.source {
303            source.validate()?;
304        }
305        Ok(())
306    }
307
308    pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
309        let terms: Vec<String> = query
310            .split_whitespace()
311            .map(|term| term.to_ascii_lowercase())
312            .collect();
313        if terms.is_empty() {
314            return Vec::new();
315        }
316
317        self.skill
318            .iter()
319            .filter(|skill| {
320                let haystack = skill.search_text();
321                terms.iter().all(|term| haystack.contains(term))
322            })
323            .collect()
324    }
325}
326
327impl IndexSource {
328    pub fn validate(&self) -> Result<()> {
329        if self.source.trim().is_empty() {
330            bail!("indexed source must not be empty");
331        }
332        Ok(())
333    }
334}
335
336impl IndexedSkill {
337    pub fn validate(&self) -> Result<()> {
338        validate_skill_name(&self.name)?;
339        if self.description.trim().is_empty() {
340            bail!("indexed skill description must not be empty: {}", self.name);
341        }
342        if self.source.trim().is_empty() {
343            bail!("indexed skill source must not be empty: {}", self.name);
344        }
345        Ok(())
346    }
347
348    fn search_text(&self) -> String {
349        format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn validates_skill_names() {
359        assert!(validate_skill_name("rust-code-review").is_ok());
360        assert!(validate_skill_name("Rust-Code-Review").is_err());
361        assert!(validate_skill_name("-rust").is_err());
362        assert!(validate_skill_name("rust-").is_err());
363        assert!(validate_skill_name("rust--review").is_err());
364    }
365
366    #[test]
367    fn parses_frontmatter() {
368        let frontmatter =
369            parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
370                .expect("frontmatter should parse");
371
372        assert_eq!(frontmatter.name, "demo-skill");
373        assert_eq!(frontmatter.description, "Use for demos.");
374    }
375
376    #[test]
377    fn rejects_missing_frontmatter() {
378        assert!(parse_frontmatter("# demo\n").is_err());
379    }
380
381    #[test]
382    fn tolerates_unknown_frontmatter_fields() {
383        // Reported by a user whose `knack list` blew up on agent-browser's
384        // SKILL.md because it set `hidden: true`. Knack doesn't model that
385        // field — and shouldn't have to — so parsing must skip past it.
386        let frontmatter = parse_frontmatter(
387            "---\n\
388             name: agent-browser\n\
389             description: Browser automation.\n\
390             allowed-tools: Bash(agent-browser:*)\n\
391             hidden: true\n\
392             custom-field: arbitrary\n\
393             ---\n",
394        )
395        .expect("foreign fields must be ignored, not rejected");
396        assert_eq!(frontmatter.name, "agent-browser");
397        assert_eq!(frontmatter.description, "Browser automation.");
398        assert_eq!(
399            frontmatter.allowed_tools.as_deref(),
400            Some("Bash(agent-browser:*)")
401        );
402    }
403
404    #[test]
405    fn still_requires_name_and_description() {
406        // Removing deny_unknown_fields shouldn't make typos in REQUIRED
407        // fields silent. Missing `description` should still fail.
408        assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
409    }
410
411    #[test]
412    fn searches_registry_index() {
413        let index = RegistryIndex {
414            skill: vec![
415                IndexedSkill {
416                    name: "pdf".to_string(),
417                    description: "Work with PDF documents".to_string(),
418                    source: "gh:anthropics/skills/skills/pdf".to_string(),
419                    tags: vec!["documents".to_string(), "ocr".to_string()],
420                },
421                IndexedSkill {
422                    name: "rust-code-review".to_string(),
423                    description: "Review Rust code".to_string(),
424                    source: "tea:platform/skills/rust-code-review".to_string(),
425                    tags: vec!["rust".to_string()],
426                },
427            ],
428            source: Vec::new(),
429        };
430
431        assert_eq!(index.search("pdf").len(), 1);
432        assert_eq!(index.search("documents ocr").len(), 1);
433        assert_eq!(index.search("python").len(), 0);
434    }
435}