skill_embed/install.rs
1use std::fs;
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, PoisonError};
6use std::time::SystemTime;
7
8use crate::error::ForceRequired;
9use crate::tree::{self, Tree};
10use crate::{
11 Action, Agent, AgentSelector, Error, Result, SKILL_FILE, Scope, Skill, SkillSet, State, agent,
12 manifest, paths, projectroot,
13};
14
15/// Installs embedded skills into agent directories.
16///
17/// Everything is set once, on the way in:
18///
19/// ```no_run
20/// use std::sync::LazyLock;
21///
22/// use include_dir::{Dir, include_dir};
23/// use skill_embed::{Installer, SkillSet};
24///
25/// static SKILLS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/skills");
26///
27/// static SKILLS: LazyLock<Installer> = LazyLock::new(|| {
28/// Installer::new(SkillSet::from_include_dir(&SKILLS_DIR).expect("valid skills"))
29/// .with_tool_name("mytool")
30/// .with_version(env!("CARGO_PKG_VERSION"))
31/// });
32/// ```
33pub struct Installer {
34 set: SkillSet,
35 tool_name: String,
36 version: String,
37 command_name: String,
38 agents: Vec<Agent>,
39 default_agents: Vec<AgentSelector>,
40 default_scope: Scope,
41 project_root: Option<PathBuf>,
42 metadata: bool,
43 executable: Arc<tree::ExecutableRule>,
44 out: Mutex<Box<dyn Write + Send>>,
45 err_out: Mutex<Box<dyn Write + Send>>,
46}
47
48impl Installer {
49 /// Creates an installer for a set of embedded skills.
50 ///
51 /// The tool name defaults to the running binary's name, the subcommand to
52 /// `skill`, the scope to [`Scope::Project`] and the agents to `detected`.
53 #[must_use]
54 pub fn new(set: SkillSet) -> Self {
55 Self {
56 set,
57 tool_name: binary_name(),
58 version: String::new(),
59 command_name: "skill".to_owned(),
60 agents: Agent::builtins(),
61 default_agents: vec![AgentSelector::Detected],
62 default_scope: Scope::Project,
63 project_root: None,
64 metadata: true,
65 executable: Arc::new(tree::has_shebang),
66 out: Mutex::new(Box::new(io::stdout())),
67 err_out: Mutex::new(Box::new(io::stderr())),
68 }
69 }
70
71 /// Sets the name recorded in installed skills and shown in help.
72 ///
73 /// An empty name is not a name, and is passed over. A skill stamped
74 /// `x-embedded-by: ""` is foreign to every run, including this tool's own.
75 #[must_use]
76 pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
77 let name = name.into();
78 if !name.is_empty() {
79 self.tool_name = name;
80 }
81 self
82 }
83
84 /// Sets the version recorded in installed skills.
85 #[must_use]
86 pub fn with_version(mut self, version: impl Into<String>) -> Self {
87 self.version = version.into();
88 self
89 }
90
91 /// Sets the subcommand name [`Installer::run`] and [`Installer::intercept`]
92 /// answer to. It defaults to `skill`.
93 #[must_use]
94 pub fn with_command_name(mut self, name: impl Into<String>) -> Self {
95 self.command_name = name.into();
96 self
97 }
98
99 /// Restricts the agents the tool offers. It defaults to every built-in one.
100 #[must_use]
101 pub fn with_agents(mut self, agents: impl IntoIterator<Item = Agent>) -> Self {
102 self.agents = agents.into_iter().collect();
103 self
104 }
105
106 /// Sets the agents used when `--agent` is not given. It defaults to
107 /// [`AgentSelector::Detected`].
108 ///
109 /// `detected` keeps the agents whose directory is already there, and falls
110 /// back to `all` when it finds none. In a fresh repository that is two
111 /// directories and reaches everything. In a home directory it is the agents
112 /// in use, rather than six directories of which most are litter. Pass
113 /// [`Agent::GITHUB_COPILOT`] for the default `gh skill install` uses.
114 #[must_use]
115 pub fn with_default_agents(mut self, agents: impl IntoIterator<Item = AgentSelector>) -> Self {
116 self.default_agents = agents.into_iter().collect();
117 self
118 }
119
120 /// Sets the scope used when `--scope` is not given. It defaults to
121 /// [`Scope::Project`], matching `gh skill install`.
122 #[must_use]
123 pub fn with_default_scope(mut self, scope: Scope) -> Self {
124 self.default_scope = scope;
125 self
126 }
127
128 /// Sets the directory project scope resolves against.
129 ///
130 /// Without it the root is searched for: the walk starts at the working
131 /// directory and stops at the repository root, and the first directory
132 /// already holding an agent directory wins. Outside a repository the
133 /// working directory is the only candidate. The home directory is refused,
134 /// since the user scope directories live there.
135 #[must_use]
136 pub fn with_project_root(mut self, dir: impl Into<PathBuf>) -> Self {
137 self.project_root = Some(dir.into());
138 self
139 }
140
141 /// Controls whether installed skills carry `x-embedded-*` frontmatter. It
142 /// is on by default.
143 ///
144 /// Without it, install cannot tell an outdated copy from an edited one and
145 /// every existing directory reads as [`State::Foreign`].
146 #[must_use]
147 pub fn with_metadata(mut self, on: bool) -> Self {
148 self.metadata = on;
149 self
150 }
151
152 /// Decides which files are written with the executable bit.
153 ///
154 /// An embedded file carries no mode, so the default marks any file starting
155 /// with a `#!` shebang.
156 #[must_use]
157 pub fn with_executable(
158 mut self,
159 rule: impl Fn(&str, &[u8]) -> bool + Send + Sync + 'static,
160 ) -> Self {
161 self.executable = Arc::new(rule);
162 self
163 }
164
165 /// Sets where [`Installer::run`] writes what was asked for: the report, and
166 /// the help text when help was requested. It defaults to standard output.
167 #[must_use]
168 pub fn with_output(mut self, w: impl Write + Send + 'static) -> Self {
169 self.out = Mutex::new(Box::new(w));
170 self
171 }
172
173 /// Sets where [`Installer::run`] writes diagnostics: the message for a bad
174 /// flag or an unknown subcommand, and the usage that goes with it. It
175 /// defaults to standard error.
176 ///
177 /// The two are separate for the sake of redirection. `mytool skill list >
178 /// skills.txt` puts the list in the file and the complaint on the terminal.
179 #[must_use]
180 pub fn with_error_output(mut self, w: impl Write + Send + 'static) -> Self {
181 self.err_out = Mutex::new(Box::new(w));
182 self
183 }
184
185 /// The subcommand name.
186 #[must_use]
187 pub fn command_name(&self) -> &str {
188 &self.command_name
189 }
190
191 /// The name recorded in installed skills.
192 #[must_use]
193 pub fn tool_name(&self) -> &str {
194 &self.tool_name
195 }
196
197 /// The scope used when none is given.
198 #[must_use]
199 pub fn default_scope(&self) -> Scope {
200 self.default_scope
201 }
202
203 /// The agents used when `--agent` is not given.
204 #[must_use]
205 pub fn default_agents(&self) -> &[AgentSelector] {
206 &self.default_agents
207 }
208
209 /// The skills the binary carries.
210 #[must_use]
211 pub fn skills(&self) -> &SkillSet {
212 &self.set
213 }
214
215 /// Renders the `--agent` help string, such as `{a|b|c}`.
216 ///
217 /// An adapter that writes its own flag help uses it to name the same
218 /// agents.
219 #[must_use]
220 pub fn agent_choices(&self) -> String {
221 agent::choices(&self.agents)
222 }
223
224 /// Writes a report to wherever [`Installer::with_output`] points.
225 ///
226 /// A front end that renders its own output calls it, so that one setting
227 /// reaches every one of them. [`crate::render_results`] and
228 /// [`crate::render_status`] are what it is given.
229 ///
230 /// # Errors
231 ///
232 /// Whatever the writer refused.
233 pub fn write_report(&self, text: &str) -> Result<()> {
234 self.write_out(text).map_err(|e| Error::io("write the report", e))
235 }
236
237 pub(crate) fn write_out(&self, s: &str) -> io::Result<()> {
238 let mut w = self.out.lock().unwrap_or_else(PoisonError::into_inner);
239 w.write_all(s.as_bytes())?;
240 w.flush()
241 }
242
243 pub(crate) fn write_err(&self, s: &str) -> io::Result<()> {
244 let mut w = self.err_out.lock().unwrap_or_else(PoisonError::into_inner);
245 w.write_all(s.as_bytes())?;
246 w.flush()
247 }
248
249 /// What project scope resolves against.
250 ///
251 /// [`Installer::with_project_root`] wins, and without it the root is
252 /// searched for.
253 fn project_root_of(&self, scope: Scope) -> Result<Option<PathBuf>> {
254 if scope != Scope::Project || self.project_root.is_some() {
255 return Ok(self.project_root.clone());
256 }
257 let wd = std::env::current_dir().map_err(|e| Error::io("read the working directory", e))?;
258 // Derived from the agent table rather than written out, so a seventh
259 // agent is searched for without touching this. Five of the six share
260 // `.agents`, so the list is deduplicated before it becomes one question
261 // per entry.
262 let mut markers: Vec<PathBuf> = Vec::new();
263 for a in &self.agents {
264 let marker = PathBuf::from(a.project_dir().replace('/', std::path::MAIN_SEPARATOR_STR));
265 let Some(parent) = marker.parent().filter(|p| !p.as_os_str().is_empty()) else {
266 continue;
267 };
268 if !markers.iter().any(|m| m == parent) {
269 markers.push(parent.to_path_buf());
270 }
271 }
272 match projectroot::find(&wd, &markers) {
273 Ok(root) => Ok(Some(root)),
274 // The search says what happened and where. What to do about it is
275 // in this tool's vocabulary, which is not the search's to know.
276 Err(projectroot::FindError::IsHome(dir)) => Err(Error::ProjectIsHome { dir }),
277 Err(projectroot::FindError::Io(e)) => Err(Error::io("find the project root", e)),
278 }
279 }
280
281 /// Resolves the destination directories for `options`.
282 ///
283 /// At project scope every agent but Claude Code shares `.agents/skills`, so
284 /// those are merged into one target and a skill is never written there
285 /// twice.
286 ///
287 /// # Errors
288 ///
289 /// Fails when `--agent` names nothing, when the project root cannot be
290 /// found, or when a project scope destination would land outside it.
291 pub fn targets(&self, options: &InstallOptions) -> Result<Vec<InstallTarget>> {
292 let scope = options.scope.unwrap_or(self.default_scope);
293
294 if let Some(dir) = &options.dir {
295 // An empty name is not a directory. It resolves to the working
296 // directory, which would put every skill loose in whatever
297 // directory the tool was run from.
298 if dir.as_os_str().is_empty() {
299 return Err(Error::Usage("the install directory must be named".to_owned()));
300 }
301 // Validated before `dir` wins, so that a bad `--agent` alongside it
302 // is a diagnosis rather than silence.
303 agent::resolve(&self.agents, &options.agents, &|_| true)?;
304 let dir = paths::absolute(dir)
305 .map_err(|e| Error::io(format!("resolve {}", dir.display()), e))?;
306 return Ok(vec![InstallTarget { dir, agents: Vec::new(), root: None }]);
307 }
308
309 let names = if options.agents.is_empty() { &self.default_agents } else { &options.agents };
310 let root = self.project_root_of(scope)?;
311
312 // An agent counts as present when the directory holding its skills
313 // directory is there. The skills directory itself need not be.
314 let detected = |a: &Agent| {
315 a.dir(scope, root.as_deref()).is_ok_and(|dir| dir.parent().is_some_and(Path::is_dir))
316 };
317
318 let agents = agent::resolve(&self.agents, names, &detected)?;
319 let agents = agent::fall_back_to_all(&self.agents, agents, names);
320 if agents.is_empty() {
321 return Err(Error::NoAgentSelected);
322 }
323
324 // Only a project install has a project. User scope writes into the home
325 // directory, and a named directory is the destination outright.
326 let stamped = (scope == Scope::Project).then(|| root.clone()).flatten();
327
328 let mut targets: Vec<InstallTarget> = Vec::new();
329 for a in agents {
330 let dir = a.dir(scope, root.as_deref())?;
331 let dir = paths::absolute(&dir)
332 .map_err(|e| Error::io(format!("resolve {}", dir.display()), e))?;
333 // Checked per agent rather than per target, so that the agent whose
334 // directory escapes is the one reached before any of them is
335 // written.
336 if let Some(root) = root.as_deref().filter(|_| scope == Scope::Project) {
337 match projectroot::within(root, &dir) {
338 Ok(()) => {}
339 Err(projectroot::WithinError::Outside(o)) => {
340 return Err(Error::ProjectEscapes { dir: o.dir, real: o.real });
341 }
342 Err(projectroot::WithinError::Io(e)) => {
343 return Err(Error::io(format!("resolve {}", dir.display()), e));
344 }
345 }
346 }
347 match targets.iter_mut().find(|t| t.dir == dir) {
348 Some(t) => t.agents.push(a),
349 None => targets.push(InstallTarget { dir, agents: vec![a], root: stamped.clone() }),
350 }
351 }
352 Ok(targets)
353 }
354
355 /// Resolves `options.names` against the embedded set.
356 ///
357 /// `also_installed` holds the names found at the destinations that the set
358 /// does not carry. A name in there is an orphan, which has a row of its own
359 /// and no embedded skill behind it, so it is accepted and skipped rather
360 /// than called unknown. Listing a name and then refusing it is the worse
361 /// answer.
362 fn selected(&self, options: &InstallOptions, also_installed: &[String]) -> Result<Vec<Skill>> {
363 if options.names.is_empty() {
364 return Ok(self.set.skills().to_vec());
365 }
366 let mut out = Vec::with_capacity(options.names.len());
367 for name in &options.names {
368 match self.set.get(name) {
369 Some(sk) => out.push(sk.clone()),
370 None if also_installed.iter().any(|n| n == name) => {}
371 None => {
372 let mut embedded: Vec<String> = self.set.names().map(str::to_owned).collect();
373 embedded.sort();
374 return Err(Error::UnknownSkill { name: name.clone(), embedded });
375 }
376 }
377 }
378 Ok(out)
379 }
380
381 /// Reports what is installed where, without changing anything.
382 ///
383 /// It also reports what a destination holds that this tool wrote and the
384 /// binary no longer carries, as [`State::Orphaned`].
385 ///
386 /// # Errors
387 ///
388 /// Fails for the reasons [`Installer::targets`] does, when a named skill is
389 /// not embedded, or when a destination cannot be read at all.
390 pub fn status(&self, options: &InstallOptions) -> Result<Vec<InstallStatus>> {
391 let targets = self.targets(options)?;
392
393 // Found before the names are resolved. `list` prints these, so a name
394 // it printed has to be one uninstall accepts, and the embedded set
395 // alone cannot say whether a name exists at the destination.
396 let mut orphans_at = Vec::with_capacity(targets.len());
397 let mut also_installed = Vec::new();
398 for t in &targets {
399 let orphans = self.orphaned(t, options)?;
400 also_installed.extend(orphans.iter().map(|st| st.skill.clone()));
401 orphans_at.push(orphans);
402 }
403
404 let skills = self.selected(options, &also_installed)?;
405
406 let mut out = Vec::with_capacity(targets.len() * (skills.len() + 1));
407 for (t, orphans) in targets.iter().zip(orphans_at) {
408 for sk in &skills {
409 options.check_cancelled()?;
410 out.push(self.inspect(t, sk)?);
411 }
412 for st in orphans {
413 // A named run acts on what it was told to. `install demo` is
414 // about demo, and sweeping the directory on the way past would
415 // remove skills the run was never asked about.
416 if options.names.is_empty() || options.names.contains(&st.skill) {
417 out.push(st);
418 }
419 }
420 }
421 Ok(out)
422 }
423
424 /// Lists the directories at `target` that this tool wrote and the binary no
425 /// longer carries.
426 ///
427 /// Nothing else finds them. Every other walk starts from the embedded set,
428 /// so a skill dropped between two versions is never looked at again:
429 /// install passes it by, list does not mention it, and uninstall leaves it
430 /// behind for good.
431 ///
432 /// A directory is claimed on three things together, and each is necessary.
433 /// Its `x-embedded-by` names this tool, so nothing anyone else put there is
434 /// in reach. Its contents still hash to the digest that was recorded when it
435 /// was written, so it is byte for byte what this tool left and removing it
436 /// loses nothing the binary could not write again. And the binary has no
437 /// skill of that name to put back.
438 ///
439 /// A directory that fails the digest is not claimed and is not reported. It
440 /// held this tool's work once and holds something else now, which is what a
441 /// skill copied and then edited into one of the user's own looks like.
442 /// There is no version of that this tool should delete: nothing is being
443 /// installed over it, so there is no conflict for `--force` to resolve, and
444 /// `--force` therefore has no part in the sweep at all.
445 fn orphaned(
446 &self,
447 target: &InstallTarget,
448 options: &InstallOptions,
449 ) -> Result<Vec<InstallStatus>> {
450 let entries = match fs::read_dir(&target.dir) {
451 Ok(entries) => entries,
452 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
453 Err(e) => return Err(Error::io(format!("read {}", target.dir.display()), e)),
454 };
455
456 // Where the embedded skills live at this target. A name that is not in
457 // the set can still be one of these directories: a file system that
458 // folds case or normalizes Unicode answers to more than one spelling,
459 // so a skill renamed to another spelling of itself reads as both an
460 // embedded row and an orphan, and both name the same directory.
461 // Removing the orphan would then delete the skill the same run just
462 // wrote. Asking the file system settles it without this having to guess
463 // what it treats as equal.
464 let embedded: Vec<PathBuf> = self
465 .set
466 .skills()
467 .iter()
468 .map(|sk| target.dir.join(sk.name()))
469 .filter(|p| p.exists())
470 .collect();
471
472 let mut out = Vec::new();
473 for entry in entries {
474 // The one place the sweep does real work, a directory at a time.
475 options.check_cancelled()?;
476 let entry =
477 entry.map_err(|e| Error::io(format!("read {}", target.dir.display()), e))?;
478 // The entry's own type, which does not follow a symbolic link. A
479 // link is something the user put there, and the sweep removes what
480 // it finds, so it must not reach through one.
481 // An entry whose type cannot be read is one the sweep cannot claim,
482 // so it is passed over rather than made to end the run. Go reads
483 // the type off the directory entry, where it cannot fail at all.
484 if !entry.file_type().is_ok_and(|t| t.is_dir()) {
485 continue;
486 }
487 // A name that is not UTF-8 is not a skill name, so nothing embedded
488 // can share it and nothing here can claim it.
489 let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
490 continue;
491 };
492 if self.set.get(&name).is_some() {
493 continue;
494 }
495 // A write stages and rescues under a leading dot, and the rescue is
496 // a verbatim copy of an installation, stamp and all, so it matches
497 // everything below. The error that leaves one behind names it for
498 // the user to go and recover, and the next run must not take it
499 // away. Agents pass over these too.
500 if name.starts_with('.') {
501 continue;
502 }
503 let dest = target.dir.join(&name);
504 if embedded.iter().any(|p| paths::same_file(p, &dest)) {
505 continue;
506 }
507 let Ok(installed) = fs::read(dest.join(SKILL_FILE)) else {
508 continue; // not a skill directory, or not one that can be read
509 };
510 let fields = manifest::fields(&installed);
511 // A key that is there and empty carries no claim, so it is no claim.
512 let recorded = fields.get(manifest::KEY_EMBEDDED_DIGEST).filter(|d| !d.is_empty());
513 if recorded.is_none()
514 || fields.get(manifest::KEY_EMBEDDED_BY).map(String::as_str)
515 != Some(&self.tool_name)
516 {
517 continue; // someone else's, or carrying no claim at all
518 }
519 let Ok(actual) = Tree::read_dir(&dest).map(|t| t.digest()) else {
520 continue;
521 };
522 if recorded != Some(&actual) {
523 continue; // not what this tool left there, so not this tool's to take
524 }
525
526 out.push(InstallStatus {
527 skill: name,
528 description: String::new(),
529 target: target.clone(),
530 path: dest,
531 state: State::Orphaned,
532 installed_by: fields.get(manifest::KEY_EMBEDDED_BY).cloned(),
533 installed_version: fields.get(manifest::KEY_EMBEDDED_VERSION).cloned(),
534 });
535 }
536 out.sort_by(|a, b| a.skill.cmp(&b.skill));
537 Ok(out)
538 }
539
540 fn inspect(&self, target: &InstallTarget, sk: &Skill) -> Result<InstallStatus> {
541 let dest = target.dir.join(sk.name());
542 let mut st = InstallStatus {
543 skill: sk.name().to_owned(),
544 description: sk.description().to_owned(),
545 target: target.clone(),
546 path: dest.clone(),
547 state: State::Missing,
548 installed_by: None,
549 installed_version: None,
550 };
551
552 // `metadata`, which follows a symbolic link, because a link standing in
553 // for an installed skill is one the agent reads through. A link is
554 // therefore described by what it points at, and a dangling one is
555 // nothing at all.
556 match fs::metadata(&dest) {
557 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(st),
558 // Not being able to describe the destination at all is not a state.
559 // It says nothing about what is there, and the run cannot go on.
560 Err(e) => return Err(Error::io(format!("read {}", dest.display()), e)),
561 Ok(meta) if !meta.is_dir() => {
562 st.state = State::Foreign;
563 return Ok(st);
564 }
565 Ok(_) => {}
566 }
567
568 let Ok(installed) = fs::read(dest.join(SKILL_FILE)) else {
569 // A directory with no manifest is not ours to replace silently.
570 st.state = State::Foreign;
571 return Ok(st);
572 };
573 let fields = manifest::fields(&installed);
574 st.installed_by = fields.get(manifest::KEY_EMBEDDED_BY).cloned();
575 st.installed_version = fields.get(manifest::KEY_EMBEDDED_VERSION).cloned();
576 // A key that is there and empty carries no claim, so it is no claim.
577 let Some(recorded) = fields.get(manifest::KEY_EMBEDDED_DIGEST).filter(|d| !d.is_empty())
578 else {
579 st.state = State::Foreign;
580 return Ok(st);
581 };
582 if st.installed_by.as_deref() != Some(&self.tool_name) {
583 st.state = State::Foreign;
584 return Ok(st);
585 }
586
587 // Something there cannot be read or hashed, such as a symlink a user
588 // dropped in. Nothing can be said about the copy, so it is not claimed
589 // as this tool's, and `--force` stays the way out. An error here would
590 // instead fail the whole run for every other skill at this target.
591 let Ok(found) = Tree::read_dir(&dest) else {
592 st.state = State::Foreign;
593 return Ok(st);
594 };
595
596 st.state = if found.digest() != *recorded {
597 State::Modified
598 } else if *recorded != sk.digest() {
599 State::Outdated
600 } else if found.executable_bits_match(&*self.executable) {
601 State::UpToDate
602 } else {
603 // The contents match. The executable bit is not in the digest, and
604 // a script that lost it cannot be run by the agent. That reads as
605 // outdated rather than modified, because the user did not do it and
606 // repairing it should not need `--force`.
607 State::Outdated
608 };
609 Ok(st)
610 }
611
612 /// Writes the selected skills into the resolved targets.
613 ///
614 /// A run over the whole set also removes what this tool wrote and the
615 /// binary no longer carries, reported as [`State::Orphaned`]. Nothing else
616 /// would ever reach those directories again. A run naming skills installs
617 /// those and sweeps nothing it was not told to.
618 ///
619 /// A destination this tool did not write, or one edited after it did, is
620 /// left alone unless [`InstallOptions::force`] is set. Those skills come
621 /// back as [`Action::Skipped`] with a reason, exactly as
622 /// [`Installer::uninstall`] reports them, and the outcome is
623 /// [`Error::NeedsForce`]. One blocked destination does not stop the others
624 /// from being written.
625 ///
626 /// The results are worth reporting even when the outcome is an error. They
627 /// describe everything that happened before it.
628 pub fn install(&self, options: &InstallOptions) -> (Vec<InstallResult>, Result<()>) {
629 let statuses = match self.status(options) {
630 Ok(statuses) => statuses,
631 Err(e) => return (Vec::new(), Err(e)),
632 };
633
634 let mut blocked = Vec::new();
635 let mut results = Vec::with_capacity(statuses.len());
636 for st in statuses {
637 if let Err(e) = options.check_cancelled() {
638 return (results, Err(e));
639 }
640 let embedded = self.set.get(&st.skill);
641 let (action, reason) = match st.state {
642 // The binary has no copy to put back, so removing it is the
643 // whole action. No `--force`: the directory is byte for byte
644 // what this tool wrote, nothing is being installed over it, and
645 // leaving it is the one outcome nobody wants.
646 State::Orphaned => {
647 (Action::Removed, Some(format!("no longer embedded in {}", self.tool_name)))
648 }
649 State::Modified if !options.force => {
650 blocked.push(st.clone());
651 (Action::Skipped, Some("edited after installing; use --force".to_owned()))
652 }
653 State::Foreign if !options.force => {
654 blocked.push(st.clone());
655 (Action::Skipped, Some("installed by something else; use --force".to_owned()))
656 }
657 State::UpToDate if !options.force => {
658 (Action::Skipped, Some("already up to date".to_owned()))
659 }
660 State::Missing => (Action::Installed, None),
661 _ => (Action::Updated, None),
662 };
663 let result = InstallResult {
664 skill: st.skill.clone(),
665 target: st.target.clone(),
666 path: st.path.clone(),
667 before: st.state,
668 action,
669 reason,
670 };
671 if !options.dry_run {
672 // Switched on the action rather than on "not skipped", because
673 // an orphan has no embedded skill behind it and nothing to
674 // write.
675 let done = match action {
676 Action::Removed => fs::remove_dir_all(&st.path).map_err(|e| {
677 Error::io(
678 format!("remove {} from {}", st.skill, st.target.dir.display()),
679 e,
680 )
681 }),
682 // Only an orphan has no embedded skill behind it, and an
683 // orphan is removed rather than written.
684 Action::Installed | Action::Updated => embedded.map_or(Ok(()), |sk| {
685 self.write(sk, &st.path).map_err(|e| {
686 Error::io(
687 format!("install {} into {}", st.skill, st.target.dir.display()),
688 e,
689 )
690 })
691 }),
692 Action::Skipped => Ok(()),
693 };
694 if let Err(e) = done {
695 // The row says what was done, so it is added once that is
696 // true. Adding it first made a failed write print
697 // `installed` above its own error.
698 return (results, Err(e));
699 }
700 }
701 results.push(result);
702 }
703 let outcome =
704 if blocked.is_empty() { Ok(()) } else { Err(ForceRequired { blocked }.into()) };
705 (results, outcome)
706 }
707
708 /// Removes the selected skills from the resolved targets. Skills this tool
709 /// did not install are left alone unless [`InstallOptions::force`] is set.
710 ///
711 /// A run over the whole set also removes what this tool wrote and the
712 /// binary no longer carries, so that a full uninstall leaves nothing of this
713 /// tool's behind.
714 ///
715 /// The results are worth reporting even when the outcome is an error.
716 pub fn uninstall(&self, options: &InstallOptions) -> (Vec<InstallResult>, Result<()>) {
717 let statuses = match self.status(options) {
718 Ok(statuses) => statuses,
719 Err(e) => return (Vec::new(), Err(e)),
720 };
721 let mut results = Vec::with_capacity(statuses.len());
722 for st in statuses {
723 if let Err(e) = options.check_cancelled() {
724 return (results, Err(e));
725 }
726 let (action, reason) = match st.state {
727 State::Missing => (Action::Skipped, Some("not installed".to_owned())),
728 State::Foreign if !options.force => {
729 (Action::Skipped, Some("installed by something else; use --force".to_owned()))
730 }
731 State::Modified if !options.force => {
732 (Action::Skipped, Some("edited after installing; use --force".to_owned()))
733 }
734 _ => (Action::Removed, None),
735 };
736 if action == Action::Removed
737 && !options.dry_run
738 && let Err(e) = fs::remove_dir_all(&st.path)
739 {
740 // The row is added once the removal is true, so a failure never
741 // prints `removed` above its own error.
742 return (results, Err(Error::io(format!("remove {}", st.path.display()), e)));
743 }
744 results.push(InstallResult {
745 skill: st.skill,
746 target: st.target,
747 path: st.path,
748 before: st.state,
749 action,
750 reason,
751 });
752 }
753 (results, Ok(()))
754 }
755
756 /// Materialises one skill at `dest`.
757 fn write(&self, sk: &Skill, dest: &Path) -> io::Result<()> {
758 let stamp = self.stamp(sk);
759 sk.tree().write(dest, stamp.as_deref(), &*self.executable)
760 }
761
762 /// Records who installed the skill, from what version, and what it held, in
763 /// the manifest's frontmatter.
764 fn stamp<'a>(&'a self, sk: &'a Skill) -> Option<Box<tree::Transform<'a>>> {
765 if !self.metadata {
766 return None;
767 }
768 Some(Box::new(move |name: &str, data: &[u8]| {
769 if name != SKILL_FILE {
770 return None;
771 }
772 Some(manifest::with(
773 data,
774 &[
775 manifest::Entry {
776 key: manifest::KEY_EMBEDDED_BY,
777 value: self.tool_name.clone(),
778 },
779 manifest::Entry {
780 key: manifest::KEY_EMBEDDED_VERSION,
781 value: self.version.clone(),
782 },
783 manifest::Entry {
784 key: manifest::KEY_EMBEDDED_AT,
785 value: installed_at(SystemTime::now()),
786 },
787 manifest::Entry {
788 key: manifest::KEY_EMBEDDED_DIGEST,
789 value: sk.digest().to_owned(),
790 },
791 ],
792 ))
793 }))
794 }
795}
796
797/// The one timestamp this crate writes: when the skill was installed, in UTC
798/// and to the second.
799///
800/// A clock the calendar cannot describe reads as the epoch. Nothing installs a
801/// skill then, and it is not worth a second return value.
802fn installed_at(t: SystemTime) -> String {
803 jiff::Timestamp::try_from(t)
804 .unwrap_or(jiff::Timestamp::UNIX_EPOCH)
805 .strftime("%Y-%m-%dT%H:%M:%SZ")
806 .to_string()
807}
808
809fn binary_name() -> String {
810 let arg0 = std::env::args_os().next().map(PathBuf::from);
811 arg0.as_deref()
812 .and_then(Path::file_name)
813 .map_or_else(|| "tool".to_owned(), |n| n.to_string_lossy().into_owned())
814}
815
816/// The inputs shared by install, uninstall and list.
817///
818/// Every field has a default, so a caller names only what it means to change:
819///
820/// ```no_run
821/// use skill_embed::{Agent, AgentSelector, InstallOptions, Scope};
822///
823/// let options = InstallOptions {
824/// agents: vec![AgentSelector::from(&Agent::CLAUDE_CODE)],
825/// scope: Some(Scope::User),
826/// ..InstallOptions::default()
827/// };
828/// ```
829#[derive(Clone, Debug, Default)]
830pub struct InstallOptions {
831 /// Selects the destinations. Empty means the installer default.
832 pub agents: Vec<AgentSelector>,
833 /// [`None`] means the installer default.
834 pub scope: Option<Scope>,
835 /// Installs into this directory, overriding `agents` and `scope`.
836 pub dir: Option<PathBuf>,
837 /// Overwrites skills that were edited, or that something else installed.
838 pub force: bool,
839 /// Reports what would happen without touching the file system.
840 pub dry_run: bool,
841 /// Selects skills by name. Empty means every embedded skill.
842 pub names: Vec<String>,
843 /// Stops the run between skills once it is set.
844 ///
845 /// A single skill is written whole or not at all, so the flag is read
846 /// between them rather than during one.
847 pub cancel: Option<Arc<AtomicBool>>,
848}
849
850impl InstallOptions {
851 fn check_cancelled(&self) -> Result<()> {
852 match &self.cancel {
853 Some(c) if c.load(Ordering::Relaxed) => Err(Error::Cancelled),
854 _ => Ok(()),
855 }
856 }
857}
858
859/// One destination directory and the agents that read from it.
860#[derive(Clone, Debug)]
861pub struct InstallTarget {
862 /// The absolute skills directory.
863 pub dir: PathBuf,
864 /// The agents that read from [`InstallTarget::dir`]. It is empty when
865 /// [`InstallOptions::dir`] was used.
866 pub agents: Vec<Agent>,
867 /// The project directory [`InstallTarget::dir`] was resolved against. It is
868 /// [`None`] for user scope, and when [`InstallOptions::dir`] named the
869 /// destination outright.
870 pub root: Option<PathBuf>,
871}
872
873impl InstallTarget {
874 /// Renders the target for human readable output.
875 #[must_use]
876 pub fn label(&self) -> String {
877 if self.agents.is_empty() {
878 return self.dir.display().to_string();
879 }
880 let titles: Vec<&str> = self.agents.iter().map(Agent::title).collect();
881 format!("{} ({})", self.dir.display(), titles.join(", "))
882 }
883}
884
885/// The state of one skill at one destination.
886#[derive(Clone, Debug)]
887pub struct InstallStatus {
888 /// The skill's name. An orphan has one and no embedded skill behind it.
889 pub skill: String,
890 /// The skill's description, empty for an orphan.
891 pub description: String,
892 /// The destination this row is about.
893 pub target: InstallTarget,
894 /// The skill's own directory inside [`InstallTarget::dir`].
895 pub path: PathBuf,
896 /// What is there.
897 pub state: State,
898 /// From the installed frontmatter.
899 pub installed_by: Option<String>,
900 /// From the installed frontmatter.
901 pub installed_version: Option<String>,
902}
903
904/// The outcome for one skill at one destination.
905#[derive(Clone, Debug)]
906pub struct InstallResult {
907 /// The skill's name.
908 pub skill: String,
909 /// The destination this row is about.
910 pub target: InstallTarget,
911 /// The skill's own directory inside [`InstallTarget::dir`].
912 pub path: PathBuf,
913 /// The state found at [`InstallResult::path`].
914 pub before: State,
915 /// What was done.
916 pub action: Action,
917 /// Why nothing was done.
918 pub reason: Option<String>,
919}
920
921#[cfg(test)]
922mod tests {
923 use std::time::{Duration, UNIX_EPOCH};
924
925 use super::*;
926
927 /// The stamp is read back by a human and compared by nothing, but it is in
928 /// a file this tool writes, so its shape is part of the format.
929 #[test]
930 fn the_stamp_is_rfc_3339_in_utc_and_to_the_second() {
931 let at = |s| installed_at(UNIX_EPOCH + Duration::from_secs(s));
932 assert_eq!(at(0), "1970-01-01T00:00:00Z");
933 assert_eq!(at(1_774_195_793), "2026-03-22T16:09:53Z");
934 // A leap day, and one in a century that is a leap year after all.
935 assert_eq!(at(1_709_208_000), "2024-02-29T12:00:00Z");
936 assert_eq!(at(951_826_800), "2000-02-29T12:20:00Z");
937 }
938}