Skip to main content

agent_first_data/
skill.rs

1//! Reusable Agent Skill installer for spore CLIs.
2//!
3//! A spore that embeds its `SKILL.md` describes itself with a [`SkillSpec`] and calls
4//! [`run_skill_admin`] to install, uninstall, or report status of that skill across supported
5//! coding agents (Codex, Claude Code, opencode, Hermes).
6//!
7//! The function performs the filesystem work and returns a typed [`SkillReport`] (the caller
8//! serializes it for output) or a [`SkillError`]. It never writes to stdout/stderr itself.
9//!
10//! Requires the `skill-admin` feature.
11
12use serde::Serialize;
13use std::path::{Path, PathBuf};
14
15const SKILL_FILE_NAME: &str = "SKILL.md";
16const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
17const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3;
18
19/// A bundled auxiliary file installed alongside `SKILL.md` under the skill
20/// directory — for example a `references/` document a skill points its agent to.
21///
22/// The file is written verbatim; unlike `SKILL.md` it carries no managed marker.
23#[derive(Clone, Copy, Debug)]
24pub struct SkillAsset<'a> {
25    /// Path relative to the skill directory, using `/` separators
26    /// (e.g. `references/naming-output.md`). Must be relative with no `.` or `..`
27    /// segment, so an asset can never escape the skill directory.
28    pub path: &'a str,
29    /// Bundled file contents (typically `include_str!`).
30    pub contents: &'a str,
31}
32
33/// Identity of the skill being managed and the tool that manages it.
34///
35/// `name` is both the skill directory name and the `name:` front-matter field. `source` is the
36/// bundled `SKILL.md` (typically `include_str!`). `title` is a human label for error messages.
37/// `marker_slug` seeds the managed-skill marker and the `Generated by <slug> skill install`
38/// comment, and is referenced in hints (e.g. `afwidget`). `assets` are auxiliary files bundled
39/// under the skill directory (e.g. `references/`); empty for a single-file skill.
40#[derive(Clone, Copy, Debug)]
41pub struct SkillSpec<'a> {
42    /// Skill directory name and front-matter `name` (e.g. `agent-first-widget`).
43    pub name: &'a str,
44    /// Bundled `SKILL.md` contents.
45    pub source: &'a str,
46    /// Human-readable skill title for error messages (e.g. `Agent-First Widget`).
47    pub title: &'a str,
48    /// Short tool slug used in the managed marker, generated-by comment, and hints (e.g. `afwidget`).
49    pub marker_slug: &'a str,
50    /// Auxiliary files bundled under the skill directory alongside `SKILL.md`
51    /// (e.g. `references/`). Installed and removed with the skill; empty `&[]`
52    /// for a single-file skill.
53    pub assets: &'a [SkillAsset<'a>],
54}
55
56/// Which agent target(s) to manage.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum SkillAgentSelection {
59    /// Every agent that supports the requested scope.
60    All,
61    /// Codex.
62    Codex,
63    /// Claude Code.
64    ClaudeCode,
65    /// opencode.
66    Opencode,
67    /// Hermes Agent.
68    Hermes,
69}
70
71/// A concrete agent a skill is installed for (no `All`).
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum SkillAgent {
75    /// Codex (`$CODEX_HOME/skills`, `~/.codex/skills`, or `.codex/skills`).
76    Codex,
77    /// Claude Code (`~/.claude/skills` or `.claude/skills`).
78    ClaudeCode,
79    /// opencode (`~/.config/opencode/skills` or `.opencode/skills`).
80    Opencode,
81    /// Hermes Agent (`$HERMES_HOME/skills`, `~/.hermes/skills`, or `.hermes/skills`).
82    Hermes,
83}
84
85/// Where to install the skill.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "lowercase")]
88pub enum SkillScope {
89    /// User-level skills directory.
90    Personal,
91    /// Current workspace's skills directory.
92    Workspace,
93}
94
95/// Options shared by every skill action.
96#[derive(Clone, Debug)]
97pub struct SkillOptions {
98    /// Agent target selection.
99    pub agent: SkillAgentSelection,
100    /// Skill scope.
101    pub scope: SkillScope,
102    /// Explicit skills directory; requires a single concrete `agent`.
103    pub skills_dir: Option<String>,
104    /// Overwrite or remove a skill that this tool did not manage.
105    pub force: bool,
106}
107
108/// The skill action to perform.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum SkillAction {
111    /// Report whether the skill is installed, valid, managed, and current.
112    Status,
113    /// Install (or refresh) the skill.
114    Install,
115    /// Remove a managed skill.
116    Uninstall,
117}
118
119/// Per-target outcome of `status` / `install`.
120#[derive(Clone, Debug, Serialize)]
121pub struct SkillTargetStatus {
122    /// The agent this target belongs to.
123    pub agent: SkillAgent,
124    /// The scope this target belongs to.
125    pub scope: SkillScope,
126    /// Directory that holds skill folders.
127    pub skills_dir: PathBuf,
128    /// Directory for this skill under `skills_dir`.
129    pub skill_dir: PathBuf,
130    /// Full path to the target `SKILL.md`.
131    pub skill_path: PathBuf,
132    /// Whether a skill file exists at `skill_path`.
133    pub installed: bool,
134    /// Whether the installed file was generated by this tool (or is byte-equal to the bundle).
135    pub managed: bool,
136    /// Whether the installed file has valid front matter.
137    pub valid: bool,
138    /// Whether the installed content matches the bundled skill (up to date).
139    pub current: bool,
140    /// Front-matter validation error, when the installed file is invalid.
141    pub validation_error: Option<String>,
142}
143
144/// Per-target outcome of `uninstall`.
145#[derive(Clone, Debug, Serialize)]
146pub struct SkillUninstallStatus {
147    /// The agent this target belongs to.
148    pub agent: SkillAgent,
149    /// The scope this target belongs to.
150    pub scope: SkillScope,
151    /// Directory that holds skill folders.
152    pub skills_dir: PathBuf,
153    /// Directory for this skill under `skills_dir`.
154    pub skill_dir: PathBuf,
155    /// Full path to the target `SKILL.md`.
156    pub skill_path: PathBuf,
157    /// Whether the skill file itself was removed (false if nothing was installed).
158    pub removed: bool,
159    /// Bundled asset paths, relative to the skill directory, that were removed
160    /// with it. Empty for a single-file skill, and for a target that had
161    /// nothing installed.
162    pub assets_removed: Vec<String>,
163    /// Whether the skill's own directory outlived the uninstall — which happens
164    /// when it holds files this tool did not install. They are left untouched,
165    /// and saying so is the difference between a clean removal and one that
166    /// left something behind.
167    pub directory_retained: bool,
168}
169
170/// The result of a skill action. Serializes to the protocol shape, carrying a `code`
171/// discriminator (`skill_status` / `skill_install` / `skill_uninstall`).
172#[derive(Clone, Debug, Serialize)]
173#[serde(tag = "code")]
174pub enum SkillReport {
175    /// `status` result.
176    #[serde(rename = "skill_status")]
177    Status {
178        /// Skill name.
179        skill: String,
180        /// True when every target is installed.
181        installed_all: bool,
182        /// True when every target has valid front matter.
183        valid_all: bool,
184        /// True when every target is up to date with the bundle.
185        current_all: bool,
186        /// Per-target detail.
187        targets: Vec<SkillTargetStatus>,
188    },
189    /// `install` result.
190    #[serde(rename = "skill_install")]
191    Install {
192        /// Skill name.
193        skill: String,
194        /// Always true (install succeeded for every target).
195        installed: bool,
196        /// Per-target detail after writing.
197        targets: Vec<SkillTargetStatus>,
198        /// Operator hint.
199        hint: &'static str,
200    },
201    /// `uninstall` result.
202    #[serde(rename = "skill_uninstall")]
203    Uninstall {
204        /// Skill name.
205        skill: String,
206        /// True when at least one file was removed.
207        removed_any: bool,
208        /// Per-target detail.
209        targets: Vec<SkillUninstallStatus>,
210    },
211}
212
213/// What kind of failure a [`SkillError`] is, so a caller can pick an exit
214/// status and a stable code without parsing the message.
215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub enum SkillErrorKind {
217    /// The options cannot name a target: `--skills-dir` with every agent, or
218    /// no usable home directory. Nothing was touched.
219    Usage,
220    /// The embedding program's [`SkillSpec`] is malformed — a defect in the
221    /// program, not in the call.
222    InvalidSpec,
223    /// A target holds something afdata will not overwrite or remove: an
224    /// unmanaged or invalid skill, a symlink, a path outside the skills
225    /// directory.
226    Refused,
227    /// The filesystem failed while reading or writing a target.
228    Io,
229}
230
231impl SkillErrorKind {
232    /// The stable `error.code` for this kind.
233    #[must_use]
234    pub fn code(self) -> &'static str {
235        match self {
236            Self::Usage => "skill_usage_error",
237            Self::InvalidSpec => "skill_spec_invalid",
238            Self::Refused => "skill_refused",
239            Self::Io => "skill_io_failed",
240        }
241    }
242}
243
244/// A skill admin failure with an operator-facing message and optional hint.
245#[derive(Clone, Debug)]
246pub struct SkillError {
247    /// What kind of failure this is.
248    pub kind: SkillErrorKind,
249    /// What went wrong.
250    pub message: String,
251    /// Optional remediation hint.
252    pub hint: Option<String>,
253    /// Per-target report captured after a multi-target operation failed.
254    pub partial_report: Option<Box<SkillReport>>,
255}
256
257impl std::fmt::Display for SkillError {
258    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        formatter.write_str(&self.message)
260    }
261}
262
263impl std::error::Error for SkillError {}
264
265impl SkillError {
266    fn new(kind: SkillErrorKind, message: String, hint: Option<String>) -> Self {
267        Self {
268            kind,
269            message,
270            hint,
271            partial_report: None,
272        }
273    }
274
275    fn io(action: &str, err: std::io::Error) -> Self {
276        Self {
277            kind: SkillErrorKind::Io,
278            message: format!("{action} failed: {err}"),
279            hint: None,
280            partial_report: None,
281        }
282    }
283
284    /// A failed atomic installation, which names the step it failed at.
285    fn write_failed(action: &str, err: crate::atomic_file::AtomicError) -> Self {
286        // Only the closing directory fsync can fail with the new file already
287        // in place. Saying so is the difference between "nothing happened" and
288        // "it happened but may not survive a crash", and only the second one
289        // asks the operator to re-run.
290        let hint = err.commit_uncertain().then(|| {
291            "the file is installed but its durability is unconfirmed; re-run to be sure".to_string()
292        });
293        Self {
294            kind: SkillErrorKind::Io,
295            message: format!("{action} failed: {err}"),
296            hint,
297            partial_report: None,
298        }
299    }
300
301    fn with_partial_report(mut self, report: SkillReport) -> Self {
302        self.partial_report = Some(Box::new(report));
303        self
304    }
305}
306
307/// Install, uninstall, or report status of `spec`'s skill across the selected agent target(s).
308///
309/// Returns a typed [`SkillReport`] (caller serializes it for output) or a [`SkillError`].
310/// Does not touch stdout/stderr.
311pub fn run_skill_admin(
312    spec: &SkillSpec,
313    action: SkillAction,
314    options: &SkillOptions,
315) -> Result<SkillReport, SkillError> {
316    validate_spec(spec)?;
317    match action {
318        SkillAction::Status => status(spec, options),
319        SkillAction::Install => install(spec, options),
320        SkillAction::Uninstall => uninstall(spec, options),
321    }
322}
323
324fn status(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
325    let targets = resolve_targets(spec, options)?;
326    let mut statuses = Vec::with_capacity(targets.len());
327    for target in &targets {
328        statuses.push(target_status(spec, target)?);
329    }
330    Ok(SkillReport::Status {
331        skill: spec.name.to_string(),
332        installed_all: statuses.iter().all(|s| s.installed),
333        valid_all: statuses.iter().all(|s| s.valid),
334        current_all: statuses.iter().all(|s| s.current),
335        targets: statuses,
336    })
337}
338
339fn install(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
340    validate_skill_text(spec, spec.source, SkillErrorKind::InvalidSpec)?;
341    for asset in spec.assets {
342        validate_asset_path(asset.path)?;
343    }
344    let targets = resolve_targets(spec, options)?;
345    let content = managed_skill_contents(spec);
346    preflight_install_targets(spec, options, &targets)?;
347    for target in &targets {
348        if let Err(err) = std::fs::create_dir_all(&target.skill_dir)
349            .map_err(|e| SkillError::io("create skill dir", e))
350        {
351            return Err(err.with_partial_report(install_report_lossy(spec, &targets, false)));
352        }
353    }
354    install_targets(spec, &targets, &content)
355}
356
357fn preflight_install_targets(
358    spec: &SkillSpec,
359    options: &SkillOptions,
360    targets: &[SkillTarget],
361) -> Result<(), SkillError> {
362    let mut failures = Vec::new();
363    let mut escaped = false;
364    for target in targets {
365        // Before anything about the file itself: nothing on the way down to it
366        // may be a link out of the skills directory.
367        if let Err(err) = containment_failure(spec, target) {
368            failures.push(err);
369            escaped = true;
370            continue;
371        }
372        // An asset path is checked before anything is written, for the same
373        // reason the skill file is: a directory found there halfway through
374        // leaves `SKILL.md` installed beside a missing reference, and a file the
375        // user put there is theirs until a managed `SKILL.md` claims the
376        // directory. Byte-equal content is already what install would write.
377        let skill_dir_managed = skill_path_file_type(&target.skill_path)?
378            .is_some_and(|kind| kind.is_file())
379            && is_managed_or_bundled_skill(spec, &target.skill_path)?;
380        for asset in spec.assets {
381            let dest = asset_target_path(&target.skill_dir, asset.path);
382            let Some(kind) = skill_path_file_type(&dest)? else {
383                continue;
384            };
385            if !kind.is_file() && !kind.is_symlink() {
386                failures.push(format!(
387                    "refusing to overwrite non-regular skill asset at {}",
388                    dest.display()
389                ));
390            } else if !skill_dir_managed
391                && !options.force
392                && (kind.is_symlink()
393                    || std::fs::read(&dest).map_err(|e| SkillError::io("read skill asset", e))?
394                        != asset.contents.as_bytes())
395            {
396                failures.push(format!(
397                    "refusing to overwrite unmanaged skill asset at {}",
398                    dest.display()
399                ));
400            }
401        }
402        if let Some(kind) = skill_path_file_type(&target.skill_path)? {
403            if kind.is_symlink() {
404                if !options.force {
405                    failures.push(format!(
406                        "refusing to overwrite symlinked skill at {}",
407                        target.skill_path.display()
408                    ));
409                }
410                continue;
411            }
412            if !kind.is_file() {
413                failures.push(format!(
414                    "refusing to overwrite non-regular skill at {}",
415                    target.skill_path.display()
416                ));
417                continue;
418            }
419            if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
420                failures.push(format!(
421                    "refusing to overwrite unmanaged skill at {}",
422                    target.skill_path.display()
423                ));
424            }
425        }
426    }
427    if failures.is_empty() {
428        return Ok(());
429    }
430    // `--force` replaces an unmanaged file inside the skills directory. It is
431    // not permission to write outside it, so a containment failure must not be
432    // answered with a hint that reads like one.
433    let hint = if escaped {
434        "--force does not permit writing outside the skills directory"
435    } else {
436        "pass --force to replace unmanaged files or symlinks"
437    };
438    Err(SkillError::new(
439        SkillErrorKind::Refused,
440        failures.join("; "),
441        Some(hint.to_string()),
442    )
443    .with_partial_report(install_report_lossy(spec, targets, false)))
444}
445
446fn install_targets(
447    spec: &SkillSpec,
448    targets: &[SkillTarget],
449    content: &str,
450) -> Result<SkillReport, SkillError> {
451    let mut installed = Vec::with_capacity(targets.len());
452    for target in targets {
453        if let Err(err) = write_skill_atomic(target, content) {
454            return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
455        }
456        if let Err(err) = install_target_assets(spec, target) {
457            return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
458        }
459        if let Err(err) = validate_installed_skill(spec, &target.skill_path) {
460            return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
461        }
462        match target_status(spec, target) {
463            Ok(status) => installed.push(status),
464            Err(err) => {
465                return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
466            }
467        }
468    }
469    Ok(SkillReport::Install {
470        skill: spec.name.to_string(),
471        installed: true,
472        targets: installed,
473        hint: "restart the agent so it reloads installed skills",
474    })
475}
476
477fn uninstall(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
478    let targets = resolve_targets(spec, options)?;
479    preflight_uninstall_targets(spec, options, &targets)?;
480    let mut removed = Vec::with_capacity(targets.len());
481    for target in &targets {
482        let Some(kind) = skill_path_file_type(&target.skill_path)? else {
483            removed.push(target_uninstall_status(target, false));
484            continue;
485        };
486        if !kind.is_file() && !kind.is_symlink() {
487            let err = SkillError::new(
488                SkillErrorKind::Refused,
489                format!(
490                    "refusing to remove non-regular skill at {}",
491                    target.skill_path.display()
492                ),
493                None,
494            );
495            return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
496        }
497        if let Err(err) = ensure_no_symlinked_dirs(&target.skills_dir, &target.skill_path) {
498            return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
499        }
500        // Assets go first and `SKILL.md` last, so `SKILL.md` keeps marking the
501        // directory as this tool's until everything else is gone. A target that
502        // fails partway therefore still reads as installed, and running
503        // uninstall again finishes the job instead of skipping a directory whose
504        // marker was already removed.
505        let assets_removed = match remove_target_assets(spec, target) {
506            Ok(assets_removed) => assets_removed,
507            Err(err) => {
508                return Err(
509                    err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed))
510                );
511            }
512        };
513        if let Err(err) =
514            std::fs::remove_file(&target.skill_path).map_err(|e| SkillError::io("remove skill", e))
515        {
516            return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
517        }
518        let _ = std::fs::remove_dir(&target.skill_dir);
519        removed.push(SkillUninstallStatus {
520            assets_removed,
521            ..target_uninstall_status(target, true)
522        });
523    }
524    Ok(SkillReport::Uninstall {
525        skill: spec.name.to_string(),
526        removed_any: removed.iter().any(|s| s.removed),
527        targets: removed,
528    })
529}
530
531fn preflight_uninstall_targets(
532    spec: &SkillSpec,
533    options: &SkillOptions,
534    targets: &[SkillTarget],
535) -> Result<(), SkillError> {
536    let mut failures = Vec::new();
537    let mut escaped = false;
538    for target in targets {
539        if let Err(err) = containment_failure(spec, target) {
540            failures.push(err);
541            escaped = true;
542            continue;
543        }
544        // A managed asset path now holding a directory is checked here rather
545        // than discovered halfway through: by then the skill file is gone and
546        // the target is half-removed.
547        for asset in spec.assets {
548            let dest = asset_target_path(&target.skill_dir, asset.path);
549            if let Some(kind) = skill_path_file_type(&dest)?
550                && kind.is_dir()
551            {
552                failures.push(format!(
553                    "refusing to remove bundled asset {} because it is a directory",
554                    dest.display()
555                ));
556            }
557        }
558        let Some(kind) = skill_path_file_type(&target.skill_path)? else {
559            continue;
560        };
561        if kind.is_symlink() {
562            if !options.force {
563                failures.push(format!(
564                    "refusing to remove symlinked skill at {}",
565                    target.skill_path.display()
566                ));
567            }
568            continue;
569        }
570        if !kind.is_file() {
571            failures.push(format!(
572                "refusing to remove non-regular skill at {}",
573                target.skill_path.display()
574            ));
575            continue;
576        }
577        if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
578            failures.push(format!(
579                "refusing to remove unmanaged skill at {}",
580                target.skill_path.display()
581            ));
582        }
583    }
584    if failures.is_empty() {
585        return Ok(());
586    }
587    let hint = if escaped {
588        "--force does not permit removing files outside the skills directory".to_string()
589    } else {
590        format!(
591            "only skills generated by {} skill install can be removed without --force",
592            spec.marker_slug
593        )
594    };
595    Err(
596        SkillError::new(SkillErrorKind::Refused, failures.join("; "), Some(hint))
597            .with_partial_report(uninstall_report_lossy(spec, targets, &[])),
598    )
599}
600
601struct SkillTarget {
602    agent: SkillAgent,
603    scope: SkillScope,
604    skills_dir: PathBuf,
605    skill_dir: PathBuf,
606    skill_path: PathBuf,
607}
608
609fn resolve_targets(
610    spec: &SkillSpec,
611    options: &SkillOptions,
612) -> Result<Vec<SkillTarget>, SkillError> {
613    if options.skills_dir.is_some() && options.agent == SkillAgentSelection::All {
614        return Err(SkillError::new(
615            SkillErrorKind::Usage,
616            "--skills-dir requires a single --agent".to_string(),
617            Some("custom skills directories are ambiguous when --agent all is used".to_string()),
618        ));
619    }
620    match (options.agent, options.scope) {
621        (SkillAgentSelection::All, SkillScope::Personal) => Ok(vec![
622            resolve_target(spec, SkillAgent::Codex, SkillScope::Personal, None)?,
623            resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Personal, None)?,
624            resolve_target(spec, SkillAgent::Opencode, SkillScope::Personal, None)?,
625            resolve_target(spec, SkillAgent::Hermes, SkillScope::Personal, None)?,
626        ]),
627        (SkillAgentSelection::All, SkillScope::Workspace) => Ok(vec![
628            resolve_target(spec, SkillAgent::Codex, SkillScope::Workspace, None)?,
629            resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Workspace, None)?,
630            resolve_target(spec, SkillAgent::Opencode, SkillScope::Workspace, None)?,
631            resolve_target(spec, SkillAgent::Hermes, SkillScope::Workspace, None)?,
632        ]),
633        (SkillAgentSelection::Codex, SkillScope::Workspace) => Ok(vec![resolve_target(
634            spec,
635            SkillAgent::Codex,
636            SkillScope::Workspace,
637            options.skills_dir.as_deref(),
638        )?]),
639        (SkillAgentSelection::Codex, SkillScope::Personal) => Ok(vec![resolve_target(
640            spec,
641            SkillAgent::Codex,
642            SkillScope::Personal,
643            options.skills_dir.as_deref(),
644        )?]),
645        (SkillAgentSelection::ClaudeCode, scope) => Ok(vec![resolve_target(
646            spec,
647            SkillAgent::ClaudeCode,
648            scope,
649            options.skills_dir.as_deref(),
650        )?]),
651        (SkillAgentSelection::Opencode, scope) => Ok(vec![resolve_target(
652            spec,
653            SkillAgent::Opencode,
654            scope,
655            options.skills_dir.as_deref(),
656        )?]),
657        (SkillAgentSelection::Hermes, scope) => Ok(vec![resolve_target(
658            spec,
659            SkillAgent::Hermes,
660            scope,
661            options.skills_dir.as_deref(),
662        )?]),
663    }
664}
665
666fn resolve_target(
667    spec: &SkillSpec,
668    agent: SkillAgent,
669    scope: SkillScope,
670    skills_dir: Option<&str>,
671) -> Result<SkillTarget, SkillError> {
672    let skills_dir = match skills_dir {
673        Some(dir) => expand_tilde(dir)?,
674        None => default_skills_dir(agent, scope)?,
675    };
676    let skill_dir = skills_dir.join(spec.name);
677    let skill_path = skill_dir.join(SKILL_FILE_NAME);
678    Ok(SkillTarget {
679        agent,
680        scope,
681        skills_dir,
682        skill_dir,
683        skill_path,
684    })
685}
686
687fn default_skills_dir(agent: SkillAgent, scope: SkillScope) -> Result<PathBuf, SkillError> {
688    match (agent, scope) {
689        (SkillAgent::Codex, SkillScope::Personal) => {
690            if let Some(codex_home) = env_dir("CODEX_HOME") {
691                Ok(codex_home.join("skills"))
692            } else {
693                Ok(home_dir()?.join(".codex").join("skills"))
694            }
695        }
696        (SkillAgent::Codex, SkillScope::Workspace) => workspace_skills_dir(".codex"),
697        (SkillAgent::ClaudeCode, SkillScope::Personal) => {
698            Ok(home_dir()?.join(".claude").join("skills"))
699        }
700        (SkillAgent::ClaudeCode, SkillScope::Workspace) => workspace_skills_dir(".claude"),
701        (SkillAgent::Opencode, SkillScope::Personal) => {
702            if let Some(xdg) = env_dir("XDG_CONFIG_HOME") {
703                Ok(xdg.join("opencode").join("skills"))
704            } else {
705                Ok(home_dir()?.join(".config").join("opencode").join("skills"))
706            }
707        }
708        (SkillAgent::Opencode, SkillScope::Workspace) => workspace_skills_dir(".opencode"),
709        (SkillAgent::Hermes, SkillScope::Personal) => {
710            if let Some(hermes_home) = env_dir("HERMES_HOME") {
711                Ok(hermes_home.join("skills"))
712            } else {
713                Ok(home_dir()?.join(".hermes").join("skills"))
714            }
715        }
716        (SkillAgent::Hermes, SkillScope::Workspace) => workspace_skills_dir(".hermes"),
717    }
718}
719
720fn workspace_skills_dir(agent_dir: &str) -> Result<PathBuf, SkillError> {
721    std::env::current_dir()
722        .map(|dir| dir.join(agent_dir).join("skills"))
723        .map_err(|e| SkillError::io("resolve current directory", e))
724}
725
726fn target_status(spec: &SkillSpec, target: &SkillTarget) -> Result<SkillTargetStatus, SkillError> {
727    // Reported rather than raised: one target reachable only through a link
728    // must not cost the operator the status of every other target. Nothing is
729    // read through it, so this says what is wrong without following it.
730    if let Err(message) = containment_failure(spec, target) {
731        return Ok(SkillTargetStatus {
732            agent: target.agent,
733            scope: target.scope,
734            skills_dir: target.skills_dir.clone(),
735            skill_dir: target.skill_dir.clone(),
736            skill_path: target.skill_path.clone(),
737            installed: false,
738            managed: false,
739            valid: false,
740            current: false,
741            validation_error: Some(message),
742        });
743    }
744    let Some(kind) = skill_path_file_type(&target.skill_path)? else {
745        return Ok(SkillTargetStatus {
746            agent: target.agent,
747            scope: target.scope,
748            skills_dir: target.skills_dir.clone(),
749            skill_dir: target.skill_dir.clone(),
750            skill_path: target.skill_path.clone(),
751            installed: false,
752            managed: false,
753            valid: false,
754            current: false,
755            validation_error: None,
756        });
757    };
758    let installed = true;
759    let mut valid = false;
760    let mut current = false;
761    let mut validation_error = None;
762    let mut managed = false;
763    if kind.is_symlink() {
764        validation_error = Some("target SKILL.md is a symlink; refusing to follow it".to_string());
765    } else if kind.is_file() {
766        let text = std::fs::read_to_string(&target.skill_path)
767            .map_err(|e| SkillError::io("read skill", e))?;
768        managed = skill_text_is_managed_or_bundled(spec, &text);
769        current = normalized_content_hash(spec, &text) == source_hash(spec)
770            && assets_current(spec, &target.skill_dir);
771        match validate_skill_text(spec, &text, SkillErrorKind::Refused) {
772            Ok(()) => valid = true,
773            Err(err) => validation_error = Some(err.message),
774        }
775    } else {
776        validation_error = Some("target SKILL.md is not a regular file".to_string());
777    }
778    Ok(SkillTargetStatus {
779        agent: target.agent,
780        scope: target.scope,
781        skills_dir: target.skills_dir.clone(),
782        skill_dir: target.skill_dir.clone(),
783        skill_path: target.skill_path.clone(),
784        installed,
785        managed,
786        valid,
787        current,
788        validation_error,
789    })
790}
791
792fn target_uninstall_status(target: &SkillTarget, removed: bool) -> SkillUninstallStatus {
793    SkillUninstallStatus {
794        agent: target.agent,
795        scope: target.scope,
796        skills_dir: target.skills_dir.clone(),
797        skill_dir: target.skill_dir.clone(),
798        skill_path: target.skill_path.clone(),
799        removed,
800        assets_removed: Vec::new(),
801        directory_retained: std::fs::symlink_metadata(&target.skill_dir).is_ok(),
802    }
803}
804
805fn target_status_lossy(spec: &SkillSpec, target: &SkillTarget) -> SkillTargetStatus {
806    target_status(spec, target).unwrap_or_else(|err| {
807        let installed = std::fs::symlink_metadata(&target.skill_path).is_ok();
808        SkillTargetStatus {
809            agent: target.agent,
810            scope: target.scope,
811            skills_dir: target.skills_dir.clone(),
812            skill_dir: target.skill_dir.clone(),
813            skill_path: target.skill_path.clone(),
814            installed,
815            managed: false,
816            valid: false,
817            current: false,
818            validation_error: Some(err.message),
819        }
820    })
821}
822
823fn install_report_lossy(spec: &SkillSpec, targets: &[SkillTarget], installed: bool) -> SkillReport {
824    SkillReport::Install {
825        skill: spec.name.to_string(),
826        installed,
827        targets: targets
828            .iter()
829            .map(|target| target_status_lossy(spec, target))
830            .collect(),
831        hint: "restart the agent so it reloads installed skills",
832    }
833}
834
835fn uninstall_report_lossy(
836    spec: &SkillSpec,
837    targets: &[SkillTarget],
838    removed: &[SkillUninstallStatus],
839) -> SkillReport {
840    let mut statuses = Vec::with_capacity(targets.len());
841    for target in targets {
842        if let Some(status) = removed.iter().find(|status| {
843            status.agent == target.agent
844                && status.scope == target.scope
845                && status.skill_path == target.skill_path
846        }) {
847            statuses.push(status.clone());
848        } else {
849            statuses.push(target_uninstall_status(target, false));
850        }
851    }
852    SkillReport::Uninstall {
853        skill: spec.name.to_string(),
854        removed_any: statuses.iter().any(|status| status.removed),
855        targets: statuses,
856    }
857}
858
859fn generated_by(spec: &SkillSpec) -> String {
860    format!("Generated by {} skill install", spec.marker_slug)
861}
862
863fn text_hash(text: &str) -> String {
864    let mut hash = FNV1A64_OFFSET;
865    for byte in text.as_bytes() {
866        hash ^= u64::from(*byte);
867        hash = hash.wrapping_mul(FNV1A64_PRIME);
868    }
869    format!("{hash:016x}")
870}
871
872fn source_hash(spec: &SkillSpec) -> String {
873    normalized_content_hash(spec, spec.source)
874}
875
876fn normalized_content_hash(spec: &SkillSpec, text: &str) -> String {
877    text_hash(&normalize_skill_text(spec, text))
878}
879
880fn managed_marker_block(spec: &SkillSpec) -> String {
881    let slug = spec.marker_slug;
882    format!(
883        "<!--\n{}\n{}-managed-skill: true\n{}-managed-skill-name: {}\n{}-managed-skill-owner: {}\n{}-managed-skill-content-hash-fnv1a64: {}\n-->",
884        generated_by(spec),
885        slug,
886        slug,
887        spec.name,
888        slug,
889        slug,
890        slug,
891        source_hash(spec)
892    )
893}
894
895fn managed_skill_contents(spec: &SkillSpec) -> String {
896    let block = managed_marker_block(spec);
897    let mut lines = spec.source.lines();
898    let mut output = String::new();
899    let mut inserted = false;
900    if let Some(first) = lines.next() {
901        output.push_str(first);
902        output.push('\n');
903    }
904    for line in lines {
905        output.push_str(line);
906        output.push('\n');
907        if !inserted && line.trim() == "---" {
908            output.push_str(&block);
909            output.push_str("\n\n");
910            inserted = true;
911        }
912    }
913    if !inserted {
914        output.push_str(&block);
915        output.push('\n');
916    }
917    output
918}
919
920fn validate_installed_skill(spec: &SkillSpec, path: &Path) -> Result<(), SkillError> {
921    let text =
922        std::fs::read_to_string(path).map_err(|e| SkillError::io("read installed skill", e))?;
923    // The source was validated before writing, so a rejection here means the
924    // file on disk is not what was written.
925    validate_skill_text(spec, &text, SkillErrorKind::Io)
926}
927
928fn validate_skill_text(
929    spec: &SkillSpec,
930    text: &str,
931    kind: SkillErrorKind,
932) -> Result<(), SkillError> {
933    crate::skill::validate_skill_named(text, spec.name).map_err(|err| {
934        SkillError::new(
935            kind,
936            format!("invalid {} skill front matter: {err}", spec.title),
937            Some(format!(
938                "make SKILL.md metadata conform to the Agent Skills specification and set name to {}",
939                spec.name
940            )),
941        )
942    })?;
943    Ok(())
944}
945
946fn is_managed_or_bundled_skill(spec: &SkillSpec, path: &Path) -> Result<bool, SkillError> {
947    let Some(kind) = skill_path_file_type(path)? else {
948        return Ok(false);
949    };
950    if kind.is_symlink() {
951        return Err(SkillError::new(
952            SkillErrorKind::Refused,
953            format!("refusing to inspect symlinked skill at {}", path.display()),
954            Some("pass --force to replace or remove the symlink itself".to_string()),
955        ));
956    }
957    let text = std::fs::read_to_string(path).map_err(|e| SkillError::io("read skill", e))?;
958    Ok(skill_text_is_managed_or_bundled(spec, &text))
959}
960
961fn skill_text_is_managed_or_bundled(spec: &SkillSpec, text: &str) -> bool {
962    skill_text_has_managed_identity(spec, text)
963        || normalize_skill_text(spec, text) == normalize_skill_text(spec, spec.source)
964}
965
966fn skill_text_has_managed_identity(spec: &SkillSpec, text: &str) -> bool {
967    for block in html_comment_blocks(text) {
968        if managed_marker_block_has_identity(spec, &block) {
969            return true;
970        }
971    }
972    false
973}
974
975fn managed_marker_block_has_identity(spec: &SkillSpec, block: &str) -> bool {
976    let slug = spec.marker_slug;
977    let generated_by_line = generated_by(spec);
978    let managed_line = format!("{slug}-managed-skill: true");
979    let name_line = format!("{slug}-managed-skill-name: {}", spec.name);
980    let owner_line = format!("{slug}-managed-skill-owner: {slug}");
981    let mut has_generated_by = false;
982    let mut has_managed = false;
983    let mut has_name = false;
984    let mut has_owner = false;
985    for line in block.replace("\r\n", "\n").lines() {
986        let trimmed = line.trim();
987        has_generated_by |= trimmed == generated_by_line;
988        has_managed |= trimmed == managed_line;
989        has_name |= trimmed == name_line;
990        has_owner |= trimmed == owner_line;
991    }
992    has_generated_by && has_managed && has_name && has_owner
993}
994
995fn html_comment_blocks(text: &str) -> Vec<String> {
996    let normalized = text.replace("\r\n", "\n");
997    let mut blocks = Vec::new();
998    let mut lines = normalized.lines();
999    while let Some(line) = lines.next() {
1000        if line.trim() != "<!--" {
1001            continue;
1002        }
1003        let mut block = vec![line.to_string()];
1004        for next in lines.by_ref() {
1005            block.push(next.to_string());
1006            if next.trim() == "-->" {
1007                blocks.push(block.join("\n"));
1008                break;
1009            }
1010        }
1011    }
1012    blocks
1013}
1014
1015fn strip_managed_marker_blocks(spec: &SkillSpec, text: &str) -> String {
1016    let normalized = text.replace("\r\n", "\n");
1017    let mut output = Vec::new();
1018    let mut lines = normalized.lines();
1019    while let Some(line) = lines.next() {
1020        if line.trim() != "<!--" {
1021            output.push(line.to_string());
1022            continue;
1023        }
1024        let mut block = vec![line.to_string()];
1025        let mut closed = false;
1026        for next in lines.by_ref() {
1027            block.push(next.to_string());
1028            if next.trim() == "-->" {
1029                closed = true;
1030                break;
1031            }
1032        }
1033        if closed {
1034            let block_text = block.join("\n");
1035            if managed_marker_block_has_identity(spec, &block_text) {
1036                continue;
1037            }
1038        }
1039        output.extend(block);
1040    }
1041    output.join("\n")
1042}
1043
1044fn normalize_skill_text(spec: &SkillSpec, text: &str) -> String {
1045    let text = strip_managed_marker_blocks(spec, text);
1046    // Drop the managed-marker blocks, then collapse runs of blank lines to one so that the blank
1047    // line `managed_skill_contents` inserts after the marker block does not make a managed install
1048    // compare unequal to the bundled source.
1049    let mut out: Vec<&str> = Vec::new();
1050    for line in text.lines() {
1051        let trimmed = line.trim();
1052        if trimmed.is_empty() && out.last().is_some_and(|prev| prev.trim().is_empty()) {
1053            continue;
1054        }
1055        out.push(line);
1056    }
1057    out.join("\n").trim().to_string()
1058}
1059
1060fn validate_spec(spec: &SkillSpec) -> Result<(), SkillError> {
1061    validate_slug("skill name", spec.name)?;
1062    validate_slug("marker slug", spec.marker_slug)?;
1063    validate_skill_text(spec, spec.source, SkillErrorKind::InvalidSpec)
1064}
1065
1066fn validate_slug(field: &str, value: &str) -> Result<(), SkillError> {
1067    if slug_is_valid(value) {
1068        return Ok(());
1069    }
1070    Err(SkillError::new(
1071        SkillErrorKind::InvalidSpec,
1072        format!(
1073            "invalid {field} {value:?}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]"
1074        ),
1075        Some("use lowercase ASCII letters, digits, and single hyphen-separated words".to_string()),
1076    ))
1077}
1078
1079fn slug_is_valid(value: &str) -> bool {
1080    let bytes = value.as_bytes();
1081    if bytes.is_empty() {
1082        return false;
1083    }
1084    fn is_lower_alnum(byte: u8) -> bool {
1085        byte.is_ascii_lowercase() || byte.is_ascii_digit()
1086    }
1087    if !is_lower_alnum(bytes[0]) || !is_lower_alnum(bytes[bytes.len() - 1]) {
1088        return false;
1089    }
1090    bytes
1091        .iter()
1092        .all(|byte| is_lower_alnum(*byte) || *byte == b'-')
1093}
1094
1095fn skill_path_file_type(path: &Path) -> Result<Option<std::fs::FileType>, SkillError> {
1096    match std::fs::symlink_metadata(path) {
1097        Ok(metadata) => Ok(Some(metadata.file_type())),
1098        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1099        Err(err) => Err(SkillError::io("inspect skill path", err)),
1100    }
1101}
1102
1103/// Refuse to reach `path` through a symbolic link somewhere below `root`.
1104///
1105/// Everything a skill install writes goes to a fixed name under a skills root
1106/// the caller named, and the final file's own type is judged separately — a
1107/// symlink there is replaced, never followed. What nothing else judges is the
1108/// directories in between. `create_dir_all` walks straight through a link
1109/// without complaint, so a workspace that ships `.claude/skills/<name>` as a
1110/// link to another directory would silently redirect a fixed-name write, and
1111/// the matching uninstall would delete files wherever it pointed.
1112///
1113/// `--force` does not relax this. It permits replacing an unmanaged file
1114/// *inside* the root; it is not permission to write outside it.
1115///
1116/// `root` itself is deliberately not checked: the caller chose it, and a home
1117/// directory that is a link onto another volume is ordinary.
1118///
1119/// This is a check, so a link swapped in between it and the write that follows
1120/// is still possible. Closing that window needs directory-handle-relative
1121/// opens, which Rust's standard library does not expose; an attacker able to
1122/// win that race can already write inside the tree being installed into.
1123fn ensure_no_symlinked_dirs(root: &Path, path: &Path) -> Result<(), SkillError> {
1124    let Ok(relative) = path.strip_prefix(root) else {
1125        return Err(SkillError::new(
1126            SkillErrorKind::Refused,
1127            format!(
1128                "{} is not inside the skills directory {}",
1129                path.display(),
1130                root.display()
1131            ),
1132            Some("pass --skills-dir to install where you mean to".to_string()),
1133        ));
1134    };
1135    let mut current = root.to_path_buf();
1136    let mut components = relative.components().peekable();
1137    while let Some(component) = components.next() {
1138        current.push(component);
1139        // The last component is the file itself, and installing over it is a
1140        // rename that replaces a link rather than following it.
1141        if components.peek().is_none() {
1142            break;
1143        }
1144        match std::fs::symlink_metadata(&current) {
1145            Ok(metadata) if metadata.file_type().is_symlink() => {
1146                return Err(SkillError::new(
1147                    SkillErrorKind::Refused,
1148                    format!(
1149                        "refusing to reach {} through the symlinked directory {}",
1150                        path.display(),
1151                        current.display()
1152                    ),
1153                    Some(
1154                        "replace the link with a real directory, or pass --skills-dir".to_string(),
1155                    ),
1156                ));
1157            }
1158            Ok(_) => {}
1159            // Nothing exists below here yet, so nothing below here is a link.
1160            Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
1161            Err(err) => return Err(SkillError::io("inspect skill path", err)),
1162        }
1163    }
1164    Ok(())
1165}
1166
1167/// The containment message for a target whose skill file or bundled assets
1168/// cannot be reached without following a link out of the skills directory, or
1169/// `Ok(())` when every one of them stays inside it.
1170fn containment_failure(spec: &SkillSpec, target: &SkillTarget) -> Result<(), String> {
1171    let mut paths = vec![target.skill_path.clone()];
1172    paths.extend(
1173        spec.assets
1174            .iter()
1175            .map(|asset| asset_target_path(&target.skill_dir, asset.path)),
1176    );
1177    for path in paths {
1178        if let Err(err) = ensure_no_symlinked_dirs(&target.skills_dir, &path) {
1179            return Err(err.message);
1180        }
1181    }
1182    Ok(())
1183}
1184
1185fn write_skill_atomic(target: &SkillTarget, content: &str) -> Result<(), SkillError> {
1186    ensure_no_symlinked_dirs(&target.skills_dir, &target.skill_path)?;
1187    write_file_atomic(&target.skill_path, content, "install skill file")
1188}
1189
1190/// Atomically install `content` at `path` through the crate's one atomic file
1191/// installation, so a reader never sees a partial file, a pre-existing symlink
1192/// is replaced rather than followed, and the new directory entry is synced
1193/// before this reports success.
1194///
1195/// An existing regular file keeps its own permissions; a file created here is
1196/// world-readable, which is what a skill an agent must load has to be.
1197fn write_file_atomic(path: &Path, content: &str, action: &str) -> Result<(), SkillError> {
1198    let preserved = match std::fs::symlink_metadata(path) {
1199        Ok(metadata) if metadata.file_type().is_file() => Some(metadata.permissions()),
1200        _ => None,
1201    };
1202    let unix_mode = if preserved.is_some() {
1203        None
1204    } else {
1205        Some(0o644)
1206    };
1207    crate::atomic_file::install(
1208        path,
1209        crate::atomic_file::AtomicInstall::replacing(content.as_bytes())
1210            .with_permissions(preserved)
1211            .with_unix_mode(unix_mode),
1212    )
1213    .map_err(|err| SkillError::write_failed(action, err))
1214}
1215
1216/// Reject an asset path that is absolute or carries a `.`/`..`/empty segment, so
1217/// a bundled asset can only ever land inside the skill directory.
1218fn validate_asset_path(path: &str) -> Result<(), SkillError> {
1219    let bad = path.is_empty()
1220        || Path::new(path).is_absolute()
1221        || path
1222            .split(['/', '\\'])
1223            .any(|seg| seg.is_empty() || seg == "." || seg == "..");
1224    if bad {
1225        return Err(SkillError::new(
1226            SkillErrorKind::InvalidSpec,
1227            format!("invalid skill asset path: {path}"),
1228            Some(
1229                "asset paths must be relative to the skill directory with no `.`/`..` segments"
1230                    .to_string(),
1231            ),
1232        ));
1233    }
1234    Ok(())
1235}
1236
1237/// Resolve an asset's `/`-separated relative path to an absolute path under
1238/// `skill_dir`, one segment at a time so it works on every platform.
1239fn asset_target_path(skill_dir: &Path, rel_path: &str) -> PathBuf {
1240    let mut out = skill_dir.to_path_buf();
1241    for seg in rel_path.split(['/', '\\']) {
1242        out.push(seg);
1243    }
1244    out
1245}
1246
1247/// Write every bundled asset under the target skill directory, creating parent
1248/// directories as needed. Paths are assumed already validated by `install`.
1249fn install_target_assets(spec: &SkillSpec, target: &SkillTarget) -> Result<(), SkillError> {
1250    for asset in spec.assets {
1251        let dest = asset_target_path(&target.skill_dir, asset.path);
1252        // Re-checked here rather than only in the preflight: the asset tree is
1253        // several directories deep, and this is the last moment before the
1254        // write that could land outside the root.
1255        ensure_no_symlinked_dirs(&target.skills_dir, &dest)?;
1256        if let Some(parent) = dest.parent() {
1257            std::fs::create_dir_all(parent)
1258                .map_err(|e| SkillError::io("create skill asset dir", e))?;
1259        }
1260        write_file_atomic(&dest, asset.contents, "install skill asset")?;
1261    }
1262    Ok(())
1263}
1264
1265/// True when every bundled asset is present on disk and byte-equal to the
1266/// bundle. A single-file skill (`assets == []`) is trivially current.
1267fn assets_current(spec: &SkillSpec, skill_dir: &Path) -> bool {
1268    spec.assets.iter().all(|asset| {
1269        let dest = asset_target_path(skill_dir, asset.path);
1270        std::fs::read_to_string(&dest)
1271            .map(|text| text == asset.contents)
1272            .unwrap_or(false)
1273    })
1274}
1275
1276/// Remove the bundled asset files, then their now-empty parent directories,
1277/// deepest first.
1278///
1279/// The two halves are not the same promise, and used to be reported as if they
1280/// were. Every managed *file* must go: one that survives is a stale reference
1281/// the next `status` cannot see, because that reads `SKILL.md` — which is
1282/// already gone — and calls the target uninstalled. So a failed `remove_file`
1283/// is returned, not swallowed. A *directory* that refuses to go is the normal
1284/// case rather than a fault: `remove_dir` fails exactly when the user put
1285/// something of their own in it, and their file is not this tool's to delete.
1286/// That outcome is reported instead of raising.
1287fn remove_target_assets(spec: &SkillSpec, target: &SkillTarget) -> Result<Vec<String>, SkillError> {
1288    let mut removed = Vec::new();
1289    let mut dirs: Vec<PathBuf> = Vec::new();
1290    for asset in spec.assets {
1291        let dest = asset_target_path(&target.skill_dir, asset.path);
1292        ensure_no_symlinked_dirs(&target.skills_dir, &dest)?;
1293        match std::fs::remove_file(&dest) {
1294            Ok(()) => removed.push(asset.path.to_string()),
1295            // Already absent: the end state this asked for.
1296            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1297            Err(err) => {
1298                return Err(SkillError::io(
1299                    &format!("remove bundled skill asset {}", asset.path),
1300                    err,
1301                ));
1302            }
1303        }
1304        let mut dir = dest.parent().map(Path::to_path_buf);
1305        while let Some(current) = dir {
1306            if current == target.skill_dir || !current.starts_with(&target.skill_dir) {
1307                break;
1308            }
1309            if !dirs.contains(&current) {
1310                dirs.push(current.clone());
1311            }
1312            dir = current.parent().map(Path::to_path_buf);
1313        }
1314    }
1315    dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
1316    for dir in dirs {
1317        let _ = std::fs::remove_dir(&dir);
1318    }
1319    Ok(removed)
1320}
1321
1322/// An environment variable naming a directory, ignored when empty or
1323/// relative: either would put a personal skill under the current directory.
1324fn env_dir(name: &str) -> Option<PathBuf> {
1325    std::env::var_os(name)
1326        .map(PathBuf::from)
1327        .filter(|path| path.is_absolute())
1328}
1329
1330fn home_dir() -> Result<PathBuf, SkillError> {
1331    env_dir("HOME")
1332        .or_else(|| env_dir("USERPROFILE"))
1333        .ok_or_else(|| {
1334            SkillError::new(
1335                SkillErrorKind::Usage,
1336                "cannot determine home directory".to_string(),
1337                Some("pass --skills-dir explicitly".to_string()),
1338            )
1339        })
1340}
1341
1342fn expand_tilde(input: &str) -> Result<PathBuf, SkillError> {
1343    if input == "~" {
1344        return home_dir();
1345    }
1346    if let Some(rest) = input.strip_prefix("~/") {
1347        return Ok(home_dir()?.join(rest));
1348    }
1349    Ok(PathBuf::from(input))
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354    use super::*;
1355    use std::time::{SystemTime, UNIX_EPOCH};
1356
1357    const SKILL_SOURCE: &str =
1358        "---\nname: agent-first-test\ndescription: test skill\n---\n\n# Body\n\nrules.\n";
1359
1360    fn spec() -> SkillSpec<'static> {
1361        SkillSpec {
1362            name: "agent-first-test",
1363            source: SKILL_SOURCE,
1364            title: "Agent-First Test",
1365            marker_slug: "aftest",
1366            assets: &[],
1367        }
1368    }
1369
1370    fn managed_skill_with_body(body: &str) -> String {
1371        format!(
1372            "---\nname: agent-first-test\ndescription: test skill\n---\n{}\n\n{body}",
1373            managed_marker_block(&spec())
1374        )
1375    }
1376
1377    fn temp_skills_dir(name: &str) -> PathBuf {
1378        let suffix = SystemTime::now()
1379            .duration_since(UNIX_EPOCH)
1380            .map(|d| d.as_nanos())
1381            .unwrap_or(0);
1382        std::env::temp_dir().join(format!(
1383            "afdata_skill_{name}_{}_{}",
1384            std::process::id(),
1385            suffix
1386        ))
1387    }
1388
1389    fn options(agent: SkillAgentSelection, dir: &Path, force: bool) -> SkillOptions {
1390        SkillOptions {
1391            agent,
1392            scope: SkillScope::Personal,
1393            skills_dir: Some(dir.to_string_lossy().to_string()),
1394            force,
1395        }
1396    }
1397
1398    fn custom_target(agent: SkillAgent, dir: &Path) -> SkillTarget {
1399        let skill_dir = dir.join("agent-first-test");
1400        SkillTarget {
1401            agent,
1402            scope: SkillScope::Personal,
1403            skills_dir: dir.to_path_buf(),
1404            skill_path: skill_dir.join(SKILL_FILE_NAME),
1405            skill_dir,
1406        }
1407    }
1408
1409    #[test]
1410    fn validates_bundled_frontmatter() {
1411        assert!(crate::skill::validate_skill_named(SKILL_SOURCE, "agent-first-test").is_ok());
1412    }
1413
1414    #[test]
1415    fn rejects_unquoted_colon_space() {
1416        let bad = "---\nname: x\ndescription: broken: yaml\n---\n";
1417        assert!(crate::skill::validate_skill(bad).is_err());
1418    }
1419
1420    fn install_status_uninstall_for(agent: SkillAgentSelection, expect: SkillAgent, tag: &str) {
1421        let dir = temp_skills_dir(tag);
1422        let opts = options(agent, &dir, false);
1423        let skill_path = dir.join("agent-first-test").join(SKILL_FILE_NAME);
1424
1425        let installed = run_skill_admin(&spec(), SkillAction::Install, &opts);
1426        assert!(installed.is_ok());
1427        assert!(skill_path.is_file());
1428        let text = std::fs::read_to_string(&skill_path).unwrap_or_default();
1429        assert!(text.contains(&managed_marker_block(&spec())));
1430        assert!(text.contains("aftest-managed-skill-name: agent-first-test"));
1431        assert!(text.contains("aftest-managed-skill-owner: aftest"));
1432        assert!(text.contains("aftest-managed-skill-content-hash-fnv1a64:"));
1433        assert!(!text.contains("aftest-managed-skill-source-hash-fnv1a64:"));
1434
1435        let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1436        assert!(status.is_ok());
1437        if let Ok(SkillReport::Status {
1438            installed_all,
1439            valid_all,
1440            current_all,
1441            targets,
1442            ..
1443        }) = status
1444        {
1445            assert!(installed_all);
1446            assert!(valid_all);
1447            assert!(current_all);
1448            assert_eq!(targets.first().map(|t| t.agent), Some(expect));
1449            assert_eq!(targets.first().map(|t| t.current), Some(true));
1450        }
1451
1452        let removed = run_skill_admin(&spec(), SkillAction::Uninstall, &opts);
1453        assert!(removed.is_ok());
1454        assert!(!skill_path.exists());
1455        let _ = std::fs::remove_dir_all(dir);
1456    }
1457
1458    #[test]
1459    fn install_status_uninstall_codex() {
1460        install_status_uninstall_for(SkillAgentSelection::Codex, SkillAgent::Codex, "codex");
1461    }
1462
1463    #[test]
1464    fn install_status_uninstall_claude_code() {
1465        install_status_uninstall_for(
1466            SkillAgentSelection::ClaudeCode,
1467            SkillAgent::ClaudeCode,
1468            "claude",
1469        );
1470    }
1471
1472    #[test]
1473    fn install_status_uninstall_opencode() {
1474        install_status_uninstall_for(
1475            SkillAgentSelection::Opencode,
1476            SkillAgent::Opencode,
1477            "opencode",
1478        );
1479    }
1480
1481    #[test]
1482    fn install_status_uninstall_hermes() {
1483        install_status_uninstall_for(SkillAgentSelection::Hermes, SkillAgent::Hermes, "hermes");
1484    }
1485
1486    fn spec_with_assets() -> SkillSpec<'static> {
1487        const ASSETS: &[SkillAsset] = &[
1488            SkillAsset {
1489                path: "references/guide.md",
1490                contents: "# guide\n",
1491            },
1492            SkillAsset {
1493                path: "references/registry.json",
1494                contents: "{\"ok\":true}\n",
1495            },
1496        ];
1497        SkillSpec {
1498            name: "agent-first-test",
1499            source: SKILL_SOURCE,
1500            title: "Agent-First Test",
1501            marker_slug: "aftest",
1502            assets: ASSETS,
1503        }
1504    }
1505
1506    #[test]
1507    fn install_writes_and_uninstall_removes_bundled_assets() {
1508        let dir = temp_skills_dir("assets");
1509        let opts = options(SkillAgentSelection::Codex, &dir, false);
1510        let skill_dir = dir.join("agent-first-test");
1511        let guide = skill_dir.join("references").join("guide.md");
1512        let registry = skill_dir.join("references").join("registry.json");
1513
1514        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1515        assert_eq!(
1516            std::fs::read_to_string(&guide).unwrap_or_default(),
1517            "# guide\n"
1518        );
1519        assert_eq!(
1520            std::fs::read_to_string(&registry).unwrap_or_default(),
1521            "{\"ok\":true}\n"
1522        );
1523
1524        // A SKILL.md-only install (a bundled asset missing) must report
1525        // not-current so a plain re-install repopulates it — the exact
1526        // regression this fixes.
1527        std::fs::remove_file(&guide).unwrap();
1528        match run_skill_admin(&spec_with_assets(), SkillAction::Status, &opts) {
1529            Ok(SkillReport::Status { current_all, .. }) => {
1530                assert!(
1531                    !current_all,
1532                    "a missing asset must make the skill not-current"
1533                );
1534            }
1535            other => panic!("unexpected status: {other:?}"),
1536        }
1537        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1538        assert!(guide.is_file(), "re-install must restore the missing asset");
1539
1540        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_ok());
1541        assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
1542        assert!(!guide.exists(), "uninstall must remove bundled assets");
1543        assert!(
1544            !skill_dir.join("references").exists(),
1545            "uninstall must remove now-empty asset directories"
1546        );
1547        assert!(
1548            !skill_dir.exists(),
1549            "uninstall must remove the skill directory"
1550        );
1551        let _ = std::fs::remove_dir_all(dir);
1552    }
1553
1554    #[test]
1555    fn install_rejects_escaping_asset_path() {
1556        const ESCAPE: &[SkillAsset] = &[SkillAsset {
1557            path: "../evil.md",
1558            contents: "x",
1559        }];
1560        let dir = temp_skills_dir("assets-escape");
1561        let opts = options(SkillAgentSelection::Codex, &dir, false);
1562        let spec = SkillSpec {
1563            name: "agent-first-test",
1564            source: SKILL_SOURCE,
1565            title: "Agent-First Test",
1566            marker_slug: "aftest",
1567            assets: ESCAPE,
1568        };
1569        assert!(
1570            run_skill_admin(&spec, SkillAction::Install, &opts).is_err(),
1571            "an asset path escaping the skill dir must be rejected"
1572        );
1573        assert!(!dir.join("evil.md").exists());
1574        let _ = std::fs::remove_dir_all(dir);
1575    }
1576
1577    #[test]
1578    fn status_reports_stale_install_as_not_current() {
1579        let dir = temp_skills_dir("stale");
1580        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1581        let skill_dir = dir.join("agent-first-test");
1582        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1583        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1584        // A managed marker but stale body: valid + managed, but not current.
1585        let stale = managed_skill_with_body("# Body\n\nOLD rules.\n");
1586        assert!(std::fs::write(&skill_path, stale).is_ok());
1587
1588        let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1589        if let Ok(SkillReport::Status {
1590            current_all,
1591            targets,
1592            ..
1593        }) = status
1594        {
1595            assert!(!current_all);
1596            if let Some(t) = targets.first() {
1597                assert!(t.installed);
1598                assert!(t.valid);
1599                assert!(t.managed);
1600                assert!(!t.current);
1601            }
1602        }
1603
1604        // Reinstall makes it current again.
1605        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_ok());
1606        let refreshed = std::fs::read_to_string(&skill_path).unwrap_or_default();
1607        assert!(refreshed.contains(&managed_marker_block(&spec())));
1608        assert!(!refreshed.contains("<!-- aftest-managed-skill: true -->"));
1609        if let Ok(SkillReport::Status { targets, .. }) =
1610            run_skill_admin(&spec(), SkillAction::Status, &opts)
1611        {
1612            assert_eq!(targets.first().map(|t| t.current), Some(true));
1613        }
1614        let _ = std::fs::remove_dir_all(dir);
1615    }
1616
1617    #[test]
1618    fn random_text_with_marker_words_is_not_managed() {
1619        let dir = temp_skills_dir("marker-words");
1620        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1621        let skill_dir = dir.join("agent-first-test");
1622        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1623        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1624        let random = format!(
1625            "---\nname: agent-first-test\ndescription: test skill\n---\n\nThis mentions {} and {} but is not a generated block.\n",
1626            generated_by(&spec()),
1627            "aftest-managed-skill: true"
1628        );
1629        assert!(std::fs::write(&skill_path, random).is_ok());
1630
1631        if let Ok(SkillReport::Status { targets, .. }) =
1632            run_skill_admin(&spec(), SkillAction::Status, &opts)
1633        {
1634            assert_eq!(targets.first().map(|t| t.managed), Some(false));
1635        }
1636        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1637        let _ = std::fs::remove_dir_all(dir);
1638    }
1639
1640    #[test]
1641    fn old_marker_format_is_not_managed() {
1642        let dir = temp_skills_dir("old-marker");
1643        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1644        let skill_dir = dir.join("agent-first-test");
1645        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1646        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1647        let old_marker = concat!(
1648            "---\n",
1649            "name: agent-first-test\n",
1650            "description: test skill\n",
1651            "---\n",
1652            "<!--\n",
1653            "Generated by aftest skill install\n",
1654            "aftest-managed-skill: true\n",
1655            "aftest-managed-skill-name: agent-first-test\n",
1656            "aftest-managed-skill-source-hash-fnv1a64: deadbeef\n",
1657            "-->\n",
1658            "\n",
1659            "# Body\n",
1660            "\n",
1661            "rules.\n"
1662        );
1663        assert!(std::fs::write(&skill_path, old_marker).is_ok());
1664
1665        if let Ok(SkillReport::Status { targets, .. }) =
1666            run_skill_admin(&spec(), SkillAction::Status, &opts)
1667            && let Some(t) = targets.first()
1668        {
1669            assert!(t.installed);
1670            assert!(t.valid);
1671            assert!(!t.managed);
1672            assert!(!t.current);
1673        }
1674        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1675        let _ = std::fs::remove_dir_all(dir);
1676    }
1677
1678    #[test]
1679    fn install_preflight_reports_all_targets_without_writing() {
1680        let dir = temp_skills_dir("install-preflight");
1681        let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1682        let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1683        assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1684        assert!(
1685            std::fs::write(
1686                &opencode.skill_path,
1687                "---\nname: custom\ndescription: custom\n---\n"
1688            )
1689            .is_ok()
1690        );
1691        let opts = SkillOptions {
1692            agent: SkillAgentSelection::All,
1693            scope: SkillScope::Personal,
1694            skills_dir: None,
1695            force: false,
1696        };
1697
1698        let result = preflight_install_targets(&spec(), &opts, &[codex, opencode]);
1699        assert!(result.is_err());
1700        let Err(err) = result else {
1701            return;
1702        };
1703        assert!(
1704            err.message
1705                .contains("refusing to overwrite unmanaged skill")
1706        );
1707        assert!(!dir.join("codex").join("agent-first-test").exists());
1708        let partial_report = err.partial_report.map(|report| *report);
1709        assert!(matches!(partial_report, Some(SkillReport::Install { .. })));
1710        let Some(SkillReport::Install {
1711            installed, targets, ..
1712        }) = partial_report
1713        else {
1714            return;
1715        };
1716        assert!(!installed);
1717        assert_eq!(targets.len(), 2);
1718        assert_eq!(targets.first().map(|target| target.installed), Some(false));
1719        assert_eq!(targets.get(1).map(|target| target.installed), Some(true));
1720        assert_eq!(targets.get(1).map(|target| target.managed), Some(false));
1721        let _ = std::fs::remove_dir_all(dir);
1722    }
1723
1724    #[test]
1725    fn uninstall_preflight_reports_all_targets_without_removing() {
1726        let dir = temp_skills_dir("uninstall-preflight");
1727        let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1728        let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1729        assert!(std::fs::create_dir_all(&codex.skill_dir).is_ok());
1730        assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1731        assert!(std::fs::write(&codex.skill_path, managed_skill_contents(&spec())).is_ok());
1732        assert!(
1733            std::fs::write(
1734                &opencode.skill_path,
1735                "---\nname: custom\ndescription: custom\n---\n"
1736            )
1737            .is_ok()
1738        );
1739        let opts = SkillOptions {
1740            agent: SkillAgentSelection::All,
1741            scope: SkillScope::Personal,
1742            skills_dir: None,
1743            force: false,
1744        };
1745
1746        let result = preflight_uninstall_targets(&spec(), &opts, &[codex, opencode]);
1747        assert!(result.is_err());
1748        let Err(err) = result else {
1749            return;
1750        };
1751        assert!(err.message.contains("refusing to remove unmanaged skill"));
1752        assert!(
1753            dir.join("codex")
1754                .join("agent-first-test")
1755                .join(SKILL_FILE_NAME)
1756                .exists()
1757        );
1758        let partial_report = err.partial_report.map(|report| *report);
1759        assert!(matches!(
1760            partial_report,
1761            Some(SkillReport::Uninstall { .. })
1762        ));
1763        let Some(SkillReport::Uninstall {
1764            removed_any,
1765            targets,
1766            ..
1767        }) = partial_report
1768        else {
1769            return;
1770        };
1771        assert!(!removed_any);
1772        assert_eq!(targets.len(), 2);
1773        assert!(targets.iter().all(|target| !target.removed));
1774        let _ = std::fs::remove_dir_all(dir);
1775    }
1776
1777    #[test]
1778    fn install_and_uninstall_refuse_unmanaged() {
1779        let dir = temp_skills_dir("unmanaged");
1780        let skill_dir = dir.join("agent-first-test");
1781        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1782        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1783        assert!(
1784            std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
1785        );
1786        let opts = options(SkillAgentSelection::Codex, &dir, false);
1787
1788        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1789        assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1790        assert!(skill_path.exists());
1791        let _ = std::fs::remove_dir_all(dir);
1792    }
1793
1794    #[test]
1795    fn invalid_spec_slugs_are_rejected_before_path_resolution() {
1796        for name in ["", "../x", "x/y", ".hidden", "bad_name", "Bad"] {
1797            let bad = SkillSpec {
1798                name,
1799                source: SKILL_SOURCE,
1800                title: "Bad",
1801                marker_slug: "aftest",
1802                assets: &[],
1803            };
1804            let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1805            assert!(
1806                run_skill_admin(&bad, SkillAction::Status, &opts).is_err(),
1807                "{name:?}"
1808            );
1809        }
1810
1811        let bad_marker = SkillSpec {
1812            name: "agent-first-test",
1813            source: SKILL_SOURCE,
1814            title: "Bad",
1815            marker_slug: "../aftest",
1816            assets: &[],
1817        };
1818        let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1819        assert!(run_skill_admin(&bad_marker, SkillAction::Status, &opts).is_err());
1820    }
1821
1822    #[test]
1823    fn frontmatter_name_must_match_spec_name() {
1824        let bad = SkillSpec {
1825            name: "agent-first-test",
1826            source: "---\nname: other-skill\ndescription: test skill\n---\n",
1827            title: "Bad",
1828            marker_slug: "aftest",
1829            assets: &[],
1830        };
1831        let dir = temp_skills_dir("frontmatter-name");
1832        let opts = options(SkillAgentSelection::Codex, &dir, false);
1833        assert!(run_skill_admin(&bad, SkillAction::Install, &opts).is_err());
1834        let _ = std::fs::remove_dir_all(dir);
1835    }
1836
1837    #[cfg(unix)]
1838    #[test]
1839    fn symlink_target_is_rejected_by_default_and_force_does_not_follow() {
1840        use std::os::unix::fs::symlink;
1841
1842        let dir = temp_skills_dir("symlink-install");
1843        let opts = options(SkillAgentSelection::Codex, &dir, false);
1844        let force_opts = options(SkillAgentSelection::Codex, &dir, true);
1845        let skill_dir = dir.join("agent-first-test");
1846        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1847        let external = dir.join("external.md");
1848        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1849        assert!(std::fs::write(&external, "external").is_ok());
1850        assert!(symlink(&external, &skill_path).is_ok());
1851
1852        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1853        assert_eq!(
1854            std::fs::read_to_string(&external).unwrap_or_default(),
1855            "external"
1856        );
1857        assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1858        assert!(skill_path.is_symlink());
1859
1860        assert!(run_skill_admin(&spec(), SkillAction::Install, &force_opts).is_ok());
1861        assert_eq!(
1862            std::fs::read_to_string(&external).unwrap_or_default(),
1863            "external"
1864        );
1865        assert!(skill_path.is_file());
1866        assert!(!skill_path.is_symlink());
1867        let _ = std::fs::remove_dir_all(dir);
1868    }
1869
1870    #[cfg(unix)]
1871    #[test]
1872    fn a_symlinked_skill_directory_never_receives_a_write() {
1873        use std::os::unix::fs::symlink;
1874
1875        let dir = temp_skills_dir("symlink-skill-dir");
1876        let outside = temp_skills_dir("symlink-skill-dir-outside");
1877        assert!(std::fs::create_dir_all(&dir).is_ok());
1878        assert!(std::fs::create_dir_all(&outside).is_ok());
1879        let sentinel = outside.join(SKILL_FILE_NAME);
1880        assert!(std::fs::write(&sentinel, "sentinel").is_ok());
1881        // The shape a hostile checkout ships: the skill's own directory is a
1882        // link somewhere else, and every path below it is a fixed name.
1883        assert!(symlink(&outside, dir.join("agent-first-test")).is_ok());
1884
1885        for force in [false, true] {
1886            let opts = options(SkillAgentSelection::Codex, &dir, force);
1887            let err = run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts)
1888                .expect_err("install through a symlinked skill directory must fail");
1889            assert!(
1890                err.message.contains("symlinked directory"),
1891                "unexpected message: {}",
1892                err.message
1893            );
1894            assert_eq!(
1895                err.hint.as_deref(),
1896                Some("--force does not permit writing outside the skills directory"),
1897                "--force must not read as permission to escape the root"
1898            );
1899            assert!(
1900                run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_err(),
1901                "uninstall must refuse the same path"
1902            );
1903        }
1904
1905        assert_eq!(
1906            std::fs::read_to_string(&sentinel).unwrap_or_default(),
1907            "sentinel",
1908            "the file outside the skills directory must be untouched"
1909        );
1910        assert!(!outside.join("references").exists());
1911        let _ = std::fs::remove_dir_all(dir);
1912        let _ = std::fs::remove_dir_all(outside);
1913    }
1914
1915    #[cfg(unix)]
1916    #[test]
1917    fn a_symlinked_asset_directory_never_receives_a_write() {
1918        use std::os::unix::fs::symlink;
1919
1920        let dir = temp_skills_dir("symlink-asset-dir");
1921        let outside = temp_skills_dir("symlink-asset-dir-outside");
1922        let skill_dir = dir.join("agent-first-test");
1923        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1924        assert!(std::fs::create_dir_all(&outside).is_ok());
1925        let sentinel = outside.join("guide.md");
1926        assert!(std::fs::write(&sentinel, "sentinel").is_ok());
1927        // One level deeper than the skill directory: the bundled asset tree's
1928        // own parent is the link.
1929        assert!(symlink(&outside, skill_dir.join("references")).is_ok());
1930
1931        let opts = options(SkillAgentSelection::Codex, &dir, true);
1932        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_err());
1933
1934        assert_eq!(
1935            std::fs::read_to_string(&sentinel).unwrap_or_default(),
1936            "sentinel"
1937        );
1938        assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
1939        let _ = std::fs::remove_dir_all(dir);
1940        let _ = std::fs::remove_dir_all(outside);
1941    }
1942
1943    #[cfg(unix)]
1944    #[test]
1945    fn status_names_a_symlinked_skill_directory_instead_of_reading_through_it() {
1946        use std::os::unix::fs::symlink;
1947
1948        let dir = temp_skills_dir("symlink-status");
1949        let outside = temp_skills_dir("symlink-status-outside");
1950        assert!(std::fs::create_dir_all(&dir).is_ok());
1951        assert!(std::fs::create_dir_all(&outside).is_ok());
1952        assert!(std::fs::write(outside.join(SKILL_FILE_NAME), "sentinel").is_ok());
1953        assert!(symlink(&outside, dir.join("agent-first-test")).is_ok());
1954
1955        let opts = options(SkillAgentSelection::Codex, &dir, false);
1956        let report = run_skill_admin(&spec(), SkillAction::Status, &opts)
1957            .expect("status must report every target rather than fail outright");
1958        let SkillReport::Status { targets, .. } = report else {
1959            panic!("expected a status report");
1960        };
1961        let target = targets.first().expect("one target");
1962        assert!(!target.installed, "nothing may be read through the link");
1963        assert!(
1964            target
1965                .validation_error
1966                .as_deref()
1967                .unwrap_or_default()
1968                .contains("symlinked directory")
1969        );
1970        let _ = std::fs::remove_dir_all(dir);
1971        let _ = std::fs::remove_dir_all(outside);
1972    }
1973
1974    #[test]
1975    fn uninstall_reports_the_assets_it_removed_and_a_directory_the_user_kept() {
1976        let dir = temp_skills_dir("uninstall-report");
1977        let opts = options(SkillAgentSelection::Codex, &dir, false);
1978        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1979        // A file this tool did not install, in the directory it is about to
1980        // try to remove.
1981        let mine = dir.join("agent-first-test").join("notes.md");
1982        assert!(std::fs::write(&mine, "mine").is_ok());
1983
1984        let report = run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts)
1985            .expect("uninstall must succeed");
1986        let SkillReport::Uninstall { targets, .. } = report else {
1987            panic!("expected an uninstall report");
1988        };
1989        let target = targets.first().expect("one target");
1990        assert!(target.removed);
1991        assert_eq!(
1992            target.assets_removed,
1993            vec![
1994                "references/guide.md".to_string(),
1995                "references/registry.json".to_string()
1996            ]
1997        );
1998        assert!(
1999            target.directory_retained,
2000            "a directory holding the user's own file survives, and the report says so"
2001        );
2002        assert_eq!(std::fs::read_to_string(&mine).unwrap_or_default(), "mine");
2003        let _ = std::fs::remove_dir_all(dir);
2004    }
2005
2006    #[test]
2007    fn install_leaves_a_users_own_reference_alone_without_force() {
2008        let dir = temp_skills_dir("unmanaged-asset");
2009        let opts = options(SkillAgentSelection::Codex, &dir, false);
2010        let skill_dir = dir.join("agent-first-test");
2011        let guide = skill_dir.join("references").join("guide.md");
2012        std::fs::create_dir_all(guide.parent().unwrap()).unwrap();
2013        std::fs::write(&guide, "my notes\n").unwrap();
2014
2015        let err = run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts)
2016            .expect_err("a file the user put there is not this tool's to replace");
2017        assert!(
2018            err.message.contains("unmanaged skill asset"),
2019            "{}",
2020            err.message
2021        );
2022        assert_eq!(std::fs::read_to_string(&guide).unwrap(), "my notes\n");
2023        assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
2024
2025        // Content identical to the bundle is already what install would write.
2026        std::fs::write(&guide, "# guide\n").unwrap();
2027        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2028
2029        // Once a managed SKILL.md owns the directory, a changed asset is a
2030        // stale copy to refresh, not the user's file.
2031        std::fs::write(&guide, "older bundle\n").unwrap();
2032        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2033        assert_eq!(std::fs::read_to_string(&guide).unwrap(), "# guide\n");
2034
2035        std::fs::remove_file(skill_dir.join(SKILL_FILE_NAME)).unwrap();
2036        std::fs::write(&guide, "my notes\n").unwrap();
2037        let forced = options(SkillAgentSelection::Codex, &dir, true);
2038        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &forced).is_ok());
2039        assert_eq!(std::fs::read_to_string(&guide).unwrap(), "# guide\n");
2040        let _ = std::fs::remove_dir_all(dir);
2041    }
2042
2043    #[test]
2044    fn install_refuses_a_directory_at_an_asset_path_before_writing_anything() {
2045        let dir = temp_skills_dir("asset-is-dir");
2046        let opts = options(SkillAgentSelection::Codex, &dir, true);
2047        let skill_dir = dir.join("agent-first-test");
2048        std::fs::create_dir_all(skill_dir.join("references").join("guide.md")).unwrap();
2049
2050        let err = run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts)
2051            .expect_err("a directory cannot be replaced by an asset file");
2052        assert!(
2053            err.message.contains("non-regular skill asset"),
2054            "{}",
2055            err.message
2056        );
2057        assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
2058        let _ = std::fs::remove_dir_all(dir);
2059    }
2060
2061    /// `SKILL.md` is the marker uninstall goes by, so it has to be the last
2062    /// thing removed: a run that fails on an asset must leave a target the
2063    /// next run still recognises and can finish.
2064    #[cfg(unix)]
2065    #[test]
2066    fn an_uninstall_that_fails_partway_can_be_run_again() {
2067        use std::os::unix::fs::PermissionsExt;
2068        let dir = temp_skills_dir("uninstall-retry");
2069        let opts = options(SkillAgentSelection::Codex, &dir, false);
2070        let skill_dir = dir.join("agent-first-test");
2071        let references = skill_dir.join("references");
2072        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2073
2074        std::fs::set_permissions(&references, std::fs::Permissions::from_mode(0o555)).unwrap();
2075        let failed = run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts);
2076        std::fs::set_permissions(&references, std::fs::Permissions::from_mode(0o755)).unwrap();
2077        assert!(failed.is_err());
2078        assert!(skill_dir.join(SKILL_FILE_NAME).is_file());
2079
2080        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_ok());
2081        assert!(!references.join("guide.md").exists());
2082        assert!(!skill_dir.exists());
2083        let _ = std::fs::remove_dir_all(dir);
2084    }
2085
2086    #[test]
2087    fn uninstall_reports_a_bundled_asset_it_could_not_remove() {
2088        let dir = temp_skills_dir("uninstall-stuck-asset");
2089        let opts = options(SkillAgentSelection::Codex, &dir, false);
2090        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2091        // A directory where a managed file belongs: `remove_file` cannot take
2092        // it, and the old code reported the target removed anyway.
2093        let asset = dir
2094            .join("agent-first-test")
2095            .join("references")
2096            .join("guide.md");
2097        assert!(std::fs::remove_file(&asset).is_ok());
2098        assert!(std::fs::create_dir(&asset).is_ok());
2099        assert!(std::fs::write(asset.join("kept.md"), "kept").is_ok());
2100
2101        let err = run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts)
2102            .expect_err("a managed asset that cannot be removed is not a clean uninstall");
2103        // The spec spells a bundled asset with `/` because that is one fixed
2104        // name for every platform; the message names the file the operator has
2105        // to go and look at, which is spelled the local way.
2106        let named = Path::new("references").join("guide.md");
2107        assert!(
2108            err.message.contains(&named.display().to_string()),
2109            "unexpected message: {}",
2110            err.message
2111        );
2112        assert!(
2113            asset.join("kept.md").exists(),
2114            "the user's file must survive the refusal"
2115        );
2116        let _ = std::fs::remove_dir_all(dir);
2117    }
2118
2119    #[cfg(unix)]
2120    #[test]
2121    fn force_uninstall_removes_symlink_without_following() {
2122        use std::os::unix::fs::symlink;
2123
2124        let dir = temp_skills_dir("symlink-uninstall");
2125        let force_opts = options(SkillAgentSelection::Codex, &dir, true);
2126        let skill_dir = dir.join("agent-first-test");
2127        let skill_path = skill_dir.join(SKILL_FILE_NAME);
2128        let external = dir.join("external.md");
2129        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
2130        assert!(std::fs::write(&external, "external").is_ok());
2131        assert!(symlink(&external, &skill_path).is_ok());
2132
2133        assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &force_opts).is_ok());
2134        assert!(!skill_path.exists());
2135        assert_eq!(
2136            std::fs::read_to_string(&external).unwrap_or_default(),
2137            "external"
2138        );
2139        let _ = std::fs::remove_dir_all(dir);
2140    }
2141
2142    #[test]
2143    fn serializes_to_protocol_shape() {
2144        let dir = temp_skills_dir("serialize");
2145        let opts = options(SkillAgentSelection::Opencode, &dir, false);
2146        if let Ok(report) = run_skill_admin(&spec(), SkillAction::Install, &opts) {
2147            let value = serde_json::to_value(&report).unwrap_or(serde_json::Value::Null);
2148            assert_eq!(value["code"], "skill_install");
2149            assert_eq!(value["installed"], true);
2150            assert_eq!(value["targets"][0]["agent"], "opencode");
2151            assert_eq!(value["targets"][0]["current"], true);
2152            assert_eq!(
2153                value["targets"][0]["skill_dir"],
2154                serde_json::json!(dir.join("agent-first-test").to_string_lossy().to_string())
2155            );
2156        }
2157        let _ = std::fs::remove_dir_all(dir);
2158    }
2159
2160    #[test]
2161    fn all_personal_resolves_four_targets() {
2162        let opts = SkillOptions {
2163            agent: SkillAgentSelection::All,
2164            scope: SkillScope::Personal,
2165            skills_dir: None,
2166            force: false,
2167        };
2168        let targets = resolve_targets(&spec(), &opts);
2169        assert!(targets.is_ok());
2170        if let Ok(targets) = targets {
2171            assert_eq!(targets.len(), 4);
2172            assert_eq!(targets[0].agent, SkillAgent::Codex);
2173            assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
2174            assert_eq!(targets[2].agent, SkillAgent::Opencode);
2175            assert_eq!(targets[3].agent, SkillAgent::Hermes);
2176        }
2177    }
2178
2179    #[test]
2180    fn all_workspace_resolves_four_targets() {
2181        let opts = SkillOptions {
2182            agent: SkillAgentSelection::All,
2183            scope: SkillScope::Workspace,
2184            skills_dir: None,
2185            force: false,
2186        };
2187        let targets = resolve_targets(&spec(), &opts);
2188        assert!(targets.is_ok());
2189        if let Ok(targets) = targets {
2190            assert_eq!(targets.len(), 4);
2191            assert_eq!(targets[0].agent, SkillAgent::Codex);
2192            assert_eq!(targets[0].scope, SkillScope::Workspace);
2193            assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
2194            assert_eq!(targets[1].scope, SkillScope::Workspace);
2195            assert_eq!(targets[2].agent, SkillAgent::Opencode);
2196            assert_eq!(targets[2].scope, SkillScope::Workspace);
2197            assert_eq!(targets[3].agent, SkillAgent::Hermes);
2198            assert_eq!(targets[3].scope, SkillScope::Workspace);
2199        }
2200    }
2201
2202    #[test]
2203    fn codex_workspace_scope_uses_codex_skills_dir() {
2204        let opts = SkillOptions {
2205            agent: SkillAgentSelection::Codex,
2206            scope: SkillScope::Workspace,
2207            skills_dir: None,
2208            force: false,
2209        };
2210        let targets = resolve_targets(&spec(), &opts);
2211        assert!(targets.is_ok());
2212        if let Ok(targets) = targets {
2213            assert_eq!(targets.len(), 1);
2214            assert_eq!(targets[0].agent, SkillAgent::Codex);
2215            assert_eq!(targets[0].scope, SkillScope::Workspace);
2216            assert!(targets[0].skills_dir.ends_with(".codex/skills"));
2217        }
2218    }
2219
2220    #[test]
2221    fn hermes_workspace_scope_uses_hermes_skills_dir() {
2222        let opts = SkillOptions {
2223            agent: SkillAgentSelection::Hermes,
2224            scope: SkillScope::Workspace,
2225            skills_dir: None,
2226            force: false,
2227        };
2228        let targets = resolve_targets(&spec(), &opts);
2229        assert!(targets.is_ok());
2230        if let Ok(targets) = targets {
2231            assert_eq!(targets.len(), 1);
2232            assert_eq!(targets[0].agent, SkillAgent::Hermes);
2233            assert_eq!(targets[0].scope, SkillScope::Workspace);
2234            assert!(targets[0].skills_dir.ends_with(".hermes/skills"));
2235        }
2236    }
2237}