1use std::path::Path;
14
15use crate::walk::py_join;
16
17pub struct SkillSpec {
19 pub name: &'static str,
20 pub description: &'static str,
21}
22
23pub const BUNDLED_SKILLS: [SkillSpec; 4] = [
26 SkillSpec {
27 name: "decided-artifacts",
28 description: "Author and maintain AsDecided Markdown artifacts with the decided CLI.",
29 },
30 SkillSpec {
31 name: "decided-review",
32 description: "Review an AsDecided corpus and work findings worst-first.",
33 },
34 SkillSpec {
35 name: "decided-import",
36 description: "Reformat one document into one valid AsDecided artifact, with human review.",
37 },
38 SkillSpec {
39 name: "decided-capture",
40 description: "Capture a new decision or requirement into a valid AsDecided artifact.",
41 },
42];
43
44pub(crate) const SKILL_BYTES: [&[u8]; 4] = [
46 include_bytes!("../assets/skills/decided-artifacts/SKILL.md"),
47 include_bytes!("../assets/skills/decided-review/SKILL.md"),
48 include_bytes!("../assets/skills/decided-import/SKILL.md"),
49 include_bytes!("../assets/skills/decided-capture/SKILL.md"),
50];
51
52pub fn available_skills() -> Vec<&'static str> {
54 BUNDLED_SKILLS.iter().map(|s| s.name).collect()
55}
56
57fn skill_bytes(name: &str) -> Option<&'static [u8]> {
58 BUNDLED_SKILLS
59 .iter()
60 .position(|s| s.name == name)
61 .map(|i| SKILL_BYTES[i])
62}
63
64pub struct InstalledSkill {
67 pub skill: String,
68 pub path: String,
69}
70
71pub struct SkillInstallation {
73 pub skills: Vec<InstalledSkill>,
74}
75
76pub enum SkillInstallError {
78 NotFound(String),
80 FileExists(String),
82 Io(String),
85}
86
87pub fn install_skills(
96 target_dir: &str,
97 skill_name: Option<&str>,
98) -> Result<SkillInstallation, SkillInstallError> {
99 if let Some(name) = skill_name {
100 if skill_bytes(name).is_none() {
101 return Err(SkillInstallError::NotFound(format!(
102 "unknown skill: {name} (available: {})",
103 available_skills().join(", ")
104 )));
105 }
106 }
107 let names: Vec<&str> = match skill_name {
108 Some(name) => vec![name],
109 None => available_skills(),
110 };
111
112 let destinations: Vec<String> = names
115 .iter()
116 .map(|name| py_join(target_dir, &[".claude", "skills", name, "SKILL.md"]))
117 .collect();
118 let existing: Vec<&str> = destinations
119 .iter()
120 .filter(|dest| Path::new(dest.as_str()).exists())
121 .map(String::as_str)
122 .collect();
123 if !existing.is_empty() {
124 let message = if existing.len() == 1 {
125 format!("{} already exists; decided skill install never overwrites", existing[0])
126 } else {
127 let listing: Vec<String> = existing.iter().map(|p| format!(" - {p}")).collect();
128 format!(
129 "{} skill files already exist; decided skill install never overwrites:\n{}",
130 existing.len(),
131 listing.join("\n")
132 )
133 };
134 return Err(SkillInstallError::FileExists(message));
135 }
136
137 let mut installed: Vec<InstalledSkill> = Vec::new();
138 for (name, dest) in names.iter().zip(&destinations) {
139 let content = skill_bytes(name).expect("registered skill");
140 let path = Path::new(dest.as_str());
141 if let Some(parent) = path.parent() {
142 std::fs::create_dir_all(parent)
143 .map_err(|e| SkillInstallError::Io(format!("{e}: {}", parent.display())))?;
144 }
145 std::fs::write(path, content)
146 .map_err(|e| SkillInstallError::Io(format!("{e}: {dest}")))?;
147 installed.push(InstalledSkill {
148 skill: (*name).to_string(),
149 path: dest.clone(),
150 });
151 }
152 Ok(SkillInstallation { skills: installed })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn registry_order_and_names() {
161 assert_eq!(
162 available_skills(),
163 vec!["decided-artifacts", "decided-review", "decided-import", "decided-capture"]
164 );
165 }
166}