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#[derive(Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20#[allow(dead_code)]
21struct SkillFrontmatter {
22    name: String,
23    description: String,
24    #[serde(default)]
25    license: Option<String>,
26    #[serde(default)]
27    compatibility: Option<String>,
28    #[serde(default)]
29    metadata: Option<serde_yaml::Mapping>,
30    #[serde(default, rename = "allowed-tools")]
31    allowed_tools: Option<String>,
32}
33
34pub fn read_skill(path: &Path) -> Result<Skill> {
35    if !path.is_dir() {
36        bail!("skill path is not a directory: {}", path.display());
37    }
38
39    let skill_file = path.join("SKILL.md");
40    let contents = fs::read_to_string(&skill_file)
41        .with_context(|| format!("failed to read {}", skill_file.display()))?;
42    let frontmatter = parse_frontmatter(&contents)
43        .with_context(|| format!("failed to parse {}", skill_file.display()))?;
44
45    Ok(Skill {
46        path: path.to_path_buf(),
47        name: frontmatter.name,
48        description: frontmatter.description,
49    })
50}
51
52fn parse_frontmatter(contents: &str) -> Result<SkillFrontmatter> {
53    let mut lines = contents.lines();
54    if lines.next() != Some("---") {
55        bail!("SKILL.md must start with YAML frontmatter delimited by ---");
56    }
57
58    let mut yaml = String::new();
59    for line in lines {
60        if line == "---" {
61            let frontmatter = serde_yaml::from_str(&yaml)?;
62            return Ok(frontmatter);
63        }
64        yaml.push_str(line);
65        yaml.push('\n');
66    }
67
68    bail!("SKILL.md frontmatter is missing closing ---");
69}
70
71pub fn validate_skill(skill: &Skill) -> Result<()> {
72    validate_skill_name(&skill.name)?;
73
74    let dirname = skill
75        .path
76        .file_name()
77        .and_then(|name| name.to_str())
78        .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
79    if dirname != skill.name {
80        bail!(
81            "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
82            skill.name,
83            dirname
84        );
85    }
86
87    if skill.description.trim().is_empty() {
88        bail!("description must not be empty");
89    }
90
91    if skill.description.chars().count() > 1024 {
92        bail!("description must be at most 1024 characters");
93    }
94
95    Ok(())
96}
97
98pub fn validate_skill_name(name: &str) -> Result<()> {
99    let len = name.chars().count();
100    if len == 0 || len > 64 {
101        bail!("skill name must be 1-64 characters");
102    }
103
104    if name.starts_with('-') || name.ends_with('-') {
105        bail!("skill name must not start or end with a hyphen");
106    }
107
108    if name.contains("--") {
109        bail!("skill name must not contain consecutive hyphens");
110    }
111
112    if !name
113        .chars()
114        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
115    {
116        bail!("skill name may only contain lowercase letters, numbers, and hyphens");
117    }
118
119    Ok(())
120}
121
122pub fn checksum_dir(path: &Path) -> Result<String> {
123    let mut hasher = Sha256::new();
124    for file in collect_files(path)? {
125        let relative_path = file.strip_prefix(path).with_context(|| {
126            format!(
127                "failed to make {} relative to {}",
128                file.display(),
129                path.display()
130            )
131        })?;
132        hasher.update(relative_path.to_string_lossy().as_bytes());
133        hasher.update([0]);
134        hasher
135            .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
136        hasher.update([0]);
137    }
138    Ok(format!("sha256:{:x}", hasher.finalize()))
139}
140
141pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
142    let mut files = Vec::new();
143    collect_files_inner(path, &mut files)?;
144    files.sort();
145    Ok(files)
146}
147
148fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
149    for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
150        let entry = entry?;
151        let path = entry.path();
152        let file_type = entry.file_type()?;
153
154        if file_type.is_dir() {
155            collect_files_inner(&path, files)?;
156        } else if file_type.is_file() {
157            files.push(path);
158        }
159    }
160
161    Ok(())
162}
163
164#[derive(Debug, Clone, Deserialize, Serialize)]
165pub struct Manifest {
166    pub install: InstallConfig,
167    #[serde(default)]
168    pub skills: BTreeMap<String, String>,
169    #[serde(default)]
170    pub registries: BTreeMap<String, RegistryConfig>,
171}
172
173impl Manifest {
174    pub fn new(target: PathBuf) -> Self {
175        Self {
176            install: InstallConfig { target },
177            skills: BTreeMap::new(),
178            registries: BTreeMap::new(),
179        }
180    }
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize)]
184pub struct InstallConfig {
185    pub target: PathBuf,
186}
187
188/// Current lockfile schema version. Bump when the on-disk layout
189/// changes in a way an older `knack` couldn't safely interpret.
190///
191/// Backward compatibility is one-way: a new knack reading an old
192/// lockfile should keep working (with default values for new fields).
193/// An old knack reading a new lockfile errors loudly rather than
194/// guessing — that's what `Lockfile::ensure_supported_version`
195/// enforces.
196pub const LOCKFILE_VERSION: u32 = 1;
197
198fn default_lockfile_version() -> u32 {
199    LOCKFILE_VERSION
200}
201
202#[derive(Debug, Deserialize, Serialize)]
203pub struct Lockfile {
204    /// Schema version. Older lockfiles without this field default to
205    /// version 1 since v1 is identical to the pre-versioned layout —
206    /// only the SHA-pinning convention differs, and that's transparent
207    /// to the parser.
208    #[serde(default = "default_lockfile_version")]
209    pub version: u32,
210    #[serde(default)]
211    pub skill: Vec<LockedSkill>,
212}
213
214impl Default for Lockfile {
215    fn default() -> Self {
216        Self {
217            version: LOCKFILE_VERSION,
218            skill: Vec::new(),
219        }
220    }
221}
222
223impl Lockfile {
224    /// Refuse to operate on a lockfile from a future knack version.
225    /// New schema versions may add fields or change semantics in ways
226    /// this binary can't preserve on round-trip; bailing avoids
227    /// silently corrupting the file when we write it back.
228    pub fn ensure_supported_version(&self) -> Result<(), String> {
229        if self.version > LOCKFILE_VERSION {
230            return Err(format!(
231                "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
232                self.version
233            ));
234        }
235        Ok(())
236    }
237}
238
239#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct LockedSkill {
241    pub name: String,
242    pub source: String,
243    pub resolved: String,
244    pub checksum: String,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
248pub struct RegistryConfig {
249    pub kind: RegistryKind,
250    pub url: String,
251    pub default_ref: String,
252}
253
254#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
255#[serde(rename_all = "kebab-case")]
256pub enum RegistryKind {
257    GitHost,
258    Http,
259}
260
261#[derive(Debug, Default, Clone, Deserialize, Serialize)]
262pub struct RegistryIndex {
263    #[serde(default)]
264    pub skill: Vec<IndexedSkill>,
265    #[serde(default)]
266    pub source: Vec<IndexSource>,
267}
268
269#[derive(Debug, Clone, Deserialize, Serialize)]
270pub struct IndexedSkill {
271    pub name: String,
272    pub description: String,
273    pub source: String,
274    #[serde(default)]
275    pub tags: Vec<String>,
276}
277
278#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct IndexSource {
280    pub source: String,
281    #[serde(default)]
282    pub tags: Vec<String>,
283}
284
285impl RegistryIndex {
286    pub fn validate(&self) -> Result<()> {
287        for skill in &self.skill {
288            skill.validate()?;
289        }
290        for source in &self.source {
291            source.validate()?;
292        }
293        Ok(())
294    }
295
296    pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
297        let terms: Vec<String> = query
298            .split_whitespace()
299            .map(|term| term.to_ascii_lowercase())
300            .collect();
301        if terms.is_empty() {
302            return Vec::new();
303        }
304
305        self.skill
306            .iter()
307            .filter(|skill| {
308                let haystack = skill.search_text();
309                terms.iter().all(|term| haystack.contains(term))
310            })
311            .collect()
312    }
313}
314
315impl IndexSource {
316    pub fn validate(&self) -> Result<()> {
317        if self.source.trim().is_empty() {
318            bail!("indexed source must not be empty");
319        }
320        Ok(())
321    }
322}
323
324impl IndexedSkill {
325    pub fn validate(&self) -> Result<()> {
326        validate_skill_name(&self.name)?;
327        if self.description.trim().is_empty() {
328            bail!("indexed skill description must not be empty: {}", self.name);
329        }
330        if self.source.trim().is_empty() {
331            bail!("indexed skill source must not be empty: {}", self.name);
332        }
333        Ok(())
334    }
335
336    fn search_text(&self) -> String {
337        format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn validates_skill_names() {
347        assert!(validate_skill_name("rust-code-review").is_ok());
348        assert!(validate_skill_name("Rust-Code-Review").is_err());
349        assert!(validate_skill_name("-rust").is_err());
350        assert!(validate_skill_name("rust-").is_err());
351        assert!(validate_skill_name("rust--review").is_err());
352    }
353
354    #[test]
355    fn parses_frontmatter() {
356        let frontmatter =
357            parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
358                .expect("frontmatter should parse");
359
360        assert_eq!(frontmatter.name, "demo-skill");
361        assert_eq!(frontmatter.description, "Use for demos.");
362    }
363
364    #[test]
365    fn rejects_missing_frontmatter() {
366        assert!(parse_frontmatter("# demo\n").is_err());
367    }
368
369    #[test]
370    fn searches_registry_index() {
371        let index = RegistryIndex {
372            skill: vec![
373                IndexedSkill {
374                    name: "pdf".to_string(),
375                    description: "Work with PDF documents".to_string(),
376                    source: "gh:anthropics/skills/skills/pdf".to_string(),
377                    tags: vec!["documents".to_string(), "ocr".to_string()],
378                },
379                IndexedSkill {
380                    name: "rust-code-review".to_string(),
381                    description: "Review Rust code".to_string(),
382                    source: "tea:platform/skills/rust-code-review".to_string(),
383                    tags: vec!["rust".to_string()],
384                },
385            ],
386            source: Vec::new(),
387        };
388
389        assert_eq!(index.search("pdf").len(), 1);
390        assert_eq!(index.search("documents ocr").len(), 1);
391        assert_eq!(index.search("python").len(), 0);
392    }
393}