Skip to main content

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}