use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug)]
pub struct Skill {
pub path: PathBuf,
pub name: String,
pub description: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct SkillFrontmatter {
name: String,
description: String,
#[serde(default)]
license: Option<String>,
#[serde(default)]
compatibility: Option<String>,
#[serde(default)]
metadata: Option<serde_yaml::Mapping>,
#[serde(default, rename = "allowed-tools")]
allowed_tools: Option<String>,
}
pub fn read_skill(path: &Path) -> Result<Skill> {
if !path.is_dir() {
bail!("skill path is not a directory: {}", path.display());
}
let skill_file = path.join("SKILL.md");
let contents = fs::read_to_string(&skill_file)
.with_context(|| format!("failed to read {}", skill_file.display()))?;
let frontmatter = parse_frontmatter(&contents)
.with_context(|| format!("failed to parse {}", skill_file.display()))?;
Ok(Skill {
path: path.to_path_buf(),
name: frontmatter.name,
description: frontmatter.description,
})
}
fn parse_frontmatter(contents: &str) -> Result<SkillFrontmatter> {
let mut lines = contents.lines();
if lines.next() != Some("---") {
bail!("SKILL.md must start with YAML frontmatter delimited by ---");
}
let mut yaml = String::new();
for line in lines {
if line == "---" {
let frontmatter = serde_yaml::from_str(&yaml)?;
return Ok(frontmatter);
}
yaml.push_str(line);
yaml.push('\n');
}
bail!("SKILL.md frontmatter is missing closing ---");
}
pub fn validate_skill_metadata(skill: &Skill) -> Result<()> {
validate_skill_name(&skill.name)?;
if skill.description.trim().is_empty() {
bail!("description must not be empty");
}
Ok(())
}
pub fn validate_skill(skill: &Skill) -> Result<()> {
validate_skill_metadata(skill)?;
let dirname = skill
.path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
if dirname != skill.name {
bail!(
"skill name must match directory name: frontmatter has {:?}, directory is {:?}",
skill.name,
dirname
);
}
Ok(())
}
pub fn validate_skill_name(name: &str) -> Result<()> {
let len = name.chars().count();
if len == 0 || len > 64 {
bail!("skill name must be 1-64 characters");
}
if name.starts_with('-') || name.ends_with('-') {
bail!("skill name must not start or end with a hyphen");
}
if name.contains("--") {
bail!("skill name must not contain consecutive hyphens");
}
if !name
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
{
bail!("skill name may only contain lowercase letters, numbers, and hyphens");
}
Ok(())
}
pub fn checksum_dir(path: &Path) -> Result<String> {
let mut hasher = Sha256::new();
for file in collect_files(path)? {
let relative_path = file.strip_prefix(path).with_context(|| {
format!(
"failed to make {} relative to {}",
file.display(),
path.display()
)
})?;
hasher.update(relative_path.to_string_lossy().as_bytes());
hasher.update([0]);
hasher
.update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
hasher.update([0]);
}
Ok(format!("sha256:{:x}", hasher.finalize()))
}
pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_files_inner(path, &mut files)?;
files.sort();
Ok(files)
}
fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_files_inner(&path, files)?;
} else if file_type.is_file() {
files.push(path);
}
}
Ok(())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Manifest {
pub install: InstallConfig,
#[serde(default)]
pub skills: BTreeMap<String, String>,
#[serde(default)]
pub registries: BTreeMap<String, RegistryConfig>,
}
impl Manifest {
pub fn new(target: PathBuf) -> Self {
Self {
install: InstallConfig {
target,
default_registry: None,
},
skills: BTreeMap::new(),
registries: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstallConfig {
pub target: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_registry: Option<String>,
}
pub const LOCKFILE_VERSION: u32 = 2;
fn default_lockfile_version() -> u32 {
1
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Lockfile {
#[serde(default = "default_lockfile_version")]
pub version: u32,
#[serde(default)]
pub skill: Vec<LockedSkill>,
}
impl Default for Lockfile {
fn default() -> Self {
Self {
version: LOCKFILE_VERSION,
skill: Vec::new(),
}
}
}
impl Lockfile {
pub fn ensure_supported_version(&self) -> Result<(), String> {
if self.version > LOCKFILE_VERSION {
return Err(format!(
"lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
self.version
));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LockedSkill {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
pub source: String,
pub resolved: String,
pub checksum: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct RegistryConfig {
pub kind: RegistryKind,
pub url: String,
pub default_ref: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RegistryKind {
GitHost,
Http,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct RegistryIndex {
#[serde(default)]
pub skill: Vec<IndexedSkill>,
#[serde(default)]
pub source: Vec<IndexSource>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IndexedSkill {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
pub description: String,
pub source: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<f64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IndexSource {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
impl RegistryIndex {
pub fn validate(&self) -> Result<()> {
for skill in &self.skill {
skill.validate()?;
}
for source in &self.source {
source.validate()?;
}
Ok(())
}
pub fn search(&self, query: &str) -> Vec<(&IndexedSkill, f64)> {
let terms: Vec<String> = query
.split_whitespace()
.map(|term| term.to_ascii_lowercase())
.collect();
if terms.is_empty() {
return Vec::new();
}
let total_skills = self.skill.len();
let idf_weights: Vec<f64> = terms
.iter()
.map(|term| {
let doc_freq = self
.skill
.iter()
.filter(|skill| skill.matches_term(term))
.count();
inverse_document_frequency(total_skills, doc_freq)
})
.collect();
let mut scored: Vec<(&IndexedSkill, f64)> = self
.skill
.iter()
.filter_map(|skill| {
skill
.match_score(&terms, &idf_weights)
.map(|score| (skill, score))
})
.collect();
scored.sort_by(|(a, a_score), (b, b_score)| {
b_score
.partial_cmp(a_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.qualified_name().cmp(&b.qualified_name()))
});
scored
}
}
fn inverse_document_frequency(total_skills: usize, doc_freq: usize) -> f64 {
if total_skills == 0 || doc_freq == 0 {
return 1.0;
}
let n = total_skills as f64;
let df = doc_freq as f64;
(1.0 + (n - df + 0.5) / (df + 0.5)).ln()
}
impl IndexSource {
pub fn validate(&self) -> Result<()> {
if self.source.trim().is_empty() {
bail!("indexed source must not be empty");
}
Ok(())
}
}
impl IndexedSkill {
pub fn validate(&self) -> Result<()> {
validate_skill_name(&self.name)?;
if let Some(ns) = &self.namespace {
validate_skill_name(ns).map_err(|err| anyhow!("invalid namespace: {err}"))?;
}
if self.description.trim().is_empty() {
bail!("indexed skill description must not be empty: {}", self.name);
}
if self.source.trim().is_empty() {
bail!("indexed skill source must not be empty: {}", self.name);
}
Ok(())
}
pub fn qualified_name(&self) -> String {
match &self.namespace {
Some(ns) => format!("{ns}/{}", self.name),
None => self.name.clone(),
}
}
fn field_weight(term: &str, field: &str, is_name_or_tag: bool) -> f64 {
if field.is_empty() {
return 0.0;
}
if is_name_or_tag {
if field == term {
return 4.0;
}
if field.starts_with(term) {
return 3.0;
}
if word_boundary_match(field, term) {
return 2.0;
}
}
if field.contains(term) {
if is_name_or_tag { 1.0 } else { 0.5 }
} else {
0.0
}
}
fn best_field_weight(&self, term: &str) -> f64 {
let name = self.name.to_ascii_lowercase();
let namespace = self
.namespace
.as_deref()
.map(|ns| ns.to_ascii_lowercase())
.unwrap_or_default();
let description = self.description.to_ascii_lowercase();
let mut best = Self::field_weight(term, &name, true);
best = best.max(Self::field_weight(term, &namespace, true));
for tag in &self.tags {
best = best.max(Self::field_weight(term, &tag.to_ascii_lowercase(), true));
}
best.max(Self::field_weight(term, &description, false))
}
fn matches_term(&self, term: &str) -> bool {
self.best_field_weight(term) > 0.0
}
fn match_score(&self, terms: &[String], idf_weights: &[f64]) -> Option<f64> {
let mut total = 0.0;
for (term, idf) in terms.iter().zip(idf_weights) {
let best = self.best_field_weight(term);
if best <= 0.0 {
return None;
}
total += best * idf;
}
Some(total)
}
}
fn word_boundary_match(field: &str, term: &str) -> bool {
let mut start = 0;
while let Some(idx) = field[start..].find(term) {
let match_start = start + idx;
let match_end = match_start + term.len();
let before_ok = match_start == 0
|| !field[..match_start]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric());
let after_ok = match_end == field.len()
|| !field[match_end..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric());
if before_ok && after_ok {
return true;
}
start = match_start + 1;
if start >= field.len() {
break;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_skill_names() {
assert!(validate_skill_name("rust-code-review").is_ok());
assert!(validate_skill_name("Rust-Code-Review").is_err());
assert!(validate_skill_name("-rust").is_err());
assert!(validate_skill_name("rust-").is_err());
assert!(validate_skill_name("rust--review").is_err());
}
#[test]
fn parses_frontmatter() {
let frontmatter =
parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
.expect("frontmatter should parse");
assert_eq!(frontmatter.name, "demo-skill");
assert_eq!(frontmatter.description, "Use for demos.");
}
#[test]
fn rejects_missing_frontmatter() {
assert!(parse_frontmatter("# demo\n").is_err());
}
#[test]
fn tolerates_unknown_frontmatter_fields() {
let frontmatter = parse_frontmatter(
"---\n\
name: agent-browser\n\
description: Browser automation.\n\
allowed-tools: Bash(agent-browser:*)\n\
hidden: true\n\
custom-field: arbitrary\n\
---\n",
)
.expect("foreign fields must be ignored, not rejected");
assert_eq!(frontmatter.name, "agent-browser");
assert_eq!(frontmatter.description, "Browser automation.");
assert_eq!(
frontmatter.allowed_tools.as_deref(),
Some("Bash(agent-browser:*)")
);
}
#[test]
fn still_requires_name_and_description() {
assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
}
#[test]
fn validate_skill_metadata_ignores_directory_mismatch() {
let skill = Skill {
path: PathBuf::from("/tmp/composition-patterns"),
name: "vercel-composition-patterns".to_string(),
description: "React composition patterns.".to_string(),
};
assert!(validate_skill_metadata(&skill).is_ok());
assert!(validate_skill(&skill).is_err());
}
#[test]
fn accepts_long_descriptions() {
let long = "Use when the user is doing things. ".repeat(200);
assert!(long.len() > 1024);
let skill = Skill {
path: PathBuf::from("/tmp/example"),
name: "example".to_string(),
description: long,
};
assert!(validate_skill(&skill).is_ok());
let blank = Skill {
path: PathBuf::from("/tmp/example"),
name: "example".to_string(),
description: " ".to_string(),
};
assert!(validate_skill(&blank).is_err());
}
#[test]
fn searches_registry_index() {
let index = RegistryIndex {
skill: vec![
IndexedSkill {
name: "pdf".to_string(),
namespace: Some("anthropics".to_string()),
description: "Work with PDF documents".to_string(),
source: "anthropics/pdf".to_string(),
tags: vec!["documents".to_string(), "ocr".to_string()],
score: None,
},
IndexedSkill {
name: "rust-code-review".to_string(),
namespace: None,
description: "Review Rust code".to_string(),
source: "rust-code-review".to_string(),
tags: vec!["rust".to_string()],
score: None,
},
],
source: Vec::new(),
};
assert_eq!(index.search("pdf").len(), 1);
assert_eq!(index.search("documents ocr").len(), 1);
assert_eq!(index.search("python").len(), 0);
assert_eq!(index.search("anthropics").len(), 1);
}
#[test]
fn ranks_name_matches_above_description_only_matches() {
let index = RegistryIndex {
skill: vec![
IndexedSkill {
name: "changelog-writer".to_string(),
namespace: None,
description: "Summarize commits, including ones touching Rust code."
.to_string(),
source: "changelog-writer".to_string(),
tags: vec![],
score: None,
},
IndexedSkill {
name: "rust-code-review".to_string(),
namespace: None,
description: "Review code for correctness".to_string(),
source: "rust-code-review".to_string(),
tags: vec!["rust".to_string()],
score: None,
},
],
source: Vec::new(),
};
let results = index.search("rust");
assert_eq!(results.len(), 2);
assert_eq!(results[0].0.name, "rust-code-review");
assert!(results[0].1 > results[1].1);
}
#[test]
fn discounts_common_terms_against_rare_terms_via_idf() {
let mut skill = vec![
IndexedSkill {
name: "ci-tools".to_string(),
namespace: None,
description: "Helps deploy your pipeline safely.".to_string(),
source: "ci-tools".to_string(),
tags: vec!["ci".to_string()],
score: None,
},
IndexedSkill {
name: "deploy".to_string(),
namespace: None,
description: "Also handles some ci related tasks.".to_string(),
source: "deploy".to_string(),
tags: vec!["deploy".to_string()],
score: None,
},
];
for i in 0..15 {
skill.push(IndexedSkill {
name: format!("filler-{i}"),
namespace: None,
description: "Handles deployment automation for unrelated workflows.".to_string(),
source: format!("filler-{i}"),
tags: vec![],
score: None,
});
}
let index = RegistryIndex {
skill,
source: Vec::new(),
};
let results = index.search("ci deploy");
assert_eq!(results.len(), 2);
assert_eq!(
results[0].0.name, "ci-tools",
"strong match on the rarer, more discriminating term should outrank \
a strong match on the term that's common across the index"
);
assert!(results[0].1 > results[1].1);
}
#[test]
fn requires_every_term_to_match_somewhere() {
let index = RegistryIndex {
skill: vec![IndexedSkill {
name: "pdf".to_string(),
namespace: Some("anthropics".to_string()),
description: "Work with PDF documents".to_string(),
source: "anthropics/pdf".to_string(),
tags: vec!["documents".to_string()],
score: None,
}],
source: Vec::new(),
};
assert_eq!(index.search("pdf python").len(), 0);
assert_eq!(index.search("pdf documents").len(), 1);
}
#[test]
fn ties_break_alphabetically_by_qualified_name() {
let index = RegistryIndex {
skill: vec![
IndexedSkill {
name: "zeta".to_string(),
namespace: None,
description: "docs helper".to_string(),
source: "zeta".to_string(),
tags: vec![],
score: None,
},
IndexedSkill {
name: "alpha".to_string(),
namespace: None,
description: "docs helper".to_string(),
source: "alpha".to_string(),
tags: vec![],
score: None,
},
],
source: Vec::new(),
};
let results = index.search("docs");
assert_eq!(results[0].0.name, "alpha");
assert_eq!(results[1].0.name, "zeta");
}
#[test]
fn qualified_name_round_trips() {
let scoped = IndexedSkill {
name: "pdf".to_string(),
namespace: Some("anthropics".to_string()),
description: "x".to_string(),
source: "anthropics/pdf".to_string(),
tags: vec![],
score: None,
};
assert_eq!(scoped.qualified_name(), "anthropics/pdf");
let unscoped = IndexedSkill {
name: "legacy".to_string(),
namespace: None,
description: "x".to_string(),
source: "legacy".to_string(),
tags: vec![],
score: None,
};
assert_eq!(unscoped.qualified_name(), "legacy");
}
#[test]
fn validates_namespace_charset() {
let mut skill = IndexedSkill {
name: "ok".to_string(),
namespace: Some("good-ns".to_string()),
description: "x".to_string(),
source: "good-ns/ok".to_string(),
tags: vec![],
score: None,
};
assert!(skill.validate().is_ok());
skill.namespace = Some("Bad_Namespace".to_string());
let err = skill.validate().unwrap_err().to_string();
assert!(err.contains("invalid namespace"), "got: {err}");
}
#[test]
fn parses_v1_lockfile_without_version_or_namespace() {
let toml_v1 = r#"
[[skill]]
name = "pdf"
source = "public:pdf"
resolved = "http+knack:https://example.com/skills/pdf/archive#sha=abc123"
checksum = "sha256:deadbeef"
"#;
let lockfile: Lockfile = toml::from_str(toml_v1).expect("v1 lockfile must parse");
assert_eq!(lockfile.version, 1);
assert_eq!(lockfile.skill.len(), 1);
assert_eq!(lockfile.skill[0].namespace, None);
assert!(lockfile.ensure_supported_version().is_ok());
}
#[test]
fn parses_v2_lockfile_with_namespace() {
let toml_v2 = r#"
version = 2
[[skill]]
name = "pdf"
namespace = "anthropics"
source = "public:anthropics/pdf"
resolved = "http+knack:https://example.com/skills/anthropics/pdf/archive#sha=abc"
checksum = "sha256:deadbeef"
"#;
let lockfile: Lockfile = toml::from_str(toml_v2).expect("v2 lockfile must parse");
assert_eq!(lockfile.version, 2);
assert_eq!(lockfile.skill[0].namespace.as_deref(), Some("anthropics"));
}
#[test]
fn rejects_lockfile_from_newer_knack() {
let future = r#"
version = 999
[[skill]]
name = "pdf"
source = "public:pdf"
resolved = "x"
checksum = "x"
"#;
let lockfile: Lockfile = toml::from_str(future).unwrap();
let err = lockfile
.ensure_supported_version()
.expect_err("future lockfile must be rejected");
assert!(err.contains("newer than this knack supports"), "got: {err}");
}
#[test]
fn locked_skill_omits_namespace_when_absent() {
let skill = LockedSkill {
name: "pdf".to_string(),
namespace: None,
source: "public:pdf".to_string(),
resolved: "x".to_string(),
checksum: "y".to_string(),
};
let serialized = toml::to_string(&skill).unwrap();
assert!(
!serialized.contains("namespace"),
"namespace should be omitted from legacy entries, got: {serialized}"
);
}
#[test]
fn parses_legacy_unnamespaced_index_json() {
let json = r#"{
"name": "pdf",
"description": "PDF docs",
"source": "public:pdf",
"tags": ["documents"]
}"#;
let parsed: IndexedSkill =
serde_json::from_str(json).expect("legacy index.json must parse");
assert_eq!(parsed.name, "pdf");
assert_eq!(parsed.namespace, None);
assert_eq!(parsed.qualified_name(), "pdf");
}
#[test]
fn omits_namespace_field_when_absent_on_serialize() {
let skill = IndexedSkill {
name: "legacy".to_string(),
namespace: None,
description: "x".to_string(),
source: "legacy".to_string(),
tags: vec![],
score: None,
};
let json = serde_json::to_string(&skill).unwrap();
assert!(
!json.contains("namespace"),
"namespace should be omitted, got: {json}"
);
}
}