Skip to main content

devboy_skills/
install.rs

1//! Install target resolution plus the three-state install / upgrade flow.
2//!
3//! The resolver turns user-supplied flags (`--global`, `--agent`,
4//! `--local`) into a concrete list of on-disk directories where skills
5//! should be written. The flow consumes that list together with the
6//! embedded [`HistoricalHashes`] registry to apply the three-state
7//! install logic from
8//! ADR-014 in `docs/architecture/adr/ADR-014-skills-lifecycle.md` at
9//! the repository root.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use chrono::Utc;
15
16use crate::error::{Result, SkillError};
17use crate::manifest::{
18    HistoricalHashes, InstallState, InstalledFile, InstalledSkill, MANIFEST_FILE, Manifest,
19    classify_path, sha256_hex,
20};
21use crate::skill::Skill;
22
23// ---------------------------------------------------------------------------
24// Supported agents
25// ---------------------------------------------------------------------------
26
27/// One of the known agents that has a canonical skills directory.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Agent {
30    /// Claude Code — `~/.claude/skills/` (user) or `./.claude/skills/`
31    /// (project).
32    Claude,
33    /// Codex — `~/.codex/skills/` (user) or `./.codex/skills/`
34    /// (project).
35    Codex,
36    /// Cursor — `~/.cursor/skills/` (user) or `./.cursor/skills/`
37    /// (project).
38    Cursor,
39    /// Kimi — `~/.kimi/skills/` (user) or `./.kimi/skills/` (project).
40    Kimi,
41}
42
43impl Agent {
44    /// Canonical spelling of the agent id (same as the CLI flag value).
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            Self::Claude => "claude",
48            Self::Codex => "codex",
49            Self::Cursor => "cursor",
50            Self::Kimi => "kimi",
51        }
52    }
53
54    /// Every agent this crate knows about.
55    pub fn all() -> &'static [Agent] {
56        &[Self::Claude, Self::Codex, Self::Cursor, Self::Kimi]
57    }
58
59    /// Parse a flag value (`claude`, `codex`, `cursor`, `kimi`).
60    pub fn parse(s: &str) -> Option<Self> {
61        match s {
62            "claude" => Some(Self::Claude),
63            "codex" => Some(Self::Codex),
64            "cursor" => Some(Self::Cursor),
65            "kimi" => Some(Self::Kimi),
66            _ => None,
67        }
68    }
69
70    /// Name of the hidden directory the agent reads from (e.g.
71    /// `.claude`, `.codex`, …).
72    pub fn dir_name(&self) -> &'static str {
73        match self {
74            Self::Claude => ".claude",
75            Self::Codex => ".codex",
76            Self::Cursor => ".cursor",
77            Self::Kimi => ".kimi",
78        }
79    }
80}
81
82/// Detect which agents have a home directory on this machine. Used by
83/// `--agent all` to decide where to install.
84pub fn detect_installed_agents(home: &Path) -> Vec<Agent> {
85    Agent::all()
86        .iter()
87        .copied()
88        .filter(|a| home.join(a.dir_name()).is_dir())
89        .collect()
90}
91
92// ---------------------------------------------------------------------------
93// Install target specification
94// ---------------------------------------------------------------------------
95
96/// Specification of where to install (from CLI flags), before resolution.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct InstallSpec {
99    /// Whether `--global` was passed.
100    pub global: bool,
101    /// Whether `--local` was passed.
102    pub local: bool,
103    /// Agents named by `--agent` (possibly multiple; `--agent all`
104    /// expands to every detected agent).
105    pub agents: Vec<Agent>,
106    /// When `--agent all` was passed, we also install to the
107    /// vendor-neutral `~/.agents/skills/` location — this flag records
108    /// that intent so the resolver can produce that extra target.
109    pub include_vendor_neutral_global: bool,
110}
111
112/// A concrete install target — a single directory on disk.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct InstallTarget {
115    /// Human-readable label used in logs / CLI output.
116    pub label: String,
117    /// Parent directory that will hold the per-skill subdirectories.
118    ///
119    /// Example: `/home/alice/.agents/skills` or
120    /// `/path/to/repo/.agents/skills`.
121    pub skills_dir: PathBuf,
122}
123
124/// Paths the resolver needs from the environment — factored out so
125/// tests can inject their own without touching the real filesystem.
126#[derive(Debug, Clone)]
127pub struct Environment {
128    pub cwd: PathBuf,
129    /// User's home directory (typically `dirs::home_dir()`).
130    pub home: PathBuf,
131    /// Root of the current repository, if any.
132    pub repo_root: Option<PathBuf>,
133}
134
135impl Environment {
136    /// Build an [`Environment`] from the real operating system.
137    ///
138    /// The `DEVBOY_HOME_OVERRIDE` environment variable, when set,
139    /// replaces the detected home directory. This exists mostly for
140    /// integration tests that want to redirect installs into a
141    /// temporary directory on platforms (notably Windows) where
142    /// `dirs::home_dir()` resolves through the OS rather than through
143    /// `$HOME` / `$USERPROFILE`.
144    pub fn detect() -> Result<Self> {
145        let cwd = std::env::current_dir().map_err(|source| SkillError::Io {
146            path: PathBuf::from("."),
147            source,
148        })?;
149        let home = match std::env::var_os("DEVBOY_HOME_OVERRIDE") {
150            Some(p) if !p.is_empty() => PathBuf::from(p),
151            _ => dirs::home_dir().ok_or_else(|| SkillError::Io {
152                path: PathBuf::from("~"),
153                source: std::io::Error::other("home directory is not set"),
154            })?,
155        };
156        let repo_root = locate_repo_root(&cwd);
157        Ok(Self {
158            cwd,
159            home,
160            repo_root,
161        })
162    }
163}
164
165fn locate_repo_root(start: &Path) -> Option<PathBuf> {
166    let mut cur = start;
167    loop {
168        if cur.join(".git").exists() || cur.join(".devboy.toml").exists() {
169            return Some(cur.to_path_buf());
170        }
171        match cur.parent() {
172            Some(p) => cur = p,
173            None => return None,
174        }
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Resolver
180// ---------------------------------------------------------------------------
181
182/// Resolve an [`InstallSpec`] into one or more concrete [`InstallTarget`]s.
183///
184/// Rules (ADR-013):
185///
186/// - Default (no flags) — repo-local at `<repo>/.agents/skills/`.
187/// - `--global` — `~/.agents/skills/`.
188/// - `--agent X` — agent's home path unless `--local` was also passed,
189///   in which case `<repo>/<agent-dir>/skills/`.
190/// - `--agent all` — every detected agent plus `~/.agents/skills/`.
191///
192/// When neither `--global` nor `--agent` is passed and the environment
193/// has no repository root, this function returns
194/// [`SkillError::MissingRequiredField`] (reusing that variant as the
195/// "user must pick a target" signal; the CLI layer formats it into the
196/// helpful multi-line error described in the ADR).
197pub fn resolve_targets(env: &Environment, spec: &InstallSpec) -> Result<Vec<InstallTarget>> {
198    let mut targets: Vec<InstallTarget> = Vec::new();
199
200    if spec.global {
201        targets.push(InstallTarget {
202            label: "global (~/.agents/skills)".into(),
203            skills_dir: env.home.join(".agents").join("skills"),
204        });
205    }
206
207    if !spec.agents.is_empty() {
208        for agent in &spec.agents {
209            let (label, root) = if spec.local {
210                let repo =
211                    env.repo_root
212                        .as_ref()
213                        .ok_or_else(|| SkillError::MissingRequiredField {
214                            skill: "<install-target>".into(),
215                            field: "repository root",
216                        })?;
217                (
218                    format!(
219                        "{} (local: <repo>/{}/skills)",
220                        agent.as_str(),
221                        agent.dir_name()
222                    ),
223                    repo.join(agent.dir_name()).join("skills"),
224                )
225            } else {
226                (
227                    format!("{} (~/{}/skills)", agent.as_str(), agent.dir_name()),
228                    env.home.join(agent.dir_name()).join("skills"),
229                )
230            };
231            targets.push(InstallTarget {
232                label,
233                skills_dir: root,
234            });
235        }
236        if spec.include_vendor_neutral_global
237            && !targets
238                .iter()
239                .any(|t| t.skills_dir == env.home.join(".agents").join("skills"))
240        {
241            targets.push(InstallTarget {
242                label: "global (~/.agents/skills)".into(),
243                skills_dir: env.home.join(".agents").join("skills"),
244            });
245        }
246    }
247
248    if !spec.global && spec.agents.is_empty() {
249        let repo = env
250            .repo_root
251            .as_ref()
252            .ok_or_else(|| SkillError::MissingRequiredField {
253                skill: "<install-target>".into(),
254                field: "repository root (pass --global or --agent to install outside a repo)",
255            })?;
256        targets.push(InstallTarget {
257            label: "repo-local (<repo>/.agents/skills)".into(),
258            skills_dir: repo.join(".agents").join("skills"),
259        });
260    }
261
262    Ok(targets)
263}
264
265// ---------------------------------------------------------------------------
266// Install / upgrade flow
267// ---------------------------------------------------------------------------
268
269/// Options for a single install pass at a single target.
270#[derive(Debug, Clone, Default)]
271pub struct InstallOptions {
272    /// Overwrite files classified as `UserModified` / `Unknown`.
273    pub force: bool,
274    /// Do not write anything; just compute the plan.
275    pub dry_run: bool,
276    /// Label to record in the manifest as the originating devboy
277    /// version.
278    pub installed_from: Option<String>,
279}
280
281/// Per-skill result of a single install attempt.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum InstallOutcome {
284    /// Skill was newly written to disk.
285    Installed,
286    /// File already matches the current shipped version.
287    Unchanged,
288    /// Existing file was one of our previous shipped versions — auto-upgraded.
289    Upgraded {
290        /// Version recorded in the manifest before the upgrade (if any).
291        from_version: Option<u32>,
292    },
293    /// Existing file was user-modified; overwrite refused.
294    SkippedUserModified,
295    /// `--force` was set; user-modified file was overwritten anyway.
296    OverwrittenWithForce,
297    /// Skill is unknown to the embedded history AND already has a
298    /// file on disk with an unknown hash — nothing classified, nothing
299    /// changed.
300    SkippedUnknown,
301}
302
303/// Aggregate result across every skill installed to a target.
304#[derive(Debug, Clone, Default)]
305pub struct InstallReport {
306    /// Per-skill outcomes keyed by skill name.
307    pub outcomes: std::collections::BTreeMap<String, InstallOutcome>,
308}
309
310impl InstallReport {
311    /// Count of skills with a given outcome.
312    pub fn count(&self, outcome: &InstallOutcome) -> usize {
313        self.outcomes.values().filter(|o| o == &outcome).count()
314    }
315
316    /// `true` if nothing was installed or upgraded.
317    pub fn is_all_noop(&self) -> bool {
318        self.outcomes.values().all(|o| {
319            matches!(
320                o,
321                InstallOutcome::Unchanged
322                    | InstallOutcome::SkippedUserModified
323                    | InstallOutcome::SkippedUnknown
324            )
325        })
326    }
327}
328
329/// Install a set of skills to a single target directory.
330pub fn install_skills_to_target(
331    target: &InstallTarget,
332    skills: &[Skill],
333    history: &HistoricalHashes,
334    options: &InstallOptions,
335) -> Result<InstallReport> {
336    if !options.dry_run {
337        fs::create_dir_all(&target.skills_dir).map_err(|source| SkillError::Io {
338            path: target.skills_dir.clone(),
339            source,
340        })?;
341    }
342
343    let manifest_path = target.skills_dir.join(MANIFEST_FILE);
344    // `Manifest::load` returns an empty manifest only when the file does
345    // not exist. A corrupt manifest is propagated so the caller sees the
346    // parse error rather than silently discarding every install record.
347    let mut manifest = Manifest::load(&manifest_path)?;
348    manifest.installed_from = options.installed_from.clone().or(manifest.installed_from);
349
350    let mut report = InstallReport::default();
351
352    for skill in skills {
353        let skill_dir = target.skills_dir.join(&skill.frontmatter.name);
354        let skill_path = skill_dir.join("SKILL.md");
355        let body = render_skill_file(skill);
356
357        // First, try to classify against the embedded history.
358        let mut state = classify_path(history, &skill.frontmatter.name, &skill_path)?
359            .unwrap_or(InstallState::Unknown);
360
361        // Fallback: when the embedded history is empty for this skill,
362        // the manifest is still a trustworthy source of truth about
363        // "did we install this, and has it changed since?". Promote
364        // matching-manifest-hash to Unchanged; keep everything else as
365        // classified.
366        if matches!(state, InstallState::Unknown)
367            && let Some(recorded) = manifest.get(&skill.frontmatter.name)
368            && let Some(file_entry) = recorded.files.get("SKILL.md")
369            && let Some(actual_sha) = hash_file_if_exists(&skill_path)?
370        {
371            state = if actual_sha.eq_ignore_ascii_case(&file_entry.sha256) {
372                InstallState::Unchanged
373            } else {
374                InstallState::UserModified
375            };
376        }
377
378        let previously_installed = skill_path.is_file();
379
380        let outcome = match (state, previously_installed, options.force) {
381            (_, false, _) => {
382                write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
383                if !options.dry_run {
384                    manifest.record(
385                        &skill.frontmatter.name,
386                        record_for(skill, body.as_bytes(), options, "embedded"),
387                    );
388                }
389                InstallOutcome::Installed
390            }
391            (InstallState::Unchanged, true, _) => InstallOutcome::Unchanged,
392            (InstallState::HistoricalSafe, true, _) => {
393                let prev_version = manifest.get(&skill.frontmatter.name).map(|m| m.version);
394                write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
395                if !options.dry_run {
396                    manifest.record(
397                        &skill.frontmatter.name,
398                        record_for(skill, body.as_bytes(), options, "embedded"),
399                    );
400                }
401                InstallOutcome::Upgraded {
402                    from_version: prev_version,
403                }
404            }
405            (InstallState::UserModified, true, true) => {
406                write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
407                if !options.dry_run {
408                    manifest.record(
409                        &skill.frontmatter.name,
410                        record_for(skill, body.as_bytes(), options, "embedded"),
411                    );
412                }
413                InstallOutcome::OverwrittenWithForce
414            }
415            (InstallState::UserModified, true, false) => InstallOutcome::SkippedUserModified,
416            (InstallState::Unknown, true, true) => {
417                write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
418                if !options.dry_run {
419                    manifest.record(
420                        &skill.frontmatter.name,
421                        record_for(skill, body.as_bytes(), options, "embedded"),
422                    );
423                }
424                InstallOutcome::OverwrittenWithForce
425            }
426            (InstallState::Unknown, true, false) => InstallOutcome::SkippedUnknown,
427        };
428
429        report
430            .outcomes
431            .insert(skill.frontmatter.name.clone(), outcome);
432    }
433
434    if !options.dry_run {
435        manifest.save(&manifest_path)?;
436    }
437
438    Ok(report)
439}
440
441/// Remove installed skills from a target, clearing manifest entries.
442/// Missing skills are silently ignored unless `strict` is set.
443pub fn remove_skills_from_target(
444    target: &InstallTarget,
445    names: &[String],
446    strict: bool,
447    dry_run: bool,
448) -> Result<Vec<String>> {
449    let manifest_path = target.skills_dir.join(MANIFEST_FILE);
450    // Manifest parse errors propagate; a missing file produces an empty
451    // manifest as for `install`.
452    let mut manifest = Manifest::load(&manifest_path)?;
453    let mut removed = Vec::new();
454
455    for name in names {
456        let skill_dir = target.skills_dir.join(name);
457        let dir_present = skill_dir.exists();
458        let in_manifest = manifest.get(name).is_some();
459
460        if !dir_present && !in_manifest {
461            if strict {
462                return Err(SkillError::NotFound {
463                    name: name.clone(),
464                    source_name: "install-target",
465                });
466            }
467            continue;
468        }
469
470        if dir_present && !dry_run {
471            fs::remove_dir_all(&skill_dir).map_err(|source| SkillError::Io {
472                path: skill_dir.clone(),
473                source,
474            })?;
475        }
476        // Always clean the manifest entry — if the directory vanished
477        // out of band, the manifest row becomes stale and callers see
478        // the skill as installed forever. This brings the two sources
479        // of truth back in sync.
480        if !dry_run {
481            manifest.forget(name);
482        }
483        removed.push(name.clone());
484    }
485
486    if !dry_run {
487        manifest.save(&manifest_path)?;
488    }
489    Ok(removed)
490}
491
492// ---------------------------------------------------------------------------
493// Legacy name migration
494// ---------------------------------------------------------------------------
495
496/// Result of `scan_legacy_skills_at_target` for one entry.
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct LegacySkill {
499    /// Legacy directory name on disk, e.g. `devboy-setup`.
500    pub legacy_name: String,
501    /// New name without the `devboy-` prefix, e.g. `setup`.
502    pub canonical_name: String,
503    /// `true` if a sibling directory with the canonical name already
504    /// exists at the same target (meaning the legacy entry is a safe
505    /// duplicate to remove).
506    pub canonical_present: bool,
507    /// Absolute path of the legacy skill directory.
508    pub path: PathBuf,
509}
510
511/// Find `devboy-<name>` directories at a target where the new
512/// canonical entry `<name>` is also present (a safe-to-remove
513/// duplicate). Directories without a sibling are returned with
514/// `canonical_present: false` and the caller decides what to do.
515pub fn scan_legacy_skills_at_target(target: &InstallTarget) -> Result<Vec<LegacySkill>> {
516    let mut out = Vec::new();
517    let entries = match fs::read_dir(&target.skills_dir) {
518        Ok(e) => e,
519        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
520        Err(source) => {
521            return Err(SkillError::Io {
522                path: target.skills_dir.clone(),
523                source,
524            });
525        }
526    };
527    for entry in entries.flatten() {
528        let name = entry.file_name().to_string_lossy().into_owned();
529        let Some(canonical) = name.strip_prefix("devboy-").map(str::to_owned) else {
530            continue;
531        };
532        // Only consider directories (real or symlinks to dirs); skip files.
533        let path = entry.path();
534        if !path.is_dir() {
535            continue;
536        }
537        let canonical_path = target.skills_dir.join(&canonical);
538        out.push(LegacySkill {
539            legacy_name: name,
540            canonical_name: canonical,
541            canonical_present: canonical_path.exists(),
542            path,
543        });
544    }
545    out.sort_by(|a, b| a.legacy_name.cmp(&b.legacy_name));
546    Ok(out)
547}
548
549/// Remove legacy `devboy-<name>` skill directories at a target where a
550/// canonical sibling `<name>` is present (safe duplicates). Returns the
551/// list of legacy names actually removed. Skips entries without a
552/// canonical sibling — those are surfaced by the caller as warnings.
553///
554/// `dry_run = true` reports what would be removed without touching the
555/// filesystem.
556pub fn migrate_legacy_skills_at_target(
557    target: &InstallTarget,
558    dry_run: bool,
559) -> Result<Vec<String>> {
560    let scan = scan_legacy_skills_at_target(target)?;
561    let safe: Vec<String> = scan
562        .iter()
563        .filter(|s| s.canonical_present)
564        .map(|s| s.legacy_name.clone())
565        .collect();
566    if safe.is_empty() {
567        return Ok(safe);
568    }
569    remove_skills_from_target(target, &safe, false, dry_run)
570}
571
572// ---------------------------------------------------------------------------
573// Internals
574// ---------------------------------------------------------------------------
575
576fn hash_file_if_exists(path: &Path) -> Result<Option<String>> {
577    match fs::read(path) {
578        Ok(bytes) => Ok(Some(sha256_hex(&bytes))),
579        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
580        Err(source) => Err(SkillError::Io {
581            path: path.to_path_buf(),
582            source,
583        }),
584    }
585}
586
587fn render_skill_file(skill: &Skill) -> String {
588    // Round-trip the frontmatter through serde_yaml so we normalise
589    // ordering and any `extra` fields round-trip correctly.
590    let yaml =
591        serde_yaml::to_string(&skill.frontmatter).expect("frontmatter should always serialise");
592    let mut out = String::with_capacity(yaml.len() + skill.body.len() + 16);
593    out.push_str("---\n");
594    out.push_str(&yaml);
595    if !yaml.ends_with('\n') {
596        out.push('\n');
597    }
598    out.push_str("---\n");
599    out.push_str(&skill.body);
600    out
601}
602
603fn write_skill(skill_dir: &Path, file_path: &Path, bytes: &[u8], dry_run: bool) -> Result<()> {
604    if dry_run {
605        return Ok(());
606    }
607    fs::create_dir_all(skill_dir).map_err(|source| SkillError::Io {
608        path: skill_dir.to_path_buf(),
609        source,
610    })?;
611    fs::write(file_path, bytes).map_err(|source| SkillError::Io {
612        path: file_path.to_path_buf(),
613        source,
614    })?;
615    Ok(())
616}
617
618fn record_for(
619    skill: &Skill,
620    body: &[u8],
621    _options: &InstallOptions,
622    source: &str,
623) -> InstalledSkill {
624    let mut files = std::collections::BTreeMap::new();
625    files.insert(
626        "SKILL.md".to_string(),
627        InstalledFile {
628            sha256: sha256_hex(body),
629            size: body.len() as u64,
630        },
631    );
632    // `InstalledSkill.source` records which `SkillSource` produced the
633    // skill (`"embedded"`, a future `"marketplace"`, `"langfuse"` etc.).
634    // The devboy-tools version that did the install lives in the
635    // top-level `Manifest.installed_from` field — keeping the two
636    // distinct is deliberate.
637    InstalledSkill {
638        version: skill.frontmatter.version,
639        installed_at: Utc::now(),
640        source: source.to_string(),
641        files,
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Tests
647// ---------------------------------------------------------------------------
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use tempfile::tempdir;
653
654    fn test_env(home: &Path, cwd: &Path, repo_root: Option<PathBuf>) -> Environment {
655        Environment {
656            cwd: cwd.to_path_buf(),
657            home: home.to_path_buf(),
658            repo_root,
659        }
660    }
661
662    #[test]
663    fn resolver_default_is_repo_local() {
664        let home = tempdir().unwrap();
665        let repo = tempdir().unwrap();
666        let env = test_env(home.path(), repo.path(), Some(repo.path().to_path_buf()));
667        let spec = InstallSpec::default();
668        let targets = resolve_targets(&env, &spec).unwrap();
669        assert_eq!(targets.len(), 1);
670        assert_eq!(
671            targets[0].skills_dir,
672            repo.path().join(".agents").join("skills")
673        );
674    }
675
676    #[test]
677    fn resolver_fails_without_repo_and_flags() {
678        let home = tempdir().unwrap();
679        let env = test_env(home.path(), home.path(), None);
680        let spec = InstallSpec::default();
681        let err = resolve_targets(&env, &spec).unwrap_err();
682        assert!(
683            matches!(err, SkillError::MissingRequiredField { .. }),
684            "expected MissingRequiredField, got {err:?}"
685        );
686    }
687
688    #[test]
689    fn resolver_global() {
690        let home = tempdir().unwrap();
691        let env = test_env(home.path(), home.path(), None);
692        let spec = InstallSpec {
693            global: true,
694            ..Default::default()
695        };
696        let targets = resolve_targets(&env, &spec).unwrap();
697        assert_eq!(targets.len(), 1);
698        assert_eq!(
699            targets[0].skills_dir,
700            home.path().join(".agents").join("skills")
701        );
702    }
703
704    #[test]
705    fn resolver_agent_maps_to_home() {
706        let home = tempdir().unwrap();
707        let env = test_env(home.path(), home.path(), None);
708        let spec = InstallSpec {
709            agents: vec![Agent::Claude],
710            ..Default::default()
711        };
712        let targets = resolve_targets(&env, &spec).unwrap();
713        assert_eq!(targets.len(), 1);
714        assert_eq!(
715            targets[0].skills_dir,
716            home.path().join(".claude").join("skills")
717        );
718    }
719
720    #[test]
721    fn resolver_agent_local_maps_to_repo() {
722        let home = tempdir().unwrap();
723        let repo = tempdir().unwrap();
724        let env = test_env(home.path(), repo.path(), Some(repo.path().to_path_buf()));
725        let spec = InstallSpec {
726            agents: vec![Agent::Codex],
727            local: true,
728            ..Default::default()
729        };
730        let targets = resolve_targets(&env, &spec).unwrap();
731        assert_eq!(targets.len(), 1);
732        assert_eq!(
733            targets[0].skills_dir,
734            repo.path().join(".codex").join("skills")
735        );
736    }
737
738    #[test]
739    fn resolver_agent_all_expands_and_includes_vendor_neutral() {
740        let home = tempdir().unwrap();
741        let env = test_env(home.path(), home.path(), None);
742        let spec = InstallSpec {
743            agents: vec![Agent::Claude, Agent::Codex],
744            include_vendor_neutral_global: true,
745            ..Default::default()
746        };
747        let targets = resolve_targets(&env, &spec).unwrap();
748        assert_eq!(targets.len(), 3);
749        let paths: Vec<_> = targets.iter().map(|t| t.skills_dir.clone()).collect();
750        assert!(paths.contains(&home.path().join(".claude").join("skills")));
751        assert!(paths.contains(&home.path().join(".codex").join("skills")));
752        assert!(paths.contains(&home.path().join(".agents").join("skills")));
753    }
754
755    #[test]
756    fn detect_installed_agents_filters_by_dir_presence() {
757        let home = tempdir().unwrap();
758        fs::create_dir(home.path().join(".claude")).unwrap();
759        fs::create_dir(home.path().join(".cursor")).unwrap();
760        let detected = detect_installed_agents(home.path());
761        assert!(detected.contains(&Agent::Claude));
762        assert!(detected.contains(&Agent::Cursor));
763        assert!(!detected.contains(&Agent::Codex));
764        assert!(!detected.contains(&Agent::Kimi));
765    }
766
767    #[test]
768    fn install_writes_new_skill_and_skips_unchanged() {
769        let dir = tempdir().unwrap();
770        let target = InstallTarget {
771            label: "test".into(),
772            skills_dir: dir.path().to_path_buf(),
773        };
774        let skill = crate::skill::Skill::parse(
775            "setup",
776            r#"---
777name: setup
778description: test
779category: self-bootstrap
780version: 1
781---
782body
783"#,
784        )
785        .unwrap();
786
787        // Prepare history so the rendered-current matches.
788        let body = render_skill_file(&skill);
789        let mut history = HistoricalHashes::default();
790        history.by_skill.insert(
791            "setup".into(),
792            crate::manifest::SkillHistory {
793                current: crate::manifest::HistoricalVersion {
794                    version: 1,
795                    sha256: sha256_hex(body.as_bytes()),
796                },
797                history: vec![],
798            },
799        );
800
801        let options = InstallOptions::default();
802        let report =
803            install_skills_to_target(&target, std::slice::from_ref(&skill), &history, &options)
804                .unwrap();
805        assert_eq!(
806            report.outcomes.get("setup"),
807            Some(&InstallOutcome::Installed)
808        );
809
810        // Second run: file on disk now matches current; outcome = Unchanged.
811        let report = install_skills_to_target(&target, &[skill], &history, &options).unwrap();
812        assert_eq!(
813            report.outcomes.get("setup"),
814            Some(&InstallOutcome::Unchanged)
815        );
816    }
817
818    #[test]
819    fn install_refuses_user_modification_without_force() {
820        let dir = tempdir().unwrap();
821        let target = InstallTarget {
822            label: "test".into(),
823            skills_dir: dir.path().to_path_buf(),
824        };
825        let skill = crate::skill::Skill::parse(
826            "setup",
827            r#"---
828name: setup
829description: test
830category: self-bootstrap
831version: 1
832---
833body
834"#,
835        )
836        .unwrap();
837
838        // Plant a user-modified file.
839        let skill_dir = dir.path().join("setup");
840        fs::create_dir_all(&skill_dir).unwrap();
841        fs::write(skill_dir.join("SKILL.md"), b"user version").unwrap();
842
843        // History knows setup but neither the current nor any
844        // historical hash matches "user version".
845        let mut history = HistoricalHashes::default();
846        history.by_skill.insert(
847            "setup".into(),
848            crate::manifest::SkillHistory {
849                current: crate::manifest::HistoricalVersion {
850                    version: 1,
851                    sha256: sha256_hex(b"current shipped body"),
852                },
853                history: vec![],
854            },
855        );
856
857        let report = install_skills_to_target(
858            &target,
859            std::slice::from_ref(&skill),
860            &history,
861            &InstallOptions::default(),
862        )
863        .unwrap();
864        assert_eq!(
865            report.outcomes.get("setup"),
866            Some(&InstallOutcome::SkippedUserModified)
867        );
868
869        // With --force the same call overwrites.
870        let report = install_skills_to_target(
871            &target,
872            &[skill],
873            &history,
874            &InstallOptions {
875                force: true,
876                ..Default::default()
877            },
878        )
879        .unwrap();
880        assert_eq!(
881            report.outcomes.get("setup"),
882            Some(&InstallOutcome::OverwrittenWithForce)
883        );
884    }
885
886    #[test]
887    fn remove_deletes_skill_and_manifest_entry() {
888        let dir = tempdir().unwrap();
889        let target = InstallTarget {
890            label: "test".into(),
891            skills_dir: dir.path().to_path_buf(),
892        };
893        let skill_dir = dir.path().join("setup");
894        fs::create_dir_all(&skill_dir).unwrap();
895        fs::write(skill_dir.join("SKILL.md"), b"hi").unwrap();
896
897        let removed = remove_skills_from_target(&target, &["setup".into()], false, false).unwrap();
898        assert_eq!(removed, vec!["setup".to_string()]);
899        assert!(!skill_dir.exists());
900    }
901
902    // -------------------------------------------------------------------
903    // Agent parsing / detection
904    // -------------------------------------------------------------------
905
906    #[test]
907    fn agent_parse_and_as_str_round_trip() {
908        for agent in Agent::all() {
909            let parsed = Agent::parse(agent.as_str()).unwrap();
910            assert_eq!(parsed, *agent);
911            assert!(!agent.dir_name().is_empty());
912            assert!(agent.dir_name().starts_with('.'));
913        }
914        assert!(Agent::parse("not-an-agent").is_none());
915        // `all` advertises exactly the four agents enumerated today —
916        // bumping this count is intentional and should be done
917        // alongside a matching `parse` arm.
918        assert_eq!(Agent::all().len(), 4);
919    }
920
921    #[test]
922    fn detect_installed_agents_filters_on_disk() {
923        let home = tempdir().unwrap();
924        // No agent dirs created → nothing detected.
925        assert!(detect_installed_agents(home.path()).is_empty());
926
927        // Drop two agent directories; expect exactly those back.
928        fs::create_dir_all(home.path().join(".claude")).unwrap();
929        fs::create_dir_all(home.path().join(".kimi")).unwrap();
930        let detected = detect_installed_agents(home.path());
931        assert!(detected.contains(&Agent::Claude));
932        assert!(detected.contains(&Agent::Kimi));
933        assert!(!detected.contains(&Agent::Codex));
934    }
935
936    // -------------------------------------------------------------------
937    // render_skill_file
938    // -------------------------------------------------------------------
939
940    #[test]
941    fn render_skill_file_produces_round_trippable_markdown() {
942        let original = r#"---
943name: setup
944description: Walk the user through initial devboy configuration.
945category: self-bootstrap
946version: 3
947compatibility: devboy-tools >= 0.18
948activation:
949  - "setup devboy"
950tools:
951  - doctor
952---
953
954# setup
955
956Body stays intact across a render round-trip.
957"#;
958        let skill = Skill::parse("setup", original).unwrap();
959        let rendered = render_skill_file(&skill);
960        assert!(rendered.starts_with("---\n"));
961        assert!(rendered.contains("\n---\n"));
962        assert!(rendered.contains("Body stays intact"));
963
964        // Re-parse: the rendered file must round-trip back to the same
965        // frontmatter + body pair.
966        let reparsed = Skill::parse("setup", &rendered).unwrap();
967        assert_eq!(reparsed.name(), skill.name());
968        assert_eq!(reparsed.version(), skill.version());
969        assert_eq!(reparsed.category(), skill.category());
970        assert_eq!(reparsed.body.trim_end(), skill.body.trim_end());
971    }
972
973    // -------------------------------------------------------------------
974    // Legacy migration
975    // -------------------------------------------------------------------
976
977    fn plant_skill(target: &InstallTarget, name: &str) {
978        let dir = target.skills_dir.join(name);
979        fs::create_dir_all(&dir).unwrap();
980        fs::write(
981            dir.join("SKILL.md"),
982            format!(
983                "---\nname: {n}\ndescription: x\ncategory: self-bootstrap\nversion: 1\n---\nbody\n",
984                n = name
985            ),
986        )
987        .unwrap();
988    }
989
990    #[test]
991    fn scan_finds_legacy_dirs_and_reports_canonical_presence() {
992        let dir = tempdir().unwrap();
993        let target = InstallTarget {
994            label: "test".into(),
995            skills_dir: dir.path().to_path_buf(),
996        };
997        // Legacy with sibling — safe to remove.
998        plant_skill(&target, "devboy-setup");
999        plant_skill(&target, "setup");
1000        // Legacy without sibling — caller decides.
1001        plant_skill(&target, "devboy-orphan");
1002        // New-style entry — should be ignored by the scanner.
1003        plant_skill(&target, "review-mr");
1004        // A regular file with `devboy-` prefix — must NOT be flagged
1005        // (the scanner only considers directories).
1006        fs::write(dir.path().join("devboy-readme.txt"), "hi").unwrap();
1007
1008        let scan = scan_legacy_skills_at_target(&target).unwrap();
1009        let names: Vec<&str> = scan.iter().map(|s| s.legacy_name.as_str()).collect();
1010        assert_eq!(names, vec!["devboy-orphan", "devboy-setup"]);
1011        let setup = scan
1012            .iter()
1013            .find(|s| s.legacy_name == "devboy-setup")
1014            .unwrap();
1015        assert!(setup.canonical_present);
1016        assert_eq!(setup.canonical_name, "setup");
1017        let orphan = scan
1018            .iter()
1019            .find(|s| s.legacy_name == "devboy-orphan")
1020            .unwrap();
1021        assert!(!orphan.canonical_present);
1022    }
1023
1024    #[test]
1025    fn scan_returns_empty_when_target_dir_missing() {
1026        let target = InstallTarget {
1027            label: "test".into(),
1028            skills_dir: PathBuf::from("/definitely/does/not/exist"),
1029        };
1030        let scan = scan_legacy_skills_at_target(&target).unwrap();
1031        assert!(scan.is_empty());
1032    }
1033
1034    #[test]
1035    fn migrate_removes_safe_duplicates_only() {
1036        let dir = tempdir().unwrap();
1037        let target = InstallTarget {
1038            label: "test".into(),
1039            skills_dir: dir.path().to_path_buf(),
1040        };
1041        plant_skill(&target, "devboy-setup");
1042        plant_skill(&target, "setup");
1043        plant_skill(&target, "devboy-orphan");
1044
1045        let removed = migrate_legacy_skills_at_target(&target, false).unwrap();
1046        assert_eq!(removed, vec!["devboy-setup".to_string()]);
1047
1048        // The safe duplicate is gone; the canonical and the orphan stay.
1049        assert!(!dir.path().join("devboy-setup").exists());
1050        assert!(dir.path().join("setup").exists());
1051        assert!(dir.path().join("devboy-orphan").exists());
1052    }
1053
1054    #[test]
1055    fn migrate_dry_run_does_not_touch_filesystem() {
1056        let dir = tempdir().unwrap();
1057        let target = InstallTarget {
1058            label: "test".into(),
1059            skills_dir: dir.path().to_path_buf(),
1060        };
1061        plant_skill(&target, "devboy-setup");
1062        plant_skill(&target, "setup");
1063
1064        let would_remove = migrate_legacy_skills_at_target(&target, true).unwrap();
1065        assert_eq!(would_remove, vec!["devboy-setup".to_string()]);
1066        assert!(dir.path().join("devboy-setup").exists());
1067        assert!(dir.path().join("setup").exists());
1068    }
1069
1070    #[test]
1071    fn migrate_is_noop_when_nothing_legacy() {
1072        let dir = tempdir().unwrap();
1073        let target = InstallTarget {
1074            label: "test".into(),
1075            skills_dir: dir.path().to_path_buf(),
1076        };
1077        plant_skill(&target, "setup");
1078        plant_skill(&target, "review-mr");
1079        let removed = migrate_legacy_skills_at_target(&target, false).unwrap();
1080        assert!(removed.is_empty());
1081        assert!(dir.path().join("setup").exists());
1082        assert!(dir.path().join("review-mr").exists());
1083    }
1084}