Skip to main content

bb_cli/
skill.rs

1use crate::error::{BbError, Result};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6pub const SKILL_NAME: &str = "bitbucket-cloud";
7
8/// The skill text ships *inside* the binary, so every upgrade path — brew,
9/// cargo, `bb update` — carries new content as an inherent consequence rather
10/// than needing a separate sync. It also means the installed skill can never
11/// describe a flag this binary lacks.
12pub const SKILL_MD: &str = include_str!("../.agents/skills/bitbucket-cloud/SKILL.md");
13
14pub fn content_hash(bytes: &[u8]) -> String {
15    let mut hasher = Sha256::new();
16    hasher.update(bytes);
17    format!("{:x}", hasher.finalize())
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Agent {
22    /// `.agents/skills/` — read by Codex, Cursor and OpenCode.
23    Agents,
24    /// `.claude/skills/` — Claude Code reads only this location.
25    Claude,
26}
27
28impl Agent {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Agents => "agents",
32            Self::Claude => "claude",
33        }
34    }
35
36    pub fn all() -> [Agent; 2] {
37        [Agent::Agents, Agent::Claude]
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Entry {
43    pub path: PathBuf,
44    pub agent: String,
45    /// `"file"` or `"symlink"` — a refresh has to rewrite the real file, and an
46    /// uninstall has to remove the right kind of thing.
47    pub kind: String,
48    /// Hash of what bb itself wrote. Comparing it against the file on disk is
49    /// how a local edit is detected and protected.
50    pub sha256: String,
51    pub version: String,
52}
53
54pub fn state_path() -> PathBuf {
55    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
56        if !xdg.is_empty() {
57            return PathBuf::from(xdg).join("bb").join("skills.json");
58        }
59    }
60    let home = std::env::var_os("HOME").unwrap_or_default();
61    PathBuf::from(home)
62        .join(".config")
63        .join("bb")
64        .join("skills.json")
65}
66
67/// Entries plus an optional warning. A missing state file simply means nothing
68/// is tracked; a corrupt one is reported but treated as empty, so a hand-edited
69/// file cannot brick `bb update`.
70pub fn load_state() -> (Vec<Entry>, Option<String>) {
71    let path = state_path();
72    let raw = match std::fs::read_to_string(&path) {
73        Ok(text) => text,
74        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (Vec::new(), None),
75        Err(err) => {
76            return (
77                Vec::new(),
78                Some(format!("could not read {}: {err}", path.display())),
79            )
80        }
81    };
82    if raw.trim().is_empty() {
83        return (Vec::new(), None);
84    }
85    match serde_json::from_str::<Vec<Entry>>(&raw) {
86        Ok(entries) => (entries, None),
87        Err(err) => (
88            Vec::new(),
89            Some(format!("ignoring unreadable {}: {err}", path.display())),
90        ),
91    }
92}
93
94pub fn save_state(entries: &[Entry]) -> Result<()> {
95    let path = state_path();
96    if let Some(parent) = path.parent() {
97        std::fs::create_dir_all(parent).map_err(BbError::Io)?;
98    }
99    let json = serde_json::to_string_pretty(entries)?;
100    std::fs::write(&path, json).map_err(BbError::Io)?;
101    Ok(())
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Action {
106    Installed,
107    Refreshed,
108    Unchanged,
109    SkippedModified,
110}
111
112impl Action {
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::Installed => "installed",
116            Self::Refreshed => "refreshed",
117            Self::Unchanged => "unchanged",
118            Self::SkippedModified => "skipped_modified",
119        }
120    }
121}
122
123#[derive(Debug, Clone)]
124pub struct Outcome {
125    pub path: PathBuf,
126    pub agent: String,
127    pub action: Action,
128}
129
130/// Where the real file lives for each agent.
131pub fn skill_file(root: &Path, agent: Agent) -> PathBuf {
132    let base = match agent {
133        Agent::Agents => root.join(".agents").join("skills"),
134        Agent::Claude => root.join(".claude").join("skills"),
135    };
136    base.join(SKILL_NAME).join("SKILL.md")
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum State {
141    Current,
142    Stale,
143    Modified,
144    Missing,
145}
146
147impl State {
148    pub fn as_str(self) -> &'static str {
149        match self {
150            Self::Current => "current",
151            Self::Stale => "stale",
152            Self::Modified => "modified",
153            Self::Missing => "missing",
154        }
155    }
156}
157
158#[derive(Debug, Clone)]
159pub struct StatusRow {
160    pub path: PathBuf,
161    pub agent: String,
162    pub state: State,
163}
164
165/// Refuses any path that does not end in `.agents/skills/<SKILL_NAME>/SKILL.md`
166/// or `.claude/skills/<SKILL_NAME>/SKILL.md`. Every removal or write driven by
167/// a state entry must go through this first: the state file is user-editable
168/// (by hand or by a bad merge), and nothing it names should let `bb` touch an
169/// arbitrary path on disk. Deliberately checks shape, not existence or type —
170/// `state_of` already treats a directory as `Missing` (it can't be read as a
171/// file), and that used to be enough to make the `Missing` repair branches
172/// reach a `remove_dir_all`/`write_file` on whatever the state file named.
173fn is_shaped_like_a_skill_path(path: &Path) -> bool {
174    let mut components: Vec<_> = path.components().collect();
175    let Some(file) = components.pop() else {
176        return false;
177    };
178    if file.as_os_str() != "SKILL.md" {
179        return false;
180    }
181    let Some(skill_dir) = components.pop() else {
182        return false;
183    };
184    if skill_dir.as_os_str() != SKILL_NAME {
185        return false;
186    }
187    let Some(skills_dir) = components.pop() else {
188        return false;
189    };
190    if skills_dir.as_os_str() != "skills" {
191        return false;
192    }
193    matches!(
194        components.pop().map(|c| c.as_os_str().to_owned()),
195        Some(agents_dir) if agents_dir == ".agents" || agents_dir == ".claude"
196    )
197}
198
199fn state_of(entry: &Entry, wanted: &str) -> State {
200    match std::fs::read(&entry.path) {
201        Err(_) => State::Missing,
202        Ok(bytes) => {
203            let actual = content_hash(&bytes);
204            if actual == wanted {
205                State::Current
206            } else if actual == entry.sha256 {
207                State::Stale
208            } else {
209                State::Modified
210            }
211        }
212    }
213}
214
215pub fn status() -> (Vec<StatusRow>, Option<String>) {
216    let (entries, warning) = load_state();
217    let wanted = content_hash(SKILL_MD.as_bytes());
218    let rows = entries
219        .iter()
220        .map(|e| StatusRow {
221            path: e.path.clone(),
222            agent: e.agent.clone(),
223            state: state_of(e, &wanted),
224        })
225        .collect();
226    (rows, warning)
227}
228
229/// Distinguishes *why* an entry did not end up removed, so the caller can be
230/// honest about it instead of collapsing "refused because modified" and
231/// "wasn't there to begin with" into the same boolean.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum RemovalOutcome {
234    Removed,
235    RefusedModified,
236    RefusedUnsafePath,
237    Absent,
238}
239
240impl RemovalOutcome {
241    pub fn as_str(self) -> &'static str {
242        match self {
243            Self::Removed => "removed",
244            Self::RefusedModified => "refused_modified",
245            Self::RefusedUnsafePath => "refused_unsafe_path",
246            Self::Absent => "absent",
247        }
248    }
249}
250
251/// True when the directory *containing* `path` is itself a symlink — the
252/// shape a Claude entry takes when `install_claude_dir` linked it. Checked
253/// against disk rather than trusted from the recorded `kind`, because `kind`
254/// can be wrong: a pre-existing hand-made symlink that `install` finds
255/// `Unchanged` or `SkippedModified` (no prior state entry to inherit from)
256/// used to default to `kind: "file"`, which then bypassed this exact guard.
257fn parent_is_symlink(path: &Path) -> bool {
258    path.parent()
259        .and_then(|p| std::fs::symlink_metadata(p).ok())
260        .map(|m| m.file_type().is_symlink())
261        .unwrap_or(false)
262}
263
264/// Removes what bb recorded. A customized file is left in place unless `force`,
265/// and an untracked file is never touched at all.
266pub fn uninstall(root: Option<&Path>, force: bool) -> Result<Vec<(PathBuf, RemovalOutcome)>> {
267    let (entries, warning) = load_state();
268    if let Some(warning) = warning {
269        crate::output::warn(&warning);
270    }
271    let wanted = content_hash(SKILL_MD.as_bytes());
272    let mut results = Vec::new();
273    let mut keep = Vec::new();
274
275    for entry in entries {
276        let in_scope = root.is_none_or(|r| entry.path.starts_with(r));
277        if !in_scope {
278            keep.push(entry);
279            continue;
280        }
281        if !is_shaped_like_a_skill_path(&entry.path) {
282            crate::output::warn(&format!(
283                "refusing to touch {} — does not look like a skill path bb would have written",
284                entry.path.display()
285            ));
286            results.push((entry.path.clone(), RemovalOutcome::RefusedUnsafePath));
287            keep.push(entry);
288            continue;
289        }
290        let modified = matches!(state_of(&entry, &wanted), State::Modified);
291        if modified && !force {
292            results.push((entry.path.clone(), RemovalOutcome::RefusedModified));
293            keep.push(entry);
294            continue;
295        }
296        // A symlinked Claude entry's `path` is `SKILL.md` *inside* the linked
297        // directory, so removing it directly would follow the link and delete
298        // the `.agents` copy it points at. The thing actually on disk at the
299        // Claude location is the symlink one level up — remove that instead,
300        // and don't recurse into what it points to. Trusts disk over the
301        // recorded `kind`: a hand-made symlink that predates any bb-recorded
302        // `kind` must still be removed as a link, not followed.
303        let is_symlinked_dir = entry.kind == "symlink" || parent_is_symlink(&entry.path);
304        let removal_target: &Path = if is_symlinked_dir {
305            entry.path.parent().unwrap_or(&entry.path)
306        } else {
307            &entry.path
308        };
309        let existed = removal_target.exists() || std::fs::symlink_metadata(removal_target).is_ok();
310        remove_existing(removal_target)?;
311        // A `kind: "file"` Claude fallback can leave an empty
312        // `.claude/skills/<SKILL_NAME>/` directory behind once `SKILL.md`
313        // inside it is gone. Clean up that one directory — never a parent,
314        // and never one that still has something in it.
315        if !is_symlinked_dir {
316            if let Some(dir) = entry.path.parent() {
317                let is_empty = std::fs::read_dir(dir)
318                    .map(|mut i| i.next().is_none())
319                    .unwrap_or(false);
320                if is_empty {
321                    let _ = std::fs::remove_dir(dir);
322                }
323            }
324        }
325        let outcome = if existed {
326            RemovalOutcome::Removed
327        } else {
328            RemovalOutcome::Absent
329        };
330        results.push((entry.path.clone(), outcome));
331    }
332
333    save_state(&keep)?;
334    Ok(results)
335}
336
337/// `.cursor/` and `.opencode/` both read `.agents/skills/`, so their presence
338/// asks for the `.agents` write rather than a location of their own.
339pub fn detect_agents(root: &Path) -> Vec<Agent> {
340    let mut found = Vec::new();
341    let shares_agents = [".agents", ".cursor", ".opencode"]
342        .iter()
343        .any(|d| root.join(d).is_dir());
344    if shares_agents {
345        found.push(Agent::Agents);
346    }
347    if root.join(".claude").is_dir() {
348        found.push(Agent::Claude);
349    }
350    found
351}
352
353fn write_file(path: &Path, contents: &str) -> Result<()> {
354    if let Some(parent) = path.parent() {
355        std::fs::create_dir_all(parent).map_err(BbError::Io)?;
356    }
357    std::fs::write(path, contents).map_err(BbError::Io)?;
358    Ok(())
359}
360
361/// Removes whatever is at `path` — file, dir, or symlink — without following a
362/// symlink into its target. `remove_file` handles symlinks-to-files and plain
363/// files; a symlink-to-directory needs `remove_dir_all` refusing to peek inside
364/// on most platforms, but to be safe we check `symlink_metadata` first.
365fn remove_existing(path: &Path) -> Result<()> {
366    match std::fs::symlink_metadata(path) {
367        Ok(meta) => {
368            if meta.file_type().is_symlink() || !meta.is_dir() {
369                std::fs::remove_file(path).map_err(BbError::Io)?;
370            } else {
371                std::fs::remove_dir_all(path).map_err(BbError::Io)?;
372            }
373            Ok(())
374        }
375        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
376        Err(err) => Err(BbError::Io(err)),
377    }
378}
379
380/// Installs the Claude copy as a relative symlink to the `.agents` skill
381/// directory when both are present, falling back to a real file otherwise.
382/// Returns the `kind` that was actually written ("symlink" or "file").
383fn install_claude_dir(root: &Path, agents_installed: bool) -> Result<&'static str> {
384    let claude_dir = root.join(".claude").join("skills").join(SKILL_NAME);
385    let claude_file = claude_dir.join("SKILL.md");
386
387    if agents_installed {
388        #[cfg(unix)]
389        {
390            if let Some(parent) = claude_dir.parent() {
391                std::fs::create_dir_all(parent).map_err(BbError::Io)?;
392            }
393            remove_existing(&claude_dir)?;
394            let target = Path::new("..")
395                .join("..")
396                .join(".agents")
397                .join("skills")
398                .join(SKILL_NAME);
399            if std::os::unix::fs::symlink(&target, &claude_dir).is_ok() {
400                return Ok("symlink");
401            }
402        }
403    }
404
405    remove_existing(&claude_dir)?;
406    write_file(&claude_file, SKILL_MD)?;
407    Ok("file")
408}
409
410pub fn install(root: &Path, agents: &[Agent], force: bool) -> Result<Vec<Outcome>> {
411    let (mut state, _warning) = load_state();
412    let wanted = content_hash(SKILL_MD.as_bytes());
413    let mut outcomes = Vec::new();
414
415    let agents_dir_present = root
416        .join(".agents")
417        .join("skills")
418        .join(SKILL_NAME)
419        .join("SKILL.md")
420        .exists()
421        || agents.contains(&Agent::Agents);
422
423    for agent in agents {
424        let path = skill_file(root, *agent);
425        let recorded = state.iter().find(|e| e.path == path).cloned();
426        let on_disk = std::fs::read(&path).ok();
427
428        let action = match (&on_disk, &recorded) {
429            (None, _) => Action::Installed,
430            (Some(bytes), _) if content_hash(bytes) == wanted => Action::Unchanged,
431            // We wrote it and the binary now carries newer text.
432            (Some(bytes), Some(entry)) if content_hash(bytes) == entry.sha256 => Action::Refreshed,
433            // Either untracked or edited since we wrote it — someone's own work.
434            (Some(_), _) if force => Action::Refreshed,
435            (Some(_), _) => Action::SkippedModified,
436        };
437
438        let mut kind = "file".to_string();
439        if action != Action::SkippedModified && action != Action::Unchanged {
440            if *agent == Agent::Claude {
441                kind = install_claude_dir(root, agents_dir_present)?.to_string();
442            } else {
443                write_file(&path, SKILL_MD)?;
444            }
445        } else if let Some(entry) = &recorded {
446            kind = entry.kind.clone();
447        } else if parent_is_symlink(&path) {
448            // No prior state entry to inherit `kind` from — e.g. a hand-made
449            // Claude symlink, exactly what older docs told users to create
450            // themselves — so it must be read off disk rather than defaulted
451            // to `"file"`, or a later uninstall would bypass the symlink
452            // guard and follow the link into whatever it points at.
453            kind = "symlink".to_string();
454        }
455
456        if action != Action::SkippedModified {
457            state.retain(|e| e.path != path);
458            state.push(Entry {
459                path: path.clone(),
460                agent: agent.as_str().to_string(),
461                kind,
462                sha256: wanted.clone(),
463                version: env!("CARGO_PKG_VERSION").to_string(),
464            });
465        }
466
467        outcomes.push(Outcome {
468            path,
469            agent: agent.as_str().to_string(),
470            action,
471        });
472    }
473
474    save_state(&state)?;
475    Ok(outcomes)
476}
477
478/// A tracked Claude entry's recorded `path` is `SKILL.md` *inside* the linked
479/// directory (see `uninstall`'s comment on the same shape), so the project
480/// root sits four components above it: `SKILL.md`, `SKILL_NAME`, `skills`,
481/// `.claude`.
482fn claude_root_from_entry_path(path: &Path) -> Result<&Path> {
483    path.parent()
484        .and_then(Path::parent)
485        .and_then(Path::parent)
486        .and_then(Path::parent)
487        .ok_or_else(|| {
488            BbError::Config(format!(
489                "cannot determine the project root from {}",
490                path.display()
491            ))
492        })
493}
494
495/// Recreates a Claude entry whose recorded `kind` is `"symlink"` but whose
496/// link (or fallback file) is missing from disk. Delegates to
497/// `install_claude_dir` so the same relative-symlink-with-fallback logic that
498/// `install` uses is not duplicated here, and returns the `kind` that was
499/// actually written so the caller can keep the recorded state honest even
500/// when the platform refuses a symlink and falls back to a real file.
501fn restore_claude_link(entry_path: &Path) -> Result<String> {
502    if !is_shaped_like_a_skill_path(entry_path) {
503        return Err(BbError::Config(format!(
504            "refusing to touch {} — does not look like a skill path bb would have written",
505            entry_path.display()
506        )));
507    }
508    let root = claude_root_from_entry_path(entry_path)?;
509    let agents_installed = root
510        .join(".agents")
511        .join("skills")
512        .join(SKILL_NAME)
513        .join("SKILL.md")
514        .exists();
515    Ok(install_claude_dir(root, agents_installed)?.to_string())
516}
517
518/// Refreshes every tracked entry against the currently-running binary's
519/// embedded text. Driven by the recorded entries rather than a root and an
520/// agent list, so a single call spans every project the user has installed
521/// into. Uses the same drift rules as `install`, via `state_of`: `Stale` or
522/// `Missing` rewrites the file and updates the recorded hash, `Modified` is
523/// left byte-identical and reported as `SkippedModified`, and `Current` is
524/// reported as `Unchanged` without touching anything.
525pub fn refresh_tracked() -> Result<Vec<Outcome>> {
526    let (mut state, warning) = load_state();
527    if let Some(warning) = warning {
528        crate::output::warn(&warning);
529    }
530    let wanted = content_hash(SKILL_MD.as_bytes());
531    let mut outcomes = Vec::new();
532
533    for entry in &mut state {
534        if !is_shaped_like_a_skill_path(&entry.path) {
535            crate::output::warn(&format!(
536                "refusing to touch {} — does not look like a skill path bb would have written",
537                entry.path.display()
538            ));
539            continue;
540        }
541        let action = match state_of(entry, &wanted) {
542            // The link is intact — writing to `entry.path` follows it straight
543            // into the `.agents` file it points at, refreshing the shared
544            // content without disturbing the link itself.
545            State::Stale => {
546                write_file(&entry.path, SKILL_MD)?;
547                entry.sha256 = wanted.clone();
548                entry.version = env!("CARGO_PKG_VERSION").to_string();
549                Action::Refreshed
550            }
551            // The link (or file) itself is gone. A plain `write_file` here
552            // would create a *real* file where a symlink used to be, leaving
553            // state still claiming `"symlink"` while disk disagrees. Restore
554            // the same kind of thing that used to be there instead.
555            State::Missing => {
556                entry.kind = if entry.kind == "symlink" {
557                    restore_claude_link(&entry.path)?
558                } else {
559                    write_file(&entry.path, SKILL_MD)?;
560                    "file".to_string()
561                };
562                entry.sha256 = wanted.clone();
563                entry.version = env!("CARGO_PKG_VERSION").to_string();
564                Action::Refreshed
565            }
566            State::Modified => Action::SkippedModified,
567            State::Current => Action::Unchanged,
568        };
569
570        outcomes.push(Outcome {
571            path: entry.path.clone(),
572            agent: entry.agent.clone(),
573            action,
574        });
575    }
576
577    save_state(&state)?;
578    Ok(outcomes)
579}
580
581#[cfg(test)]
582#[allow(clippy::unwrap_used, clippy::expect_used)]
583mod tests {
584    use super::*;
585
586    /// A packaging regression — an added `exclude` entry in Cargo.toml, or a moved
587    /// file — must fail the build rather than ship an empty skill.
588    #[test]
589    fn embedded_skill_is_present_and_has_frontmatter() {
590        assert!(!SKILL_MD.trim().is_empty());
591        assert!(
592            SKILL_MD.starts_with("---"),
593            "skill must open with yaml frontmatter"
594        );
595        assert!(
596            SKILL_MD.contains("name: bitbucket-cloud"),
597            "frontmatter should name the skill"
598        );
599    }
600
601    #[test]
602    fn content_hash_is_stable_and_distinguishes_content() {
603        assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
604        assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
605        // sha256 hex is 64 chars
606        assert_eq!(content_hash(b"abc").len(), 64);
607    }
608
609    #[test]
610    #[serial_test::serial]
611    fn state_path_prefers_xdg_config_home() {
612        temp_env(
613            &[
614                ("XDG_CONFIG_HOME", Some("/tmp/xdg")),
615                ("HOME", Some("/tmp/home")),
616            ],
617            || {
618                assert_eq!(
619                    state_path(),
620                    std::path::Path::new("/tmp/xdg/bb/skills.json")
621                );
622            },
623        );
624    }
625
626    #[test]
627    #[serial_test::serial]
628    fn state_path_falls_back_to_home_config() {
629        temp_env(
630            &[("XDG_CONFIG_HOME", None), ("HOME", Some("/tmp/home"))],
631            || {
632                assert_eq!(
633                    state_path(),
634                    std::path::Path::new("/tmp/home/.config/bb/skills.json")
635                );
636            },
637        );
638    }
639
640    #[test]
641    #[serial_test::serial]
642    fn saved_state_round_trips() {
643        let dir = tempfile::tempdir().unwrap();
644        temp_env(
645            &[
646                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
647                ("HOME", None),
648            ],
649            || {
650                let entries = vec![Entry {
651                    path: std::path::PathBuf::from("/p/.agents/skills/bitbucket-cloud/SKILL.md"),
652                    agent: "agents".into(),
653                    kind: "file".into(),
654                    sha256: content_hash(SKILL_MD.as_bytes()),
655                    version: env!("CARGO_PKG_VERSION").into(),
656                }];
657                save_state(&entries).unwrap();
658                let (loaded, warning) = load_state();
659                assert!(warning.is_none());
660                assert_eq!(loaded.len(), 1);
661                assert_eq!(loaded[0].agent, "agents");
662                assert_eq!(loaded[0].sha256, entries[0].sha256);
663            },
664        );
665    }
666
667    /// A hand-edited or truncated state file must not brick the command.
668    #[test]
669    #[serial_test::serial]
670    fn corrupt_state_is_tolerated_with_a_warning() {
671        let dir = tempfile::tempdir().unwrap();
672        temp_env(
673            &[
674                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
675                ("HOME", None),
676            ],
677            || {
678                let p = state_path();
679                std::fs::create_dir_all(p.parent().unwrap()).unwrap();
680                std::fs::write(&p, "{not json").unwrap();
681                let (loaded, warning) = load_state();
682                assert!(loaded.is_empty());
683                assert!(warning.is_some(), "corrupt state should warn");
684            },
685        );
686    }
687
688    /// Drives `Stale` (and `Current`) through `status()` end to end, not just
689    /// through `install()`'s refresh path. A tracked entry whose sha256 matches
690    /// what's on disk, but not what the binary ships now, is stale; a tracked
691    /// entry whose sha256 matches the binary's current text is current.
692    #[test]
693    #[serial_test::serial]
694    fn status_reports_stale_when_the_binary_shipped_newer_text() {
695        let dir = tempfile::tempdir().unwrap();
696        let root = tempfile::tempdir().unwrap();
697        temp_env(
698            &[
699                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
700                ("HOME", None),
701            ],
702            || {
703                let path = skill_file(root.path(), Agent::Agents);
704                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
705                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
706                std::fs::write(&path, old).unwrap();
707                save_state(&[Entry {
708                    path: path.clone(),
709                    agent: "agents".into(),
710                    kind: "file".into(),
711                    sha256: content_hash(old.as_bytes()),
712                    version: "0.0.1".into(),
713                }])
714                .unwrap();
715
716                let (rows, warning) = status();
717                assert!(warning.is_none());
718                assert_eq!(rows.len(), 1);
719                assert_eq!(
720                    rows[0].state,
721                    State::Stale,
722                    "on-disk text matches the recorded sha256, just not the binary's current text"
723                );
724
725                // Same entry, but now the file holds exactly what the binary ships:
726                // that must read as Current, not Stale.
727                std::fs::write(&path, SKILL_MD).unwrap();
728                save_state(&[Entry {
729                    path: path.clone(),
730                    agent: "agents".into(),
731                    kind: "file".into(),
732                    sha256: content_hash(SKILL_MD.as_bytes()),
733                    version: env!("CARGO_PKG_VERSION").into(),
734                }])
735                .unwrap();
736                let (rows, _) = status();
737                assert_eq!(rows[0].state, State::Current);
738            },
739        );
740    }
741
742    /// Uninstalling a symlinked Claude entry must remove the link itself, not
743    /// follow it into the `.agents` copy it points at. Scopes the uninstall to
744    /// just the Claude subtree so the `.agents` entry is never itself in scope
745    /// for removal — the only way to prove the target survives *because* the
746    /// link wasn't followed, rather than because it was also being deleted on
747    /// its own account.
748    #[test]
749    #[serial_test::serial]
750    fn uninstall_removes_the_claude_link_without_deleting_its_target() {
751        let dir = tempfile::tempdir().unwrap();
752        let cfg = tempfile::tempdir().unwrap();
753        temp_env(
754            &[
755                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
756                ("HOME", None),
757            ],
758            || {
759                install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
760                let agents_path = skill_file(dir.path(), Agent::Agents);
761                let claude_root = dir.path().join(".claude");
762                assert!(agents_path.is_file(), "sanity: agents copy installed");
763
764                let results = uninstall(Some(&claude_root), false).unwrap();
765                assert_eq!(results.len(), 1, "only the claude entry was in scope");
766                assert_eq!(
767                    results[0].1,
768                    RemovalOutcome::Removed,
769                    "the claude entry should report removed"
770                );
771
772                let claude_dir = dir.path().join(".claude/skills/bitbucket-cloud");
773                assert!(
774                    !claude_dir.exists(),
775                    "the claude link (or fallback file) should be gone"
776                );
777                assert!(
778                    agents_path.is_file(),
779                    "removing the claude link must not delete the agents copy it points at"
780                );
781
782                let (remaining, _) = load_state();
783                assert_eq!(remaining.len(), 1, "the agents entry stays tracked");
784                assert_eq!(remaining[0].agent, "agents");
785            },
786        );
787    }
788
789    #[test]
790    #[serial_test::serial]
791    fn missing_state_is_empty_and_silent() {
792        let dir = tempfile::tempdir().unwrap();
793        temp_env(
794            &[
795                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
796                ("HOME", None),
797            ],
798            || {
799                let (loaded, warning) = load_state();
800                assert!(loaded.is_empty());
801                assert!(warning.is_none());
802            },
803        );
804    }
805
806    /// Restores saved env vars on drop, so a panic inside `temp_env`'s closure
807    /// still puts `HOME`/`XDG_CONFIG_HOME` back rather than leaking a
808    /// soon-to-be-dropped tempdir path into whichever `#[serial]` test runs next.
809    struct EnvGuard {
810        saved: Vec<(String, Option<String>)>,
811    }
812
813    impl Drop for EnvGuard {
814        fn drop(&mut self) {
815            for (k, v) in &self.saved {
816                match v {
817                    Some(val) => std::env::set_var(k, val),
818                    None => std::env::remove_var(k),
819                }
820            }
821        }
822    }
823
824    /// Sets env vars for the closure and restores them afterwards, even if the
825    /// closure panics. `None` removes. Tests that call this must be `#[serial]`,
826    /// because process env is global.
827    fn temp_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
828        let saved: Vec<(String, Option<String>)> = vars
829            .iter()
830            .map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
831            .collect();
832        let _guard = EnvGuard { saved };
833        for (k, v) in vars {
834            match v {
835                Some(val) => std::env::set_var(k, val),
836                None => std::env::remove_var(k),
837            }
838        }
839        f();
840    }
841
842    #[test]
843    #[serial_test::serial]
844    fn temp_env_restores_vars_even_if_the_closure_panics() {
845        std::env::set_var("XDG_CONFIG_HOME", "/before/panic");
846        std::env::remove_var("HOME");
847
848        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
849            temp_env(
850                &[
851                    ("XDG_CONFIG_HOME", Some("/tmp/during-panic")),
852                    ("HOME", Some("/tmp/home")),
853                ],
854                || panic!("simulated test failure inside temp_env"),
855            );
856        }));
857        assert!(result.is_err(), "closure should have panicked");
858
859        assert_eq!(
860            std::env::var("XDG_CONFIG_HOME").ok(),
861            Some("/before/panic".to_string()),
862            "XDG_CONFIG_HOME must be restored even after a panic"
863        );
864        assert_eq!(
865            std::env::var("HOME").ok(),
866            None,
867            "HOME must be restored to unset even after a panic"
868        );
869
870        std::env::remove_var("XDG_CONFIG_HOME");
871    }
872
873    #[test]
874    #[serial_test::serial]
875    fn install_writes_the_embedded_content() {
876        let dir = tempfile::tempdir().unwrap();
877        let cfg = tempfile::tempdir().unwrap();
878        temp_env(
879            &[
880                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
881                ("HOME", None),
882            ],
883            || {
884                let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
885                assert_eq!(outcomes.len(), 1);
886                assert!(matches!(outcomes[0].action, Action::Installed));
887
888                let written =
889                    std::fs::read_to_string(skill_file(dir.path(), Agent::Agents)).unwrap();
890                assert_eq!(
891                    written, SKILL_MD,
892                    "installed content must equal the embedded skill"
893                );
894
895                let (state, _) = load_state();
896                assert_eq!(state.len(), 1);
897                assert_eq!(state[0].sha256, content_hash(SKILL_MD.as_bytes()));
898            },
899        );
900    }
901
902    #[test]
903    #[serial_test::serial]
904    fn a_second_install_reports_unchanged_and_rewrites_nothing() {
905        let dir = tempfile::tempdir().unwrap();
906        let cfg = tempfile::tempdir().unwrap();
907        temp_env(
908            &[
909                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
910                ("HOME", None),
911            ],
912            || {
913                install(dir.path(), &[Agent::Agents], false).unwrap();
914                let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
915                assert!(
916                    matches!(outcomes[0].action, Action::Unchanged),
917                    "{:?}",
918                    outcomes[0].action
919                );
920            },
921        );
922    }
923
924    /// A local edit is somebody's deliberate customization. It must survive.
925    #[test]
926    #[serial_test::serial]
927    fn a_modified_file_is_refused_and_left_byte_identical() {
928        let dir = tempfile::tempdir().unwrap();
929        let cfg = tempfile::tempdir().unwrap();
930        temp_env(
931            &[
932                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
933                ("HOME", None),
934            ],
935            || {
936                install(dir.path(), &[Agent::Agents], false).unwrap();
937                let path = skill_file(dir.path(), Agent::Agents);
938                std::fs::write(&path, "# my own notes\n").unwrap();
939
940                let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
941                assert!(matches!(outcomes[0].action, Action::SkippedModified));
942                assert_eq!(std::fs::read_to_string(&path).unwrap(), "# my own notes\n");
943            },
944        );
945    }
946
947    #[test]
948    #[serial_test::serial]
949    fn force_overwrites_a_modified_file_and_updates_the_hash() {
950        let dir = tempfile::tempdir().unwrap();
951        let cfg = tempfile::tempdir().unwrap();
952        temp_env(
953            &[
954                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
955                ("HOME", None),
956            ],
957            || {
958                install(dir.path(), &[Agent::Agents], false).unwrap();
959                let path = skill_file(dir.path(), Agent::Agents);
960                std::fs::write(&path, "# my own notes\n").unwrap();
961
962                let outcomes = install(dir.path(), &[Agent::Agents], true).unwrap();
963                assert!(matches!(
964                    outcomes[0].action,
965                    Action::Refreshed | Action::Installed
966                ));
967                assert_eq!(std::fs::read_to_string(&path).unwrap(), SKILL_MD);
968                let (state, _) = load_state();
969                assert_eq!(state[0].sha256, content_hash(SKILL_MD.as_bytes()));
970            },
971        );
972    }
973
974    /// Stale means "we wrote it, and the binary has newer text now". It refreshes
975    /// without asking, because nobody customized it.
976    #[test]
977    #[serial_test::serial]
978    fn a_stale_file_is_refreshed_silently() {
979        let dir = tempfile::tempdir().unwrap();
980        let cfg = tempfile::tempdir().unwrap();
981        temp_env(
982            &[
983                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
984                ("HOME", None),
985            ],
986            || {
987                let path = skill_file(dir.path(), Agent::Agents);
988                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
989                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
990                std::fs::write(&path, old).unwrap();
991                save_state(&[Entry {
992                    path: path.clone(),
993                    agent: "agents".into(),
994                    kind: "file".into(),
995                    sha256: content_hash(old.as_bytes()),
996                    version: "0.0.1".into(),
997                }])
998                .unwrap();
999
1000                let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
1001                assert!(
1002                    matches!(outcomes[0].action, Action::Refreshed),
1003                    "{:?}",
1004                    outcomes[0].action
1005                );
1006                assert_eq!(std::fs::read_to_string(&path).unwrap(), SKILL_MD);
1007            },
1008        );
1009    }
1010
1011    #[test]
1012    #[serial_test::serial]
1013    fn claude_install_links_to_the_agents_copy() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let cfg = tempfile::tempdir().unwrap();
1016        temp_env(
1017            &[
1018                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1019                ("HOME", None),
1020            ],
1021            || {
1022                install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1023                let claude = dir.path().join(".claude/skills").join(SKILL_NAME);
1024                // Either a symlink resolving to the .agents copy, or a real file with
1025                // the same content when the platform refused a symlink.
1026                let content = std::fs::read_to_string(claude.join("SKILL.md"))
1027                    .or_else(|_| std::fs::read_to_string(&claude))
1028                    .unwrap();
1029                assert_eq!(content, SKILL_MD);
1030            },
1031        );
1032    }
1033
1034    #[test]
1035    fn detect_finds_each_agent_directory() {
1036        let dir = tempfile::tempdir().unwrap();
1037        assert!(
1038            detect_agents(dir.path()).is_empty(),
1039            "nothing present means nothing detected"
1040        );
1041
1042        std::fs::create_dir_all(dir.path().join(".cursor")).unwrap();
1043        assert_eq!(
1044            detect_agents(dir.path()),
1045            vec![Agent::Agents],
1046            "cursor reads .agents"
1047        );
1048
1049        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
1050        let found = detect_agents(dir.path());
1051        assert!(found.contains(&Agent::Agents) && found.contains(&Agent::Claude));
1052    }
1053
1054    /// A deleted Claude symlink with no `.agents` copy to point at falls back
1055    /// to a real file — same as `install_claude_dir` would on a fresh
1056    /// install — and the recorded `kind` must follow disk down to `"file"`,
1057    /// not keep claiming `"symlink"`.
1058    #[test]
1059    #[serial_test::serial]
1060    fn refresh_recreates_a_deleted_symlink_as_a_file_when_no_agents_copy_exists() {
1061        let dir = tempfile::tempdir().unwrap();
1062        let cfg = tempfile::tempdir().unwrap();
1063        temp_env(
1064            &[
1065                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1066                ("HOME", None),
1067            ],
1068            || {
1069                install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1070                let claude_path = skill_file(dir.path(), Agent::Claude);
1071                let claude_dir = claude_path.parent().unwrap();
1072
1073                // Only the claude entry is tracked, and its target is gone —
1074                // the state this test wants to force is "link recorded, but
1075                // nothing left to link to".
1076                let (state, _) = load_state();
1077                let claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1078                assert_eq!(claude_entry.kind, "symlink", "sanity: install made a link");
1079                save_state(&[claude_entry]).unwrap();
1080
1081                remove_existing(claude_dir).unwrap();
1082                std::fs::remove_dir_all(dir.path().join(".agents")).unwrap();
1083
1084                let outcomes = refresh_tracked().unwrap();
1085                assert_eq!(outcomes.len(), 1);
1086                assert!(matches!(outcomes[0].action, Action::Refreshed));
1087
1088                assert_eq!(std::fs::read_to_string(&claude_path).unwrap(), SKILL_MD);
1089                let (state, _) = load_state();
1090                assert_eq!(
1091                    state[0].kind, "file",
1092                    "disk fell back to a real file, so state must say so too"
1093                );
1094            },
1095        );
1096    }
1097
1098    /// A deleted Claude symlink is recreated as a symlink, not a file, when
1099    /// the `.agents` copy it used to point at is still there.
1100    #[test]
1101    #[serial_test::serial]
1102    fn refresh_recreates_a_deleted_symlink_as_a_symlink_when_the_agents_copy_survives() {
1103        let dir = tempfile::tempdir().unwrap();
1104        let cfg = tempfile::tempdir().unwrap();
1105        temp_env(
1106            &[
1107                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1108                ("HOME", None),
1109            ],
1110            || {
1111                install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1112                let claude_dir = skill_file(dir.path(), Agent::Claude)
1113                    .parent()
1114                    .unwrap()
1115                    .to_path_buf();
1116
1117                remove_existing(&claude_dir).unwrap();
1118                assert!(!claude_dir.exists(), "sanity: the link is gone");
1119
1120                let outcomes = refresh_tracked().unwrap();
1121                let claude_outcome = outcomes.iter().find(|o| o.agent == "claude").unwrap();
1122                assert!(matches!(claude_outcome.action, Action::Refreshed));
1123
1124                assert!(
1125                    std::fs::symlink_metadata(&claude_dir)
1126                        .unwrap()
1127                        .file_type()
1128                        .is_symlink(),
1129                    "the agents copy was still there, so a link should come back, not a file"
1130                );
1131                assert_eq!(
1132                    std::fs::read_to_string(claude_dir.join("SKILL.md")).unwrap(),
1133                    SKILL_MD
1134                );
1135                let (state, _) = load_state();
1136                let claude_entry = state.iter().find(|e| e.agent == "claude").unwrap();
1137                assert_eq!(claude_entry.kind, "symlink");
1138            },
1139        );
1140    }
1141
1142    /// A stale Claude symlink whose link is still intact refreshes the
1143    /// shared `.agents` content in place — `write_file` follows the link
1144    /// rather than replacing it — so the link itself must survive untouched.
1145    #[test]
1146    #[serial_test::serial]
1147    fn refresh_updates_content_through_an_intact_symlink_without_replacing_it() {
1148        let dir = tempfile::tempdir().unwrap();
1149        let cfg = tempfile::tempdir().unwrap();
1150        temp_env(
1151            &[
1152                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1153                ("HOME", None),
1154            ],
1155            || {
1156                install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1157                let claude_path = skill_file(dir.path(), Agent::Claude);
1158                let claude_dir = claude_path.parent().unwrap().to_path_buf();
1159
1160                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1161                std::fs::write(&claude_path, old).unwrap();
1162
1163                // Only the claude entry is tracked, so the refresh's only
1164                // write comes from the `Stale` branch on this entry, not from
1165                // an `.agents` entry rewriting the same underlying file.
1166                let (state, _) = load_state();
1167                let mut claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1168                claude_entry.sha256 = content_hash(old.as_bytes());
1169                save_state(&[claude_entry]).unwrap();
1170
1171                let outcomes = refresh_tracked().unwrap();
1172                assert_eq!(outcomes.len(), 1);
1173                assert!(matches!(outcomes[0].action, Action::Refreshed));
1174
1175                assert!(
1176                    std::fs::symlink_metadata(&claude_dir)
1177                        .unwrap()
1178                        .file_type()
1179                        .is_symlink(),
1180                    "an intact link must not be replaced by a file just to refresh content"
1181                );
1182                assert_eq!(std::fs::read_to_string(&claude_path).unwrap(), SKILL_MD);
1183                let (state, _) = load_state();
1184                assert_eq!(state[0].kind, "symlink");
1185            },
1186        );
1187    }
1188
1189    #[test]
1190    fn shape_guard_accepts_only_the_two_real_skill_locations() {
1191        assert!(is_shaped_like_a_skill_path(Path::new(
1192            "/proj/.agents/skills/bitbucket-cloud/SKILL.md"
1193        )));
1194        assert!(is_shaped_like_a_skill_path(Path::new(
1195            "/proj/.claude/skills/bitbucket-cloud/SKILL.md"
1196        )));
1197        for bad in [
1198            "/proj/src",
1199            "/proj/src/main.rs",
1200            "/proj/.agents/skills/bitbucket-cloud",
1201            "/proj/.agents/skills/some-other-skill/SKILL.md",
1202            "/proj/.opencode/skills/bitbucket-cloud/SKILL.md",
1203            "/etc/passwd",
1204        ] {
1205            assert!(
1206                !is_shaped_like_a_skill_path(Path::new(bad)),
1207                "{bad} should have been refused"
1208            );
1209        }
1210    }
1211
1212    /// Critical 2, reproduced and fixed: a hand-made Claude symlink — exactly
1213    /// what the old README's `ln -s` instructions told users to create —
1214    /// must not be deleted-through by `uninstall` just because no prior state
1215    /// entry existed to tell `install` its `kind` was `"symlink"`.
1216    #[test]
1217    #[serial_test::serial]
1218    fn uninstall_does_not_follow_a_hand_made_symlink_into_its_target() {
1219        let dir = tempfile::tempdir().unwrap();
1220        let cfg = tempfile::tempdir().unwrap();
1221        temp_env(
1222            &[
1223                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1224                ("HOME", None),
1225            ],
1226            || {
1227                // Set up the .agents copy the way a real project would have
1228                // it, then hand-make the Claude symlink exactly as the old
1229                // README instructed, *before* bb has ever recorded anything.
1230                let agents_path = skill_file(dir.path(), Agent::Agents);
1231                std::fs::create_dir_all(agents_path.parent().unwrap()).unwrap();
1232                std::fs::write(&agents_path, SKILL_MD).unwrap();
1233
1234                let claude_dir = dir.path().join(".claude").join("skills").join(SKILL_NAME);
1235                std::fs::create_dir_all(claude_dir.parent().unwrap()).unwrap();
1236                #[cfg(unix)]
1237                std::os::unix::fs::symlink(
1238                    Path::new("..")
1239                        .join("..")
1240                        .join(".agents")
1241                        .join("skills")
1242                        .join(SKILL_NAME),
1243                    &claude_dir,
1244                )
1245                .unwrap();
1246
1247                let outcomes = install(dir.path(), &[Agent::Claude], false).unwrap();
1248                assert!(
1249                    matches!(outcomes[0].action, Action::Unchanged),
1250                    "sanity: content already matches, so install should not rewrite it"
1251                );
1252
1253                let results = uninstall(None, false).unwrap();
1254                assert_eq!(results.len(), 1);
1255                assert_eq!(results[0].1, RemovalOutcome::Removed);
1256
1257                assert!(
1258                    std::fs::read_to_string(&agents_path).unwrap() == SKILL_MD,
1259                    "the .agents copy must survive uninstall of the claude link"
1260                );
1261                assert!(
1262                    std::fs::symlink_metadata(&claude_dir).is_err(),
1263                    "no dangling claude symlink should remain (Path::exists() would \
1264                     wrongly report false for a dangling link, so this checks \
1265                     symlink_metadata instead)"
1266                );
1267            },
1268        );
1269    }
1270
1271    /// Important 3, reproduced and fixed: a state entry pointing at an
1272    /// unrelated directory must be refused, and that directory must survive.
1273    #[test]
1274    #[serial_test::serial]
1275    fn uninstall_refuses_a_state_entry_pointing_outside_the_skill_shape() {
1276        let dir = tempfile::tempdir().unwrap();
1277        let cfg = tempfile::tempdir().unwrap();
1278        temp_env(
1279            &[
1280                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1281                ("HOME", None),
1282            ],
1283            || {
1284                let victim_dir = dir.path().join("src");
1285                std::fs::create_dir_all(&victim_dir).unwrap();
1286                std::fs::write(victim_dir.join("main.rs"), "fn main() {}").unwrap();
1287
1288                let victim_file = dir.path().join("Cargo.toml");
1289                std::fs::write(&victim_file, "[package]").unwrap();
1290
1291                save_state(&[
1292                    Entry {
1293                        path: victim_dir.clone(),
1294                        agent: "agents".into(),
1295                        kind: "file".into(),
1296                        sha256: "deadbeef".into(),
1297                        version: "0.0.1".into(),
1298                    },
1299                    Entry {
1300                        path: victim_file.clone(),
1301                        agent: "agents".into(),
1302                        kind: "file".into(),
1303                        sha256: "deadbeef".into(),
1304                        version: "0.0.1".into(),
1305                    },
1306                ])
1307                .unwrap();
1308
1309                let results = uninstall(None, true).unwrap();
1310                assert_eq!(results.len(), 2);
1311                assert!(results
1312                    .iter()
1313                    .all(|(_, o)| *o == RemovalOutcome::RefusedUnsafePath));
1314
1315                assert!(victim_dir.is_dir(), "unrelated directory must survive");
1316                assert!(
1317                    victim_dir.join("main.rs").exists(),
1318                    "unrelated directory's contents must survive"
1319                );
1320                assert!(victim_file.is_file(), "unrelated file must survive");
1321
1322                // Refused entries stay tracked rather than being dropped.
1323                let (remaining, _) = load_state();
1324                assert_eq!(remaining.len(), 2);
1325            },
1326        );
1327    }
1328
1329    /// A legitimate entry still works after the shape guard was added.
1330    #[test]
1331    #[serial_test::serial]
1332    fn uninstall_still_removes_a_legitimate_entry() {
1333        let dir = tempfile::tempdir().unwrap();
1334        let cfg = tempfile::tempdir().unwrap();
1335        temp_env(
1336            &[
1337                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1338                ("HOME", None),
1339            ],
1340            || {
1341                install(dir.path(), &[Agent::Agents], false).unwrap();
1342                let results = uninstall(None, false).unwrap();
1343                assert_eq!(
1344                    results,
1345                    vec![(
1346                        skill_file(dir.path(), Agent::Agents),
1347                        RemovalOutcome::Removed
1348                    )]
1349                );
1350            },
1351        );
1352    }
1353
1354    /// The design's core claim: one customized skill must not block another
1355    /// tracked skill's refresh, and the skipped one must be named in the
1356    /// output rather than silently dropped.
1357    #[test]
1358    #[serial_test::serial]
1359    fn refresh_rewrites_a_stale_entry_while_leaving_a_modified_one_alone() {
1360        let stale_root = tempfile::tempdir().unwrap();
1361        let modified_root = tempfile::tempdir().unwrap();
1362        let cfg = tempfile::tempdir().unwrap();
1363        temp_env(
1364            &[
1365                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1366                ("HOME", None),
1367            ],
1368            || {
1369                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1370                let stale_path = skill_file(stale_root.path(), Agent::Agents);
1371                std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1372                std::fs::write(&stale_path, old).unwrap();
1373
1374                let modified_path = skill_file(modified_root.path(), Agent::Agents);
1375                std::fs::create_dir_all(modified_path.parent().unwrap()).unwrap();
1376                let ours = "# our own version\n";
1377                std::fs::write(&modified_path, ours).unwrap();
1378
1379                save_state(&[
1380                    Entry {
1381                        path: stale_path.clone(),
1382                        agent: "agents".into(),
1383                        kind: "file".into(),
1384                        sha256: content_hash(old.as_bytes()),
1385                        version: "0.0.1".into(),
1386                    },
1387                    Entry {
1388                        path: modified_path.clone(),
1389                        agent: "agents".into(),
1390                        kind: "file".into(),
1391                        // Recorded hash disagrees with what's on disk now —
1392                        // someone edited it after bb wrote it.
1393                        sha256: content_hash(old.as_bytes()),
1394                        version: "0.0.1".into(),
1395                    },
1396                ])
1397                .unwrap();
1398
1399                let outcomes = refresh_tracked().unwrap();
1400                assert_eq!(outcomes.len(), 2);
1401
1402                let stale_outcome = outcomes.iter().find(|o| o.path == stale_path).unwrap();
1403                assert!(matches!(stale_outcome.action, Action::Refreshed));
1404                assert_eq!(std::fs::read_to_string(&stale_path).unwrap(), SKILL_MD);
1405
1406                let modified_outcome = outcomes.iter().find(|o| o.path == modified_path).unwrap();
1407                assert!(matches!(modified_outcome.action, Action::SkippedModified));
1408                assert_eq!(std::fs::read_to_string(&modified_path).unwrap(), ours);
1409            },
1410        );
1411    }
1412}