use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub when_to_use: Option<String>,
pub allowed_tools: Vec<String>,
pub user_invocable: bool,
pub argument_hint: Option<String>,
pub source: SkillSource,
}
#[derive(Debug, Clone)]
pub enum SkillSource {
BuiltIn,
User,
Project,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub meta: SkillMeta,
pub content: String,
}
#[derive(Debug, Default)]
pub struct SkillRegistry {
pub(crate) skills: HashMap<String, Skill>,
}
impl SkillRegistry {
pub fn discover(project_root: &Path) -> Self {
let mut registry = Self::default();
registry.load_builtin();
if let Ok(config_dir) = crate::db::config_dir() {
let user_dir = config_dir.join("skills");
registry.load_directory(&user_dir, SkillSource::User);
}
let project_dir = project_root.join(".koda").join("skills");
registry.load_directory(&project_dir, SkillSource::Project);
registry
}
fn load_builtin(&mut self) {
let builtins: &[(&str, &str)] = &[
(
"code-review",
include_str!("../skills/code-review/SKILL.md"),
),
(
"security-audit",
include_str!("../skills/security-audit/SKILL.md"),
),
("simplify", include_str!("../skills/simplify/SKILL.md")),
("debug", include_str!("../skills/debug/SKILL.md")),
("remember", include_str!("../skills/remember/SKILL.md")),
];
for (name, content) in builtins {
if let Some(skill) = parse_skill_md(content, SkillSource::BuiltIn) {
self.skills.insert(name.to_string(), skill);
}
}
}
fn load_directory(&mut self, dir: &Path, source: SkillSource) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let skill_file = entry.path().join("SKILL.md");
if let Some(skill) = std::fs::read_to_string(&skill_file)
.ok()
.and_then(|content| parse_skill_md(&content, source.clone()))
{
self.skills.insert(skill.meta.name.clone(), skill);
}
}
}
pub fn list(&self) -> Vec<&SkillMeta> {
let mut metas: Vec<&SkillMeta> = self.skills.values().map(|s| &s.meta).collect();
metas.sort_by_key(|m| &m.name);
metas
}
pub fn list_user_invocable(&self) -> Vec<&SkillMeta> {
let mut metas: Vec<&SkillMeta> = self
.skills
.values()
.filter(|s| s.meta.user_invocable)
.map(|s| &s.meta)
.collect();
metas.sort_by_key(|m| &m.name);
metas
}
pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
let q = query.to_lowercase();
let mut results: Vec<&SkillMeta> = self
.skills
.values()
.filter(|s| {
s.meta.name.to_lowercase().contains(&q)
|| s.meta.description.to_lowercase().contains(&q)
|| s.meta.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.map(|s| &s.meta)
.collect();
results.sort_by_key(|m| &m.name);
results
}
pub fn activate(&self, name: &str) -> Option<&str> {
self.skills.get(name).map(|s| s.content.as_str())
}
pub fn get(&self, name: &str) -> Option<&Skill> {
self.skills.get(name)
}
pub fn add_builtin(
&mut self,
name: &str,
description: &str,
when_to_use: Option<&str>,
content: &str,
) {
let skill = Skill {
meta: SkillMeta {
name: name.to_string(),
description: description.to_string(),
tags: vec![],
when_to_use: when_to_use.map(str::to_string),
allowed_tools: vec![],
user_invocable: true,
argument_hint: None,
source: SkillSource::BuiltIn,
},
content: content.to_string(),
};
self.skills.insert(name.to_string(), skill);
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
}
fn parse_skill_md(raw: &str, source: SkillSource) -> Option<Skill> {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_open = &trimmed[3..];
let close_pos = after_open.find("\n---")?;
let frontmatter = &after_open[..close_pos].trim();
let content = after_open[close_pos + 4..].trim_start().to_string();
let mut name = String::new();
let mut description = String::new();
let mut tags = Vec::new();
let mut when_to_use: Option<String> = None;
let mut allowed_tools: Vec<String> = Vec::new();
let mut user_invocable = true;
let mut argument_hint: Option<String> = None;
for line in frontmatter.lines() {
let line = line.trim();
if let Some(val) = line.strip_prefix("name:") {
name = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("description:") {
description = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("when_to_use:") {
when_to_use = Some(val.trim().to_string());
} else if let Some(val) = line
.strip_prefix("allowed_tools:")
.or_else(|| line.strip_prefix("allowed-tools:"))
{
let val = val.trim();
if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
allowed_tools = inner
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
} else if !val.is_empty() {
allowed_tools = val
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
}
} else if let Some(val) = line
.strip_prefix("user_invocable:")
.or_else(|| line.strip_prefix("user-invocable:"))
{
user_invocable = val.trim() != "false";
} else if let Some(val) = line
.strip_prefix("argument_hint:")
.or_else(|| line.strip_prefix("argument-hint:"))
{
let val = val.trim();
if !val.is_empty() {
argument_hint = Some(val.to_string());
}
} else if let Some(val) = line.strip_prefix("tags:") {
let val = val.trim();
if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
tags = inner.split(',').map(|t| t.trim().to_string()).collect();
}
}
}
if name.is_empty() {
return None;
}
Some(Skill {
meta: SkillMeta {
name,
description,
tags,
when_to_use,
allowed_tools,
user_invocable,
argument_hint,
source,
},
content,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_skill_md() {
let raw = r#"---
name: code-review
description: Senior code review
tags: [review, quality]
when_to_use: Use when asked to review code or a PR.
---
# Code Review
Do the review.
"#;
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert_eq!(skill.meta.name, "code-review");
assert_eq!(skill.meta.description, "Senior code review");
assert_eq!(skill.meta.tags, vec!["review", "quality"]);
assert_eq!(
skill.meta.when_to_use.as_deref(),
Some("Use when asked to review code or a PR.")
);
assert!(skill.meta.allowed_tools.is_empty());
assert!(skill.meta.user_invocable);
assert!(skill.meta.argument_hint.is_none());
assert!(skill.content.contains("# Code Review"));
assert!(skill.content.contains("Do the review."));
}
#[test]
fn test_parse_allowed_tools() {
let raw = "---\nname: scoped\ndescription: Scoped skill\ntags: []\nallowed_tools: [Read, Grep, Glob]\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert_eq!(skill.meta.allowed_tools, vec!["Read", "Grep", "Glob"]);
}
#[test]
fn test_parse_allowed_tools_hyphenated() {
let raw = "---\nname: scoped\ndescription: Scoped skill\ntags: []\nallowed-tools: [Read, Write]\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert_eq!(skill.meta.allowed_tools, vec!["Read", "Write"]);
}
#[test]
fn test_parse_user_invocable_false() {
let raw = "---\nname: model-only\ndescription: hidden\ntags: []\nuser_invocable: false\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert!(!skill.meta.user_invocable);
}
#[test]
fn test_parse_user_invocable_hyphenated() {
let raw = "---\nname: model-only\ndescription: hidden\ntags: []\nuser-invocable: false\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert!(!skill.meta.user_invocable);
}
#[test]
fn test_parse_user_invocable_default_true() {
let raw = "---\nname: visible\ndescription: shown\ntags: []\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert!(skill.meta.user_invocable);
}
#[test]
fn test_parse_argument_hint() {
let raw = "---\nname: pdf\ndescription: Generate PDF\ntags: []\nargument_hint: <file_path>\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert_eq!(skill.meta.argument_hint.as_deref(), Some("<file_path>"));
}
#[test]
fn test_parse_argument_hint_hyphenated() {
let raw = "---\nname: pdf\ndescription: Generate PDF\ntags: []\nargument-hint: <output_dir>\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert_eq!(skill.meta.argument_hint.as_deref(), Some("<output_dir>"));
}
#[test]
fn test_list_user_invocable_excludes_model_only() {
let mut registry = SkillRegistry::default();
registry.add_builtin("user-skill", "for users", None, "content");
registry.skills.insert(
"model-skill".to_string(),
Skill {
meta: SkillMeta {
name: "model-skill".to_string(),
description: "model only".to_string(),
tags: vec![],
when_to_use: None,
allowed_tools: vec![],
user_invocable: false,
argument_hint: None,
source: SkillSource::BuiltIn,
},
content: "secret".to_string(),
},
);
assert_eq!(registry.list().len(), 2);
assert_eq!(registry.list_user_invocable().len(), 1);
assert_eq!(registry.list_user_invocable()[0].name, "user-skill");
}
#[test]
fn test_get_returns_full_skill() {
let mut registry = SkillRegistry::default();
registry.add_builtin("test", "desc", None, "body");
let skill = registry.get("test").unwrap();
assert_eq!(skill.meta.name, "test");
assert_eq!(skill.content, "body");
}
#[test]
fn test_parse_when_to_use_absent() {
let raw = "---\nname: minimal\ndescription: minimal skill\ntags: []\n---\ncontent";
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert!(skill.meta.when_to_use.is_none());
}
#[test]
fn test_parse_no_frontmatter() {
assert!(parse_skill_md("# Just markdown", SkillSource::BuiltIn).is_none());
}
#[test]
fn test_parse_no_name() {
let raw = "---\ndescription: no name\n---\ncontent";
assert!(parse_skill_md(raw, SkillSource::BuiltIn).is_none());
}
#[test]
fn test_builtin_skills_load() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
assert!(registry.len() >= 2);
assert!(registry.activate("code-review").is_some());
assert!(registry.activate("security-audit").is_some());
assert!(registry.activate("simplify").is_some());
assert!(registry.activate("debug").is_some());
assert!(registry.activate("remember").is_some());
}
#[test]
fn test_search() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
let results = registry.search("review");
assert!(!results.is_empty());
assert!(results.iter().any(|s| s.name == "code-review"));
let results = registry.search("security");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "security-audit");
}
#[test]
fn test_search_by_tag() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
let results = registry.search("owasp");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "security-audit");
}
#[test]
fn test_add_builtin_injects_skill() {
let mut registry = SkillRegistry::default();
registry.add_builtin(
"my-app-docs",
"My app user manual",
Some("Use when the user asks about the app."),
"# My App\n\nDo stuff.",
);
assert_eq!(registry.len(), 1);
let content = registry.activate("my-app-docs").unwrap();
assert!(content.contains("Do stuff."));
let meta = registry.list();
assert!(matches!(meta[0].source, SkillSource::BuiltIn));
assert_eq!(
meta[0].when_to_use.as_deref(),
Some("Use when the user asks about the app.")
);
}
#[test]
fn test_add_builtin_overwrites_same_name() {
let mut registry = SkillRegistry::default();
registry.add_builtin("docs", "v1", None, "version one");
registry.add_builtin("docs", "v2", None, "version two");
assert_eq!(registry.len(), 1);
assert!(registry.activate("docs").unwrap().contains("version two"));
}
#[test]
fn test_list_sorted() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
let list = registry.list();
let names: Vec<&str> = list.iter().map(|s| s.name.as_str()).collect();
assert!(list.len() >= 5);
assert_eq!(names[0], "code-review");
assert_eq!(names[1], "debug");
assert_eq!(names[2], "remember");
assert_eq!(names[3], "security-audit");
assert_eq!(names[4], "simplify");
}
#[test]
fn test_directory_discovery() {
let tmp = tempfile::TempDir::new().unwrap();
let skill_dir = tmp.path().join("my-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: test\ntags: []\n---\n# Test",
)
.unwrap();
let mut registry = SkillRegistry::default();
registry.load_directory(tmp.path(), SkillSource::Project);
assert_eq!(registry.len(), 1);
assert!(registry.activate("my-skill").is_some());
}
}