Skip to main content

release_kit/commands/
skill.rs

1//! `rk skill`: the agent skills at user scope.
2//!
3//! This handler resolves three things — the home directory, the roots the
4//! chosen agent implies, and the record that sits beside them — and renders
5//! what the installer reports, in both the human and the machine form.
6//! Install and uninstall semantics live in `skills::installer`; nothing
7//! here decides them.
8
9use camino::{Utf8Path, Utf8PathBuf};
10use serde::Serialize;
11
12use crate::cli::skill::{Agent, Scope, SkillAction, SkillArgs};
13use crate::error::RkError;
14use crate::output::Output;
15use crate::skills;
16use crate::skills::installer::{self, Action, Layout};
17use crate::skills::record::RECORD_PATH;
18use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, LEGACY_SHARED_ROOT, home};
19
20/// The machine form of an install or uninstall report.
21#[derive(Debug, Serialize)]
22struct Report<'a> {
23    /// The shape version of this document.
24    schema: &'static str,
25    /// `install` or `uninstall`.
26    command: &'static str,
27    /// `preview` or `apply`.
28    mode: &'static str,
29    /// Everything the run did, or would do.
30    actions: &'a [Action],
31    /// What plausibly follows.
32    next: &'a [String],
33}
34
35/// Dispatch the skill action.
36///
37/// # Errors
38///
39/// Returns [`RkError::NotFound`] for an unknown skill name,
40/// [`RkError::Refused`] when the home is unusable or a destination cannot be
41/// touched, and [`RkError::Io`] on filesystem failure.
42pub fn run(args: &SkillArgs) -> Result<(), RkError> {
43    match &args.action {
44        SkillAction::List => {
45            let out = Output::human();
46            for skill in skills::all()? {
47                out.result_line(&skill.name);
48            }
49            Ok(())
50        }
51        SkillAction::Show { name } => show(name),
52        SkillAction::Install {
53            agent,
54            scope,
55            apply,
56            force,
57            json,
58        } => {
59            let layout = layout(*agent, *scope)?;
60            let actions = installer::install(&layout, *apply, *force)?;
61            render(Output::new(*json), "install", *apply, &actions)
62        }
63        SkillAction::Uninstall {
64            agent,
65            scope,
66            apply,
67            json,
68        } => {
69            let layout = layout(*agent, *scope)?;
70            let actions = installer::uninstall(&layout, *apply)?;
71            render(Output::new(*json), "uninstall", *apply, &actions)
72        }
73    }
74}
75
76/// Render what the installer reported: the human lines by default, the
77/// `rk.skill/1` object under `--json`.
78fn render(
79    out: Output,
80    command: &'static str,
81    apply: bool,
82    actions: &[Action],
83) -> Result<(), RkError> {
84    if apply {
85        for action in actions {
86            out.result_line(applied_line(command, action));
87        }
88    } else {
89        out.result_line(format!(
90            "DRY RUN: rk skill {command} {} these files; re-run with --apply",
91            if command == "install" {
92                "writes"
93            } else {
94                "removes"
95            }
96        ));
97        for action in actions {
98            out.result_line(planned_line(action));
99        }
100    }
101    let next = next_lines(command, apply);
102    out.next(&next);
103    out.emit(&Report {
104        schema: "rk.skill/1",
105        command,
106        mode: if apply { "apply" } else { "preview" },
107        actions,
108        next: &next,
109    })
110}
111
112/// The one human line for a planned action.
113fn planned_line(action: &Action) -> String {
114    match action {
115        Action::Write { destination } | Action::Remove { destination } => destination.to_string(),
116        Action::Sweep { destination } => {
117            format!("sweep (no longer carried by this binary) {destination}")
118        }
119        Action::KeptEdited { destination } => format!("keep (edited by you) {destination}"),
120        other => format!("{other:?}"),
121    }
122}
123
124/// The one human line for a performed action, byte-identical to what the
125/// installer printed before the boundary existed.
126fn applied_line(command: &str, action: &Action) -> String {
127    match action {
128        Action::Write { destination } => format!("wrote {destination}"),
129        Action::Unchanged { destination } => format!("unchanged {destination}"),
130        Action::Sweep { destination } => format!("swept {destination}"),
131        Action::SweepFailed { destination, error } => {
132            format!("could not sweep {destination}; remove it by hand: {error}")
133        }
134        Action::Remove { destination } => format!("removed {destination}"),
135        Action::KeptEdited { destination } => format!("kept (edited by you) {destination}"),
136        Action::KeptDirectory { directory } => format!("kept (not empty) {directory}"),
137        Action::RecordUnwritten { record } => {
138            if command == "install" {
139                format!(
140                    "note: could not record the installed digests at {record}; a later install may ask for --force"
141                )
142            } else {
143                format!(
144                    "note: could not update the record at {record}; a later install may ask for --force"
145                )
146            }
147        }
148    }
149}
150
151/// What plausibly follows each outcome.
152fn next_lines(command: &str, apply: bool) -> Vec<String> {
153    match (command, apply) {
154        ("install", false) => vec!["rk skill install --apply".to_owned()],
155        ("install", true) => vec![
156            "rk skill list names the installed skills".to_owned(),
157            "an agent now resolves each skill by name".to_owned(),
158        ],
159        (_, false) => vec!["rk skill uninstall --apply".to_owned()],
160        (_, true) => vec!["rk skill install lands them again".to_owned()],
161    }
162}
163
164/// Print one skill's `SKILL.md`, byte-identical to the authored file.
165fn show(name: &str) -> Result<(), RkError> {
166    let out = Output::human();
167    skills::all()?
168        .into_iter()
169        .find(|skill| skill.name == name)
170        .map_or_else(
171            || {
172                Err(RkError::NotFound {
173                    kind: "skill",
174                    name: name.to_owned(),
175                })
176            },
177            |skill| {
178                out.result_raw(skill.text);
179                Ok(())
180            },
181        )
182}
183
184/// The roots a run touches, and the record that vouches for them.
185fn layout(agent: Agent, scope: Scope) -> Result<Layout, RkError> {
186    let Scope::User = scope;
187    let home = home()?;
188    Ok(Layout {
189        roots: roots(&home, agent),
190        legacy_shared: matches!(agent, Agent::All).then(|| home.join(LEGACY_SHARED_ROOT)),
191        record: home.join(RECORD_PATH),
192    })
193}
194
195/// The skill roots under `home`, in the order an apply writes them.
196fn roots(home: &Utf8Path, agent: Agent) -> Vec<Utf8PathBuf> {
197    let mut roots = Vec::new();
198    if matches!(agent, Agent::Claude | Agent::All) {
199        roots.push(home.join(CLAUDE_ROOT));
200    }
201    if matches!(agent, Agent::Codex | Agent::All) {
202        roots.push(home.join(AGENTS_ROOT));
203    }
204    roots
205}
206
207#[cfg(test)]
208mod tests {
209    use camino::Utf8Path;
210
211    use super::roots;
212    use crate::cli::skill::Agent;
213    use crate::skills::installer::Action;
214
215    #[test]
216    fn each_agent_selects_its_own_roots() {
217        let home = Utf8Path::new("/home/<user>");
218        assert_eq!(roots(home, Agent::Claude), ["/home/<user>/.claude/skills"]);
219        assert_eq!(roots(home, Agent::Codex), ["/home/<user>/.agents/skills"]);
220        assert_eq!(
221            roots(home, Agent::All),
222            ["/home/<user>/.claude/skills", "/home/<user>/.agents/skills"]
223        );
224    }
225
226    /// The complete `rk.skill/1` report shape, held by snapshot.
227    #[test]
228    fn the_skill_report_schema_snapshot_holds() {
229        let actions = vec![Action::Write {
230            destination: "/home/<user>/.claude/skills/rk-setup/SKILL.md".into(),
231        }];
232        let next = vec!["rk skill list names the installed skills".to_owned()];
233        let report = super::Report {
234            schema: "rk.skill/1",
235            command: "install",
236            mode: "apply",
237            actions: &actions,
238            next: &next,
239        };
240        assert_eq!(
241            serde_json::to_string(&report).expect("a report serializes"),
242            r#"{"schema":"rk.skill/1","command":"install","mode":"apply","actions":[{"action":"write","destination":"/home/<user>/.claude/skills/rk-setup/SKILL.md"}],"next":["rk skill list names the installed skills"]}"#
243        );
244    }
245
246    /// The `rk.skill/1` action shape, held by snapshot across every
247    /// variant, so the whole tagged-union vocabulary is exact and a
248    /// rename in any one arm fails here first.
249    #[test]
250    fn the_skill_action_schema_snapshot_holds() {
251        let cases: Vec<(Action, &str)> = vec![
252            (
253                Action::Write {
254                    destination: "/h/SKILL.md".into(),
255                },
256                r#"{"action":"write","destination":"/h/SKILL.md"}"#,
257            ),
258            (
259                Action::Unchanged {
260                    destination: "/h/SKILL.md".into(),
261                },
262                r#"{"action":"unchanged","destination":"/h/SKILL.md"}"#,
263            ),
264            (
265                Action::Sweep {
266                    destination: "/h/SKILL.md".into(),
267                },
268                r#"{"action":"sweep","destination":"/h/SKILL.md"}"#,
269            ),
270            (
271                Action::SweepFailed {
272                    destination: "/h/SKILL.md".into(),
273                    error: "permission denied".into(),
274                },
275                r#"{"action":"sweep-failed","destination":"/h/SKILL.md","error":"permission denied"}"#,
276            ),
277            (
278                Action::Remove {
279                    destination: "/h/SKILL.md".into(),
280                },
281                r#"{"action":"remove","destination":"/h/SKILL.md"}"#,
282            ),
283            (
284                Action::KeptEdited {
285                    destination: "/h/SKILL.md".into(),
286                },
287                r#"{"action":"kept-edited","destination":"/h/SKILL.md"}"#,
288            ),
289            (
290                Action::KeptDirectory {
291                    directory: "/h/rk-setup".into(),
292                },
293                r#"{"action":"kept-directory","directory":"/h/rk-setup"}"#,
294            ),
295            (
296                Action::RecordUnwritten {
297                    record: "/h/skills.sha256".into(),
298                },
299                r#"{"action":"record-unwritten","record":"/h/skills.sha256"}"#,
300            ),
301        ];
302        for (action, expected) in cases {
303            assert_eq!(
304                serde_json::to_string(&action).expect("an action serializes"),
305                expected
306            );
307        }
308    }
309}