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