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, LEGACY_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 carried by this binary) {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 legacy_shared: matches!(agent, Agent::All).then(|| home.join(LEGACY_SHARED_ROOT)),
191 record: home.join(RECORD_PATH),
192 })
193}
194
195fn 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 #[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 #[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}