1use 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
21const CLAUDE_ROOT: &str = ".claude/skills";
23
24const AGENTS_ROOT: &str = ".agents/skills";
27
28const SHARED_ROOT: &str = ".local/state/release-kit/skills/shared";
36
37#[derive(Debug, Serialize)]
39struct Report<'a> {
40 schema: &'static str,
42 command: &'static str,
44 mode: &'static str,
46 actions: &'a [Action],
48 next: &'a [String],
50}
51
52pub 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
93fn 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
129fn 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
141fn 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
168fn 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
181fn 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
201fn 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
213fn 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
225fn 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 #[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 #[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}