Skip to main content

cmx_core/
skill_install.rs

1//! High-level, embeddable skill-installation API.
2//!
3//! A tool bundles its companion skill (as [`BundledSkill`]) and calls
4//! [`SkillInstaller`] to install, query, or remove it — without knowing about
5//! any cmx internals.
6//!
7//! ```no_run
8//! # use anyhow::Result;
9//! # fn main() -> Result<()> {
10//! use cmx_core::production::ProductionContext;
11//! use cmx_core::skill_install::{BundledSkill, Scope, SkillInstaller, ToolIdentity};
12//!
13//! // The SKILL.md needs no version of its own — the installer stamps
14//! // `metadata.version` from the ToolIdentity below at install time.
15//! let skill = BundledSkill::single_md("---\nname: mytool\n---\n# My skill\n");
16//! let installer = SkillInstaller::new(ToolIdentity::new("mytool", "1.2.0"));
17//! let prod_ctx = ProductionContext::claude()?;
18//! let ctx = prod_ctx.ctx();
19//! let plan = installer.plan(&skill, Scope::Global, false, &ctx)?;
20//! println!("{plan}");
21//! let report = installer.apply(&skill, &plan, &ctx)?;
22//! println!("{report}");
23//! # Ok(())
24//! # }
25//! ```
26
27use anyhow::{Result, bail};
28use std::cmp::Ordering;
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::PathBuf;
31
32use crate::checksum;
33use crate::config;
34use crate::context::AppContext;
35use crate::frontmatter;
36use crate::fs_util;
37use crate::lockfile;
38use crate::platform::Platform;
39use crate::platform_iter;
40use crate::skill_fs::{self, SkillFile};
41use crate::targets;
42use crate::types::{ArtifactKind, InstallScope, LockEntry, LockSource, SourceEntry, SourceType};
43
44// ---------------------------------------------------------------------------
45// Public API types
46// ---------------------------------------------------------------------------
47
48/// Identity of the embedding tool — name and semver version string.
49#[derive(Debug, Clone)]
50pub struct ToolIdentity {
51    pub name: String,
52    pub version: String,
53}
54
55impl ToolIdentity {
56    /// Construct a tool identity from a name and version string.
57    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
58        Self {
59            name: name.into(),
60            version: version.into(),
61        }
62    }
63}
64
65/// A skill bundled inside a tool binary (via `include_str!` or similar).
66pub struct BundledSkill {
67    pub files: Vec<SkillFile>,
68}
69
70impl BundledSkill {
71    /// Construct from a list of files (e.g. assembled by the embedding tool from
72    /// `include_str!` or `include_bytes!` calls).
73    pub fn from_files(files: Vec<SkillFile>) -> Self {
74        Self { files }
75    }
76
77    /// Convenience constructor for the common single-`SKILL.md` case.
78    ///
79    /// Builds a bundle containing exactly one file at path `SKILL.md` with the
80    /// given content. Use `from_files` when the skill includes additional files.
81    pub fn single_md(content: &str) -> Self {
82        Self {
83            files: vec![SkillFile {
84                rel_path: PathBuf::from("SKILL.md"),
85                bytes: content.as_bytes().to_vec(),
86            }],
87        }
88    }
89
90    /// Returns `true` when the bundle contains a `SKILL.md` at the root level
91    /// (i.e. `rel_path == "SKILL.md"`).
92    pub fn has_skill_md(&self) -> bool {
93        self.files.iter().any(|f| f.rel_path.as_os_str() == "SKILL.md")
94    }
95}
96
97/// Installation scope — global (user-wide) or local (project-scoped).
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
99pub enum Scope {
100    #[default]
101    Global,
102    Local,
103}
104
105impl Scope {
106    pub fn to_install_scope(self) -> InstallScope {
107        match self {
108            Scope::Global => InstallScope::Global,
109            Scope::Local => InstallScope::Local,
110        }
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Plan types
116// ---------------------------------------------------------------------------
117
118/// The action to take for a single target platform during an install.
119///
120/// # Non-exhaustive
121///
122/// This enum is `#[non_exhaustive]`: new action variants may be added in
123/// future minor releases. Embedders should render actions via the `Display`
124/// impl on `Report`/`InstallPlan` or match on specific variants they care
125/// about with a catch-all `_` arm. The `will_write()` and `is_blocked()`
126/// helpers cover the two common branching points without requiring exhaustive
127/// matching.
128#[non_exhaustive]
129#[derive(Debug, Clone)]
130pub enum TargetAction {
131    /// First-time install (no existing copy).
132    Install,
133    /// Overwrite an older installed version.
134    Update {
135        /// The previously installed version string (if known).
136        from: Option<String>,
137    },
138    /// Already installed at the same version and checksum — nothing to do.
139    Skip,
140    /// Same version but the on-disk content differs from the bundled content,
141    /// and `force` was not requested.
142    DriftedSkip { installed: String },
143    /// The installed version is newer than the bundled version, and `force` was
144    /// not requested.
145    RefuseNewer { installed: String },
146    /// The installed version is newer, but `force` was requested — downgrade.
147    Downgrade { from: String },
148}
149
150impl TargetAction {
151    /// Whether this action will write files to disk.
152    pub fn will_write(&self) -> bool {
153        matches!(self, Self::Install | Self::Update { .. } | Self::Downgrade { .. })
154    }
155
156    /// Whether this action blocks the install from proceeding.
157    pub fn is_blocked(&self) -> bool {
158        matches!(self, Self::RefuseNewer { .. })
159    }
160}
161
162/// A single file to be written, with its relative and absolute destination paths.
163#[derive(Debug, Clone)]
164pub struct PlannedFile {
165    /// Relative path within the skill directory (e.g. `SKILL.md`).
166    pub rel_path: PathBuf,
167    /// Absolute (or scope-relative) destination path on disk.
168    pub dest_path: PathBuf,
169}
170
171/// The plan for a single target platform.
172#[derive(Debug)]
173pub struct TargetPlan {
174    pub platform: Platform,
175    pub scope: InstallScope,
176    pub dest_dir: PathBuf,
177    pub files: Vec<PlannedFile>,
178    pub action: TargetAction,
179    /// Whether this platform is in the cmx-managed set.
180    pub cmx_managed: bool,
181}
182
183/// The full installation plan — computed from source metadata, with no writes.
184#[derive(Debug)]
185pub struct InstallPlan {
186    pub tool: ToolIdentity,
187    pub scope: InstallScope,
188    pub source_checksum: String,
189    /// Whether cmx is managing this machine (config or non-empty lock exists).
190    pub cmx_present: bool,
191    pub force: bool,
192    pub targets: Vec<TargetPlan>,
193}
194
195impl InstallPlan {
196    /// Returns `true` if any target action is blocked (e.g. `RefuseNewer`).
197    pub fn is_blocked(&self) -> bool {
198        self.targets.iter().any(|t| t.action.is_blocked())
199    }
200
201    /// The number of targets that will write files to disk.
202    pub fn write_count(&self) -> usize {
203        self.targets.iter().filter(|t| t.action.will_write()).count()
204    }
205}
206
207impl std::fmt::Display for InstallPlan {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        writeln!(f, "Install plan for {} v{}", self.tool.name, self.tool.version)?;
210        writeln!(f, "  scope: {}", self.scope.label())?;
211        writeln!(f, "  checksum: {}", self.source_checksum)?;
212        for target in &self.targets {
213            writeln!(
214                f,
215                "  {} → {} ({})",
216                target.platform,
217                target.dest_dir.display(),
218                format_action(&target.action)
219            )?;
220        }
221        Ok(())
222    }
223}
224
225fn format_action(action: &TargetAction) -> String {
226    match action {
227        TargetAction::Install => "install".to_string(),
228        TargetAction::Update { from } => {
229            format!("update from {}", from.as_deref().unwrap_or("?"))
230        }
231        TargetAction::Skip => "skip (up to date)".to_string(),
232        TargetAction::DriftedSkip { installed } => {
233            format!("skip (drifted from {installed})")
234        }
235        TargetAction::RefuseNewer { installed } => {
236            format!("refuse (installed {installed} is newer)")
237        }
238        TargetAction::Downgrade { from } => format!("downgrade from {from}"),
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Apply result types
244// ---------------------------------------------------------------------------
245
246/// The outcome for a single target platform after `apply`.
247///
248/// Both written and skipped targets appear in `Report::targets`; the `action`
249/// field distinguishes them. Use `Report::applied()` / `Report::skipped()` for
250/// filtered views.
251#[derive(Debug)]
252pub struct TargetOutcome {
253    pub platform: Platform,
254    pub dest_dir: PathBuf,
255    pub action: TargetAction,
256    /// Number of files written to disk (0 for skipped targets).
257    pub files_written: usize,
258    /// Checksum recorded in the lock file. `Some` for written targets, `None`
259    /// for skipped targets (no lock entry was touched).
260    pub installed_checksum: Option<String>,
261    /// Concrete target files whose local changes were discarded by `--force`.
262    pub discarded_paths: Vec<PathBuf>,
263}
264
265/// The full report returned by [`SkillInstaller::apply`].
266///
267/// All targets — both written and skipped — are captured in `targets` so that
268/// skipped targets retain their `dest_dir` and the `action` discriminant
269/// distinguishes an ordinary up-to-date skip from a `DriftedSkip` (local edits
270/// preserved). Use the `applied()` and `skipped()` iterators for filtered views.
271#[derive(Debug)]
272pub struct Report {
273    pub tool: ToolIdentity,
274    pub scope: InstallScope,
275    /// Every platform that was considered, written or not.
276    pub targets: Vec<TargetOutcome>,
277    /// Whether a source entry was registered in sources.json.
278    pub source_registered: bool,
279}
280
281impl Report {
282    /// Targets where files were written to disk (Install / Update / Downgrade).
283    pub fn applied(&self) -> impl Iterator<Item = &TargetOutcome> {
284        self.targets.iter().filter(|o| o.action.will_write())
285    }
286
287    /// Targets where no files were written (`Skip` / `DriftedSkip` / `RefuseNewer`).
288    pub fn skipped(&self) -> impl Iterator<Item = &TargetOutcome> {
289        self.targets.iter().filter(|o| !o.action.will_write())
290    }
291}
292
293struct PreparedWrites {
294    dirs_to_write: BTreeSet<PathBuf>,
295    discarded_paths_by_dir: BTreeMap<PathBuf, Vec<PathBuf>>,
296}
297
298impl std::fmt::Display for Report {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        writeln!(
301            f,
302            "Installed {} v{} ({})",
303            self.tool.name,
304            self.tool.version,
305            self.scope.label()
306        )?;
307        for outcome in &self.targets {
308            writeln!(
309                f,
310                "  {} → {} ({})",
311                outcome.platform,
312                outcome.dest_dir.display(),
313                format_action(&outcome.action)
314            )?;
315        }
316        if self.source_registered {
317            writeln!(f, "  (registered as cmx source)")?;
318        }
319        Ok(())
320    }
321}
322
323impl std::fmt::Display for RemoveReport {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        writeln!(f, "Removed {} ({})", self.tool_name, self.scope.label())?;
326        for platform in &self.platforms_cleared {
327            writeln!(f, "  {platform} lock entry cleared")?;
328        }
329        for dir in &self.removed_dirs {
330            writeln!(f, "  removed: {}", dir.display())?;
331        }
332        if self.source_unregistered {
333            writeln!(f, "  unregistered from cmx sources")?;
334        }
335        writeln!(f, "  note: cmx-lock.json left on disk (shared with other tools)")?;
336        Ok(())
337    }
338}
339
340// ---------------------------------------------------------------------------
341// Status types
342// ---------------------------------------------------------------------------
343
344/// Status of a skill on a single platform.
345#[derive(Debug)]
346pub struct TargetStatus {
347    pub platform: Platform,
348    pub installed: bool,
349    pub installed_version: Option<String>,
350    pub drifted: bool,
351    pub tracked: bool,
352}
353
354/// Status of a skill across all relevant platforms.
355#[derive(Debug)]
356pub struct Status {
357    pub tool_name: String,
358    pub scope: InstallScope,
359    pub targets: Vec<TargetStatus>,
360}
361
362// ---------------------------------------------------------------------------
363// Remove result types
364// ---------------------------------------------------------------------------
365
366/// The result of removing a skill.
367#[derive(Debug)]
368pub struct RemoveReport {
369    pub tool_name: String,
370    pub scope: InstallScope,
371    pub removed_dirs: Vec<PathBuf>,
372    pub platforms_cleared: Vec<Platform>,
373    pub source_unregistered: bool,
374    pub was_on_disk: bool,
375    pub was_tracked: bool,
376}
377
378// ---------------------------------------------------------------------------
379// SkillInstaller
380// ---------------------------------------------------------------------------
381
382/// High-level skill lifecycle manager for embedding tools.
383pub struct SkillInstaller {
384    tool: ToolIdentity,
385}
386
387impl SkillInstaller {
388    /// Create a new installer for the given tool identity.
389    pub fn new(tool: ToolIdentity) -> Self {
390        Self { tool }
391    }
392
393    /// Compute a dry-run install plan without writing anything.
394    ///
395    /// Fails if the bundle does not contain a `SKILL.md`.
396    pub fn plan(
397        &self,
398        skill: &BundledSkill,
399        scope: Scope,
400        force: bool,
401        ctx: &AppContext<'_>,
402    ) -> Result<InstallPlan> {
403        if !skill.has_skill_md() {
404            bail!("BundledSkill for '{}' is missing SKILL.md", self.tool.name);
405        }
406
407        // Reconcile the SKILL.md frontmatter's `metadata.version` to this tool's
408        // version before anything else, so the checksum, the written bytes, and the
409        // lock entry all describe the same, version-stamped content.
410        let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
411        let source_checksum = skill_fs::checksum_bundled(&files);
412        let install_scope = scope.to_install_scope();
413
414        let platform_targets =
415            targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
416
417        let cmx_managed = config::managed_platforms(ctx.fs, ctx.paths)?.is_some();
418        let cmx_present = cmx_managed || {
419            // Check whether any platform has a non-empty lock file
420            platform_iter::views_for(ctx.paths, platform_iter::all(), ArtifactKind::Skill).any(
421                |view| {
422                    lockfile::load(install_scope, ctx.fs, &view.paths)
423                        .ok()
424                        .is_some_and(|l| !l.packages.is_empty())
425                },
426            )
427        };
428
429        let mut target_plans = Vec::new();
430        // Track which dest_dirs we've already planned files for (shared dirs).
431        // For platforms that share a dest_dir (e.g. .agents/skills), we still
432        // produce a TargetPlan per platform but with the same files list.
433        // Apply will dedup writes by dest_dir.
434
435        for &platform in &platform_targets {
436            let pv = ctx.paths.with_platform(platform);
437            let dest_dir = pv.require_install_dir(ArtifactKind::Skill, install_scope)?;
438            let skill_dest = dest_dir.join(&self.tool.name);
439
440            // Build planned files
441            let planned_files: Vec<PlannedFile> = files
442                .iter()
443                .map(|f| PlannedFile {
444                    rel_path: f.rel_path.clone(),
445                    dest_path: skill_dest.join(&f.rel_path),
446                })
447                .collect();
448
449            // Determine the action for this platform
450            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
451            let action = if let Some(entry) = lock.packages.get(&self.tool.name) {
452                decide_action_for_entry(
453                    entry,
454                    &self.tool.version,
455                    &source_checksum,
456                    force,
457                    &skill_dest,
458                    ctx,
459                )?
460            } else if ctx.fs.exists(&skill_dest) {
461                // On disk but not tracked: treat as Install (untracked copy)
462                TargetAction::Install
463            } else {
464                TargetAction::Install
465            };
466
467            target_plans.push(TargetPlan {
468                platform,
469                scope: install_scope,
470                dest_dir: skill_dest,
471                files: planned_files,
472                action,
473                cmx_managed,
474            });
475        }
476
477        Ok(InstallPlan {
478            tool: self.tool.clone(),
479            scope: install_scope,
480            source_checksum,
481            cmx_present,
482            force,
483            targets: target_plans,
484        })
485    }
486
487    /// Apply an install plan, writing files and updating lock entries.
488    ///
489    /// Fails if:
490    /// - The plan is blocked (e.g. `RefuseNewer`).
491    /// - The bundled skill's checksum does not match the plan's `source_checksum`
492    ///   (parity guard — ensures the skill passed here is the same one planned).
493    pub fn apply(
494        &self,
495        skill: &BundledSkill,
496        plan: &InstallPlan,
497        ctx: &AppContext<'_>,
498    ) -> Result<Report> {
499        if plan.is_blocked() {
500            bail!(
501                "Install plan for '{}' is blocked. Run with force=true to override.",
502                self.tool.name
503            );
504        }
505
506        // Reconcile the same way plan() did, so the checksum below and the bytes
507        // written match the planned source_checksum exactly.
508        let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
509
510        // Parity guard: the skill passed here must match the one that was planned.
511        let current_checksum = skill_fs::checksum_bundled(&files);
512        if current_checksum != plan.source_checksum {
513            bail!(
514                "Parity check failed for '{}': the BundledSkill has changed since plan() was called.",
515                self.tool.name
516            );
517        }
518
519        let PreparedWrites {
520            dirs_to_write,
521            discarded_paths_by_dir,
522        } = prepare_writes(plan, &files, ctx)?;
523
524        // Write each distinct dir once.
525        for dir in &dirs_to_write {
526            skill_fs::write_skill_files(dir, &files, ctx.fs)?;
527        }
528
529        let installed_checksum = plan.source_checksum.clone();
530        let installed_at = ctx.clock.now().to_rfc3339();
531
532        let mut targets: Vec<TargetOutcome> = Vec::new();
533
534        for target in &plan.targets {
535            if !target.action.will_write() {
536                targets.push(TargetOutcome {
537                    platform: target.platform,
538                    dest_dir: target.dest_dir.clone(),
539                    action: target.action.clone(),
540                    files_written: 0,
541                    installed_checksum: None,
542                    discarded_paths: Vec::new(),
543                });
544                continue;
545            }
546
547            // Write lock entry for this platform.
548            let pv = ctx.paths.with_platform(target.platform);
549            lockfile::mutate(target.scope, ctx.fs, &pv, |lock| {
550                lock.packages.insert(
551                    self.tool.name.clone(),
552                    build_lock_entry(&self.tool, &installed_checksum, &installed_at),
553                );
554            })?;
555
556            targets.push(TargetOutcome {
557                platform: target.platform,
558                dest_dir: target.dest_dir.clone(),
559                action: target.action.clone(),
560                files_written: target.files.len(),
561                installed_checksum: Some(installed_checksum.clone()),
562                discarded_paths: discarded_paths_by_dir
563                    .get(&target.dest_dir)
564                    .cloned()
565                    .unwrap_or_default(),
566            });
567        }
568
569        // Register source if cmx is managing this machine.
570        let source_registered = if plan.cmx_present
571            && config::managed_platforms(ctx.fs, ctx.paths)?.is_some()
572        {
573            let source_name = format!("bundled:{}", self.tool.name);
574            // Materialize a directory under the default artifact home for source tracing.
575            let home =
576                config::resolve_artifact_home(&config::load_config(ctx.fs, ctx.paths)?, ctx.paths);
577            let materialized = home.join("skills").join(&self.tool.name);
578            skill_fs::write_skill_files(&materialized, &files, ctx.fs)?;
579
580            config::mutate_sources(ctx.fs, ctx.paths, |sources| {
581                sources.sources.entry(source_name.clone()).or_insert_with(|| SourceEntry {
582                    source_type: SourceType::Local,
583                    path: Some(materialized.clone()),
584                    url: None,
585                    local_clone: None,
586                    branch: None,
587                    last_updated: Some(ctx.clock.now().to_rfc3339()),
588                });
589                Ok(())
590            })?;
591            true
592        } else {
593            false
594        };
595
596        Ok(Report {
597            tool: self.tool.clone(),
598            scope: plan.scope,
599            targets,
600            source_registered,
601        })
602    }
603
604    /// Query the install status of this skill across relevant platforms.
605    pub fn status(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<Status> {
606        let install_scope = scope.to_install_scope();
607        let platform_targets =
608            targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
609
610        let mut target_statuses = Vec::new();
611        for &platform in &platform_targets {
612            let pv = ctx.paths.with_platform(platform);
613            let skill_dir = pv
614                .install_dir(ArtifactKind::Skill, install_scope)
615                .map(|d| d.join(&self.tool.name));
616
617            let installed = skill_dir.as_ref().is_some_and(|d| ctx.fs.exists(d));
618
619            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
620            let lock_entry = lock.packages.get(&self.tool.name);
621            let tracked = lock_entry.is_some();
622            let installed_version = lock_entry.and_then(|e| e.version.clone());
623
624            let drifted = if installed && tracked {
625                if let (Some(dir), Some(entry)) = (&skill_dir, lock_entry) {
626                    checksum::is_locally_modified(dir, ArtifactKind::Skill, entry, ctx.fs)
627                        .unwrap_or(false)
628                } else {
629                    false
630                }
631            } else {
632                false
633            };
634
635            target_statuses.push(TargetStatus {
636                platform,
637                installed,
638                installed_version,
639                drifted,
640                tracked,
641            });
642        }
643
644        Ok(Status {
645            tool_name: self.tool.name.clone(),
646            scope: install_scope,
647            targets: target_statuses,
648        })
649    }
650
651    /// Remove this skill from all relevant platforms.
652    pub fn remove(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<RemoveReport> {
653        let install_scope = scope.to_install_scope();
654        let platform_targets = config::managed_or_all_platforms(ctx.fs, ctx.paths)?
655            .into_iter()
656            .filter(|p| p.supports(ArtifactKind::Skill))
657            .collect::<Vec<_>>();
658
659        let mut dirs_to_delete: BTreeSet<PathBuf> = BTreeSet::new();
660        let mut platforms_cleared: Vec<Platform> = Vec::new();
661        let mut was_tracked = false;
662
663        for &platform in &platform_targets {
664            let pv = ctx.paths.with_platform(platform);
665
666            // Collect physical path for deletion.
667            if let Some(dir) = pv.install_dir(ArtifactKind::Skill, install_scope) {
668                let skill_dir = dir.join(&self.tool.name);
669                if ctx.fs.exists(&skill_dir) {
670                    dirs_to_delete.insert(skill_dir);
671                }
672            }
673
674            // Clear lock entry.
675            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
676            if lock.packages.contains_key(&self.tool.name) {
677                lockfile::mutate(install_scope, ctx.fs, &pv, |l| {
678                    l.packages.remove(&self.tool.name);
679                })?;
680                platforms_cleared.push(platform);
681                was_tracked = true;
682            }
683        }
684
685        let was_on_disk = !dirs_to_delete.is_empty();
686        let removed_dirs: Vec<PathBuf> = dirs_to_delete.into_iter().collect();
687        for dir in &removed_dirs {
688            ctx.fs.remove_dir_all(dir)?;
689        }
690
691        // Remove from sources and materialized home if managed.
692        let source_name = format!("bundled:{}", self.tool.name);
693        let source_unregistered = if let Ok(sources) = config::load_sources(ctx.fs, ctx.paths) {
694            if sources.sources.contains_key(&source_name) {
695                // Also remove the materialized skill directory.
696                if let Some(entry) = sources.sources.get(&source_name)
697                    && let Some(path) = &entry.path
698                    && ctx.fs.exists(path)
699                {
700                    ctx.fs.remove_dir_all(path)?;
701                }
702                config::mutate_sources(ctx.fs, ctx.paths, |s| {
703                    s.sources.remove(&source_name);
704                    Ok(())
705                })?;
706                true
707            } else {
708                false
709            }
710        } else {
711            false
712        };
713
714        Ok(RemoveReport {
715            tool_name: self.tool.name.clone(),
716            scope: install_scope,
717            removed_dirs,
718            platforms_cleared,
719            source_unregistered,
720            was_on_disk,
721            was_tracked,
722        })
723    }
724}
725
726// ---------------------------------------------------------------------------
727// Private helpers
728// ---------------------------------------------------------------------------
729
730/// Compare two semver version strings.
731///
732/// - `None` installed → `Less` (treat as "not installed").
733/// - Both parse → standard semver comparison.
734/// - Either parse fails → string equality: `Equal` if equal, else `Less`.
735fn compare_versions(installed: Option<&str>, bundled: &str) -> Ordering {
736    let Some(inst) = installed else {
737        return Ordering::Less;
738    };
739    match (semver::Version::parse(inst), semver::Version::parse(bundled)) {
740        (Ok(a), Ok(b)) => a.cmp(&b),
741        _ => {
742            if inst == bundled {
743                Ordering::Equal
744            } else {
745                Ordering::Less
746            }
747        }
748    }
749}
750
751/// Decide what action to take for a platform that already has a lock entry.
752fn decide_action_for_entry(
753    entry: &LockEntry,
754    bundled_version: &str,
755    source_checksum: &str,
756    force: bool,
757    skill_dest: &std::path::Path,
758    ctx: &AppContext<'_>,
759) -> Result<TargetAction> {
760    let installed_version = entry.version.as_deref();
761    let cmp = compare_versions(installed_version, bundled_version);
762
763    match cmp {
764        Ordering::Less => Ok(TargetAction::Update {
765            from: installed_version.map(str::to_string),
766        }),
767        Ordering::Equal => {
768            if !ctx.fs.exists(skill_dest) {
769                return Ok(TargetAction::Install);
770            }
771
772            let disk_checksum =
773                checksum::checksum_artifact(skill_dest, ArtifactKind::Skill, ctx.fs)?;
774            if disk_checksum == source_checksum {
775                Ok(TargetAction::Skip)
776            } else if force {
777                Ok(TargetAction::Update {
778                    from: installed_version.map(str::to_string),
779                })
780            } else {
781                Ok(TargetAction::DriftedSkip {
782                    installed: installed_version.unwrap_or("unknown").to_string(),
783                })
784            }
785        }
786        Ordering::Greater => {
787            if force {
788                Ok(TargetAction::Downgrade {
789                    from: installed_version.unwrap_or("unknown").to_string(),
790                })
791            } else {
792                Ok(TargetAction::RefuseNewer {
793                    installed: installed_version.unwrap_or("unknown").to_string(),
794                })
795            }
796        }
797    }
798}
799
800fn discarded_paths_against_bundle(
801    skill_dest: &std::path::Path,
802    bundled_files: &[SkillFile],
803    ctx: &AppContext<'_>,
804) -> Result<Vec<PathBuf>> {
805    if !ctx.fs.exists(skill_dest) {
806        return Ok(Vec::new());
807    }
808
809    let installed_files = fs_util::collect_files_recursive(skill_dest, ctx.fs)?;
810    let mut installed_by_rel = BTreeMap::new();
811    for path in installed_files {
812        let rel = path.strip_prefix(skill_dest).unwrap_or(&path).to_path_buf();
813        installed_by_rel.insert(rel, ctx.fs.read(&path)?);
814    }
815
816    let mut bundled_by_rel = BTreeMap::new();
817    for file in skill_fs::canonical_files(bundled_files) {
818        bundled_by_rel.insert(file.rel_path.clone(), file.bytes.clone());
819    }
820
821    let mut changed_paths = Vec::new();
822    let relative_paths: BTreeSet<_> =
823        installed_by_rel.keys().chain(bundled_by_rel.keys()).cloned().collect();
824
825    for rel_path in relative_paths {
826        match (installed_by_rel.get(&rel_path), bundled_by_rel.get(&rel_path)) {
827            (Some(installed), Some(bundled)) if installed == bundled => {}
828            (Some(_) | None, Some(_)) | (Some(_), None) => {
829                changed_paths.push(skill_dest.join(rel_path));
830            }
831            (None, None) => {}
832        }
833    }
834
835    Ok(changed_paths)
836}
837
838fn prepare_writes(
839    plan: &InstallPlan,
840    files: &[SkillFile],
841    ctx: &AppContext<'_>,
842) -> Result<PreparedWrites> {
843    let mut dirs_to_write = BTreeSet::new();
844    let mut dirs_to_replace = BTreeSet::new();
845
846    for target in &plan.targets {
847        if target.action.will_write() {
848            dirs_to_write.insert(target.dest_dir.clone());
849        }
850        if plan.force
851            && matches!(target.action, TargetAction::Update { .. } | TargetAction::Downgrade { .. })
852        {
853            dirs_to_replace.insert(target.dest_dir.clone());
854        }
855    }
856
857    let mut discarded_paths_by_dir = BTreeMap::new();
858    for dir in &dirs_to_replace {
859        discarded_paths_by_dir
860            .insert(dir.clone(), discarded_paths_against_bundle(dir, files, ctx)?);
861        if ctx.fs.exists(dir) {
862            ctx.fs.remove_dir_all(dir)?;
863        }
864    }
865
866    Ok(PreparedWrites {
867        dirs_to_write,
868        discarded_paths_by_dir,
869    })
870}
871
872fn build_lock_entry(tool: &ToolIdentity, checksum: &str, installed_at: &str) -> LockEntry {
873    LockEntry {
874        artifact_type: ArtifactKind::Skill,
875        version: Some(tool.version.clone()),
876        installed_at: installed_at.to_string(),
877        source: LockSource {
878            repo: format!("bundled:{}", tool.name),
879            path: format!("skills/{}", tool.name),
880        },
881        source_checksum: checksum.to_string(),
882        installed_checksum: checksum.to_string(),
883    }
884}
885
886// ---------------------------------------------------------------------------
887// Unit tests
888// ---------------------------------------------------------------------------
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use crate::gateway::Filesystem as _;
894    use crate::skill_fs::SkillFile;
895    use crate::test_support::TestContext;
896    use crate::types::{CmxConfig, InstallScope};
897    use crate::{checksum, config};
898
899    fn make_file(rel: &str, content: &str) -> SkillFile {
900        SkillFile {
901            rel_path: std::path::PathBuf::from(rel),
902            bytes: content.as_bytes().to_vec(),
903        }
904    }
905
906    // Uses the canonical `metadata.version` frontmatter form so that cmx-core's
907    // auto-stamp (see `frontmatter::reconcile_skill_version`) is idempotent on it:
908    // the bundled bytes already equal what the installer would write, keeping the
909    // checksum fixtures below stable.
910    fn sample_skill(version: &str) -> BundledSkill {
911        BundledSkill::from_files(vec![
912            make_file(
913                "SKILL.md",
914                &format!("---\nmetadata:\n  version: \"{version}\"\n---\n# Sample skill\n"),
915            ),
916            make_file("scripts/tool.py", "print('hello')"),
917        ])
918    }
919
920    fn installer(version: &str) -> SkillInstaller {
921        SkillInstaller::new(ToolIdentity {
922            name: "sample".to_string(),
923            version: version.to_string(),
924        })
925    }
926
927    // -----------------------------------------------------------------------
928    // Tests 1–3: skill_fs / checksum parity
929    // -----------------------------------------------------------------------
930
931    #[test]
932    fn checksum_bundled_matches_checksum_dir_after_write() {
933        let t = TestContext::new();
934        let skill = sample_skill("1.0.0");
935        let expected = skill_fs::checksum_bundled(&skill.files);
936        let dest = std::path::PathBuf::from("/dest/sample");
937        skill_fs::write_skill_files(&dest, &skill.files, &t.fs).unwrap();
938        let on_disk = checksum::checksum_dir(&dest, &t.fs).unwrap();
939        assert_eq!(expected, on_disk, "in-memory checksum must match disk checksum");
940    }
941
942    #[test]
943    fn dotfiles_and_transient_excluded_from_write_and_checksum() {
944        let files = vec![
945            make_file("SKILL.md", "# skill"),
946            make_file(".hidden", "hidden"),
947            make_file("node_modules/dep.js", "vendor"),
948        ];
949        let bundled_cs = skill_fs::checksum_bundled(&files);
950
951        // The checksum must only include SKILL.md
952        let only_skill = vec![make_file("SKILL.md", "# skill")];
953        let expected_cs = skill_fs::checksum_bundled(&only_skill);
954        assert_eq!(
955            bundled_cs, expected_cs,
956            "dotfiles and transient must be excluded from checksum"
957        );
958    }
959
960    #[test]
961    fn write_skill_files_creates_nested_dirs() {
962        let t = TestContext::new();
963        let files = vec![
964            make_file("SKILL.md", "# skill"),
965            make_file("scripts/sub/tool.py", "code"),
966        ];
967        skill_fs::write_skill_files(std::path::Path::new("/dest/s"), &files, &t.fs).unwrap();
968        assert!(t.fs.file_exists(std::path::Path::new("/dest/s/SKILL.md")));
969        assert!(t.fs.file_exists(std::path::Path::new("/dest/s/scripts/sub/tool.py")));
970    }
971
972    // -----------------------------------------------------------------------
973    // Tests 4–6: plan() target selection
974    // -----------------------------------------------------------------------
975
976    #[test]
977    fn fresh_machine_produces_single_claude_target_install() {
978        let t = TestContext::new();
979        let ctx = t.ctx();
980        let skill = sample_skill("1.0.0");
981        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
982
983        assert_eq!(plan.targets.len(), 1);
984        assert_eq!(plan.targets[0].platform, Platform::Claude);
985        assert!(matches!(plan.targets[0].action, TargetAction::Install));
986        assert!(!plan.cmx_present);
987    }
988
989    #[test]
990    fn cmx_config_two_platforms_produces_two_targets_cmx_managed() {
991        let t = TestContext::new();
992        let cfg = CmxConfig {
993            platforms: vec![Platform::Claude, Platform::Codex],
994            ..Default::default()
995        };
996        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
997
998        let ctx = t.ctx();
999        let skill = sample_skill("1.0.0");
1000        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1001
1002        let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
1003        assert!(platforms.contains(&Platform::Claude), "should include Claude");
1004        assert!(platforms.contains(&Platform::Codex), "should include Codex");
1005        assert!(plan.targets[0].cmx_managed, "cmx_managed should be true");
1006    }
1007
1008    #[test]
1009    fn no_config_but_non_empty_codex_lock_targets_codex() {
1010        let t = TestContext::new();
1011        let codex_paths = t.paths.with_platform(Platform::Codex);
1012        crate::test_support::save_lock_with_entry(
1013            &t.fs,
1014            &codex_paths,
1015            "other-skill",
1016            crate::test_support::sample_lock_entry(),
1017            InstallScope::Global,
1018        );
1019
1020        let ctx = t.ctx();
1021        let skill = sample_skill("1.0.0");
1022        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1023
1024        let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
1025        assert!(
1026            platforms.contains(&Platform::Codex),
1027            "Codex lock non-empty → should be targeted"
1028        );
1029    }
1030
1031    // -----------------------------------------------------------------------
1032    // Tests 7–12: version-guard actions
1033    // -----------------------------------------------------------------------
1034
1035    fn plan_with_locked_version(
1036        t: &TestContext,
1037        locked_version: &str,
1038        locked_checksum: &str,
1039        bundled_version: &str,
1040        force: bool,
1041    ) -> InstallPlan {
1042        // Set up a lock entry for Claude with the given version and checksum.
1043        let claude_paths = t.paths.with_platform(Platform::Claude);
1044        let skill_dir = claude_paths
1045            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1046            .unwrap()
1047            .join("sample");
1048        t.fs.add_dir(skill_dir.clone());
1049
1050        crate::test_support::save_lock_with_entry(
1051            &t.fs,
1052            &claude_paths,
1053            "sample",
1054            LockEntry {
1055                artifact_type: ArtifactKind::Skill,
1056                version: Some(locked_version.to_string()),
1057                installed_at: "2024-01-01T00:00:00Z".to_string(),
1058                source: LockSource {
1059                    repo: "bundled:sample".to_string(),
1060                    path: "skills/sample".to_string(),
1061                },
1062                source_checksum: locked_checksum.to_string(),
1063                installed_checksum: locked_checksum.to_string(),
1064            },
1065            InstallScope::Global,
1066        );
1067        let skill = sample_skill(bundled_version);
1068        let ctx = t.ctx();
1069        installer(bundled_version).plan(&skill, Scope::Global, force, &ctx).unwrap()
1070    }
1071
1072    #[test]
1073    fn older_lock_version_produces_update() {
1074        let t = TestContext::new();
1075        let plan = plan_with_locked_version(&t, "0.9.0", "sha256:old", "1.0.0", false);
1076        assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
1077    }
1078
1079    #[test]
1080    fn same_version_identical_checksum_on_disk_produces_skip() {
1081        let t = TestContext::new();
1082        let skill = sample_skill("1.0.0");
1083        let checksum = skill_fs::checksum_bundled(&skill.files);
1084
1085        let claude_paths = t.paths.with_platform(Platform::Claude);
1086        let skill_dir = claude_paths
1087            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1088            .unwrap()
1089            .join("sample");
1090        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1091
1092        crate::test_support::save_lock_with_entry(
1093            &t.fs,
1094            &claude_paths,
1095            "sample",
1096            LockEntry {
1097                artifact_type: ArtifactKind::Skill,
1098                version: Some("1.0.0".to_string()),
1099                installed_at: "2024-01-01T00:00:00Z".to_string(),
1100                source: LockSource {
1101                    repo: "bundled:sample".to_string(),
1102                    path: "skills/sample".to_string(),
1103                },
1104                source_checksum: checksum.clone(),
1105                installed_checksum: checksum.clone(),
1106            },
1107            InstallScope::Global,
1108        );
1109
1110        let ctx = t.ctx();
1111        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1112        assert!(matches!(plan.targets[0].action, TargetAction::Skip));
1113    }
1114
1115    #[test]
1116    fn same_version_differing_content_no_force_produces_drifted_skip() {
1117        let t = TestContext::new();
1118        let skill = sample_skill("1.0.0");
1119        let checksum = skill_fs::checksum_bundled(&skill.files);
1120
1121        let claude_paths = t.paths.with_platform(Platform::Claude);
1122        let skill_dir = claude_paths
1123            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1124            .unwrap()
1125            .join("sample");
1126        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1127
1128        crate::test_support::save_lock_with_entry(
1129            &t.fs,
1130            &claude_paths,
1131            "sample",
1132            LockEntry {
1133                artifact_type: ArtifactKind::Skill,
1134                version: Some("1.0.0".to_string()),
1135                installed_at: "2024-01-01T00:00:00Z".to_string(),
1136                source: LockSource {
1137                    repo: "bundled:sample".to_string(),
1138                    path: "skills/sample".to_string(),
1139                },
1140                source_checksum: checksum.clone(),
1141                installed_checksum: checksum,
1142            },
1143            InstallScope::Global,
1144        );
1145
1146        let ctx = t.ctx();
1147        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1148        assert!(matches!(plan.targets[0].action, TargetAction::DriftedSkip { .. }));
1149    }
1150
1151    #[test]
1152    fn same_version_differing_content_with_force_produces_update() {
1153        let t = TestContext::new();
1154        let skill = sample_skill("1.0.0");
1155        let checksum = skill_fs::checksum_bundled(&skill.files);
1156
1157        let claude_paths = t.paths.with_platform(Platform::Claude);
1158        let skill_dir = claude_paths
1159            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1160            .unwrap()
1161            .join("sample");
1162        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1163
1164        crate::test_support::save_lock_with_entry(
1165            &t.fs,
1166            &claude_paths,
1167            "sample",
1168            LockEntry {
1169                artifact_type: ArtifactKind::Skill,
1170                version: Some("1.0.0".to_string()),
1171                installed_at: "2024-01-01T00:00:00Z".to_string(),
1172                source: LockSource {
1173                    repo: "bundled:sample".to_string(),
1174                    path: "skills/sample".to_string(),
1175                },
1176                source_checksum: checksum.clone(),
1177                installed_checksum: checksum,
1178            },
1179            InstallScope::Global,
1180        );
1181
1182        let ctx = t.ctx();
1183        let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
1184        assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
1185    }
1186
1187    #[test]
1188    fn newer_lock_no_force_produces_refuse_newer_and_is_blocked() {
1189        let t = TestContext::new();
1190        let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", false);
1191        assert!(matches!(plan.targets[0].action, TargetAction::RefuseNewer { .. }));
1192        assert!(plan.is_blocked());
1193    }
1194
1195    #[test]
1196    fn newer_lock_with_force_produces_downgrade() {
1197        let t = TestContext::new();
1198        let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", true);
1199        assert!(matches!(plan.targets[0].action, TargetAction::Downgrade { .. }));
1200        assert!(!plan.is_blocked());
1201    }
1202
1203    #[test]
1204    fn non_semver_versions_use_string_equality_fallback() {
1205        // Both non-semver and equal → Equal → Skip (if checksum matches)
1206        let t = TestContext::new();
1207        let skill = sample_skill("v1-alpha");
1208        let checksum = skill_fs::checksum_bundled(&skill.files);
1209
1210        let claude_paths = t.paths.with_platform(Platform::Claude);
1211        let skill_dir = claude_paths
1212            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1213            .unwrap()
1214            .join("sample");
1215        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1216
1217        crate::test_support::save_lock_with_entry(
1218            &t.fs,
1219            &claude_paths,
1220            "sample",
1221            LockEntry {
1222                artifact_type: ArtifactKind::Skill,
1223                version: Some("v1-alpha".to_string()),
1224                installed_at: "2024-01-01T00:00:00Z".to_string(),
1225                source: LockSource {
1226                    repo: "bundled:sample".to_string(),
1227                    path: "skills/sample".to_string(),
1228                },
1229                source_checksum: checksum.clone(),
1230                installed_checksum: checksum.clone(),
1231            },
1232            InstallScope::Global,
1233        );
1234
1235        // Non-semver, same version strings → Equal → install since not on disk
1236        let ctx = t.ctx();
1237        let plan = installer("v1-alpha").plan(&skill, Scope::Global, false, &ctx).unwrap();
1238        // The skill_dest doesn't have a file on disk (only dir exists via add_dir)
1239        // but the dir itself exists — the logic returns Skip since checksum matches
1240        // and exists returns true for a dir.
1241        // Actually FakeFilesystem.exists checks both files AND dirs.
1242        // The dir was added via add_dir so exists returns true.
1243        // Same version + matching checksum + exists → Skip
1244        assert!(
1245            matches!(plan.targets[0].action, TargetAction::Skip)
1246                || matches!(plan.targets[0].action, TargetAction::Install),
1247            "non-semver equal versions should not produce RefuseNewer or Downgrade"
1248        );
1249    }
1250
1251    #[test]
1252    fn missing_skill_md_returns_error() {
1253        let t = TestContext::new();
1254        let skill = BundledSkill::from_files(vec![make_file("scripts/tool.py", "code")]);
1255        let ctx = t.ctx();
1256        let result = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx);
1257        assert!(result.is_err());
1258        assert!(result.unwrap_err().to_string().contains("SKILL.md"));
1259    }
1260
1261    // -----------------------------------------------------------------------
1262    // Tests 14–21: apply()
1263    // -----------------------------------------------------------------------
1264
1265    #[test]
1266    fn apply_fresh_machine_writes_files_and_lock_source_not_registered() {
1267        let t = TestContext::new();
1268        let skill = sample_skill("1.0.0");
1269        let ctx = t.ctx();
1270        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1271        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1272
1273        assert_eq!(report.applied().count(), 1);
1274        assert!(!report.source_registered, "no managed set → no source registration");
1275
1276        // Files should be on disk
1277        let skill_dir = t
1278            .paths
1279            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1280            .unwrap()
1281            .join("sample");
1282        assert!(t.fs.file_exists(&skill_dir.join("SKILL.md")));
1283
1284        // Lock entry should exist
1285        let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1286        assert!(lock.packages.contains_key("sample"));
1287    }
1288
1289    #[test]
1290    fn installed_checksum_equals_source_checksum() {
1291        let t = TestContext::new();
1292        let skill = sample_skill("1.0.0");
1293        let ctx = t.ctx();
1294        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1295        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1296
1297        let first_applied = report.applied().next().unwrap();
1298        assert_eq!(first_applied.installed_checksum.as_deref().unwrap(), plan.source_checksum);
1299    }
1300
1301    #[test]
1302    fn cmx_managed_registers_source_and_materializes_dir() {
1303        let t = TestContext::new();
1304        let cfg = CmxConfig {
1305            platforms: vec![Platform::Claude],
1306            ..Default::default()
1307        };
1308        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1309
1310        let skill = sample_skill("1.0.0");
1311        let ctx = t.ctx();
1312        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1313        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1314
1315        assert!(report.source_registered, "managed set → source should be registered");
1316
1317        let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1318        assert!(sources.sources.contains_key("bundled:sample"), "source entry should exist");
1319    }
1320
1321    #[test]
1322    fn skip_and_drifted_skip_plan_writes_nothing() {
1323        let t = TestContext::new();
1324        let skill = sample_skill("1.0.0");
1325        let checksum = skill_fs::checksum_bundled(&skill.files);
1326
1327        let claude_paths = t.paths.with_platform(Platform::Claude);
1328        let skill_dir = claude_paths
1329            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1330            .unwrap()
1331            .join("sample");
1332        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1333
1334        crate::test_support::save_lock_with_entry(
1335            &t.fs,
1336            &claude_paths,
1337            "sample",
1338            LockEntry {
1339                artifact_type: ArtifactKind::Skill,
1340                version: Some("1.0.0".to_string()),
1341                installed_at: "2024-01-01T00:00:00Z".to_string(),
1342                source: LockSource {
1343                    repo: "bundled:sample".to_string(),
1344                    path: "skills/sample".to_string(),
1345                },
1346                source_checksum: checksum.clone(),
1347                installed_checksum: checksum.clone(),
1348            },
1349            InstallScope::Global,
1350        );
1351
1352        let ctx = t.ctx();
1353        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1354        // Plan should be Skip
1355        assert!(matches!(plan.targets[0].action, TargetAction::Skip));
1356        assert_eq!(plan.write_count(), 0);
1357    }
1358
1359    #[test]
1360    fn blocked_plan_returns_err_on_apply() {
1361        let t = TestContext::new();
1362        // Create a plan with a RefuseNewer by having a newer version installed
1363        let skill = sample_skill("1.0.0");
1364        let claude_paths = t.paths.with_platform(Platform::Claude);
1365        let skill_dir = claude_paths
1366            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1367            .unwrap()
1368            .join("sample");
1369        t.fs.add_dir(skill_dir);
1370
1371        crate::test_support::save_lock_with_entry(
1372            &t.fs,
1373            &claude_paths,
1374            "sample",
1375            LockEntry {
1376                artifact_type: ArtifactKind::Skill,
1377                version: Some("2.0.0".to_string()),
1378                installed_at: "2024-01-01T00:00:00Z".to_string(),
1379                source: LockSource {
1380                    repo: "bundled:sample".to_string(),
1381                    path: "skills/sample".to_string(),
1382                },
1383                source_checksum: "sha256:abc".to_string(),
1384                installed_checksum: "sha256:abc".to_string(),
1385            },
1386            InstallScope::Global,
1387        );
1388
1389        let ctx = t.ctx();
1390        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1391        assert!(plan.is_blocked());
1392
1393        let result = installer("1.0.0").apply(&skill, &plan, &ctx);
1394        assert!(result.is_err());
1395    }
1396
1397    #[test]
1398    fn parity_guard_rejects_mismatched_bundled_skill() {
1399        let t = TestContext::new();
1400        let skill_v1 = sample_skill("1.0.0");
1401        let ctx = t.ctx();
1402        let plan = installer("1.0.0").plan(&skill_v1, Scope::Global, false, &ctx).unwrap();
1403
1404        // Apply with a skill whose *body* differs (a version-only difference would be
1405        // normalized away by the auto-stamp, so parity must be exercised on content
1406        // the stamp does not touch).
1407        let skill_v2 = BundledSkill::from_files(vec![
1408            make_file("SKILL.md", "---\nmetadata:\n  version: \"1.0.0\"\n---\n# DIFFERENT body\n"),
1409            make_file("scripts/tool.py", "print('hello')"),
1410        ]);
1411        let result = installer("1.0.0").apply(&skill_v2, &plan, &ctx);
1412        assert!(result.is_err());
1413        assert!(result.unwrap_err().to_string().contains("Parity"));
1414    }
1415
1416    #[test]
1417    fn shared_dir_managed_codex_pi_written_once_both_locks_updated() {
1418        let t = TestContext::new();
1419        // Configure both Codex and Pi as managed platforms
1420        let cfg = CmxConfig {
1421            platforms: vec![Platform::Codex, Platform::Pi],
1422            ..Default::default()
1423        };
1424        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1425
1426        let skill = sample_skill("1.0.0");
1427        let ctx = t.ctx();
1428        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1429        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1430
1431        // Both Codex and Pi resolve skills to .agents/skills — same path.
1432        let codex_paths = t.paths.with_platform(Platform::Codex);
1433        let pi_paths = t.paths.with_platform(Platform::Pi);
1434
1435        let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1436        let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1437
1438        assert!(codex_lock.packages.contains_key("sample"), "Codex lock should have entry");
1439        assert!(pi_lock.packages.contains_key("sample"), "Pi lock should have entry");
1440    }
1441
1442    #[test]
1443    fn on_disk_file_set_matches_planned_dest_paths() {
1444        let t = TestContext::new();
1445        let skill = sample_skill("1.0.0");
1446        let ctx = t.ctx();
1447        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1448        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1449
1450        for target in &plan.targets {
1451            for pf in &target.files {
1452                assert!(
1453                    t.fs.file_exists(&pf.dest_path),
1454                    "expected file on disk: {}",
1455                    pf.dest_path.display()
1456                );
1457            }
1458        }
1459    }
1460
1461    // -----------------------------------------------------------------------
1462    // Tests 22–24: status()
1463    // -----------------------------------------------------------------------
1464
1465    #[test]
1466    fn not_installed_on_fresh_machine() {
1467        let t = TestContext::new();
1468        let ctx = t.ctx();
1469        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1470        assert!(!status.targets[0].installed);
1471        assert!(!status.targets[0].tracked);
1472    }
1473
1474    #[test]
1475    fn after_apply_installed_tracked_version_matches_not_drifted() {
1476        let t = TestContext::new();
1477        let skill = sample_skill("1.0.0");
1478        let ctx = t.ctx();
1479        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1480        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1481
1482        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1483        assert!(status.targets[0].installed);
1484        assert!(status.targets[0].tracked);
1485        assert_eq!(status.targets[0].installed_version.as_deref(), Some("1.0.0"));
1486        assert!(!status.targets[0].drifted);
1487    }
1488
1489    #[test]
1490    fn mutate_skill_md_on_disk_produces_drifted() {
1491        let t = TestContext::new();
1492        let skill = sample_skill("1.0.0");
1493        let ctx = t.ctx();
1494        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1495        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1496
1497        // Mutate the on-disk SKILL.md
1498        let skill_dir = t
1499            .paths
1500            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1501            .unwrap()
1502            .join("sample");
1503        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# MODIFIED\n");
1504
1505        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1506        assert!(status.targets[0].drifted, "mutated SKILL.md should report drifted");
1507    }
1508
1509    // -----------------------------------------------------------------------
1510    // Tests 25–28: remove()
1511    // -----------------------------------------------------------------------
1512
1513    #[test]
1514    fn remove_deletes_dir_and_clears_lock() {
1515        let t = TestContext::new();
1516        let skill = sample_skill("1.0.0");
1517        let ctx = t.ctx();
1518        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1519        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1520
1521        let skill_dir = t
1522            .paths
1523            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1524            .unwrap()
1525            .join("sample");
1526        assert!(t.fs.exists(&skill_dir));
1527
1528        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1529        assert!(report.was_on_disk);
1530        assert!(report.was_tracked);
1531        assert!(!t.fs.exists(&skill_dir));
1532
1533        let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1534        assert!(!lock.packages.contains_key("sample"));
1535    }
1536
1537    #[test]
1538    fn shared_dir_managed_codex_pi_removed_once_both_locks_cleared() {
1539        let t = TestContext::new();
1540        let cfg = CmxConfig {
1541            platforms: vec![Platform::Codex, Platform::Pi],
1542            ..Default::default()
1543        };
1544        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1545
1546        let skill = sample_skill("1.0.0");
1547        let ctx = t.ctx();
1548        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1549        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1550
1551        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1552        assert!(report.was_on_disk);
1553        assert!(report.platforms_cleared.contains(&Platform::Codex));
1554        assert!(report.platforms_cleared.contains(&Platform::Pi));
1555
1556        // Both lock entries should be gone
1557        let codex_paths = t.paths.with_platform(Platform::Codex);
1558        let pi_paths = t.paths.with_platform(Platform::Pi);
1559        let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1560        let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1561        assert!(!codex_lock.packages.contains_key("sample"));
1562        assert!(!pi_lock.packages.contains_key("sample"));
1563    }
1564
1565    #[test]
1566    fn cmx_managed_remove_clears_source_and_materialized_dir() {
1567        let t = TestContext::new();
1568        let cfg = CmxConfig {
1569            platforms: vec![Platform::Claude],
1570            ..Default::default()
1571        };
1572        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1573
1574        let skill = sample_skill("1.0.0");
1575        let ctx = t.ctx();
1576        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1577        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1578
1579        // Confirm source was registered
1580        let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1581        assert!(sources.sources.contains_key("bundled:sample"));
1582
1583        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1584        assert!(report.source_unregistered);
1585
1586        let sources_after = config::load_sources(&t.fs, &t.paths).unwrap();
1587        assert!(!sources_after.sources.contains_key("bundled:sample"));
1588    }
1589
1590    #[test]
1591    fn remove_when_nothing_installed_returns_ok_all_false() {
1592        let t = TestContext::new();
1593        let ctx = t.ctx();
1594        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1595        assert!(!report.was_on_disk);
1596        assert!(!report.was_tracked);
1597        assert!(!report.source_unregistered);
1598    }
1599
1600    // -----------------------------------------------------------------------
1601    // New constructor and derive tests
1602    // -----------------------------------------------------------------------
1603
1604    #[test]
1605    fn single_md_builds_single_skill_md() {
1606        let skill = BundledSkill::single_md("---\nversion: 1.0.0\n---\n# My skill\n");
1607        assert_eq!(skill.files.len(), 1);
1608        assert!(skill.has_skill_md());
1609        assert_eq!(skill.files[0].rel_path, std::path::PathBuf::from("SKILL.md"));
1610    }
1611
1612    #[test]
1613    fn tool_identity_new_sets_fields() {
1614        let id = ToolIdentity::new("mytool", "1.2.3");
1615        assert_eq!(id.name, "mytool");
1616        assert_eq!(id.version, "1.2.3");
1617    }
1618
1619    #[test]
1620    fn scope_partial_eq() {
1621        assert_eq!(Scope::Global, Scope::Global);
1622        assert_eq!(Scope::Local, Scope::Local);
1623        assert_ne!(Scope::Global, Scope::Local);
1624    }
1625
1626    // -----------------------------------------------------------------------
1627    // Report fidelity tests
1628    // -----------------------------------------------------------------------
1629
1630    #[test]
1631    fn skipped_target_outcome_carries_dest_dir() {
1632        let t = TestContext::new();
1633        let skill = sample_skill("1.0.0");
1634        let checksum = skill_fs::checksum_bundled(&skill.files);
1635
1636        let claude_paths = t.paths.with_platform(Platform::Claude);
1637        let skill_dir = claude_paths
1638            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1639            .unwrap()
1640            .join("sample");
1641        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1642
1643        crate::test_support::save_lock_with_entry(
1644            &t.fs,
1645            &claude_paths,
1646            "sample",
1647            LockEntry {
1648                artifact_type: ArtifactKind::Skill,
1649                version: Some("1.0.0".to_string()),
1650                installed_at: "2024-01-01T00:00:00Z".to_string(),
1651                source: LockSource {
1652                    repo: "bundled:sample".to_string(),
1653                    path: "skills/sample".to_string(),
1654                },
1655                source_checksum: checksum.clone(),
1656                installed_checksum: checksum.clone(),
1657            },
1658            InstallScope::Global,
1659        );
1660
1661        let ctx = t.ctx();
1662        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1663        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1664
1665        // The skip target must preserve dest_dir
1666        let skip = report.skipped().next().expect("expected a skipped target");
1667        assert!(
1668            !skip.dest_dir.as_os_str().is_empty(),
1669            "dest_dir must be non-empty on skipped target"
1670        );
1671        assert!(matches!(skip.action, TargetAction::Skip));
1672        assert_eq!(skip.installed_checksum, None);
1673    }
1674
1675    #[test]
1676    fn drifted_skip_outcome_is_distinguishable_from_plain_skip() {
1677        let t = TestContext::new();
1678        let skill = sample_skill("1.0.0");
1679        let checksum = skill_fs::checksum_bundled(&skill.files);
1680
1681        let claude_paths = t.paths.with_platform(Platform::Claude);
1682        let skill_dir = claude_paths
1683            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1684            .unwrap()
1685            .join("sample");
1686        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1687
1688        crate::test_support::save_lock_with_entry(
1689            &t.fs,
1690            &claude_paths,
1691            "sample",
1692            LockEntry {
1693                artifact_type: ArtifactKind::Skill,
1694                version: Some("1.0.0".to_string()),
1695                installed_at: "2024-01-01T00:00:00Z".to_string(),
1696                source: LockSource {
1697                    repo: "bundled:sample".to_string(),
1698                    path: "skills/sample".to_string(),
1699                },
1700                source_checksum: checksum.clone(),
1701                installed_checksum: checksum,
1702            },
1703            InstallScope::Global,
1704        );
1705
1706        let ctx = t.ctx();
1707        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1708        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1709
1710        let skip = report.skipped().next().expect("expected a skipped target");
1711        assert!(
1712            matches!(skip.action, TargetAction::DriftedSkip { .. }),
1713            "action must be DriftedSkip, not plain Skip"
1714        );
1715    }
1716
1717    #[test]
1718    fn force_overwrites_drifted_copy_and_reports_update() {
1719        let t = TestContext::new();
1720        let skill = sample_skill("1.0.0");
1721        let checksum = skill_fs::checksum_bundled(&skill.files);
1722
1723        let claude_paths = t.paths.with_platform(Platform::Claude);
1724        let skill_dir = claude_paths
1725            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1726            .unwrap()
1727            .join("sample");
1728        let skill_md = skill_dir.join("SKILL.md");
1729        let local_only = skill_dir.join("local-only.md");
1730        t.fs.add_file(&skill_md, "---\nversion: 1.0.0\n---\n# Modified\n");
1731        t.fs.add_file(&local_only, "scratch\n");
1732
1733        crate::test_support::save_lock_with_entry(
1734            &t.fs,
1735            &claude_paths,
1736            "sample",
1737            LockEntry {
1738                artifact_type: ArtifactKind::Skill,
1739                version: Some("1.0.0".to_string()),
1740                installed_at: "2024-01-01T00:00:00Z".to_string(),
1741                source: LockSource {
1742                    repo: "bundled:sample".to_string(),
1743                    path: "skills/sample".to_string(),
1744                },
1745                source_checksum: checksum.clone(),
1746                installed_checksum: checksum.clone(),
1747            },
1748            InstallScope::Global,
1749        );
1750
1751        let ctx = t.ctx();
1752        let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
1753        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1754
1755        let updated = report.applied().next().expect("expected an updated target");
1756        assert!(matches!(updated.action, TargetAction::Update { .. }));
1757        let discarded: BTreeSet<_> = updated.discarded_paths.iter().cloned().collect();
1758        assert_eq!(
1759            discarded,
1760            BTreeSet::from([
1761                local_only.clone(),
1762                skill_md.clone(),
1763                skill_dir.join("scripts/tool.py")
1764            ])
1765        );
1766        assert_eq!(
1767            t.fs.read_to_string(&skill_md).unwrap(),
1768            "---\nmetadata:\n  version: \"1.0.0\"\n---\n# Sample skill\n"
1769        );
1770        assert!(!t.fs.exists(&local_only));
1771        assert_eq!(checksum::checksum_dir(&skill_dir, &t.fs).unwrap(), checksum);
1772    }
1773
1774    // -----------------------------------------------------------------------
1775    // Display tests
1776    // -----------------------------------------------------------------------
1777
1778    #[test]
1779    fn install_plan_display_contains_target_lines() {
1780        let t = TestContext::new();
1781        let skill = sample_skill("1.0.0");
1782        let ctx = t.ctx();
1783        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1784        let rendered = plan.to_string();
1785        assert!(rendered.contains("sample"), "plan display must include tool name");
1786        assert!(rendered.contains("1.0.0"), "plan display must include version");
1787        assert!(rendered.contains("install"), "plan display must include action");
1788    }
1789
1790    #[test]
1791    fn report_display_distinguishes_drifted_skip() {
1792        let t = TestContext::new();
1793        let skill = sample_skill("1.0.0");
1794
1795        // First apply: fresh install
1796        let ctx = t.ctx();
1797        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1798        let up_to_date_report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1799        let up_to_date_text = up_to_date_report.to_string();
1800
1801        // Second apply (same version, same checksum) → Skip
1802        let plan2 = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1803        let skip_report = installer("1.0.0").apply(&skill, &plan2, &ctx).unwrap();
1804        let skip_text = skip_report.to_string();
1805        assert!(skip_text.contains("up to date"), "up-to-date skip must say 'up to date'");
1806
1807        // Set up drifted scenario
1808        let t2 = TestContext::new();
1809        let checksum = skill_fs::checksum_bundled(&skill.files);
1810        let claude_paths = t2.paths.with_platform(Platform::Claude);
1811        let skill_dir = claude_paths
1812            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1813            .unwrap()
1814            .join("sample");
1815        t2.fs
1816            .add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1817        crate::test_support::save_lock_with_entry(
1818            &t2.fs,
1819            &claude_paths,
1820            "sample",
1821            LockEntry {
1822                artifact_type: ArtifactKind::Skill,
1823                version: Some("1.0.0".to_string()),
1824                installed_at: "2024-01-01T00:00:00Z".to_string(),
1825                source: LockSource {
1826                    repo: "bundled:sample".to_string(),
1827                    path: "skills/sample".to_string(),
1828                },
1829                source_checksum: checksum.clone(),
1830                installed_checksum: checksum,
1831            },
1832            InstallScope::Global,
1833        );
1834        let ctx2 = t2.ctx();
1835        let drifted_plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx2).unwrap();
1836        let drifted_report = installer("1.0.0").apply(&skill, &drifted_plan, &ctx2).unwrap();
1837        let drifted_text = drifted_report.to_string();
1838
1839        // Drifted display must differ from up-to-date skip display
1840        assert!(
1841            drifted_text.contains("drifted"),
1842            "drifted skip must mention 'drifted' in output, got: {drifted_text}"
1843        );
1844        assert_ne!(
1845            skip_text, drifted_text,
1846            "up-to-date skip and drifted skip must produce different display output"
1847        );
1848        let _ = up_to_date_text;
1849    }
1850
1851    #[test]
1852    fn remove_report_display_notes_lockfile_left() {
1853        let t = TestContext::new();
1854        let skill = sample_skill("1.0.0");
1855        let ctx = t.ctx();
1856        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1857        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1858
1859        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1860        let rendered = report.to_string();
1861        assert!(
1862            rendered.contains("cmx-lock.json"),
1863            "remove report must note the lockfile is left on disk"
1864        );
1865    }
1866}