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#[derive(Debug, Default, Deserialize, Serialize)]
189pub struct Lockfile {
190    #[serde(default)]
191    pub skill: Vec<LockedSkill>,
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize)]
195pub struct LockedSkill {
196    pub name: String,
197    pub source: String,
198    pub resolved: String,
199    pub checksum: String,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct RegistryConfig {
204    pub kind: RegistryKind,
205    pub url: String,
206    pub default_ref: String,
207}
208
209#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum RegistryKind {
212    GitHost,
213    Http,
214}
215
216#[derive(Debug, Default, Clone, Deserialize, Serialize)]
217pub struct RegistryIndex {
218    #[serde(default)]
219    pub skill: Vec<IndexedSkill>,
220    #[serde(default)]
221    pub source: Vec<IndexSource>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct IndexedSkill {
226    pub name: String,
227    pub description: String,
228    pub source: String,
229    #[serde(default)]
230    pub tags: Vec<String>,
231}
232
233#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct IndexSource {
235    pub source: String,
236    #[serde(default)]
237    pub tags: Vec<String>,
238}
239
240impl RegistryIndex {
241    pub fn validate(&self) -> Result<()> {
242        for skill in &self.skill {
243            skill.validate()?;
244        }
245        for source in &self.source {
246            source.validate()?;
247        }
248        Ok(())
249    }
250
251    pub fn search(&self, query: &str) -> Vec<&IndexedSkill> {
252        let terms: Vec<String> = query
253            .split_whitespace()
254            .map(|term| term.to_ascii_lowercase())
255            .collect();
256        if terms.is_empty() {
257            return Vec::new();
258        }
259
260        self.skill
261            .iter()
262            .filter(|skill| {
263                let haystack = skill.search_text();
264                terms.iter().all(|term| haystack.contains(term))
265            })
266            .collect()
267    }
268}
269
270impl IndexSource {
271    pub fn validate(&self) -> Result<()> {
272        if self.source.trim().is_empty() {
273            bail!("indexed source must not be empty");
274        }
275        Ok(())
276    }
277}
278
279impl IndexedSkill {
280    pub fn validate(&self) -> Result<()> {
281        validate_skill_name(&self.name)?;
282        if self.description.trim().is_empty() {
283            bail!("indexed skill description must not be empty: {}", self.name);
284        }
285        if self.source.trim().is_empty() {
286            bail!("indexed skill source must not be empty: {}", self.name);
287        }
288        Ok(())
289    }
290
291    fn search_text(&self) -> String {
292        format!("{} {} {}", self.name, self.description, self.tags.join(" ")).to_ascii_lowercase()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn validates_skill_names() {
302        assert!(validate_skill_name("rust-code-review").is_ok());
303        assert!(validate_skill_name("Rust-Code-Review").is_err());
304        assert!(validate_skill_name("-rust").is_err());
305        assert!(validate_skill_name("rust-").is_err());
306        assert!(validate_skill_name("rust--review").is_err());
307    }
308
309    #[test]
310    fn parses_frontmatter() {
311        let frontmatter =
312            parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
313                .expect("frontmatter should parse");
314
315        assert_eq!(frontmatter.name, "demo-skill");
316        assert_eq!(frontmatter.description, "Use for demos.");
317    }
318
319    #[test]
320    fn rejects_missing_frontmatter() {
321        assert!(parse_frontmatter("# demo\n").is_err());
322    }
323
324    #[test]
325    fn searches_registry_index() {
326        let index = RegistryIndex {
327            skill: vec![
328                IndexedSkill {
329                    name: "pdf".to_string(),
330                    description: "Work with PDF documents".to_string(),
331                    source: "gh:anthropics/skills/skills/pdf".to_string(),
332                    tags: vec!["documents".to_string(), "ocr".to_string()],
333                },
334                IndexedSkill {
335                    name: "rust-code-review".to_string(),
336                    description: "Review Rust code".to_string(),
337                    source: "tea:platform/skills/rust-code-review".to_string(),
338                    tags: vec!["rust".to_string()],
339                },
340            ],
341            source: Vec::new(),
342        };
343
344        assert_eq!(index.search("pdf").len(), 1);
345        assert_eq!(index.search("documents ocr").len(), 1);
346        assert_eq!(index.search("python").len(), 0);
347    }
348}