use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub template: String,
pub disable_model_invocation: bool,
pub user_invocable: bool,
pub argument_hint: Option<String>,
pub allowed_tools: Vec<String>,
pub skill_dir: PathBuf,
pub source_path: PathBuf,
}
impl Skill {
pub fn expand(&self, arguments: &str, session_id: &str) -> String {
let positional: Vec<&str> = arguments.split_whitespace().collect();
let mut result = self.template.clone();
for (i, arg) in positional.iter().enumerate() {
result = result.replace(&format!("$ARGUMENTS[{}]", i), arg);
}
for (i, arg) in positional.iter().enumerate() {
result = replace_positional_short(&result, i, arg);
}
if self.template.contains("$ARGUMENTS") {
result = result.replace("$ARGUMENTS", arguments);
} else if !arguments.trim().is_empty() {
result = format!("{}\n\nARGUMENTS: {}", result.trim_end(), arguments);
}
result = result.replace("${CLAUDE_SESSION_ID}", session_id);
result = result.replace("${CLAUDE_SKILL_DIR}", &self.skill_dir.to_string_lossy());
result = expand_shell_injections(&result);
result
}
}
fn replace_positional_short(s: &str, n: usize, replacement: &str) -> String {
let pattern = format!("${}", n);
let pat = pattern.as_bytes();
let src = s.as_bytes();
let mut out = Vec::with_capacity(s.len());
let mut i = 0;
while i < src.len() {
if src[i..].starts_with(pat) {
let after = i + pat.len();
let next_is_digit = src.get(after).map(|b| b.is_ascii_digit()).unwrap_or(false);
if !next_is_digit {
out.extend_from_slice(replacement.as_bytes());
i += pat.len();
continue;
}
}
out.push(src[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn expand_shell_injections(template: &str) -> String {
let mut result = template.to_string();
loop {
let Some(start) = result.find("!`") else {
break;
};
let search_from = start + 2;
let Some(rel_end) = result[search_from..].find('`') else {
break;
};
let end = search_from + rel_end;
let cmd = result[search_from..end].to_string();
let output = run_shell_command(&cmd);
result = format!("{}{}{}", &result[..start], output, &result[end + 1..]);
}
result
}
fn run_shell_command(cmd: &str) -> String {
let mut command = Command::new("sh");
command.arg("-c").arg(cmd);
crate::process_utils::suppress_console_window_sync(&mut command);
match command.output() {
Ok(out) => {
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.trim().is_empty() {
s.push('\n');
s.push_str(stderr.trim());
}
}
s.trim_end().to_string()
}
Err(e) => format!("[error: {}]", e),
}
}
struct Frontmatter {
name: Option<String>,
description: String,
disable_model_invocation: bool,
user_invocable: bool,
argument_hint: Option<String>,
allowed_tools: Vec<String>,
}
impl Frontmatter {
fn default() -> Self {
Self {
name: None,
description: String::new(),
disable_model_invocation: false,
user_invocable: true,
argument_hint: None,
allowed_tools: Vec::new(),
}
}
}
fn parse_frontmatter(content: &str) -> (Frontmatter, String) {
let mut fm = Frontmatter::default();
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return (fm, content.to_string());
}
let after_open = &content[if content.starts_with("---\r\n") { 5 } else { 4 }..];
let (close_pos, skip) = match find_frontmatter_close(after_open) {
Some(v) => v,
None => return (fm, content.to_string()),
};
let fm_text = &after_open[..close_pos];
let template = after_open[close_pos + skip..].to_string();
for line in fm_text.lines() {
if let Some(val) = line.strip_prefix("name:") {
let v = val.trim().trim_matches('"').trim_matches('\'');
if !v.is_empty() {
fm.name = Some(v.to_string());
}
} else if let Some(val) = line.strip_prefix("description:") {
fm.description = val.trim().trim_matches('"').trim_matches('\'').to_string();
} else if let Some(val) = line.strip_prefix("disable-model-invocation:") {
fm.disable_model_invocation = val.trim() == "true";
} else if let Some(val) = line.strip_prefix("user-invocable:") {
fm.user_invocable = val.trim() != "false";
} else if let Some(val) = line.strip_prefix("argument-hint:") {
let v = val.trim().trim_matches('"').trim_matches('\'');
if !v.is_empty() {
fm.argument_hint = Some(v.to_string());
}
} else if let Some(val) = line.strip_prefix("allowed-tools:") {
fm.allowed_tools = val
.split(|c| c == ' ' || c == ',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
}
(fm, template)
}
fn find_frontmatter_close(after_open: &str) -> Option<(usize, usize)> {
if after_open == "---" {
return Some((0, 3));
}
if after_open == "---\r" {
return Some((0, 4));
}
if after_open.starts_with("---\n") {
return Some((0, 4));
}
if after_open.starts_with("---\r\n") {
return Some((0, 5));
}
after_open
.find("\n---\n")
.map(|p| (p, 5usize))
.or_else(|| after_open.find("\n---\r\n").map(|p| (p, 6)))
.or_else(|| after_open.strip_suffix("\n---").map(|_| (after_open.len() - 4, 4)))
.or_else(|| {
after_open
.strip_suffix("\n---\r")
.map(|_| (after_open.len() - 5, 5))
})
}
fn first_paragraph(template: &str) -> String {
template
.lines()
.find(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
.unwrap_or("")
.trim()
.to_string()
}
fn parse_skill_file(path: &Path, namespace: Option<&str>) -> anyhow::Result<Skill> {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("filename is not valid UTF-8"))?;
validate_skill_name(stem)?;
let content = std::fs::read_to_string(path)?;
let (fm, template) = parse_frontmatter(&content);
let base_name = fm.name.as_deref().unwrap_or(stem);
let name = make_name(base_name, namespace);
let description = if fm.description.is_empty() {
first_paragraph(&template)
} else {
fm.description
};
Ok(Skill {
name,
description,
template,
disable_model_invocation: fm.disable_model_invocation,
user_invocable: fm.user_invocable,
argument_hint: fm.argument_hint,
allowed_tools: fm.allowed_tools,
skill_dir: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
source_path: path.to_path_buf(),
})
}
fn parse_skill_dir(
skill_dir: &Path,
skill_md: &Path,
namespace: Option<&str>,
) -> anyhow::Result<Skill> {
let dir_name = skill_dir
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("directory name is not valid UTF-8"))?;
let content = std::fs::read_to_string(skill_md)?;
let (fm, template) = parse_frontmatter(&content);
let base_name = fm.name.as_deref().unwrap_or(dir_name);
validate_skill_name(base_name)?;
let name = make_name(base_name, namespace);
let description = if fm.description.is_empty() {
first_paragraph(&template)
} else {
fm.description
};
Ok(Skill {
name,
description,
template,
disable_model_invocation: fm.disable_model_invocation,
user_invocable: fm.user_invocable,
argument_hint: fm.argument_hint,
allowed_tools: fm.allowed_tools,
skill_dir: skill_dir.to_path_buf(),
source_path: skill_md.to_path_buf(),
})
}
fn validate_skill_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() || name.len() > 64 {
anyhow::bail!("skill name '{}' must be 1-64 characters", name);
}
if !name
.chars()
.all(|c| c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '-' || c == '_' || c == '/')
{
anyhow::bail!(
"skill name '{}' must contain only letters, digits, hyphens, slashes, and underscores",
name
);
}
if name.starts_with('/') || name.ends_with('/') {
anyhow::bail!("skill name '{}' must not start or end with a slash", name);
}
if name.contains("//") {
anyhow::bail!("skill name '{}' must not contain consecutive slashes", name);
}
if name.starts_with('-') || name.ends_with('-') {
anyhow::bail!("skill name '{}' must not start or end with a hyphen", name);
}
if name.contains("--") {
anyhow::bail!("skill name '{}' must not contain consecutive hyphens", name);
}
Ok(())
}
fn normalize_skill_name(name: &str) -> String {
name.to_ascii_lowercase().replace('/', "-")
}
fn make_name(base: &str, namespace: Option<&str>) -> String {
match namespace {
Some(ns) => format!("{}:{}", ns.to_ascii_lowercase(), normalize_skill_name(base)),
None => normalize_skill_name(base),
}
}
pub struct SkillRegistry {
skills: BTreeMap<String, Skill>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: BTreeMap::new(),
}
}
pub fn reload(&mut self, working_dir: &Path) -> Vec<String> {
self.skills.clear();
let mut warnings: Vec<String> = Vec::new();
let system_home = crate::tool::real_home_dir();
let atomcode_config_dir = crate::config::Config::config_dir();
const LOOSE_NS: Option<&str> = Some("skills");
if let Some(ref home) = system_home {
self.load_flat_commands(&home.join(".claude").join("commands"), LOOSE_NS, &mut warnings);
self.load_skills_dir(&home.join(".claude").join("skills"), LOOSE_NS, &mut warnings);
}
self.load_flat_commands(&atomcode_config_dir.join("commands"), LOOSE_NS, &mut warnings);
self.load_skills_dir(&atomcode_config_dir.join("skills"), LOOSE_NS, &mut warnings);
self.load_flat_commands(&working_dir.join(".claude").join("commands"), LOOSE_NS, &mut warnings);
self.load_flat_commands(&working_dir.join(".atomcode").join("commands"), LOOSE_NS, &mut warnings);
self.load_skills_dir(&working_dir.join(".claude").join("skills"), LOOSE_NS, &mut warnings);
self.load_skills_dir(&working_dir.join(".atomcode").join("skills"), LOOSE_NS, &mut warnings);
for assets in crate::plugin::loader::iter_installed_plugin_assets() {
for skills_dir in assets.skills_dirs() {
self.load_skills_dir(&skills_dir, Some(&assets.plugin), &mut warnings);
}
}
warnings
}
pub fn register(&mut self, skill: Skill) {
self.skills.insert(skill.name.clone(), skill);
}
pub fn get(&self, name: &str) -> Option<&Skill> {
let normalized = normalize_skill_name(name);
if let Some(s) = self.skills.get(&normalized) {
return Some(s);
}
if normalized.contains(':') {
return None;
}
let suffix = format!(":{}", normalized);
let mut hits = self.skills.iter().filter(|(k, _)| k.ends_with(&suffix));
let first = hits.next()?;
if hits.next().is_some() {
return None;
}
Some(first.1)
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn all(&self) -> impl Iterator<Item = &Skill> {
self.skills.values()
}
pub fn user_invocable(&self) -> impl Iterator<Item = &Skill> {
self.skills.values().filter(|s| s.user_invocable)
}
pub fn invocable_by_llm(&self) -> impl Iterator<Item = &Skill> {
self.skills.values().filter(|s| !s.disable_model_invocation)
}
fn load_flat_commands(&mut self, dir: &Path, namespace: Option<&str>, warnings: &mut Vec<String>) {
if !dir.is_dir() {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match parse_skill_file(&path, namespace) {
Ok(skill) => {
self.skills.insert(skill.name.clone(), skill);
}
Err(e) => {
warnings.push(format!("[skill] skipping {}: {}", path.display(), e));
}
}
}
}
pub(crate) fn load_skills_dir(
&mut self,
dir: &Path,
namespace: Option<&str>,
warnings: &mut Vec<String>,
) {
if !dir.is_dir() {
return;
}
let self_md = dir.join("SKILL.md");
if self_md.exists() {
match parse_skill_dir(dir, &self_md, namespace) {
Ok(skill) => {
self.skills.insert(skill.name.clone(), skill);
}
Err(e) => {
warnings.push(format!("[skill] skipping {}: {}", dir.display(), e));
}
}
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let skill_dir = entry.path();
if !skill_dir.is_dir() {
continue;
}
let skill_md = skill_dir.join("SKILL.md");
if !skill_md.exists() {
continue;
}
match parse_skill_dir(&skill_dir, &skill_md, namespace) {
Ok(skill) => {
self.skills.insert(skill.name.clone(), skill);
}
Err(e) => {
warnings.push(format!("[skill] skipping {}: {}", skill_dir.display(), e));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_skill(template: &str) -> Skill {
Skill {
name: "test".into(),
description: "".into(),
template: template.into(),
disable_model_invocation: false,
user_invocable: true,
argument_hint: None,
allowed_tools: vec![],
skill_dir: PathBuf::new(),
source_path: PathBuf::new(),
}
}
#[test]
fn test_expand_with_arguments() {
let s = make_skill("Do $ARGUMENTS please.");
assert_eq!(s.expand("foo bar", ""), "Do foo bar please.");
}
#[test]
fn test_expand_no_placeholder_with_args() {
let s = make_skill("Do something.");
assert_eq!(s.expand("extra", ""), "Do something.\n\nARGUMENTS: extra");
}
#[test]
fn test_expand_no_placeholder_no_args() {
let s = make_skill("Do something.");
assert_eq!(s.expand("", ""), "Do something.");
}
#[test]
fn test_expand_positional_brackets() {
let s = make_skill("Migrate $ARGUMENTS[0] from $ARGUMENTS[1] to $ARGUMENTS[2].");
assert_eq!(
s.expand("Button React Vue", ""),
"Migrate Button from React to Vue."
);
}
#[test]
fn test_expand_positional_short() {
let s = make_skill("Migrate $0 from $1 to $2.");
assert_eq!(
s.expand("Button React Vue", ""),
"Migrate Button from React to Vue.\n\nARGUMENTS: Button React Vue"
);
}
#[test]
fn test_expand_positional_short_no_partial_match() {
let s = make_skill("a=$10 b=$1.");
assert_eq!(s.expand("x y", ""), "a=$10 b=y.\n\nARGUMENTS: x y");
}
#[test]
fn test_expand_session_id() {
let s = make_skill("session=${CLAUDE_SESSION_ID}");
assert_eq!(s.expand("", "abc-123"), "session=abc-123");
}
#[test]
fn test_expand_skill_dir() {
let mut s = make_skill("dir=${CLAUDE_SKILL_DIR}");
s.skill_dir = PathBuf::from("/home/user/.claude/skills/my-skill");
assert_eq!(s.expand("", ""), "dir=/home/user/.claude/skills/my-skill");
}
#[test]
fn test_frontmatter_none() {
let (fm, tmpl) = parse_frontmatter("Just a template.");
assert_eq!(fm.description, "");
assert!(!fm.disable_model_invocation);
assert!(fm.user_invocable);
assert!(fm.name.is_none());
assert_eq!(tmpl, "Just a template.");
}
#[test]
fn test_frontmatter_full() {
let content = "---\nname: my-skill\ndescription: \"My skill\"\ndisable-model-invocation: true\nuser-invocable: false\nargument-hint: \"[file]\"\nallowed-tools: Read Grep\n---\nBody.\n";
let (fm, tmpl) = parse_frontmatter(content);
assert_eq!(fm.name.as_deref(), Some("my-skill"));
assert_eq!(fm.description, "My skill");
assert!(fm.disable_model_invocation);
assert!(!fm.user_invocable);
assert_eq!(fm.argument_hint.as_deref(), Some("[file]"));
assert_eq!(fm.allowed_tools, vec!["Read", "Grep"]);
assert_eq!(tmpl, "Body.\n");
}
#[test]
fn test_frontmatter_closing_delimiter_at_eof() {
let content = "---\nname: eof-skill\ndescription: EOF skill\n---";
let (fm, tmpl) = parse_frontmatter(content);
assert_eq!(fm.name.as_deref(), Some("eof-skill"));
assert_eq!(fm.description, "EOF skill");
assert_eq!(tmpl, "");
}
#[test]
fn test_empty_frontmatter_before_body() {
let content = "---\n---\nBody.\n";
let (fm, tmpl) = parse_frontmatter(content);
assert_eq!(fm.description, "");
assert_eq!(tmpl, "Body.\n");
}
#[test]
fn test_frontmatter_unclosed() {
let content = "---\ndescription: broken\nno closing delimiter";
let (fm, tmpl) = parse_frontmatter(content);
assert_eq!(fm.description, "");
assert_eq!(tmpl, content);
}
#[test]
fn test_description_fallback_to_first_paragraph() {
assert_eq!(
first_paragraph("# Title\n\nActual description."),
"Actual description."
);
assert_eq!(first_paragraph(" text "), "text");
assert_eq!(first_paragraph("# Heading"), "");
}
#[test]
fn test_replace_positional_short_boundary() {
assert_eq!(replace_positional_short("$10 $1", 1, "Y"), "$10 Y");
}
#[test]
fn test_load_skills_dir_applies_namespace() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_dir = tmp.path().join("brainstorming");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: \"Test\"\n---\nTemplate body.\n",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_skills_dir(tmp.path(), Some("skills"), &mut warnings);
assert!(
reg.get("skills:brainstorming").is_some(),
"namespaced lookup must succeed"
);
assert!(
reg.get("brainstorming").is_some(),
"bare name must resolve via suffix fallback when unambiguous"
);
assert!(
reg.skills.contains_key("skills:brainstorming"),
"storage must use prefixed key"
);
assert!(
!reg.skills.contains_key("brainstorming"),
"storage must not duplicate under bare key"
);
}
#[test]
fn test_get_suffix_fallback_ambiguous_misses() {
let mut reg = SkillRegistry::new();
for ns in ["plugin-a", "plugin-b"] {
let key = format!("{}:verify", ns);
reg.skills.insert(
key.clone(),
Skill {
name: key,
description: "v".into(),
template: "body".into(),
disable_model_invocation: false,
user_invocable: true,
argument_hint: None,
allowed_tools: vec![],
skill_dir: PathBuf::new(),
source_path: PathBuf::new(),
},
);
}
assert!(
reg.get("verify").is_none(),
"ambiguous bare name must miss (forces qualified lookup)"
);
assert!(reg.get("plugin-a:verify").is_some());
assert!(reg.get("plugin-b:verify").is_some());
}
fn named_skill(name: &str) -> Skill {
Skill {
name: name.into(),
description: "d".into(),
template: "body".into(),
disable_model_invocation: false,
user_invocable: true,
argument_hint: None,
allowed_tools: vec![],
skill_dir: PathBuf::new(),
source_path: PathBuf::new(),
}
}
#[test]
fn skill_iteration_is_deterministic_sorted_order() {
let mut reg = SkillRegistry::new();
for name in [
"superpowers:zeta",
"skills:alpha",
"plugin:mid",
"skills:beta",
"aaa:first",
] {
reg.register(named_skill(name));
}
let got: Vec<&str> = reg.invocable_by_llm().map(|s| s.name.as_str()).collect();
let mut want = got.clone();
want.sort_unstable();
assert_eq!(
got, want,
"invocable_by_llm must yield names in sorted order for prompt-cache stability"
);
let got_all: Vec<&str> = reg.all().map(|s| s.name.as_str()).collect();
let mut want_all = got_all.clone();
want_all.sort_unstable();
assert_eq!(got_all, want_all, "all() iteration must be deterministic too");
}
#[test]
fn test_get_qualified_miss_does_not_fallback() {
let mut reg = SkillRegistry::new();
reg.skills.insert(
"real-plugin:verify".into(),
Skill {
name: "real-plugin:verify".into(),
description: "v".into(),
template: "body".into(),
disable_model_invocation: false,
user_invocable: true,
argument_hint: None,
allowed_tools: vec![],
skill_dir: PathBuf::new(),
source_path: PathBuf::new(),
},
);
assert!(reg.get("typo-plugin:verify").is_none());
}
#[test]
fn test_load_flat_commands_applies_namespace() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(
tmp.path().join("commit.md"),
"---\ndescription: \"Commit\"\n---\nDo a commit.\n",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_flat_commands(tmp.path(), Some("skills"), &mut warnings);
assert!(reg.get("skills:commit").is_some());
assert!(reg.get("commit").is_some());
assert!(reg.skills.contains_key("skills:commit"));
assert!(!reg.skills.contains_key("commit"));
}
#[test]
#[serial_test::serial]
fn reload_picks_up_installed_plugin_skills() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("ATOMCODE_HOME", tmp.path());
let plugins_root = tmp.path().join("plugins");
let plugin_dir = plugins_root.join("marketplaces/p");
let skill_dir = plugin_dir.join("skills/hello");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: hello\ndescription: hi\n---\nhi",
)
.unwrap();
std::fs::write(
plugins_root.join("installed_plugins.json"),
r#"{"version":1,"plugins":{"p@p":{"marketplace":"p","plugin":"p","plugin_dir":"marketplaces/p","installed_at":"x"}}}"#,
)
.unwrap();
let working = tempfile::tempdir().unwrap();
let mut reg = SkillRegistry::new();
reg.reload(working.path());
assert!(reg.get("p:hello").is_some(), "expected namespaced plugin skill");
std::env::remove_var("ATOMCODE_HOME");
}
#[test]
fn test_load_skills_dir_cc_array_layout() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_dir = tmp.path().join("skills/karpathy-guidelines");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: karpathy-guidelines\ndescription: Guidelines\n---\nBe simple.",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_skills_dir(&skill_dir, Some("karpathy-skills"), &mut warnings);
assert!(
reg.get("karpathy-skills:karpathy-guidelines").is_some(),
"CC array layout: skill directory containing SKILL.md should be loaded"
);
assert!(warnings.is_empty(), "no warnings expected");
}
#[test]
fn test_load_skills_dir_hybrid_layout() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(
tmp.path().join("SKILL.md"),
"---\nname: hybrid\ndescription: self\n---\nself body",
)
.unwrap();
let sub = tmp.path().join("sub-skill");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
sub.join("SKILL.md"),
"---\nname: sub-skill\ndescription: sub\n---\nsub body",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_skills_dir(tmp.path(), Some("test"), &mut warnings);
assert!(reg.get("test:hybrid").is_some(), "self SKILL.md should load");
assert!(reg.get("test:sub-skill").is_some(), "subdirectory SKILL.md should load");
}
#[test]
#[serial_test::serial]
fn reload_picks_up_cc_array_plugin_skills() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("ATOMCODE_HOME", tmp.path());
let plugins_root = tmp.path().join("plugins");
let plugin_dir = plugins_root.join("marketplaces/karpathy-skills");
let skill_dir = plugin_dir.join("skills/karpathy-guidelines");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: karpathy-guidelines\ndescription: Guidelines\n---\nBe simple.",
)
.unwrap();
std::fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
std::fs::write(
plugin_dir.join(".claude-plugin/plugin.json"),
r#"{"name":"andrej-karpathy-skills","skills":["./skills/karpathy-guidelines"]}"#,
)
.unwrap();
std::fs::write(
plugins_root.join("installed_plugins.json"),
r#"{"version":1,"plugins":{"andrej-karpathy-skills@karpathy-skills":{"marketplace":"karpathy-skills","plugin":"andrej-karpathy-skills","plugin_dir":"marketplaces/karpathy-skills","installed_at":"x"}}}"#,
)
.unwrap();
let working = tempfile::tempdir().unwrap();
let mut reg = SkillRegistry::new();
reg.reload(working.path());
assert!(
reg.get("andrej-karpathy-skills:karpathy-guidelines").is_some(),
"CC array plugin: skill should be loaded from direct skill directory"
);
std::env::remove_var("ATOMCODE_HOME");
}
#[test]
fn test_uppercase_skill_name_accepted_and_normalized() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_dir = tmp.path().join("Ascend-Model-Verification");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: Ascend-Model-Verification\ndescription: Verify model\n---\nDo verify.",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_skills_dir(tmp.path(), Some("MyPlugin"), &mut warnings);
assert!(warnings.is_empty(), "uppercase skill name should not produce warnings: {:?}", warnings);
assert!(
reg.get("myplugin:ascend-model-verification").is_some(),
"skill should be stored under lowercased name"
);
assert!(
reg.get("ascend-model-verification").is_some(),
"bare lowercased name should resolve via suffix fallback"
);
}
#[test]
fn test_frontmatter_uppercase_name_normalized() {
let tmp = tempfile::tempdir().expect("tempdir");
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: MyCustomSkill\ndescription: custom\n---\nBody.",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_skills_dir(tmp.path(), Some("skills"), &mut warnings);
assert!(warnings.is_empty(), "no warnings expected: {:?}", warnings);
assert!(
reg.get("skills:mycustomskill").is_some(),
"frontmatter name with uppercase should be lowercased in storage"
);
}
#[test]
fn test_flat_command_uppercase_stem() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(
tmp.path().join("MyCommand.md"),
"---\ndescription: \"Upper cmd\"\n---\nDo stuff.\n",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_flat_commands(tmp.path(), Some("skills"), &mut warnings);
assert!(warnings.is_empty(), "no warnings expected: {:?}", warnings);
assert!(
reg.get("skills:mycommand").is_some(),
"flat command with uppercase stem should be lowercased in storage"
);
}
#[test]
fn test_slash_in_skill_name_accepted_and_normalized() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_dir = tmp.path().join("long-task");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: ssh-dev-suite/long-task\ndescription: Long task\n---\nDo task.",
)
.unwrap();
let mut reg = SkillRegistry::new();
let mut warnings = Vec::new();
reg.load_skills_dir(tmp.path(), Some("MyPlugin"), &mut warnings);
assert!(warnings.is_empty(), "slash in skill name should not produce warnings: {:?}", warnings);
assert!(
reg.get("myplugin:ssh-dev-suite-long-task").is_some(),
"skill with slash should be stored with slash replaced by hyphen"
);
assert!(
reg.get("ssh-dev-suite-long-task").is_some(),
"bare normalized name should resolve via suffix fallback"
);
}
#[test]
fn test_normalize_skill_name() {
assert_eq!(normalize_skill_name("Foo"), "foo");
assert_eq!(normalize_skill_name("ssh-dev-suite/long-task"), "ssh-dev-suite-long-task");
assert_eq!(normalize_skill_name("MySkill/SubName"), "myskill-subname");
assert_eq!(normalize_skill_name("hello"), "hello");
assert_eq!(normalize_skill_name("A/B/C"), "a-b-c");
}
#[test]
fn test_get_is_case_insensitive_and_normalizes_slash() {
let mut reg = SkillRegistry::new();
reg.skills.insert(
"myplugin:ssh-dev-suite-long-task".into(),
Skill {
name: "myplugin:ssh-dev-suite-long-task".into(),
description: "v".into(),
template: "body".into(),
disable_model_invocation: false,
user_invocable: true,
argument_hint: None,
allowed_tools: vec![],
skill_dir: PathBuf::new(),
source_path: PathBuf::new(),
},
);
assert!(reg.get("MyPlugin:SSH-Dev-Suite-Long-Task").is_some());
assert!(reg.get("ssh-dev-suite/long-task").is_some());
assert!(reg.get("SSH-Dev-Suite/Long-Task").is_some());
assert!(reg.get("MyPlugin:ssh-dev-suite/long-task").is_some());
}
}