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::io::Write;
14use std::path::{Path, PathBuf};
15
16const SKILL_FILE_NAME: &str = "SKILL.md";
17const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
18const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3;
19
20/// A bundled auxiliary file installed alongside `SKILL.md` under the skill
21/// directory — for example a `references/` document a skill points its agent to.
22///
23/// The file is written verbatim; unlike `SKILL.md` it carries no managed marker.
24#[derive(Clone, Copy, Debug)]
25pub struct SkillAsset<'a> {
26    /// Path relative to the skill directory, using `/` separators
27    /// (e.g. `references/naming-output.md`). Must be relative with no `.` or `..`
28    /// segment, so an asset can never escape the skill directory.
29    pub path: &'a str,
30    /// Bundled file contents (typically `include_str!`).
31    pub contents: &'a str,
32}
33
34/// Identity of the skill being managed and the tool that manages it.
35///
36/// `name` is both the skill directory name and the `name:` front-matter field. `source` is the
37/// bundled `SKILL.md` (typically `include_str!`). `title` is a human label for error messages.
38/// `marker_slug` seeds the managed-skill marker and the `Generated by <slug> skill install`
39/// comment, and is referenced in hints (e.g. `afwidget`). `assets` are auxiliary files bundled
40/// under the skill directory (e.g. `references/`); empty for a single-file skill.
41#[derive(Clone, Copy, Debug)]
42pub struct SkillSpec<'a> {
43    /// Skill directory name and front-matter `name` (e.g. `agent-first-widget`).
44    pub name: &'a str,
45    /// Bundled `SKILL.md` contents.
46    pub source: &'a str,
47    /// Human-readable skill title for error messages (e.g. `Agent-First Widget`).
48    pub title: &'a str,
49    /// Short tool slug used in the managed marker, generated-by comment, and hints (e.g. `afwidget`).
50    pub marker_slug: &'a str,
51    /// Auxiliary files bundled under the skill directory alongside `SKILL.md`
52    /// (e.g. `references/`). Installed and removed with the skill; empty `&[]`
53    /// for a single-file skill.
54    pub assets: &'a [SkillAsset<'a>],
55}
56
57/// Which agent target(s) to manage.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum SkillAgentSelection {
60    /// Every agent that supports the requested scope.
61    All,
62    /// Codex.
63    Codex,
64    /// Claude Code.
65    ClaudeCode,
66    /// opencode.
67    Opencode,
68    /// Hermes Agent.
69    Hermes,
70}
71
72/// A concrete agent a skill is installed for (no `All`).
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum SkillAgent {
76    /// Codex (`$CODEX_HOME/skills`, `~/.codex/skills`, or `.codex/skills`).
77    Codex,
78    /// Claude Code (`~/.claude/skills` or `.claude/skills`).
79    ClaudeCode,
80    /// opencode (`~/.config/opencode/skills` or `.opencode/skills`).
81    Opencode,
82    /// Hermes Agent (`$HERMES_HOME/skills`, `~/.hermes/skills`, or `.hermes/skills`).
83    Hermes,
84}
85
86/// Where to install the skill.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum SkillScope {
90    /// User-level skills directory.
91    Personal,
92    /// Current workspace's skills directory.
93    Workspace,
94}
95
96/// Options shared by every skill action.
97#[derive(Clone, Debug)]
98pub struct SkillOptions {
99    /// Agent target selection.
100    pub agent: SkillAgentSelection,
101    /// Skill scope.
102    pub scope: SkillScope,
103    /// Explicit skills directory; requires a single concrete `agent`.
104    pub skills_dir: Option<String>,
105    /// Overwrite or remove a skill that this tool did not manage.
106    pub force: bool,
107}
108
109/// The skill action to perform.
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum SkillAction {
112    /// Report whether the skill is installed, valid, managed, and current.
113    Status,
114    /// Install (or refresh) the skill.
115    Install,
116    /// Remove a managed skill.
117    Uninstall,
118}
119
120/// Per-target outcome of `status` / `install`.
121#[derive(Clone, Debug, Serialize)]
122pub struct SkillTargetStatus {
123    /// The agent this target belongs to.
124    pub agent: SkillAgent,
125    /// The scope this target belongs to.
126    pub scope: SkillScope,
127    /// Directory that holds skill folders.
128    pub skills_dir: PathBuf,
129    /// Directory for this skill under `skills_dir`.
130    pub skill_dir: PathBuf,
131    /// Full path to the target `SKILL.md`.
132    pub skill_path: PathBuf,
133    /// Whether a skill file exists at `skill_path`.
134    pub installed: bool,
135    /// Whether the installed file was generated by this tool (or is byte-equal to the bundle).
136    pub managed: bool,
137    /// Whether the installed file has valid front matter.
138    pub valid: bool,
139    /// Whether the installed content matches the bundled skill (up to date).
140    pub current: bool,
141    /// Front-matter validation error, when the installed file is invalid.
142    pub validation_error: Option<String>,
143}
144
145/// Per-target outcome of `uninstall`.
146#[derive(Clone, Debug, Serialize)]
147pub struct SkillUninstallStatus {
148    /// The agent this target belongs to.
149    pub agent: SkillAgent,
150    /// The scope this target belongs to.
151    pub scope: SkillScope,
152    /// Directory that holds skill folders.
153    pub skills_dir: PathBuf,
154    /// Directory for this skill under `skills_dir`.
155    pub skill_dir: PathBuf,
156    /// Full path to the target `SKILL.md`.
157    pub skill_path: PathBuf,
158    /// Whether a file was removed (false if nothing was installed).
159    pub removed: bool,
160}
161
162/// The result of a skill action. Serializes to the protocol shape, carrying a `code`
163/// discriminator (`skill_status` / `skill_install` / `skill_uninstall`).
164#[derive(Clone, Debug, Serialize)]
165#[serde(tag = "code")]
166pub enum SkillReport {
167    /// `status` result.
168    #[serde(rename = "skill_status")]
169    Status {
170        /// Skill name.
171        skill: String,
172        /// True when every target is installed.
173        installed_all: bool,
174        /// True when every target has valid front matter.
175        valid_all: bool,
176        /// True when every target is up to date with the bundle.
177        current_all: bool,
178        /// Per-target detail.
179        targets: Vec<SkillTargetStatus>,
180    },
181    /// `install` result.
182    #[serde(rename = "skill_install")]
183    Install {
184        /// Skill name.
185        skill: String,
186        /// Always true (install succeeded for every target).
187        installed: bool,
188        /// Per-target detail after writing.
189        targets: Vec<SkillTargetStatus>,
190        /// Operator hint.
191        hint: &'static str,
192    },
193    /// `uninstall` result.
194    #[serde(rename = "skill_uninstall")]
195    Uninstall {
196        /// Skill name.
197        skill: String,
198        /// True when at least one file was removed.
199        removed_any: bool,
200        /// Per-target detail.
201        targets: Vec<SkillUninstallStatus>,
202    },
203}
204
205/// A skill admin failure with an operator-facing message and optional hint.
206#[derive(Clone, Debug)]
207pub struct SkillError {
208    /// What went wrong.
209    pub message: String,
210    /// Optional remediation hint.
211    pub hint: Option<String>,
212    /// Per-target report captured after a multi-target operation failed.
213    pub partial_report: Option<SkillReport>,
214}
215
216impl std::fmt::Display for SkillError {
217    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        formatter.write_str(&self.message)
219    }
220}
221
222impl std::error::Error for SkillError {}
223
224impl SkillError {
225    fn invalid_request(message: String, hint: Option<String>) -> Self {
226        Self {
227            message,
228            hint,
229            partial_report: None,
230        }
231    }
232
233    fn io(action: &str, err: std::io::Error) -> Self {
234        Self {
235            message: format!("{action} failed: {err}"),
236            hint: None,
237            partial_report: None,
238        }
239    }
240
241    fn with_partial_report(mut self, report: SkillReport) -> Self {
242        self.partial_report = Some(report);
243        self
244    }
245}
246
247/// Install, uninstall, or report status of `spec`'s skill across the selected agent target(s).
248///
249/// Returns a typed [`SkillReport`] (caller serializes it for output) or a [`SkillError`].
250/// Does not touch stdout/stderr.
251pub fn run_skill_admin(
252    spec: &SkillSpec,
253    action: SkillAction,
254    options: &SkillOptions,
255) -> Result<SkillReport, SkillError> {
256    validate_spec(spec)?;
257    match action {
258        SkillAction::Status => status(spec, options),
259        SkillAction::Install => install(spec, options),
260        SkillAction::Uninstall => uninstall(spec, options),
261    }
262}
263
264fn status(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
265    let targets = resolve_targets(spec, options)?;
266    let mut statuses = Vec::with_capacity(targets.len());
267    for target in &targets {
268        statuses.push(target_status(spec, target)?);
269    }
270    Ok(SkillReport::Status {
271        skill: spec.name.to_string(),
272        installed_all: statuses.iter().all(|s| s.installed),
273        valid_all: statuses.iter().all(|s| s.valid),
274        current_all: statuses.iter().all(|s| s.current),
275        targets: statuses,
276    })
277}
278
279fn install(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
280    validate_skill_text(spec, spec.source)?;
281    for asset in spec.assets {
282        validate_asset_path(asset.path)?;
283    }
284    let targets = resolve_targets(spec, options)?;
285    let content = managed_skill_contents(spec);
286    preflight_install_targets(spec, options, &targets)?;
287    for target in &targets {
288        if let Err(err) = std::fs::create_dir_all(&target.skill_dir)
289            .map_err(|e| SkillError::io("create skill dir", e))
290        {
291            return Err(err.with_partial_report(install_report_lossy(spec, &targets, false)));
292        }
293    }
294    install_targets(spec, &targets, &content)
295}
296
297fn preflight_install_targets(
298    spec: &SkillSpec,
299    options: &SkillOptions,
300    targets: &[SkillTarget],
301) -> Result<(), SkillError> {
302    let mut failures = Vec::new();
303    for target in targets {
304        if let Some(kind) = skill_path_file_type(&target.skill_path)? {
305            if kind.is_symlink() {
306                if !options.force {
307                    failures.push(format!(
308                        "refusing to overwrite symlinked skill at {}",
309                        target.skill_path.display()
310                    ));
311                }
312                continue;
313            }
314            if !kind.is_file() {
315                failures.push(format!(
316                    "refusing to overwrite non-regular skill at {}",
317                    target.skill_path.display()
318                ));
319                continue;
320            }
321            if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
322                failures.push(format!(
323                    "refusing to overwrite unmanaged skill at {}",
324                    target.skill_path.display()
325                ));
326            }
327        }
328    }
329    if failures.is_empty() {
330        return Ok(());
331    }
332    Err(SkillError::invalid_request(
333        failures.join("; "),
334        Some("pass --force to replace unmanaged files or symlinks".to_string()),
335    )
336    .with_partial_report(install_report_lossy(spec, targets, false)))
337}
338
339fn install_targets(
340    spec: &SkillSpec,
341    targets: &[SkillTarget],
342    content: &str,
343) -> Result<SkillReport, SkillError> {
344    let mut installed = Vec::with_capacity(targets.len());
345    for target in targets {
346        if let Err(err) = write_skill_atomic(target, content) {
347            return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
348        }
349        if let Err(err) = install_target_assets(spec, target) {
350            return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
351        }
352        if let Err(err) = validate_installed_skill(spec, &target.skill_path) {
353            return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
354        }
355        match target_status(spec, target) {
356            Ok(status) => installed.push(status),
357            Err(err) => {
358                return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
359            }
360        }
361    }
362    Ok(SkillReport::Install {
363        skill: spec.name.to_string(),
364        installed: true,
365        targets: installed,
366        hint: "restart the agent so it reloads installed skills",
367    })
368}
369
370fn uninstall(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
371    let targets = resolve_targets(spec, options)?;
372    preflight_uninstall_targets(spec, options, &targets)?;
373    let mut removed = Vec::with_capacity(targets.len());
374    for target in &targets {
375        let Some(kind) = skill_path_file_type(&target.skill_path)? else {
376            removed.push(target_uninstall_status(target, false));
377            continue;
378        };
379        if !kind.is_file() && !kind.is_symlink() {
380            let err = SkillError::invalid_request(
381                format!(
382                    "refusing to remove non-regular skill at {}",
383                    target.skill_path.display()
384                ),
385                None,
386            );
387            return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
388        }
389        if let Err(err) =
390            std::fs::remove_file(&target.skill_path).map_err(|e| SkillError::io("remove skill", e))
391        {
392            return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
393        }
394        remove_target_assets(spec, target);
395        let _ = std::fs::remove_dir(&target.skill_dir);
396        removed.push(target_uninstall_status(target, true));
397    }
398    Ok(SkillReport::Uninstall {
399        skill: spec.name.to_string(),
400        removed_any: removed.iter().any(|s| s.removed),
401        targets: removed,
402    })
403}
404
405fn preflight_uninstall_targets(
406    spec: &SkillSpec,
407    options: &SkillOptions,
408    targets: &[SkillTarget],
409) -> Result<(), SkillError> {
410    let mut failures = Vec::new();
411    for target in targets {
412        let Some(kind) = skill_path_file_type(&target.skill_path)? else {
413            continue;
414        };
415        if kind.is_symlink() {
416            if !options.force {
417                failures.push(format!(
418                    "refusing to remove symlinked skill at {}",
419                    target.skill_path.display()
420                ));
421            }
422            continue;
423        }
424        if !kind.is_file() {
425            failures.push(format!(
426                "refusing to remove non-regular skill at {}",
427                target.skill_path.display()
428            ));
429            continue;
430        }
431        if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
432            failures.push(format!(
433                "refusing to remove unmanaged skill at {}",
434                target.skill_path.display()
435            ));
436        }
437    }
438    if failures.is_empty() {
439        return Ok(());
440    }
441    Err(SkillError::invalid_request(
442        failures.join("; "),
443        Some(format!(
444            "only skills generated by {} skill install can be removed without --force",
445            spec.marker_slug
446        )),
447    )
448    .with_partial_report(uninstall_report_lossy(spec, targets, &[])))
449}
450
451struct SkillTarget {
452    agent: SkillAgent,
453    scope: SkillScope,
454    skills_dir: PathBuf,
455    skill_dir: PathBuf,
456    skill_path: PathBuf,
457}
458
459fn resolve_targets(
460    spec: &SkillSpec,
461    options: &SkillOptions,
462) -> Result<Vec<SkillTarget>, SkillError> {
463    if options.skills_dir.is_some() && options.agent == SkillAgentSelection::All {
464        return Err(SkillError::invalid_request(
465            "--skills-dir requires a single --agent".to_string(),
466            Some("custom skills directories are ambiguous when --agent all is used".to_string()),
467        ));
468    }
469    match (options.agent, options.scope) {
470        (SkillAgentSelection::All, SkillScope::Personal) => Ok(vec![
471            resolve_target(spec, SkillAgent::Codex, SkillScope::Personal, None)?,
472            resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Personal, None)?,
473            resolve_target(spec, SkillAgent::Opencode, SkillScope::Personal, None)?,
474            resolve_target(spec, SkillAgent::Hermes, SkillScope::Personal, None)?,
475        ]),
476        (SkillAgentSelection::All, SkillScope::Workspace) => Ok(vec![
477            resolve_target(spec, SkillAgent::Codex, SkillScope::Workspace, None)?,
478            resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Workspace, None)?,
479            resolve_target(spec, SkillAgent::Opencode, SkillScope::Workspace, None)?,
480            resolve_target(spec, SkillAgent::Hermes, SkillScope::Workspace, None)?,
481        ]),
482        (SkillAgentSelection::Codex, SkillScope::Workspace) => Ok(vec![resolve_target(
483            spec,
484            SkillAgent::Codex,
485            SkillScope::Workspace,
486            options.skills_dir.as_deref(),
487        )?]),
488        (SkillAgentSelection::Codex, SkillScope::Personal) => Ok(vec![resolve_target(
489            spec,
490            SkillAgent::Codex,
491            SkillScope::Personal,
492            options.skills_dir.as_deref(),
493        )?]),
494        (SkillAgentSelection::ClaudeCode, scope) => Ok(vec![resolve_target(
495            spec,
496            SkillAgent::ClaudeCode,
497            scope,
498            options.skills_dir.as_deref(),
499        )?]),
500        (SkillAgentSelection::Opencode, scope) => Ok(vec![resolve_target(
501            spec,
502            SkillAgent::Opencode,
503            scope,
504            options.skills_dir.as_deref(),
505        )?]),
506        (SkillAgentSelection::Hermes, scope) => Ok(vec![resolve_target(
507            spec,
508            SkillAgent::Hermes,
509            scope,
510            options.skills_dir.as_deref(),
511        )?]),
512    }
513}
514
515fn resolve_target(
516    spec: &SkillSpec,
517    agent: SkillAgent,
518    scope: SkillScope,
519    skills_dir: Option<&str>,
520) -> Result<SkillTarget, SkillError> {
521    let skills_dir = match skills_dir {
522        Some(dir) => expand_tilde(dir)?,
523        None => default_skills_dir(agent, scope)?,
524    };
525    let skill_dir = skills_dir.join(spec.name);
526    let skill_path = skill_dir.join(SKILL_FILE_NAME);
527    Ok(SkillTarget {
528        agent,
529        scope,
530        skills_dir,
531        skill_dir,
532        skill_path,
533    })
534}
535
536fn default_skills_dir(agent: SkillAgent, scope: SkillScope) -> Result<PathBuf, SkillError> {
537    match (agent, scope) {
538        (SkillAgent::Codex, SkillScope::Personal) => {
539            if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
540                Ok(PathBuf::from(codex_home).join("skills"))
541            } else {
542                Ok(home_dir()?.join(".codex").join("skills"))
543            }
544        }
545        (SkillAgent::Codex, SkillScope::Workspace) => workspace_skills_dir(".codex"),
546        (SkillAgent::ClaudeCode, SkillScope::Personal) => {
547            Ok(home_dir()?.join(".claude").join("skills"))
548        }
549        (SkillAgent::ClaudeCode, SkillScope::Workspace) => workspace_skills_dir(".claude"),
550        (SkillAgent::Opencode, SkillScope::Personal) => {
551            if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
552                Ok(PathBuf::from(xdg).join("opencode").join("skills"))
553            } else {
554                Ok(home_dir()?.join(".config").join("opencode").join("skills"))
555            }
556        }
557        (SkillAgent::Opencode, SkillScope::Workspace) => workspace_skills_dir(".opencode"),
558        (SkillAgent::Hermes, SkillScope::Personal) => {
559            if let Some(hermes_home) = std::env::var_os("HERMES_HOME") {
560                Ok(PathBuf::from(hermes_home).join("skills"))
561            } else {
562                Ok(home_dir()?.join(".hermes").join("skills"))
563            }
564        }
565        (SkillAgent::Hermes, SkillScope::Workspace) => workspace_skills_dir(".hermes"),
566    }
567}
568
569fn workspace_skills_dir(agent_dir: &str) -> Result<PathBuf, SkillError> {
570    std::env::current_dir()
571        .map(|dir| dir.join(agent_dir).join("skills"))
572        .map_err(|e| SkillError::io("resolve current directory", e))
573}
574
575fn target_status(spec: &SkillSpec, target: &SkillTarget) -> Result<SkillTargetStatus, SkillError> {
576    let Some(kind) = skill_path_file_type(&target.skill_path)? else {
577        return Ok(SkillTargetStatus {
578            agent: target.agent,
579            scope: target.scope,
580            skills_dir: target.skills_dir.clone(),
581            skill_dir: target.skill_dir.clone(),
582            skill_path: target.skill_path.clone(),
583            installed: false,
584            managed: false,
585            valid: false,
586            current: false,
587            validation_error: None,
588        });
589    };
590    let installed = true;
591    let mut valid = false;
592    let mut current = false;
593    let mut validation_error = None;
594    let mut managed = false;
595    if kind.is_symlink() {
596        validation_error = Some("target SKILL.md is a symlink; refusing to follow it".to_string());
597    } else if kind.is_file() {
598        let text = std::fs::read_to_string(&target.skill_path)
599            .map_err(|e| SkillError::io("read skill", e))?;
600        managed = skill_text_is_managed_or_bundled(spec, &text);
601        current = normalized_content_hash(spec, &text) == source_hash(spec)
602            && assets_current(spec, &target.skill_dir);
603        match validate_skill_text(spec, &text) {
604            Ok(()) => valid = true,
605            Err(err) => validation_error = Some(err.message),
606        }
607    } else {
608        validation_error = Some("target SKILL.md is not a regular file".to_string());
609    }
610    Ok(SkillTargetStatus {
611        agent: target.agent,
612        scope: target.scope,
613        skills_dir: target.skills_dir.clone(),
614        skill_dir: target.skill_dir.clone(),
615        skill_path: target.skill_path.clone(),
616        installed,
617        managed,
618        valid,
619        current,
620        validation_error,
621    })
622}
623
624fn target_uninstall_status(target: &SkillTarget, removed: bool) -> SkillUninstallStatus {
625    SkillUninstallStatus {
626        agent: target.agent,
627        scope: target.scope,
628        skills_dir: target.skills_dir.clone(),
629        skill_dir: target.skill_dir.clone(),
630        skill_path: target.skill_path.clone(),
631        removed,
632    }
633}
634
635fn target_status_lossy(spec: &SkillSpec, target: &SkillTarget) -> SkillTargetStatus {
636    target_status(spec, target).unwrap_or_else(|err| {
637        let installed = std::fs::symlink_metadata(&target.skill_path).is_ok();
638        SkillTargetStatus {
639            agent: target.agent,
640            scope: target.scope,
641            skills_dir: target.skills_dir.clone(),
642            skill_dir: target.skill_dir.clone(),
643            skill_path: target.skill_path.clone(),
644            installed,
645            managed: false,
646            valid: false,
647            current: false,
648            validation_error: Some(err.message),
649        }
650    })
651}
652
653fn install_report_lossy(spec: &SkillSpec, targets: &[SkillTarget], installed: bool) -> SkillReport {
654    SkillReport::Install {
655        skill: spec.name.to_string(),
656        installed,
657        targets: targets
658            .iter()
659            .map(|target| target_status_lossy(spec, target))
660            .collect(),
661        hint: "restart the agent so it reloads installed skills",
662    }
663}
664
665fn uninstall_report_lossy(
666    spec: &SkillSpec,
667    targets: &[SkillTarget],
668    removed: &[SkillUninstallStatus],
669) -> SkillReport {
670    let mut statuses = Vec::with_capacity(targets.len());
671    for target in targets {
672        if let Some(status) = removed.iter().find(|status| {
673            status.agent == target.agent
674                && status.scope == target.scope
675                && status.skill_path == target.skill_path
676        }) {
677            statuses.push(status.clone());
678        } else {
679            statuses.push(target_uninstall_status(target, false));
680        }
681    }
682    SkillReport::Uninstall {
683        skill: spec.name.to_string(),
684        removed_any: statuses.iter().any(|status| status.removed),
685        targets: statuses,
686    }
687}
688
689fn generated_by(spec: &SkillSpec) -> String {
690    format!("Generated by {} skill install", spec.marker_slug)
691}
692
693fn text_hash(text: &str) -> String {
694    let mut hash = FNV1A64_OFFSET;
695    for byte in text.as_bytes() {
696        hash ^= u64::from(*byte);
697        hash = hash.wrapping_mul(FNV1A64_PRIME);
698    }
699    format!("{hash:016x}")
700}
701
702fn source_hash(spec: &SkillSpec) -> String {
703    normalized_content_hash(spec, spec.source)
704}
705
706fn normalized_content_hash(spec: &SkillSpec, text: &str) -> String {
707    text_hash(&normalize_skill_text(spec, text))
708}
709
710fn managed_marker_block(spec: &SkillSpec) -> String {
711    let slug = spec.marker_slug;
712    format!(
713        "<!--\n{}\n{}-managed-skill: true\n{}-managed-skill-name: {}\n{}-managed-skill-owner: {}\n{}-managed-skill-content-hash-fnv1a64: {}\n-->",
714        generated_by(spec),
715        slug,
716        slug,
717        spec.name,
718        slug,
719        slug,
720        slug,
721        source_hash(spec)
722    )
723}
724
725fn managed_skill_contents(spec: &SkillSpec) -> String {
726    let block = managed_marker_block(spec);
727    let mut lines = spec.source.lines();
728    let mut output = String::new();
729    let mut inserted = false;
730    if let Some(first) = lines.next() {
731        output.push_str(first);
732        output.push('\n');
733    }
734    for line in lines {
735        output.push_str(line);
736        output.push('\n');
737        if !inserted && line.trim() == "---" {
738            output.push_str(&block);
739            output.push_str("\n\n");
740            inserted = true;
741        }
742    }
743    if !inserted {
744        output.push_str(&block);
745        output.push('\n');
746    }
747    output
748}
749
750fn validate_installed_skill(spec: &SkillSpec, path: &Path) -> Result<(), SkillError> {
751    let text =
752        std::fs::read_to_string(path).map_err(|e| SkillError::io("read installed skill", e))?;
753    validate_skill_text(spec, &text)
754}
755
756fn validate_skill_text(spec: &SkillSpec, text: &str) -> Result<(), SkillError> {
757    crate::skill::validate_skill_named(text, spec.name).map_err(|err| {
758        SkillError::invalid_request(
759            format!("invalid {} skill front matter: {err}", spec.title),
760            Some(format!(
761                "make SKILL.md metadata conform to the Agent Skills specification and set name to {}",
762                spec.name
763            )),
764        )
765    })?;
766    Ok(())
767}
768
769fn is_managed_or_bundled_skill(spec: &SkillSpec, path: &Path) -> Result<bool, SkillError> {
770    let Some(kind) = skill_path_file_type(path)? else {
771        return Ok(false);
772    };
773    if kind.is_symlink() {
774        return Err(SkillError::invalid_request(
775            format!("refusing to inspect symlinked skill at {}", path.display()),
776            Some("pass --force to replace or remove the symlink itself".to_string()),
777        ));
778    }
779    let text = std::fs::read_to_string(path).map_err(|e| SkillError::io("read skill", e))?;
780    Ok(skill_text_is_managed_or_bundled(spec, &text))
781}
782
783fn skill_text_is_managed_or_bundled(spec: &SkillSpec, text: &str) -> bool {
784    skill_text_has_managed_identity(spec, text)
785        || normalize_skill_text(spec, text) == normalize_skill_text(spec, spec.source)
786}
787
788fn skill_text_has_managed_identity(spec: &SkillSpec, text: &str) -> bool {
789    for block in html_comment_blocks(text) {
790        if managed_marker_block_has_identity(spec, &block) {
791            return true;
792        }
793    }
794    false
795}
796
797fn managed_marker_block_has_identity(spec: &SkillSpec, block: &str) -> bool {
798    let slug = spec.marker_slug;
799    let generated_by_line = generated_by(spec);
800    let managed_line = format!("{slug}-managed-skill: true");
801    let name_line = format!("{slug}-managed-skill-name: {}", spec.name);
802    let owner_line = format!("{slug}-managed-skill-owner: {slug}");
803    let mut has_generated_by = false;
804    let mut has_managed = false;
805    let mut has_name = false;
806    let mut has_owner = false;
807    for line in block.replace("\r\n", "\n").lines() {
808        let trimmed = line.trim();
809        has_generated_by |= trimmed == generated_by_line;
810        has_managed |= trimmed == managed_line;
811        has_name |= trimmed == name_line;
812        has_owner |= trimmed == owner_line;
813    }
814    has_generated_by && has_managed && has_name && has_owner
815}
816
817fn html_comment_blocks(text: &str) -> Vec<String> {
818    let normalized = text.replace("\r\n", "\n");
819    let mut blocks = Vec::new();
820    let mut lines = normalized.lines();
821    while let Some(line) = lines.next() {
822        if line.trim() != "<!--" {
823            continue;
824        }
825        let mut block = vec![line.to_string()];
826        for next in lines.by_ref() {
827            block.push(next.to_string());
828            if next.trim() == "-->" {
829                blocks.push(block.join("\n"));
830                break;
831            }
832        }
833    }
834    blocks
835}
836
837fn strip_managed_marker_blocks(spec: &SkillSpec, text: &str) -> String {
838    let normalized = text.replace("\r\n", "\n");
839    let mut output = Vec::new();
840    let mut lines = normalized.lines();
841    while let Some(line) = lines.next() {
842        if line.trim() != "<!--" {
843            output.push(line.to_string());
844            continue;
845        }
846        let mut block = vec![line.to_string()];
847        let mut closed = false;
848        for next in lines.by_ref() {
849            block.push(next.to_string());
850            if next.trim() == "-->" {
851                closed = true;
852                break;
853            }
854        }
855        if closed {
856            let block_text = block.join("\n");
857            if managed_marker_block_has_identity(spec, &block_text) {
858                continue;
859            }
860        }
861        output.extend(block);
862    }
863    output.join("\n")
864}
865
866fn normalize_skill_text(spec: &SkillSpec, text: &str) -> String {
867    let text = strip_managed_marker_blocks(spec, text);
868    // Drop the managed-marker blocks, then collapse runs of blank lines to one so that the blank
869    // line `managed_skill_contents` inserts after the marker block does not make a managed install
870    // compare unequal to the bundled source.
871    let mut out: Vec<&str> = Vec::new();
872    for line in text.lines() {
873        let trimmed = line.trim();
874        if trimmed.is_empty() && out.last().is_some_and(|prev| prev.trim().is_empty()) {
875            continue;
876        }
877        out.push(line);
878    }
879    out.join("\n").trim().to_string()
880}
881
882fn validate_spec(spec: &SkillSpec) -> Result<(), SkillError> {
883    validate_slug("skill name", spec.name)?;
884    validate_slug("marker slug", spec.marker_slug)?;
885    validate_skill_text(spec, spec.source)
886}
887
888fn validate_slug(field: &str, value: &str) -> Result<(), SkillError> {
889    if slug_is_valid(value) {
890        return Ok(());
891    }
892    Err(SkillError::invalid_request(
893        format!(
894            "invalid {field} {value:?}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]"
895        ),
896        Some("use lowercase ASCII letters, digits, and single hyphen-separated words".to_string()),
897    ))
898}
899
900fn slug_is_valid(value: &str) -> bool {
901    let bytes = value.as_bytes();
902    if bytes.is_empty() {
903        return false;
904    }
905    fn is_lower_alnum(byte: u8) -> bool {
906        byte.is_ascii_lowercase() || byte.is_ascii_digit()
907    }
908    if !is_lower_alnum(bytes[0]) || !is_lower_alnum(bytes[bytes.len() - 1]) {
909        return false;
910    }
911    bytes
912        .iter()
913        .all(|byte| is_lower_alnum(*byte) || *byte == b'-')
914}
915
916fn skill_path_file_type(path: &Path) -> Result<Option<std::fs::FileType>, SkillError> {
917    match std::fs::symlink_metadata(path) {
918        Ok(metadata) => Ok(Some(metadata.file_type())),
919        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
920        Err(err) => Err(SkillError::io("inspect skill path", err)),
921    }
922}
923
924fn write_skill_atomic(target: &SkillTarget, content: &str) -> Result<(), SkillError> {
925    write_file_atomic(&target.skill_path, content)
926}
927
928/// Atomically write `content` to `path`: a temp file in the same directory is
929/// synced then renamed over the destination, so a reader never sees a partial
930/// file and a pre-existing symlink at `path` is replaced rather than followed.
931fn write_file_atomic(path: &Path, content: &str) -> Result<(), SkillError> {
932    let parent = path.parent().ok_or_else(|| {
933        SkillError::io(
934            "resolve skill file parent",
935            std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
936        )
937    })?;
938    let file_name = path
939        .file_name()
940        .and_then(|name| name.to_str())
941        .unwrap_or(SKILL_FILE_NAME);
942    let nanos = std::time::SystemTime::now()
943        .duration_since(std::time::UNIX_EPOCH)
944        .map(|d| d.as_nanos())
945        .unwrap_or(0);
946    for attempt in 0..16 {
947        let tmp_path = parent.join(format!(
948            ".{file_name}.{}.{}.{}.tmp",
949            std::process::id(),
950            nanos,
951            attempt
952        ));
953        let mut tmp = match std::fs::OpenOptions::new()
954            .write(true)
955            .create_new(true)
956            .open(&tmp_path)
957        {
958            Ok(file) => file,
959            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
960            Err(err) => return Err(SkillError::io("create temporary skill", err)),
961        };
962        let result = (|| {
963            tmp.write_all(content.as_bytes())
964                .map_err(|e| SkillError::io("write temporary skill", e))?;
965            tmp.sync_all()
966                .map_err(|e| SkillError::io("sync temporary skill", e))?;
967            drop(tmp);
968            std::fs::rename(&tmp_path, path).map_err(|e| SkillError::io("replace skill", e))?;
969            Ok(())
970        })();
971        if result.is_err() {
972            let _ = std::fs::remove_file(&tmp_path);
973        }
974        return result;
975    }
976    Err(SkillError::io(
977        "create temporary skill",
978        std::io::Error::new(
979            std::io::ErrorKind::AlreadyExists,
980            "temporary skill path collision",
981        ),
982    ))
983}
984
985/// Reject an asset path that is absolute or carries a `.`/`..`/empty segment, so
986/// a bundled asset can only ever land inside the skill directory.
987fn validate_asset_path(path: &str) -> Result<(), SkillError> {
988    let bad = path.is_empty()
989        || Path::new(path).is_absolute()
990        || path
991            .split(['/', '\\'])
992            .any(|seg| seg.is_empty() || seg == "." || seg == "..");
993    if bad {
994        return Err(SkillError::invalid_request(
995            format!("invalid skill asset path: {path}"),
996            Some(
997                "asset paths must be relative to the skill directory with no `.`/`..` segments"
998                    .to_string(),
999            ),
1000        ));
1001    }
1002    Ok(())
1003}
1004
1005/// Resolve an asset's `/`-separated relative path to an absolute path under
1006/// `skill_dir`, one segment at a time so it works on every platform.
1007fn asset_target_path(skill_dir: &Path, rel_path: &str) -> PathBuf {
1008    let mut out = skill_dir.to_path_buf();
1009    for seg in rel_path.split(['/', '\\']) {
1010        out.push(seg);
1011    }
1012    out
1013}
1014
1015/// Write every bundled asset under the target skill directory, creating parent
1016/// directories as needed. Paths are assumed already validated by `install`.
1017fn install_target_assets(spec: &SkillSpec, target: &SkillTarget) -> Result<(), SkillError> {
1018    for asset in spec.assets {
1019        let dest = asset_target_path(&target.skill_dir, asset.path);
1020        if let Some(parent) = dest.parent() {
1021            std::fs::create_dir_all(parent)
1022                .map_err(|e| SkillError::io("create skill asset dir", e))?;
1023        }
1024        write_file_atomic(&dest, asset.contents)?;
1025    }
1026    Ok(())
1027}
1028
1029/// True when every bundled asset is present on disk and byte-equal to the
1030/// bundle. A single-file skill (`assets == []`) is trivially current.
1031fn assets_current(spec: &SkillSpec, skill_dir: &Path) -> bool {
1032    spec.assets.iter().all(|asset| {
1033        let dest = asset_target_path(skill_dir, asset.path);
1034        std::fs::read_to_string(&dest)
1035            .map(|text| text == asset.contents)
1036            .unwrap_or(false)
1037    })
1038}
1039
1040/// Remove the bundled asset files, then their now-empty parent directories,
1041/// deepest first. Best-effort: a directory the user added their own files to is
1042/// left in place (its `remove_dir` simply fails).
1043fn remove_target_assets(spec: &SkillSpec, target: &SkillTarget) {
1044    let mut dirs: Vec<PathBuf> = Vec::new();
1045    for asset in spec.assets {
1046        let dest = asset_target_path(&target.skill_dir, asset.path);
1047        let _ = std::fs::remove_file(&dest);
1048        let mut dir = dest.parent().map(Path::to_path_buf);
1049        while let Some(current) = dir {
1050            if current == target.skill_dir || !current.starts_with(&target.skill_dir) {
1051                break;
1052            }
1053            if !dirs.contains(&current) {
1054                dirs.push(current.clone());
1055            }
1056            dir = current.parent().map(Path::to_path_buf);
1057        }
1058    }
1059    dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
1060    for dir in dirs {
1061        let _ = std::fs::remove_dir(&dir);
1062    }
1063}
1064
1065fn home_dir() -> Result<PathBuf, SkillError> {
1066    std::env::var_os("HOME")
1067        .or_else(|| std::env::var_os("USERPROFILE"))
1068        .map(PathBuf::from)
1069        .ok_or_else(|| {
1070            SkillError::invalid_request(
1071                "cannot determine home directory".to_string(),
1072                Some("pass --skills-dir explicitly".to_string()),
1073            )
1074        })
1075}
1076
1077fn expand_tilde(input: &str) -> Result<PathBuf, SkillError> {
1078    if input == "~" {
1079        return home_dir();
1080    }
1081    if let Some(rest) = input.strip_prefix("~/") {
1082        return Ok(home_dir()?.join(rest));
1083    }
1084    Ok(PathBuf::from(input))
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use std::time::{SystemTime, UNIX_EPOCH};
1091
1092    const SKILL_SOURCE: &str =
1093        "---\nname: agent-first-test\ndescription: test skill\n---\n\n# Body\n\nrules.\n";
1094
1095    fn spec() -> SkillSpec<'static> {
1096        SkillSpec {
1097            name: "agent-first-test",
1098            source: SKILL_SOURCE,
1099            title: "Agent-First Test",
1100            marker_slug: "aftest",
1101            assets: &[],
1102        }
1103    }
1104
1105    fn managed_skill_with_body(body: &str) -> String {
1106        format!(
1107            "---\nname: agent-first-test\ndescription: test skill\n---\n{}\n\n{body}",
1108            managed_marker_block(&spec())
1109        )
1110    }
1111
1112    fn temp_skills_dir(name: &str) -> PathBuf {
1113        let suffix = SystemTime::now()
1114            .duration_since(UNIX_EPOCH)
1115            .map(|d| d.as_nanos())
1116            .unwrap_or(0);
1117        std::env::temp_dir().join(format!(
1118            "afdata_skill_{name}_{}_{}",
1119            std::process::id(),
1120            suffix
1121        ))
1122    }
1123
1124    fn options(agent: SkillAgentSelection, dir: &Path, force: bool) -> SkillOptions {
1125        SkillOptions {
1126            agent,
1127            scope: SkillScope::Personal,
1128            skills_dir: Some(dir.to_string_lossy().to_string()),
1129            force,
1130        }
1131    }
1132
1133    fn custom_target(agent: SkillAgent, dir: &Path) -> SkillTarget {
1134        let skill_dir = dir.join("agent-first-test");
1135        SkillTarget {
1136            agent,
1137            scope: SkillScope::Personal,
1138            skills_dir: dir.to_path_buf(),
1139            skill_path: skill_dir.join(SKILL_FILE_NAME),
1140            skill_dir,
1141        }
1142    }
1143
1144    #[test]
1145    fn validates_bundled_frontmatter() {
1146        assert!(crate::skill::validate_skill_named(SKILL_SOURCE, "agent-first-test").is_ok());
1147    }
1148
1149    #[test]
1150    fn rejects_unquoted_colon_space() {
1151        let bad = "---\nname: x\ndescription: broken: yaml\n---\n";
1152        assert!(crate::skill::validate_skill(bad).is_err());
1153    }
1154
1155    fn install_status_uninstall_for(agent: SkillAgentSelection, expect: SkillAgent, tag: &str) {
1156        let dir = temp_skills_dir(tag);
1157        let opts = options(agent, &dir, false);
1158        let skill_path = dir.join("agent-first-test").join(SKILL_FILE_NAME);
1159
1160        let installed = run_skill_admin(&spec(), SkillAction::Install, &opts);
1161        assert!(installed.is_ok());
1162        assert!(skill_path.is_file());
1163        let text = std::fs::read_to_string(&skill_path).unwrap_or_default();
1164        assert!(text.contains(&managed_marker_block(&spec())));
1165        assert!(text.contains("aftest-managed-skill-name: agent-first-test"));
1166        assert!(text.contains("aftest-managed-skill-owner: aftest"));
1167        assert!(text.contains("aftest-managed-skill-content-hash-fnv1a64:"));
1168        assert!(!text.contains("aftest-managed-skill-source-hash-fnv1a64:"));
1169
1170        let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1171        assert!(status.is_ok());
1172        if let Ok(SkillReport::Status {
1173            installed_all,
1174            valid_all,
1175            current_all,
1176            targets,
1177            ..
1178        }) = status
1179        {
1180            assert!(installed_all);
1181            assert!(valid_all);
1182            assert!(current_all);
1183            assert_eq!(targets.first().map(|t| t.agent), Some(expect));
1184            assert_eq!(targets.first().map(|t| t.current), Some(true));
1185        }
1186
1187        let removed = run_skill_admin(&spec(), SkillAction::Uninstall, &opts);
1188        assert!(removed.is_ok());
1189        assert!(!skill_path.exists());
1190        let _ = std::fs::remove_dir_all(dir);
1191    }
1192
1193    #[test]
1194    fn install_status_uninstall_codex() {
1195        install_status_uninstall_for(SkillAgentSelection::Codex, SkillAgent::Codex, "codex");
1196    }
1197
1198    #[test]
1199    fn install_status_uninstall_claude_code() {
1200        install_status_uninstall_for(
1201            SkillAgentSelection::ClaudeCode,
1202            SkillAgent::ClaudeCode,
1203            "claude",
1204        );
1205    }
1206
1207    #[test]
1208    fn install_status_uninstall_opencode() {
1209        install_status_uninstall_for(
1210            SkillAgentSelection::Opencode,
1211            SkillAgent::Opencode,
1212            "opencode",
1213        );
1214    }
1215
1216    #[test]
1217    fn install_status_uninstall_hermes() {
1218        install_status_uninstall_for(SkillAgentSelection::Hermes, SkillAgent::Hermes, "hermes");
1219    }
1220
1221    fn spec_with_assets() -> SkillSpec<'static> {
1222        const ASSETS: &[SkillAsset] = &[
1223            SkillAsset {
1224                path: "references/guide.md",
1225                contents: "# guide\n",
1226            },
1227            SkillAsset {
1228                path: "references/registry.json",
1229                contents: "{\"ok\":true}\n",
1230            },
1231        ];
1232        SkillSpec {
1233            name: "agent-first-test",
1234            source: SKILL_SOURCE,
1235            title: "Agent-First Test",
1236            marker_slug: "aftest",
1237            assets: ASSETS,
1238        }
1239    }
1240
1241    #[test]
1242    fn install_writes_and_uninstall_removes_bundled_assets() {
1243        let dir = temp_skills_dir("assets");
1244        let opts = options(SkillAgentSelection::Codex, &dir, false);
1245        let skill_dir = dir.join("agent-first-test");
1246        let guide = skill_dir.join("references").join("guide.md");
1247        let registry = skill_dir.join("references").join("registry.json");
1248
1249        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1250        assert_eq!(
1251            std::fs::read_to_string(&guide).unwrap_or_default(),
1252            "# guide\n"
1253        );
1254        assert_eq!(
1255            std::fs::read_to_string(&registry).unwrap_or_default(),
1256            "{\"ok\":true}\n"
1257        );
1258
1259        // A SKILL.md-only install (a bundled asset missing) must report
1260        // not-current so a plain re-install repopulates it — the exact
1261        // regression this fixes.
1262        std::fs::remove_file(&guide).unwrap();
1263        match run_skill_admin(&spec_with_assets(), SkillAction::Status, &opts) {
1264            Ok(SkillReport::Status { current_all, .. }) => {
1265                assert!(
1266                    !current_all,
1267                    "a missing asset must make the skill not-current"
1268                );
1269            }
1270            other => panic!("unexpected status: {other:?}"),
1271        }
1272        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1273        assert!(guide.is_file(), "re-install must restore the missing asset");
1274
1275        assert!(run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_ok());
1276        assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
1277        assert!(!guide.exists(), "uninstall must remove bundled assets");
1278        assert!(
1279            !skill_dir.join("references").exists(),
1280            "uninstall must remove now-empty asset directories"
1281        );
1282        assert!(
1283            !skill_dir.exists(),
1284            "uninstall must remove the skill directory"
1285        );
1286        let _ = std::fs::remove_dir_all(dir);
1287    }
1288
1289    #[test]
1290    fn install_rejects_escaping_asset_path() {
1291        const ESCAPE: &[SkillAsset] = &[SkillAsset {
1292            path: "../evil.md",
1293            contents: "x",
1294        }];
1295        let dir = temp_skills_dir("assets-escape");
1296        let opts = options(SkillAgentSelection::Codex, &dir, false);
1297        let spec = SkillSpec {
1298            name: "agent-first-test",
1299            source: SKILL_SOURCE,
1300            title: "Agent-First Test",
1301            marker_slug: "aftest",
1302            assets: ESCAPE,
1303        };
1304        assert!(
1305            run_skill_admin(&spec, SkillAction::Install, &opts).is_err(),
1306            "an asset path escaping the skill dir must be rejected"
1307        );
1308        assert!(!dir.join("evil.md").exists());
1309        let _ = std::fs::remove_dir_all(dir);
1310    }
1311
1312    #[test]
1313    fn status_reports_stale_install_as_not_current() {
1314        let dir = temp_skills_dir("stale");
1315        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1316        let skill_dir = dir.join("agent-first-test");
1317        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1318        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1319        // A managed marker but stale body: valid + managed, but not current.
1320        let stale = managed_skill_with_body("# Body\n\nOLD rules.\n");
1321        assert!(std::fs::write(&skill_path, stale).is_ok());
1322
1323        let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1324        if let Ok(SkillReport::Status {
1325            current_all,
1326            targets,
1327            ..
1328        }) = status
1329        {
1330            assert!(!current_all);
1331            if let Some(t) = targets.first() {
1332                assert!(t.installed);
1333                assert!(t.valid);
1334                assert!(t.managed);
1335                assert!(!t.current);
1336            }
1337        }
1338
1339        // Reinstall makes it current again.
1340        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_ok());
1341        let refreshed = std::fs::read_to_string(&skill_path).unwrap_or_default();
1342        assert!(refreshed.contains(&managed_marker_block(&spec())));
1343        assert!(!refreshed.contains("<!-- aftest-managed-skill: true -->"));
1344        if let Ok(SkillReport::Status { targets, .. }) =
1345            run_skill_admin(&spec(), SkillAction::Status, &opts)
1346        {
1347            assert_eq!(targets.first().map(|t| t.current), Some(true));
1348        }
1349        let _ = std::fs::remove_dir_all(dir);
1350    }
1351
1352    #[test]
1353    fn random_text_with_marker_words_is_not_managed() {
1354        let dir = temp_skills_dir("marker-words");
1355        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1356        let skill_dir = dir.join("agent-first-test");
1357        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1358        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1359        let random = format!(
1360            "---\nname: agent-first-test\ndescription: test skill\n---\n\nThis mentions {} and {} but is not a generated block.\n",
1361            generated_by(&spec()),
1362            "aftest-managed-skill: true"
1363        );
1364        assert!(std::fs::write(&skill_path, random).is_ok());
1365
1366        if let Ok(SkillReport::Status { targets, .. }) =
1367            run_skill_admin(&spec(), SkillAction::Status, &opts)
1368        {
1369            assert_eq!(targets.first().map(|t| t.managed), Some(false));
1370        }
1371        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1372        let _ = std::fs::remove_dir_all(dir);
1373    }
1374
1375    #[test]
1376    fn old_marker_format_is_not_managed() {
1377        let dir = temp_skills_dir("old-marker");
1378        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1379        let skill_dir = dir.join("agent-first-test");
1380        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1381        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1382        let old_marker = concat!(
1383            "---\n",
1384            "name: agent-first-test\n",
1385            "description: test skill\n",
1386            "---\n",
1387            "<!--\n",
1388            "Generated by aftest skill install\n",
1389            "aftest-managed-skill: true\n",
1390            "aftest-managed-skill-name: agent-first-test\n",
1391            "aftest-managed-skill-source-hash-fnv1a64: deadbeef\n",
1392            "-->\n",
1393            "\n",
1394            "# Body\n",
1395            "\n",
1396            "rules.\n"
1397        );
1398        assert!(std::fs::write(&skill_path, old_marker).is_ok());
1399
1400        if let Ok(SkillReport::Status { targets, .. }) =
1401            run_skill_admin(&spec(), SkillAction::Status, &opts)
1402            && let Some(t) = targets.first()
1403        {
1404            assert!(t.installed);
1405            assert!(t.valid);
1406            assert!(!t.managed);
1407            assert!(!t.current);
1408        }
1409        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1410        let _ = std::fs::remove_dir_all(dir);
1411    }
1412
1413    #[test]
1414    fn install_preflight_reports_all_targets_without_writing() {
1415        let dir = temp_skills_dir("install-preflight");
1416        let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1417        let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1418        assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1419        assert!(
1420            std::fs::write(
1421                &opencode.skill_path,
1422                "---\nname: custom\ndescription: custom\n---\n"
1423            )
1424            .is_ok()
1425        );
1426        let opts = SkillOptions {
1427            agent: SkillAgentSelection::All,
1428            scope: SkillScope::Personal,
1429            skills_dir: None,
1430            force: false,
1431        };
1432
1433        let result = preflight_install_targets(&spec(), &opts, &[codex, opencode]);
1434        assert!(result.is_err());
1435        let Err(err) = result else {
1436            return;
1437        };
1438        assert!(
1439            err.message
1440                .contains("refusing to overwrite unmanaged skill")
1441        );
1442        assert!(!dir.join("codex").join("agent-first-test").exists());
1443        let partial_report = err.partial_report;
1444        assert!(matches!(partial_report, Some(SkillReport::Install { .. })));
1445        let Some(SkillReport::Install {
1446            installed, targets, ..
1447        }) = partial_report
1448        else {
1449            return;
1450        };
1451        assert!(!installed);
1452        assert_eq!(targets.len(), 2);
1453        assert_eq!(targets.first().map(|target| target.installed), Some(false));
1454        assert_eq!(targets.get(1).map(|target| target.installed), Some(true));
1455        assert_eq!(targets.get(1).map(|target| target.managed), Some(false));
1456        let _ = std::fs::remove_dir_all(dir);
1457    }
1458
1459    #[test]
1460    fn uninstall_preflight_reports_all_targets_without_removing() {
1461        let dir = temp_skills_dir("uninstall-preflight");
1462        let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1463        let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1464        assert!(std::fs::create_dir_all(&codex.skill_dir).is_ok());
1465        assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1466        assert!(std::fs::write(&codex.skill_path, managed_skill_contents(&spec())).is_ok());
1467        assert!(
1468            std::fs::write(
1469                &opencode.skill_path,
1470                "---\nname: custom\ndescription: custom\n---\n"
1471            )
1472            .is_ok()
1473        );
1474        let opts = SkillOptions {
1475            agent: SkillAgentSelection::All,
1476            scope: SkillScope::Personal,
1477            skills_dir: None,
1478            force: false,
1479        };
1480
1481        let result = preflight_uninstall_targets(&spec(), &opts, &[codex, opencode]);
1482        assert!(result.is_err());
1483        let Err(err) = result else {
1484            return;
1485        };
1486        assert!(err.message.contains("refusing to remove unmanaged skill"));
1487        assert!(
1488            dir.join("codex")
1489                .join("agent-first-test")
1490                .join(SKILL_FILE_NAME)
1491                .exists()
1492        );
1493        let partial_report = err.partial_report;
1494        assert!(matches!(
1495            partial_report,
1496            Some(SkillReport::Uninstall { .. })
1497        ));
1498        let Some(SkillReport::Uninstall {
1499            removed_any,
1500            targets,
1501            ..
1502        }) = partial_report
1503        else {
1504            return;
1505        };
1506        assert!(!removed_any);
1507        assert_eq!(targets.len(), 2);
1508        assert!(targets.iter().all(|target| !target.removed));
1509        let _ = std::fs::remove_dir_all(dir);
1510    }
1511
1512    #[test]
1513    fn install_and_uninstall_refuse_unmanaged() {
1514        let dir = temp_skills_dir("unmanaged");
1515        let skill_dir = dir.join("agent-first-test");
1516        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1517        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1518        assert!(
1519            std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
1520        );
1521        let opts = options(SkillAgentSelection::Codex, &dir, false);
1522
1523        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1524        assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1525        assert!(skill_path.exists());
1526        let _ = std::fs::remove_dir_all(dir);
1527    }
1528
1529    #[test]
1530    fn invalid_spec_slugs_are_rejected_before_path_resolution() {
1531        for name in ["", "../x", "x/y", ".hidden", "bad_name", "Bad"] {
1532            let bad = SkillSpec {
1533                name,
1534                source: SKILL_SOURCE,
1535                title: "Bad",
1536                marker_slug: "aftest",
1537                assets: &[],
1538            };
1539            let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1540            assert!(
1541                run_skill_admin(&bad, SkillAction::Status, &opts).is_err(),
1542                "{name:?}"
1543            );
1544        }
1545
1546        let bad_marker = SkillSpec {
1547            name: "agent-first-test",
1548            source: SKILL_SOURCE,
1549            title: "Bad",
1550            marker_slug: "../aftest",
1551            assets: &[],
1552        };
1553        let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1554        assert!(run_skill_admin(&bad_marker, SkillAction::Status, &opts).is_err());
1555    }
1556
1557    #[test]
1558    fn frontmatter_name_must_match_spec_name() {
1559        let bad = SkillSpec {
1560            name: "agent-first-test",
1561            source: "---\nname: other-skill\ndescription: test skill\n---\n",
1562            title: "Bad",
1563            marker_slug: "aftest",
1564            assets: &[],
1565        };
1566        let dir = temp_skills_dir("frontmatter-name");
1567        let opts = options(SkillAgentSelection::Codex, &dir, false);
1568        assert!(run_skill_admin(&bad, SkillAction::Install, &opts).is_err());
1569        let _ = std::fs::remove_dir_all(dir);
1570    }
1571
1572    #[cfg(unix)]
1573    #[test]
1574    fn symlink_target_is_rejected_by_default_and_force_does_not_follow() {
1575        use std::os::unix::fs::symlink;
1576
1577        let dir = temp_skills_dir("symlink-install");
1578        let opts = options(SkillAgentSelection::Codex, &dir, false);
1579        let force_opts = options(SkillAgentSelection::Codex, &dir, true);
1580        let skill_dir = dir.join("agent-first-test");
1581        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1582        let external = dir.join("external.md");
1583        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1584        assert!(std::fs::write(&external, "external").is_ok());
1585        assert!(symlink(&external, &skill_path).is_ok());
1586
1587        assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1588        assert_eq!(
1589            std::fs::read_to_string(&external).unwrap_or_default(),
1590            "external"
1591        );
1592        assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1593        assert!(skill_path.is_symlink());
1594
1595        assert!(run_skill_admin(&spec(), SkillAction::Install, &force_opts).is_ok());
1596        assert_eq!(
1597            std::fs::read_to_string(&external).unwrap_or_default(),
1598            "external"
1599        );
1600        assert!(skill_path.is_file());
1601        assert!(!skill_path.is_symlink());
1602        let _ = std::fs::remove_dir_all(dir);
1603    }
1604
1605    #[cfg(unix)]
1606    #[test]
1607    fn force_uninstall_removes_symlink_without_following() {
1608        use std::os::unix::fs::symlink;
1609
1610        let dir = temp_skills_dir("symlink-uninstall");
1611        let force_opts = options(SkillAgentSelection::Codex, &dir, true);
1612        let skill_dir = dir.join("agent-first-test");
1613        let skill_path = skill_dir.join(SKILL_FILE_NAME);
1614        let external = dir.join("external.md");
1615        assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1616        assert!(std::fs::write(&external, "external").is_ok());
1617        assert!(symlink(&external, &skill_path).is_ok());
1618
1619        assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &force_opts).is_ok());
1620        assert!(!skill_path.exists());
1621        assert_eq!(
1622            std::fs::read_to_string(&external).unwrap_or_default(),
1623            "external"
1624        );
1625        let _ = std::fs::remove_dir_all(dir);
1626    }
1627
1628    #[test]
1629    fn serializes_to_protocol_shape() {
1630        let dir = temp_skills_dir("serialize");
1631        let opts = options(SkillAgentSelection::Opencode, &dir, false);
1632        if let Ok(report) = run_skill_admin(&spec(), SkillAction::Install, &opts) {
1633            let value = serde_json::to_value(&report).unwrap_or(serde_json::Value::Null);
1634            assert_eq!(value["code"], "skill_install");
1635            assert_eq!(value["installed"], true);
1636            assert_eq!(value["targets"][0]["agent"], "opencode");
1637            assert_eq!(value["targets"][0]["current"], true);
1638            assert_eq!(
1639                value["targets"][0]["skill_dir"],
1640                serde_json::json!(dir.join("agent-first-test").to_string_lossy().to_string())
1641            );
1642        }
1643        let _ = std::fs::remove_dir_all(dir);
1644    }
1645
1646    #[test]
1647    fn all_personal_resolves_four_targets() {
1648        let opts = SkillOptions {
1649            agent: SkillAgentSelection::All,
1650            scope: SkillScope::Personal,
1651            skills_dir: None,
1652            force: false,
1653        };
1654        let targets = resolve_targets(&spec(), &opts);
1655        assert!(targets.is_ok());
1656        if let Ok(targets) = targets {
1657            assert_eq!(targets.len(), 4);
1658            assert_eq!(targets[0].agent, SkillAgent::Codex);
1659            assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
1660            assert_eq!(targets[2].agent, SkillAgent::Opencode);
1661            assert_eq!(targets[3].agent, SkillAgent::Hermes);
1662        }
1663    }
1664
1665    #[test]
1666    fn all_workspace_resolves_four_targets() {
1667        let opts = SkillOptions {
1668            agent: SkillAgentSelection::All,
1669            scope: SkillScope::Workspace,
1670            skills_dir: None,
1671            force: false,
1672        };
1673        let targets = resolve_targets(&spec(), &opts);
1674        assert!(targets.is_ok());
1675        if let Ok(targets) = targets {
1676            assert_eq!(targets.len(), 4);
1677            assert_eq!(targets[0].agent, SkillAgent::Codex);
1678            assert_eq!(targets[0].scope, SkillScope::Workspace);
1679            assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
1680            assert_eq!(targets[1].scope, SkillScope::Workspace);
1681            assert_eq!(targets[2].agent, SkillAgent::Opencode);
1682            assert_eq!(targets[2].scope, SkillScope::Workspace);
1683            assert_eq!(targets[3].agent, SkillAgent::Hermes);
1684            assert_eq!(targets[3].scope, SkillScope::Workspace);
1685        }
1686    }
1687
1688    #[test]
1689    fn codex_workspace_scope_uses_codex_skills_dir() {
1690        let opts = SkillOptions {
1691            agent: SkillAgentSelection::Codex,
1692            scope: SkillScope::Workspace,
1693            skills_dir: None,
1694            force: false,
1695        };
1696        let targets = resolve_targets(&spec(), &opts);
1697        assert!(targets.is_ok());
1698        if let Ok(targets) = targets {
1699            assert_eq!(targets.len(), 1);
1700            assert_eq!(targets[0].agent, SkillAgent::Codex);
1701            assert_eq!(targets[0].scope, SkillScope::Workspace);
1702            assert!(targets[0].skills_dir.ends_with(".codex/skills"));
1703        }
1704    }
1705
1706    #[test]
1707    fn hermes_workspace_scope_uses_hermes_skills_dir() {
1708        let opts = SkillOptions {
1709            agent: SkillAgentSelection::Hermes,
1710            scope: SkillScope::Workspace,
1711            skills_dir: None,
1712            force: false,
1713        };
1714        let targets = resolve_targets(&spec(), &opts);
1715        assert!(targets.is_ok());
1716        if let Ok(targets) = targets {
1717            assert_eq!(targets.len(), 1);
1718            assert_eq!(targets[0].agent, SkillAgent::Hermes);
1719            assert_eq!(targets[0].scope, SkillScope::Workspace);
1720            assert!(targets[0].skills_dir.ends_with(".hermes/skills"));
1721        }
1722    }
1723}