Skip to main content

rac_engine/
skill.rs

1//! Bundled agent skills — `decided skill` (PORT-CONTRACT.d/15).
2//!
3//! Port of `src/rac/core/skills.py` (registry + resource loading) and
4//! `src/asdecided/services/skill.py` (`install_skills`). The packaged `SKILL.md`
5//! resources are embedded verbatim from `rust/decided-engine/assets/skills/`,
6//! vendored byte-identical copies of the Python package files — a unit test
7//! below pins that identity, because the installed file must be
8//! byte-identical to what the oracle installs (skill brief, landmine 1).
9//!
10//! `SkillResourceMissing` (a broken Python installation) has no Rust
11//! equivalent: embedded resources cannot be absent from a linked binary.
12
13use std::path::Path;
14
15use crate::walk::py_join;
16
17/// One bundled skill: name and one-line description, registry order.
18pub struct SkillSpec {
19    pub name: &'static str,
20    pub description: &'static str,
21}
22
23/// Bundled skills, in registry order (`BUNDLED_SKILLS`). `install` with no
24/// name installs all of them; `list` enumerates them.
25pub 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
44/// The embedded `SKILL.md` bytes, index-aligned with [`BUNDLED_SKILLS`].
45pub(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
52/// `available_skills()` — bundled skill names, registry order.
53pub 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
64/// One installed skill (`InstalledSkill`; `bytes_written` is in the oracle's
65/// model but deliberately absent from its JSON, so it is not carried here).
66pub struct InstalledSkill {
67    pub skill: String,
68    pub path: String,
69}
70
71/// Result of a `decided skill install` run.
72pub struct SkillInstallation {
73    pub skills: Vec<InstalledSkill>,
74}
75
76/// The failure contract of `install_skills`, message-shaped like the oracle.
77pub enum SkillInstallError {
78    /// `SkillNotFound` — unregistered name (CLI usage error, exit 2).
79    NotFound(String),
80    /// `SkillFileExists` — refused before anything is written (exit 1).
81    FileExists(String),
82    /// Filesystem write failure (the oracle would raise `OSError`; carried
83    /// so the CLI can fail loudly instead of pretending success).
84    Io(String),
85}
86
87/// `install_skills(target_dir, skill_name)` — write bundled skills into
88/// `<dir>/.claude/skills/<name>/SKILL.md`.
89///
90/// With no name every bundled skill is installed all-or-nothing: every
91/// target path is checked BEFORE any write, and one collision refuses the
92/// whole installation with nothing written (existing paths listed in
93/// registry order). Emitted paths are `str(Path(dir) / ...)` — the caller's
94/// `--dir` normalized by pathlib, never abspath'd (landmine 6).
95pub 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    // Check every destination first, then write — a refusal never leaves a
113    // partial installation behind.
114    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}