Skip to main content

cmx_core/skill_install/
mod.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
27mod types;
28pub use types::*;
29
30mod display;
31
32mod plan;
33use plan::{build_lock_entry, decide_action_for_entry, prepare_writes};
34
35use anyhow::{Result, bail};
36
37use crate::checksum;
38use crate::config;
39use crate::context::AppContext;
40use crate::frontmatter;
41use crate::lockfile;
42use crate::platform::Platform;
43use crate::platform_iter;
44use crate::skill_fs;
45use crate::targets;
46use crate::types::{ArtifactKind, SourceEntry, SourceType};
47
48// ---------------------------------------------------------------------------
49// SkillInstaller
50// ---------------------------------------------------------------------------
51
52/// High-level skill lifecycle manager for embedding tools.
53pub struct SkillInstaller {
54    tool: ToolIdentity,
55}
56
57impl SkillInstaller {
58    /// Create a new installer for the given tool identity.
59    pub fn new(tool: ToolIdentity) -> Self {
60        Self { tool }
61    }
62
63    /// Compute a dry-run install plan without writing anything.
64    ///
65    /// Fails if the bundle does not contain a `SKILL.md`.
66    pub fn plan(
67        &self,
68        skill: &BundledSkill,
69        scope: Scope,
70        force: bool,
71        ctx: &AppContext<'_>,
72    ) -> Result<InstallPlan> {
73        if !skill.has_skill_md() {
74            bail!("BundledSkill for '{}' is missing SKILL.md", self.tool.name);
75        }
76
77        // Reconcile the SKILL.md frontmatter's `metadata.version` to this tool's
78        // version before anything else, so the checksum, the written bytes, and the
79        // lock entry all describe the same, version-stamped content.
80        let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
81        let source_checksum = skill_fs::checksum_bundled(&files);
82        let install_scope = scope.to_install_scope();
83
84        let platform_targets =
85            targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
86
87        let cmx_managed = config::managed_platforms(ctx.fs, ctx.paths)?.is_some();
88        let cmx_present = cmx_managed || {
89            // Check whether any platform has a non-empty lock file
90            platform_iter::views_for(ctx.paths, platform_iter::all(), ArtifactKind::Skill).any(
91                |view| {
92                    lockfile::load(install_scope, ctx.fs, &view.paths)
93                        .ok()
94                        .is_some_and(|l| !l.packages.is_empty())
95                },
96            )
97        };
98
99        let mut target_plans = Vec::new();
100        // Track which dest_dirs we've already planned files for (shared dirs).
101        // For platforms that share a dest_dir (e.g. .agents/skills), we still
102        // produce a TargetPlan per platform but with the same files list.
103        // Apply will dedup writes by dest_dir.
104
105        for &platform in &platform_targets {
106            let pv = ctx.paths.with_platform(platform);
107            let dest_dir = pv.require_install_dir(ArtifactKind::Skill, install_scope)?;
108            let skill_dest = dest_dir.join(&self.tool.name);
109
110            // Build planned files
111            let planned_files: Vec<PlannedFile> = files
112                .iter()
113                .map(|f| PlannedFile {
114                    rel_path: f.rel_path.clone(),
115                    dest_path: skill_dest.join(&f.rel_path),
116                })
117                .collect();
118
119            // Determine the action for this platform
120            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
121            let action = if let Some(entry) = lock.packages.get(&self.tool.name) {
122                decide_action_for_entry(
123                    entry,
124                    &self.tool.version,
125                    &source_checksum,
126                    force,
127                    &skill_dest,
128                    ctx,
129                )?
130            } else if ctx.fs.exists(&skill_dest) {
131                // On disk but not tracked: treat as Install (untracked copy)
132                TargetAction::Install
133            } else {
134                TargetAction::Install
135            };
136
137            target_plans.push(TargetPlan {
138                platform,
139                scope: install_scope,
140                dest_dir: skill_dest,
141                files: planned_files,
142                action,
143                cmx_managed,
144            });
145        }
146
147        Ok(InstallPlan {
148            tool: self.tool.clone(),
149            scope: install_scope,
150            source_checksum,
151            cmx_present,
152            force,
153            targets: target_plans,
154        })
155    }
156
157    /// Apply an install plan, writing files and updating lock entries.
158    ///
159    /// Fails if:
160    /// - The plan is blocked (e.g. `RefuseNewer`).
161    /// - The bundled skill's checksum does not match the plan's `source_checksum`
162    ///   (parity guard — ensures the skill passed here is the same one planned).
163    pub fn apply(
164        &self,
165        skill: &BundledSkill,
166        plan: &InstallPlan,
167        ctx: &AppContext<'_>,
168    ) -> Result<Report> {
169        if plan.is_blocked() {
170            bail!(
171                "Install plan for '{}' is blocked. Run with force=true to override.",
172                self.tool.name
173            );
174        }
175
176        // Reconcile the same way plan() did, so the checksum below and the bytes
177        // written match the planned source_checksum exactly.
178        let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
179
180        // Parity guard: the skill passed here must match the one that was planned.
181        let current_checksum = skill_fs::checksum_bundled(&files);
182        if current_checksum != plan.source_checksum {
183            bail!(
184                "Parity check failed for '{}': the BundledSkill has changed since plan() was called.",
185                self.tool.name
186            );
187        }
188
189        let PreparedWrites {
190            dirs_to_write,
191            discarded_paths_by_dir,
192        } = prepare_writes(plan, &files, ctx)?;
193
194        // Write each distinct dir once.
195        for dir in &dirs_to_write {
196            skill_fs::write_skill_files(dir, &files, ctx.fs)?;
197        }
198
199        let installed_checksum = plan.source_checksum.clone();
200        let installed_at = ctx.clock.now().to_rfc3339();
201
202        let mut targets: Vec<TargetOutcome> = Vec::new();
203
204        for target in &plan.targets {
205            if !target.action.will_write() {
206                targets.push(TargetOutcome {
207                    platform: target.platform,
208                    dest_dir: target.dest_dir.clone(),
209                    action: target.action.clone(),
210                    files_written: 0,
211                    installed_checksum: None,
212                    discarded_paths: Vec::new(),
213                });
214                continue;
215            }
216
217            // Write lock entry for this platform.
218            let pv = ctx.paths.with_platform(target.platform);
219            lockfile::mutate(target.scope, ctx.fs, &pv, |lock| {
220                lock.packages.insert(
221                    self.tool.name.clone(),
222                    build_lock_entry(&self.tool, &installed_checksum, &installed_at),
223                );
224            })?;
225
226            targets.push(TargetOutcome {
227                platform: target.platform,
228                dest_dir: target.dest_dir.clone(),
229                action: target.action.clone(),
230                files_written: target.files.len(),
231                installed_checksum: Some(installed_checksum.clone()),
232                discarded_paths: discarded_paths_by_dir
233                    .get(&target.dest_dir)
234                    .cloned()
235                    .unwrap_or_default(),
236            });
237        }
238
239        // Register source if cmx is managing this machine.
240        let source_registered = if plan.cmx_present
241            && config::managed_platforms(ctx.fs, ctx.paths)?.is_some()
242        {
243            let source_name = format!("bundled:{}", self.tool.name);
244            // Materialize a directory under the default artifact home for source tracing.
245            let home =
246                config::resolve_artifact_home(&config::load_config(ctx.fs, ctx.paths)?, ctx.paths);
247            let materialized = home.join("skills").join(&self.tool.name);
248            skill_fs::write_skill_files(&materialized, &files, ctx.fs)?;
249
250            config::mutate_sources(ctx.fs, ctx.paths, |sources| {
251                sources.sources.entry(source_name.clone()).or_insert_with(|| SourceEntry {
252                    source_type: SourceType::Local,
253                    path: Some(materialized.clone()),
254                    url: None,
255                    local_clone: None,
256                    branch: None,
257                    last_updated: Some(ctx.clock.now().to_rfc3339()),
258                });
259                Ok(())
260            })?;
261            true
262        } else {
263            false
264        };
265
266        Ok(Report {
267            tool: self.tool.clone(),
268            scope: plan.scope,
269            targets,
270            source_registered,
271        })
272    }
273
274    /// Query the install status of this skill across relevant platforms.
275    pub fn status(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<Status> {
276        let install_scope = scope.to_install_scope();
277        let platform_targets =
278            targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
279
280        let mut target_statuses = Vec::new();
281        for &platform in &platform_targets {
282            let pv = ctx.paths.with_platform(platform);
283            let skill_dir = pv
284                .install_dir(ArtifactKind::Skill, install_scope)
285                .map(|d| d.join(&self.tool.name));
286
287            let installed = skill_dir.as_ref().is_some_and(|d| ctx.fs.exists(d));
288
289            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
290            let lock_entry = lock.packages.get(&self.tool.name);
291            let tracked = lock_entry.is_some();
292            let installed_version = lock_entry.and_then(|e| e.version.clone());
293
294            let drifted = if installed && tracked {
295                if let (Some(dir), Some(entry)) = (&skill_dir, lock_entry) {
296                    checksum::is_locally_modified(dir, ArtifactKind::Skill, entry, ctx.fs)
297                        .unwrap_or(false)
298                } else {
299                    false
300                }
301            } else {
302                false
303            };
304
305            target_statuses.push(TargetStatus {
306                platform,
307                installed,
308                installed_version,
309                drifted,
310                tracked,
311            });
312        }
313
314        Ok(Status {
315            tool_name: self.tool.name.clone(),
316            scope: install_scope,
317            targets: target_statuses,
318        })
319    }
320
321    /// Remove this skill from all relevant platforms.
322    pub fn remove(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<RemoveReport> {
323        let install_scope = scope.to_install_scope();
324        let platform_targets = config::managed_or_all_platforms(ctx.fs, ctx.paths)?
325            .into_iter()
326            .filter(|p| p.supports(ArtifactKind::Skill))
327            .collect::<Vec<_>>();
328
329        let mut dirs_to_delete: std::collections::BTreeSet<std::path::PathBuf> =
330            std::collections::BTreeSet::new();
331        let mut platforms_cleared: Vec<Platform> = Vec::new();
332        let mut was_tracked = false;
333
334        for &platform in &platform_targets {
335            let pv = ctx.paths.with_platform(platform);
336
337            // Collect physical path for deletion.
338            if let Some(dir) = pv.install_dir(ArtifactKind::Skill, install_scope) {
339                let skill_dir = dir.join(&self.tool.name);
340                if ctx.fs.exists(&skill_dir) {
341                    dirs_to_delete.insert(skill_dir);
342                }
343            }
344
345            // Clear lock entry.
346            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
347            if lock.packages.contains_key(&self.tool.name) {
348                lockfile::mutate(install_scope, ctx.fs, &pv, |l| {
349                    l.packages.remove(&self.tool.name);
350                })?;
351                platforms_cleared.push(platform);
352                was_tracked = true;
353            }
354        }
355
356        let was_on_disk = !dirs_to_delete.is_empty();
357        let removed_dirs: Vec<std::path::PathBuf> = dirs_to_delete.into_iter().collect();
358        for dir in &removed_dirs {
359            ctx.fs.remove_dir_all(dir)?;
360        }
361
362        // Remove from sources and materialized home if managed.
363        let source_name = format!("bundled:{}", self.tool.name);
364        let source_unregistered = if let Ok(sources) = config::load_sources(ctx.fs, ctx.paths) {
365            if sources.sources.contains_key(&source_name) {
366                // Also remove the materialized skill directory.
367                if let Some(entry) = sources.sources.get(&source_name)
368                    && let Some(path) = &entry.path
369                    && ctx.fs.exists(path)
370                {
371                    ctx.fs.remove_dir_all(path)?;
372                }
373                config::mutate_sources(ctx.fs, ctx.paths, |s| {
374                    s.sources.remove(&source_name);
375                    Ok(())
376                })?;
377                true
378            } else {
379                false
380            }
381        } else {
382            false
383        };
384
385        Ok(RemoveReport {
386            tool_name: self.tool.name.clone(),
387            scope: install_scope,
388            removed_dirs,
389            platforms_cleared,
390            source_unregistered,
391            was_on_disk,
392            was_tracked,
393        })
394    }
395}
396
397// ---------------------------------------------------------------------------
398// Unit tests
399// ---------------------------------------------------------------------------
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::gateway::Filesystem as _;
405    use crate::skill_fs::SkillFile;
406    use crate::test_support::TestContext;
407    use crate::types::{CmxConfig, InstallScope, LockEntry, LockSource};
408    use crate::{checksum, config};
409    use std::collections::BTreeSet;
410
411    fn make_file(rel: &str, content: &str) -> SkillFile {
412        SkillFile {
413            rel_path: std::path::PathBuf::from(rel),
414            bytes: content.as_bytes().to_vec(),
415        }
416    }
417
418    // Uses the canonical `metadata.version` frontmatter form so that cmx-core's
419    // auto-stamp (see `frontmatter::reconcile_skill_version`) is idempotent on it:
420    // the bundled bytes already equal what the installer would write, keeping the
421    // checksum fixtures below stable.
422    fn sample_skill(version: &str) -> BundledSkill {
423        BundledSkill::from_files(vec![
424            make_file(
425                "SKILL.md",
426                &format!("---\nmetadata:\n  version: \"{version}\"\n---\n# Sample skill\n"),
427            ),
428            make_file("scripts/tool.py", "print('hello')"),
429        ])
430    }
431
432    fn installer(version: &str) -> SkillInstaller {
433        SkillInstaller::new(ToolIdentity {
434            name: "sample".to_string(),
435            version: version.to_string(),
436        })
437    }
438
439    // -----------------------------------------------------------------------
440    // Tests 1–3: skill_fs / checksum parity
441    // -----------------------------------------------------------------------
442
443    #[test]
444    fn checksum_bundled_matches_checksum_dir_after_write() {
445        let t = TestContext::new();
446        let skill = sample_skill("1.0.0");
447        let expected = skill_fs::checksum_bundled(&skill.files);
448        let dest = std::path::PathBuf::from("/dest/sample");
449        skill_fs::write_skill_files(&dest, &skill.files, &t.fs).unwrap();
450        let on_disk = checksum::checksum_dir(&dest, &t.fs).unwrap();
451        assert_eq!(expected, on_disk, "in-memory checksum must match disk checksum");
452    }
453
454    #[test]
455    fn dotfiles_and_transient_excluded_from_write_and_checksum() {
456        let files = vec![
457            make_file("SKILL.md", "# skill"),
458            make_file(".hidden", "hidden"),
459            make_file("node_modules/dep.js", "vendor"),
460        ];
461        let bundled_cs = skill_fs::checksum_bundled(&files);
462
463        // The checksum must only include SKILL.md
464        let only_skill = vec![make_file("SKILL.md", "# skill")];
465        let expected_cs = skill_fs::checksum_bundled(&only_skill);
466        assert_eq!(
467            bundled_cs, expected_cs,
468            "dotfiles and transient must be excluded from checksum"
469        );
470    }
471
472    #[test]
473    fn write_skill_files_creates_nested_dirs() {
474        let t = TestContext::new();
475        let files = vec![
476            make_file("SKILL.md", "# skill"),
477            make_file("scripts/sub/tool.py", "code"),
478        ];
479        skill_fs::write_skill_files(std::path::Path::new("/dest/s"), &files, &t.fs).unwrap();
480        assert!(t.fs.file_exists(std::path::Path::new("/dest/s/SKILL.md")));
481        assert!(t.fs.file_exists(std::path::Path::new("/dest/s/scripts/sub/tool.py")));
482    }
483
484    // -----------------------------------------------------------------------
485    // Tests 4–6: plan() target selection
486    // -----------------------------------------------------------------------
487
488    #[test]
489    fn fresh_machine_produces_single_claude_target_install() {
490        let t = TestContext::new();
491        let ctx = t.ctx();
492        let skill = sample_skill("1.0.0");
493        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
494
495        assert_eq!(plan.targets.len(), 1);
496        assert_eq!(plan.targets[0].platform, Platform::Claude);
497        assert!(matches!(plan.targets[0].action, TargetAction::Install));
498        assert!(!plan.cmx_present);
499    }
500
501    #[test]
502    fn cmx_config_two_platforms_produces_two_targets_cmx_managed() {
503        let t = TestContext::new();
504        let cfg = CmxConfig {
505            platforms: vec![Platform::Claude, Platform::Codex],
506            ..Default::default()
507        };
508        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
509
510        let ctx = t.ctx();
511        let skill = sample_skill("1.0.0");
512        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
513
514        let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
515        assert!(platforms.contains(&Platform::Claude), "should include Claude");
516        assert!(platforms.contains(&Platform::Codex), "should include Codex");
517        assert!(plan.targets[0].cmx_managed, "cmx_managed should be true");
518    }
519
520    #[test]
521    fn no_config_but_non_empty_codex_lock_targets_codex() {
522        let t = TestContext::new();
523        let codex_paths = t.paths.with_platform(Platform::Codex);
524        crate::test_support::save_lock_with_entry(
525            &t.fs,
526            &codex_paths,
527            "other-skill",
528            crate::test_support::sample_lock_entry(),
529            InstallScope::Global,
530        );
531
532        let ctx = t.ctx();
533        let skill = sample_skill("1.0.0");
534        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
535
536        let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
537        assert!(
538            platforms.contains(&Platform::Codex),
539            "Codex lock non-empty → should be targeted"
540        );
541    }
542
543    // -----------------------------------------------------------------------
544    // Tests 7–12: version-guard actions
545    // -----------------------------------------------------------------------
546
547    fn plan_with_locked_version(
548        t: &TestContext,
549        locked_version: &str,
550        locked_checksum: &str,
551        bundled_version: &str,
552        force: bool,
553    ) -> InstallPlan {
554        // Set up a lock entry for Claude with the given version and checksum.
555        let claude_paths = t.paths.with_platform(Platform::Claude);
556        let skill_dir = claude_paths
557            .install_dir(ArtifactKind::Skill, InstallScope::Global)
558            .unwrap()
559            .join("sample");
560        t.fs.add_dir(skill_dir.clone());
561
562        crate::test_support::save_lock_with_entry(
563            &t.fs,
564            &claude_paths,
565            "sample",
566            LockEntry {
567                artifact_type: ArtifactKind::Skill,
568                version: Some(locked_version.to_string()),
569                installed_at: "2024-01-01T00:00:00Z".to_string(),
570                source: LockSource {
571                    repo: "bundled:sample".to_string(),
572                    path: "skills/sample".to_string(),
573                },
574                source_checksum: locked_checksum.to_string(),
575                installed_checksum: locked_checksum.to_string(),
576            },
577            InstallScope::Global,
578        );
579        let skill = sample_skill(bundled_version);
580        let ctx = t.ctx();
581        installer(bundled_version).plan(&skill, Scope::Global, force, &ctx).unwrap()
582    }
583
584    #[test]
585    fn older_lock_version_produces_update() {
586        let t = TestContext::new();
587        let plan = plan_with_locked_version(&t, "0.9.0", "sha256:old", "1.0.0", false);
588        assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
589    }
590
591    #[test]
592    fn same_version_identical_checksum_on_disk_produces_skip() {
593        let t = TestContext::new();
594        let skill = sample_skill("1.0.0");
595        let checksum = skill_fs::checksum_bundled(&skill.files);
596
597        let claude_paths = t.paths.with_platform(Platform::Claude);
598        let skill_dir = claude_paths
599            .install_dir(ArtifactKind::Skill, InstallScope::Global)
600            .unwrap()
601            .join("sample");
602        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
603
604        crate::test_support::save_lock_with_entry(
605            &t.fs,
606            &claude_paths,
607            "sample",
608            LockEntry {
609                artifact_type: ArtifactKind::Skill,
610                version: Some("1.0.0".to_string()),
611                installed_at: "2024-01-01T00:00:00Z".to_string(),
612                source: LockSource {
613                    repo: "bundled:sample".to_string(),
614                    path: "skills/sample".to_string(),
615                },
616                source_checksum: checksum.clone(),
617                installed_checksum: checksum.clone(),
618            },
619            InstallScope::Global,
620        );
621
622        let ctx = t.ctx();
623        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
624        assert!(matches!(plan.targets[0].action, TargetAction::Skip));
625    }
626
627    #[test]
628    fn same_version_differing_content_no_force_produces_drifted_skip() {
629        let t = TestContext::new();
630        let skill = sample_skill("1.0.0");
631        let checksum = skill_fs::checksum_bundled(&skill.files);
632
633        let claude_paths = t.paths.with_platform(Platform::Claude);
634        let skill_dir = claude_paths
635            .install_dir(ArtifactKind::Skill, InstallScope::Global)
636            .unwrap()
637            .join("sample");
638        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
639
640        crate::test_support::save_lock_with_entry(
641            &t.fs,
642            &claude_paths,
643            "sample",
644            LockEntry {
645                artifact_type: ArtifactKind::Skill,
646                version: Some("1.0.0".to_string()),
647                installed_at: "2024-01-01T00:00:00Z".to_string(),
648                source: LockSource {
649                    repo: "bundled:sample".to_string(),
650                    path: "skills/sample".to_string(),
651                },
652                source_checksum: checksum.clone(),
653                installed_checksum: checksum,
654            },
655            InstallScope::Global,
656        );
657
658        let ctx = t.ctx();
659        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
660        assert!(matches!(plan.targets[0].action, TargetAction::DriftedSkip { .. }));
661    }
662
663    #[test]
664    fn same_version_differing_content_with_force_produces_update() {
665        let t = TestContext::new();
666        let skill = sample_skill("1.0.0");
667        let checksum = skill_fs::checksum_bundled(&skill.files);
668
669        let claude_paths = t.paths.with_platform(Platform::Claude);
670        let skill_dir = claude_paths
671            .install_dir(ArtifactKind::Skill, InstallScope::Global)
672            .unwrap()
673            .join("sample");
674        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
675
676        crate::test_support::save_lock_with_entry(
677            &t.fs,
678            &claude_paths,
679            "sample",
680            LockEntry {
681                artifact_type: ArtifactKind::Skill,
682                version: Some("1.0.0".to_string()),
683                installed_at: "2024-01-01T00:00:00Z".to_string(),
684                source: LockSource {
685                    repo: "bundled:sample".to_string(),
686                    path: "skills/sample".to_string(),
687                },
688                source_checksum: checksum.clone(),
689                installed_checksum: checksum,
690            },
691            InstallScope::Global,
692        );
693
694        let ctx = t.ctx();
695        let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
696        assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
697    }
698
699    #[test]
700    fn newer_lock_no_force_produces_refuse_newer_and_is_blocked() {
701        let t = TestContext::new();
702        let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", false);
703        assert!(matches!(plan.targets[0].action, TargetAction::RefuseNewer { .. }));
704        assert!(plan.is_blocked());
705    }
706
707    #[test]
708    fn newer_lock_with_force_produces_downgrade() {
709        let t = TestContext::new();
710        let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", true);
711        assert!(matches!(plan.targets[0].action, TargetAction::Downgrade { .. }));
712        assert!(!plan.is_blocked());
713    }
714
715    #[test]
716    fn non_semver_versions_use_string_equality_fallback() {
717        // Both non-semver and equal → Equal → Skip (if checksum matches)
718        let t = TestContext::new();
719        let skill = sample_skill("v1-alpha");
720        let checksum = skill_fs::checksum_bundled(&skill.files);
721
722        let claude_paths = t.paths.with_platform(Platform::Claude);
723        let skill_dir = claude_paths
724            .install_dir(ArtifactKind::Skill, InstallScope::Global)
725            .unwrap()
726            .join("sample");
727        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
728
729        crate::test_support::save_lock_with_entry(
730            &t.fs,
731            &claude_paths,
732            "sample",
733            LockEntry {
734                artifact_type: ArtifactKind::Skill,
735                version: Some("v1-alpha".to_string()),
736                installed_at: "2024-01-01T00:00:00Z".to_string(),
737                source: LockSource {
738                    repo: "bundled:sample".to_string(),
739                    path: "skills/sample".to_string(),
740                },
741                source_checksum: checksum.clone(),
742                installed_checksum: checksum.clone(),
743            },
744            InstallScope::Global,
745        );
746
747        // Non-semver, same version strings → Equal → install since not on disk
748        let ctx = t.ctx();
749        let plan = installer("v1-alpha").plan(&skill, Scope::Global, false, &ctx).unwrap();
750        // The skill_dest doesn't have a file on disk (only dir exists via add_dir)
751        // but the dir itself exists — the logic returns Skip since checksum matches
752        // and exists returns true for a dir.
753        // Actually FakeFilesystem.exists checks both files AND dirs.
754        // The dir was added via add_dir so exists returns true.
755        // Same version + matching checksum + exists → Skip
756        assert!(
757            matches!(plan.targets[0].action, TargetAction::Skip)
758                || matches!(plan.targets[0].action, TargetAction::Install),
759            "non-semver equal versions should not produce RefuseNewer or Downgrade"
760        );
761    }
762
763    #[test]
764    fn missing_skill_md_returns_error() {
765        let t = TestContext::new();
766        let skill = BundledSkill::from_files(vec![make_file("scripts/tool.py", "code")]);
767        let ctx = t.ctx();
768        let result = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx);
769        assert!(result.is_err());
770        assert!(result.unwrap_err().to_string().contains("SKILL.md"));
771    }
772
773    // -----------------------------------------------------------------------
774    // Tests 14–21: apply()
775    // -----------------------------------------------------------------------
776
777    #[test]
778    fn apply_fresh_machine_writes_files_and_lock_source_not_registered() {
779        let t = TestContext::new();
780        let skill = sample_skill("1.0.0");
781        let ctx = t.ctx();
782        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
783        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
784
785        assert_eq!(report.applied().count(), 1);
786        assert!(!report.source_registered, "no managed set → no source registration");
787
788        // Files should be on disk
789        let skill_dir = t
790            .paths
791            .install_dir(ArtifactKind::Skill, InstallScope::Global)
792            .unwrap()
793            .join("sample");
794        assert!(t.fs.file_exists(&skill_dir.join("SKILL.md")));
795
796        // Lock entry should exist
797        let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
798        assert!(lock.packages.contains_key("sample"));
799    }
800
801    #[test]
802    fn installed_checksum_equals_source_checksum() {
803        let t = TestContext::new();
804        let skill = sample_skill("1.0.0");
805        let ctx = t.ctx();
806        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
807        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
808
809        let first_applied = report.applied().next().unwrap();
810        assert_eq!(first_applied.installed_checksum.as_deref().unwrap(), plan.source_checksum);
811    }
812
813    #[test]
814    fn cmx_managed_registers_source_and_materializes_dir() {
815        let t = TestContext::new();
816        let cfg = CmxConfig {
817            platforms: vec![Platform::Claude],
818            ..Default::default()
819        };
820        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
821
822        let skill = sample_skill("1.0.0");
823        let ctx = t.ctx();
824        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
825        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
826
827        assert!(report.source_registered, "managed set → source should be registered");
828
829        let sources = config::load_sources(&t.fs, &t.paths).unwrap();
830        assert!(sources.sources.contains_key("bundled:sample"), "source entry should exist");
831    }
832
833    #[test]
834    fn skip_and_drifted_skip_plan_writes_nothing() {
835        let t = TestContext::new();
836        let skill = sample_skill("1.0.0");
837        let checksum = skill_fs::checksum_bundled(&skill.files);
838
839        let claude_paths = t.paths.with_platform(Platform::Claude);
840        let skill_dir = claude_paths
841            .install_dir(ArtifactKind::Skill, InstallScope::Global)
842            .unwrap()
843            .join("sample");
844        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
845
846        crate::test_support::save_lock_with_entry(
847            &t.fs,
848            &claude_paths,
849            "sample",
850            LockEntry {
851                artifact_type: ArtifactKind::Skill,
852                version: Some("1.0.0".to_string()),
853                installed_at: "2024-01-01T00:00:00Z".to_string(),
854                source: LockSource {
855                    repo: "bundled:sample".to_string(),
856                    path: "skills/sample".to_string(),
857                },
858                source_checksum: checksum.clone(),
859                installed_checksum: checksum.clone(),
860            },
861            InstallScope::Global,
862        );
863
864        let ctx = t.ctx();
865        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
866        // Plan should be Skip
867        assert!(matches!(plan.targets[0].action, TargetAction::Skip));
868        assert_eq!(plan.write_count(), 0);
869    }
870
871    #[test]
872    fn blocked_plan_returns_err_on_apply() {
873        let t = TestContext::new();
874        // Create a plan with a RefuseNewer by having a newer version installed
875        let skill = sample_skill("1.0.0");
876        let claude_paths = t.paths.with_platform(Platform::Claude);
877        let skill_dir = claude_paths
878            .install_dir(ArtifactKind::Skill, InstallScope::Global)
879            .unwrap()
880            .join("sample");
881        t.fs.add_dir(skill_dir);
882
883        crate::test_support::save_lock_with_entry(
884            &t.fs,
885            &claude_paths,
886            "sample",
887            LockEntry {
888                artifact_type: ArtifactKind::Skill,
889                version: Some("2.0.0".to_string()),
890                installed_at: "2024-01-01T00:00:00Z".to_string(),
891                source: LockSource {
892                    repo: "bundled:sample".to_string(),
893                    path: "skills/sample".to_string(),
894                },
895                source_checksum: "sha256:abc".to_string(),
896                installed_checksum: "sha256:abc".to_string(),
897            },
898            InstallScope::Global,
899        );
900
901        let ctx = t.ctx();
902        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
903        assert!(plan.is_blocked());
904
905        let result = installer("1.0.0").apply(&skill, &plan, &ctx);
906        assert!(result.is_err());
907    }
908
909    #[test]
910    fn parity_guard_rejects_mismatched_bundled_skill() {
911        let t = TestContext::new();
912        let skill_v1 = sample_skill("1.0.0");
913        let ctx = t.ctx();
914        let plan = installer("1.0.0").plan(&skill_v1, Scope::Global, false, &ctx).unwrap();
915
916        // Apply with a skill whose *body* differs (a version-only difference would be
917        // normalized away by the auto-stamp, so parity must be exercised on content
918        // the stamp does not touch).
919        let skill_v2 = BundledSkill::from_files(vec![
920            make_file("SKILL.md", "---\nmetadata:\n  version: \"1.0.0\"\n---\n# DIFFERENT body\n"),
921            make_file("scripts/tool.py", "print('hello')"),
922        ]);
923        let result = installer("1.0.0").apply(&skill_v2, &plan, &ctx);
924        assert!(result.is_err());
925        assert!(result.unwrap_err().to_string().contains("Parity"));
926    }
927
928    #[test]
929    fn shared_dir_managed_codex_pi_written_once_both_locks_updated() {
930        let t = TestContext::new();
931        // Configure both Codex and Pi as managed platforms
932        let cfg = CmxConfig {
933            platforms: vec![Platform::Codex, Platform::Pi],
934            ..Default::default()
935        };
936        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
937
938        let skill = sample_skill("1.0.0");
939        let ctx = t.ctx();
940        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
941        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
942
943        // Both Codex and Pi resolve skills to .agents/skills — same path.
944        let codex_paths = t.paths.with_platform(Platform::Codex);
945        let pi_paths = t.paths.with_platform(Platform::Pi);
946
947        let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
948        let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
949
950        assert!(codex_lock.packages.contains_key("sample"), "Codex lock should have entry");
951        assert!(pi_lock.packages.contains_key("sample"), "Pi lock should have entry");
952    }
953
954    #[test]
955    fn on_disk_file_set_matches_planned_dest_paths() {
956        let t = TestContext::new();
957        let skill = sample_skill("1.0.0");
958        let ctx = t.ctx();
959        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
960        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
961
962        for target in &plan.targets {
963            for pf in &target.files {
964                assert!(
965                    t.fs.file_exists(&pf.dest_path),
966                    "expected file on disk: {}",
967                    pf.dest_path.display()
968                );
969            }
970        }
971    }
972
973    // -----------------------------------------------------------------------
974    // Tests 22–24: status()
975    // -----------------------------------------------------------------------
976
977    #[test]
978    fn not_installed_on_fresh_machine() {
979        let t = TestContext::new();
980        let ctx = t.ctx();
981        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
982        assert!(!status.targets[0].installed);
983        assert!(!status.targets[0].tracked);
984    }
985
986    #[test]
987    fn after_apply_installed_tracked_version_matches_not_drifted() {
988        let t = TestContext::new();
989        let skill = sample_skill("1.0.0");
990        let ctx = t.ctx();
991        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
992        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
993
994        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
995        assert!(status.targets[0].installed);
996        assert!(status.targets[0].tracked);
997        assert_eq!(status.targets[0].installed_version.as_deref(), Some("1.0.0"));
998        assert!(!status.targets[0].drifted);
999    }
1000
1001    #[test]
1002    fn mutate_skill_md_on_disk_produces_drifted() {
1003        let t = TestContext::new();
1004        let skill = sample_skill("1.0.0");
1005        let ctx = t.ctx();
1006        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1007        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1008
1009        // Mutate the on-disk SKILL.md
1010        let skill_dir = t
1011            .paths
1012            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1013            .unwrap()
1014            .join("sample");
1015        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# MODIFIED\n");
1016
1017        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1018        assert!(status.targets[0].drifted, "mutated SKILL.md should report drifted");
1019    }
1020
1021    // -----------------------------------------------------------------------
1022    // Tests 25–28: remove()
1023    // -----------------------------------------------------------------------
1024
1025    #[test]
1026    fn remove_deletes_dir_and_clears_lock() {
1027        let t = TestContext::new();
1028        let skill = sample_skill("1.0.0");
1029        let ctx = t.ctx();
1030        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1031        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1032
1033        let skill_dir = t
1034            .paths
1035            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1036            .unwrap()
1037            .join("sample");
1038        assert!(t.fs.exists(&skill_dir));
1039
1040        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1041        assert!(report.was_on_disk);
1042        assert!(report.was_tracked);
1043        assert!(!t.fs.exists(&skill_dir));
1044
1045        let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1046        assert!(!lock.packages.contains_key("sample"));
1047    }
1048
1049    #[test]
1050    fn shared_dir_managed_codex_pi_removed_once_both_locks_cleared() {
1051        let t = TestContext::new();
1052        let cfg = CmxConfig {
1053            platforms: vec![Platform::Codex, Platform::Pi],
1054            ..Default::default()
1055        };
1056        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1057
1058        let skill = sample_skill("1.0.0");
1059        let ctx = t.ctx();
1060        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1061        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1062
1063        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1064        assert!(report.was_on_disk);
1065        assert!(report.platforms_cleared.contains(&Platform::Codex));
1066        assert!(report.platforms_cleared.contains(&Platform::Pi));
1067
1068        // Both lock entries should be gone
1069        let codex_paths = t.paths.with_platform(Platform::Codex);
1070        let pi_paths = t.paths.with_platform(Platform::Pi);
1071        let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1072        let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1073        assert!(!codex_lock.packages.contains_key("sample"));
1074        assert!(!pi_lock.packages.contains_key("sample"));
1075    }
1076
1077    #[test]
1078    fn cmx_managed_remove_clears_source_and_materialized_dir() {
1079        let t = TestContext::new();
1080        let cfg = CmxConfig {
1081            platforms: vec![Platform::Claude],
1082            ..Default::default()
1083        };
1084        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1085
1086        let skill = sample_skill("1.0.0");
1087        let ctx = t.ctx();
1088        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1089        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1090
1091        // Confirm source was registered
1092        let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1093        assert!(sources.sources.contains_key("bundled:sample"));
1094
1095        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1096        assert!(report.source_unregistered);
1097
1098        let sources_after = config::load_sources(&t.fs, &t.paths).unwrap();
1099        assert!(!sources_after.sources.contains_key("bundled:sample"));
1100    }
1101
1102    #[test]
1103    fn remove_when_nothing_installed_returns_ok_all_false() {
1104        let t = TestContext::new();
1105        let ctx = t.ctx();
1106        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1107        assert!(!report.was_on_disk);
1108        assert!(!report.was_tracked);
1109        assert!(!report.source_unregistered);
1110    }
1111
1112    // -----------------------------------------------------------------------
1113    // New constructor and derive tests
1114    // -----------------------------------------------------------------------
1115
1116    #[test]
1117    fn single_md_builds_single_skill_md() {
1118        let skill = BundledSkill::single_md("---\nversion: 1.0.0\n---\n# My skill\n");
1119        assert_eq!(skill.files.len(), 1);
1120        assert!(skill.has_skill_md());
1121        assert_eq!(skill.files[0].rel_path, std::path::PathBuf::from("SKILL.md"));
1122    }
1123
1124    #[test]
1125    fn tool_identity_new_sets_fields() {
1126        let id = ToolIdentity::new("mytool", "1.2.3");
1127        assert_eq!(id.name, "mytool");
1128        assert_eq!(id.version, "1.2.3");
1129    }
1130
1131    #[test]
1132    fn scope_partial_eq() {
1133        assert_eq!(Scope::Global, Scope::Global);
1134        assert_eq!(Scope::Local, Scope::Local);
1135        assert_ne!(Scope::Global, Scope::Local);
1136    }
1137
1138    // -----------------------------------------------------------------------
1139    // Report fidelity tests
1140    // -----------------------------------------------------------------------
1141
1142    #[test]
1143    fn skipped_target_outcome_carries_dest_dir() {
1144        let t = TestContext::new();
1145        let skill = sample_skill("1.0.0");
1146        let checksum = skill_fs::checksum_bundled(&skill.files);
1147
1148        let claude_paths = t.paths.with_platform(Platform::Claude);
1149        let skill_dir = claude_paths
1150            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1151            .unwrap()
1152            .join("sample");
1153        skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1154
1155        crate::test_support::save_lock_with_entry(
1156            &t.fs,
1157            &claude_paths,
1158            "sample",
1159            LockEntry {
1160                artifact_type: ArtifactKind::Skill,
1161                version: Some("1.0.0".to_string()),
1162                installed_at: "2024-01-01T00:00:00Z".to_string(),
1163                source: LockSource {
1164                    repo: "bundled:sample".to_string(),
1165                    path: "skills/sample".to_string(),
1166                },
1167                source_checksum: checksum.clone(),
1168                installed_checksum: checksum.clone(),
1169            },
1170            InstallScope::Global,
1171        );
1172
1173        let ctx = t.ctx();
1174        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1175        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1176
1177        // The skip target must preserve dest_dir
1178        let skip = report.skipped().next().expect("expected a skipped target");
1179        assert!(
1180            !skip.dest_dir.as_os_str().is_empty(),
1181            "dest_dir must be non-empty on skipped target"
1182        );
1183        assert!(matches!(skip.action, TargetAction::Skip));
1184        assert_eq!(skip.installed_checksum, None);
1185    }
1186
1187    #[test]
1188    fn drifted_skip_outcome_is_distinguishable_from_plain_skip() {
1189        let t = TestContext::new();
1190        let skill = sample_skill("1.0.0");
1191        let checksum = skill_fs::checksum_bundled(&skill.files);
1192
1193        let claude_paths = t.paths.with_platform(Platform::Claude);
1194        let skill_dir = claude_paths
1195            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1196            .unwrap()
1197            .join("sample");
1198        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1199
1200        crate::test_support::save_lock_with_entry(
1201            &t.fs,
1202            &claude_paths,
1203            "sample",
1204            LockEntry {
1205                artifact_type: ArtifactKind::Skill,
1206                version: Some("1.0.0".to_string()),
1207                installed_at: "2024-01-01T00:00:00Z".to_string(),
1208                source: LockSource {
1209                    repo: "bundled:sample".to_string(),
1210                    path: "skills/sample".to_string(),
1211                },
1212                source_checksum: checksum.clone(),
1213                installed_checksum: checksum,
1214            },
1215            InstallScope::Global,
1216        );
1217
1218        let ctx = t.ctx();
1219        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1220        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1221
1222        let skip = report.skipped().next().expect("expected a skipped target");
1223        assert!(
1224            matches!(skip.action, TargetAction::DriftedSkip { .. }),
1225            "action must be DriftedSkip, not plain Skip"
1226        );
1227    }
1228
1229    #[test]
1230    fn force_overwrites_drifted_copy_and_reports_update() {
1231        let t = TestContext::new();
1232        let skill = sample_skill("1.0.0");
1233        let checksum = skill_fs::checksum_bundled(&skill.files);
1234
1235        let claude_paths = t.paths.with_platform(Platform::Claude);
1236        let skill_dir = claude_paths
1237            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1238            .unwrap()
1239            .join("sample");
1240        let skill_md = skill_dir.join("SKILL.md");
1241        let local_only = skill_dir.join("local-only.md");
1242        t.fs.add_file(&skill_md, "---\nversion: 1.0.0\n---\n# Modified\n");
1243        t.fs.add_file(&local_only, "scratch\n");
1244
1245        crate::test_support::save_lock_with_entry(
1246            &t.fs,
1247            &claude_paths,
1248            "sample",
1249            LockEntry {
1250                artifact_type: ArtifactKind::Skill,
1251                version: Some("1.0.0".to_string()),
1252                installed_at: "2024-01-01T00:00:00Z".to_string(),
1253                source: LockSource {
1254                    repo: "bundled:sample".to_string(),
1255                    path: "skills/sample".to_string(),
1256                },
1257                source_checksum: checksum.clone(),
1258                installed_checksum: checksum.clone(),
1259            },
1260            InstallScope::Global,
1261        );
1262
1263        let ctx = t.ctx();
1264        let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
1265        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1266
1267        let updated = report.applied().next().expect("expected an updated target");
1268        assert!(matches!(updated.action, TargetAction::Update { .. }));
1269        let discarded: BTreeSet<_> = updated.discarded_paths.iter().cloned().collect();
1270        assert_eq!(
1271            discarded,
1272            BTreeSet::from([
1273                local_only.clone(),
1274                skill_md.clone(),
1275                skill_dir.join("scripts/tool.py")
1276            ])
1277        );
1278        assert_eq!(
1279            t.fs.read_to_string(&skill_md).unwrap(),
1280            "---\nmetadata:\n  version: \"1.0.0\"\n---\n# Sample skill\n"
1281        );
1282        assert!(!t.fs.exists(&local_only));
1283        assert_eq!(checksum::checksum_dir(&skill_dir, &t.fs).unwrap(), checksum);
1284    }
1285}