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