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