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