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, 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 in the payload) {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        every_root: roots(&home, Agent::All),
191        shared: home.join(SHARED_ROOT),
192        record: home.join(RECORD_PATH),
193    })
194}
195
196/// The skill roots under `home`, in the order an apply writes them.
197fn roots(home: &Utf8Path, agent: Agent) -> Vec<Utf8PathBuf> {
198    let mut roots = Vec::new();
199    if matches!(agent, Agent::Claude | Agent::All) {
200        roots.push(home.join(CLAUDE_ROOT));
201    }
202    if matches!(agent, Agent::Codex | Agent::All) {
203        roots.push(home.join(AGENTS_ROOT));
204    }
205    roots
206}
207
208#[cfg(test)]
209mod tests {
210    #![allow(clippy::expect_used)]
211
212    use camino::Utf8Path;
213
214    use super::roots;
215    use crate::cli::skill::Agent;
216    use crate::skills::installer::Action;
217
218    #[test]
219    fn each_agent_selects_its_own_roots() {
220        let home = Utf8Path::new("/home/<user>");
221        assert_eq!(roots(home, Agent::Claude), ["/home/<user>/.claude/skills"]);
222        assert_eq!(roots(home, Agent::Codex), ["/home/<user>/.agents/skills"]);
223        assert_eq!(
224            roots(home, Agent::All),
225            ["/home/<user>/.claude/skills", "/home/<user>/.agents/skills"]
226        );
227    }
228
229    /// The complete `rk.skill/1` report shape, held by snapshot.
230    #[test]
231    fn the_skill_report_schema_snapshot_holds() {
232        let actions = vec![Action::Write {
233            destination: "/home/<user>/.claude/skills/rk-setup/SKILL.md".into(),
234        }];
235        let next = vec!["rk skill list names the installed skills".to_owned()];
236        let report = super::Report {
237            schema: "rk.skill/1",
238            command: "install",
239            mode: "apply",
240            actions: &actions,
241            next: &next,
242        };
243        assert_eq!(
244            serde_json::to_string(&report).expect("a report serializes"),
245            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"]}"#
246        );
247    }
248
249    /// The `rk.skill/1` action shape, held by snapshot across every
250    /// variant, so the whole tagged-union vocabulary is exact and a
251    /// rename in any one arm fails here first.
252    #[test]
253    fn the_skill_action_schema_snapshot_holds() {
254        let cases: Vec<(Action, &str)> = vec![
255            (
256                Action::Write {
257                    destination: "/h/SKILL.md".into(),
258                },
259                r#"{"action":"write","destination":"/h/SKILL.md"}"#,
260            ),
261            (
262                Action::Unchanged {
263                    destination: "/h/SKILL.md".into(),
264                },
265                r#"{"action":"unchanged","destination":"/h/SKILL.md"}"#,
266            ),
267            (
268                Action::Sweep {
269                    destination: "/h/SKILL.md".into(),
270                },
271                r#"{"action":"sweep","destination":"/h/SKILL.md"}"#,
272            ),
273            (
274                Action::SweepFailed {
275                    destination: "/h/SKILL.md".into(),
276                    error: "permission denied".into(),
277                },
278                r#"{"action":"sweep-failed","destination":"/h/SKILL.md","error":"permission denied"}"#,
279            ),
280            (
281                Action::Remove {
282                    destination: "/h/SKILL.md".into(),
283                },
284                r#"{"action":"remove","destination":"/h/SKILL.md"}"#,
285            ),
286            (
287                Action::KeptEdited {
288                    destination: "/h/SKILL.md".into(),
289                },
290                r#"{"action":"kept-edited","destination":"/h/SKILL.md"}"#,
291            ),
292            (
293                Action::KeptDirectory {
294                    directory: "/h/rk-setup".into(),
295                },
296                r#"{"action":"kept-directory","directory":"/h/rk-setup"}"#,
297            ),
298            (
299                Action::RecordUnwritten {
300                    record: "/h/skills.sha256".into(),
301                },
302                r#"{"action":"record-unwritten","record":"/h/skills.sha256"}"#,
303            ),
304        ];
305        for (action, expected) in cases {
306            assert_eq!(
307                serde_json::to_string(&action).expect("an action serializes"),
308                expected
309            );
310        }
311    }
312}