1use 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#[derive(Debug, Serialize)]
22struct Report<'a> {
23 schema: &'static str,
25 command: &'static str,
27 mode: &'static str,
29 actions: &'a [Action],
31 next: &'a [String],
33}
34
35pub 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
76fn 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
112fn 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
124fn 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
151fn 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
164fn 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
184fn 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
196fn 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 #[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 #[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}