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
6/// One embedded skill. The text ships *inside* the binary, so every upgrade
7/// path — brew, cargo, `bb update` — carries new content as an inherent
8/// consequence rather than needing a separate sync. It also means an installed
9/// skill can never describe a flag this binary lacks.
10pub struct Skill {
11    pub name: &'static str,
12    /// One line, shown as this skill's row in the `bb skill install` prompt.
13    /// Kept short enough to render on a narrow terminal.
14    pub summary: &'static str,
15    pub content: &'static str,
16}
17
18pub const SKILLS: [Skill; 4] = [
19    Skill {
20        name: "bitbucket-cloud",
21        summary: "read, review and comment on Bitbucket Cloud pull requests",
22        content: include_str!("../.agents/skills/bitbucket-cloud/SKILL.md"),
23    },
24    Skill {
25        name: "bbc-daily-brief",
26        summary: "a ranked morning brief of the pull requests waiting on you",
27        content: include_str!("../.agents/skills/bbc-daily-brief/SKILL.md"),
28    },
29    Skill {
30        name: "bbc-open-pr",
31        summary: "open a pull request: reviewer suggestions from git history",
32        content: include_str!("../.agents/skills/bbc-open-pr/SKILL.md"),
33    },
34    Skill {
35        name: "bbc-report-bug",
36        summary: "file a bb bug upstream: reproduce, redact, ask, then gh",
37        content: include_str!("../.agents/skills/bbc-report-bug/SKILL.md"),
38    },
39];
40
41pub fn skill_by_name(name: &str) -> Option<&'static Skill> {
42    SKILLS.iter().find(|s| s.name == name)
43}
44
45/// State files written before the second skill existed carry no `skill` field.
46/// They can only have described the first one.
47fn default_skill_name() -> String {
48    "bitbucket-cloud".to_string()
49}
50
51pub fn content_hash(bytes: &[u8]) -> String {
52    let mut hasher = Sha256::new();
53    hasher.update(bytes);
54    format!("{:x}", hasher.finalize())
55}
56
57/// True when any tracked entry was written by a different build than the one
58/// running now. This is the whole auto-refresh trigger: a string compare over a
59/// handful of entries, so the common case — everything current — costs nothing
60/// beyond reading the state file.
61pub fn tracked_version_differs(entries: &[Entry]) -> bool {
62    entries
63        .iter()
64        .any(|e| e.version != env!("CARGO_PKG_VERSION"))
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Agent {
69    /// `.agents/skills/` — read by Codex, Cursor and OpenCode.
70    Agents,
71    /// `.claude/skills/` — Claude Code reads only this location.
72    Claude,
73}
74
75impl Agent {
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::Agents => "agents",
79            Self::Claude => "claude",
80        }
81    }
82
83    pub fn all() -> [Agent; 2] {
84        [Agent::Agents, Agent::Claude]
85    }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Entry {
90    pub path: PathBuf,
91    pub agent: String,
92    /// `"file"` or `"symlink"` — a refresh has to rewrite the real file, and an
93    /// uninstall has to remove the right kind of thing.
94    pub kind: String,
95    /// Hash of what bb itself wrote. Comparing it against the file on disk is
96    /// how a local edit is detected and protected.
97    pub sha256: String,
98    pub version: String,
99    #[serde(default = "default_skill_name")]
100    pub skill: String,
101    /// Whether `bb` is what put this file on disk. A content hash cannot answer
102    /// this — it proves the bytes match, never who wrote them — so it is
103    /// recorded at write time and carried forward. `uninstall` deletes only
104    /// what this marks as ours.
105    ///
106    /// State files written before this field existed carry no value. They
107    /// default to `true`: those entries were overwhelmingly bb's own writes,
108    /// and defaulting to `false` would strand every skill installed before the
109    /// upgrade as un-removable.
110    #[serde(default = "default_created")]
111    pub created: bool,
112}
113
114fn default_created() -> bool {
115    true
116}
117
118pub fn state_path() -> PathBuf {
119    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
120        if !xdg.is_empty() {
121            return PathBuf::from(xdg).join("bb").join("skills.json");
122        }
123    }
124    let home = std::env::var_os("HOME").unwrap_or_default();
125    PathBuf::from(home)
126        .join(".config")
127        .join("bb")
128        .join("skills.json")
129}
130
131/// Entries plus an optional warning. A missing state file simply means nothing
132/// is tracked; a corrupt one is reported but treated as empty, so a hand-edited
133/// file cannot brick `bb update`.
134pub fn load_state() -> (Vec<Entry>, Option<String>) {
135    let path = state_path();
136    let raw = match std::fs::read_to_string(&path) {
137        Ok(text) => text,
138        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (Vec::new(), None),
139        Err(err) => {
140            return (
141                Vec::new(),
142                Some(format!("could not read {}: {err}", path.display())),
143            )
144        }
145    };
146    if raw.trim().is_empty() {
147        return (Vec::new(), None);
148    }
149    match serde_json::from_str::<Vec<Entry>>(&raw) {
150        Ok(entries) => (entries, None),
151        Err(err) => (
152            Vec::new(),
153            Some(format!("ignoring unreadable {}: {err}", path.display())),
154        ),
155    }
156}
157
158/// Writes via a temp file in the same directory plus `rename`, so two `bb`
159/// processes racing right after an upgrade cannot interleave and leave a
160/// truncated `skills.json` — `fs::write` truncates first, and `rename` on the
161/// same filesystem is atomic where plain writes are not.
162pub fn save_state(entries: &[Entry]) -> Result<()> {
163    let path = state_path();
164    let parent = path.parent().ok_or_else(|| {
165        BbError::Config(format!(
166            "state path {} has no parent directory",
167            path.display()
168        ))
169    })?;
170    std::fs::create_dir_all(parent).map_err(BbError::Io)?;
171    let json = serde_json::to_string_pretty(entries)?;
172    let tmp = parent.join(format!(
173        ".skills.json.tmp.{}.{}",
174        std::process::id(),
175        std::time::SystemTime::now()
176            .duration_since(std::time::UNIX_EPOCH)
177            .map(|d| d.as_nanos())
178            .unwrap_or(0)
179    ));
180    std::fs::write(&tmp, json).map_err(BbError::Io)?;
181    std::fs::rename(&tmp, &path).map_err(BbError::Io)?;
182    Ok(())
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Action {
187    Installed,
188    Refreshed,
189    Unchanged,
190    SkippedModified,
191    /// The entry named a path whose directory tree no longer exists, so it was
192    /// dropped from the state file rather than recreated.
193    Pruned,
194    /// A write that should have brought this entry current failed (EROFS,
195    /// EACCES, ...). The entry stays tracked with its old version and hash so
196    /// it is retried on the next invocation, rather than aborting every other
197    /// entry's refresh in the same batch.
198    Failed,
199}
200
201impl Action {
202    pub fn as_str(self) -> &'static str {
203        match self {
204            Self::Installed => "installed",
205            Self::Refreshed => "refreshed",
206            Self::Unchanged => "unchanged",
207            Self::SkippedModified => "skipped_modified",
208            Self::Pruned => "pruned",
209            Self::Failed => "failed",
210        }
211    }
212}
213
214/// Whether `refresh_tracked` should recreate an entry whose file has been
215/// deleted. Explicit `bb skill install`/`bb update` pass `Restore`, because a
216/// human asked for it there; the auto-refresh that runs before every command
217/// passes `Preserve`, because a deliberately deleted skill file must not be
218/// silently written back into a user's working tree.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum MissingPolicy {
221    Restore,
222    Preserve,
223}
224
225#[derive(Debug, Clone)]
226pub struct Outcome {
227    pub path: PathBuf,
228    pub agent: String,
229    pub skill: String,
230    pub action: Action,
231}
232
233/// Where the real file lives for each agent.
234pub fn skill_file(root: &Path, agent: Agent, skill: &Skill) -> PathBuf {
235    let base = match agent {
236        Agent::Agents => root.join(".agents").join("skills"),
237        Agent::Claude => root.join(".claude").join("skills"),
238    };
239    base.join(skill.name).join("SKILL.md")
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum State {
244    Current,
245    Stale,
246    Modified,
247    Missing,
248}
249
250impl State {
251    pub fn as_str(self) -> &'static str {
252        match self {
253            Self::Current => "current",
254            Self::Stale => "stale",
255            Self::Modified => "modified",
256            Self::Missing => "missing",
257        }
258    }
259}
260
261#[derive(Debug, Clone)]
262pub struct StatusRow {
263    pub path: PathBuf,
264    pub agent: String,
265    pub skill: String,
266    pub state: State,
267}
268
269/// Refuses any path that does not end in `.agents/skills/<name>/SKILL.md`
270/// or `.claude/skills/<name>/SKILL.md`, where `<name>` is a known skill. Every removal or write driven by
271/// a state entry must go through this first: the state file is user-editable
272/// (by hand or by a bad merge), and nothing it names should let `bb` touch an
273/// arbitrary path on disk. Deliberately checks shape, not existence or type —
274/// `state_of` already treats a directory as `Missing` (it can't be read as a
275/// file), and that used to be enough to make the `Missing` repair branches
276/// reach a `remove_dir_all`/`write_file` on whatever the state file named.
277fn is_shaped_like_a_skill_path(path: &Path) -> bool {
278    let mut components: Vec<_> = path.components().collect();
279    let Some(file) = components.pop() else {
280        return false;
281    };
282    if file.as_os_str() != "SKILL.md" {
283        return false;
284    }
285    let Some(skill_dir) = components.pop() else {
286        return false;
287    };
288    if skill_by_name(&skill_dir.as_os_str().to_string_lossy()).is_none() {
289        return false;
290    }
291    let Some(skills_dir) = components.pop() else {
292        return false;
293    };
294    if skills_dir.as_os_str() != "skills" {
295        return false;
296    }
297    matches!(
298        components.pop().map(|c| c.as_os_str().to_owned()),
299        Some(agents_dir) if agents_dir == ".agents" || agents_dir == ".claude"
300    )
301}
302
303/// True only when `ancestor` is *definitely* gone — a stat that returns
304/// `ENOENT`. `Path::exists()` also reads false on `EACCES` for a parent
305/// component, or on a path under an unmounted network/removable volume, and
306/// either of those must not be treated as "the tree was deleted": that would
307/// prune a still-real entry that will come back once the permission or the
308/// mount is restored, leaving the file on disk but untracked forever after.
309fn ancestor_is_definitely_gone(ancestor: &Path) -> bool {
310    matches!(
311        std::fs::symlink_metadata(ancestor),
312        Err(e) if e.kind() == std::io::ErrorKind::NotFound
313    )
314}
315
316fn state_of(entry: &Entry, wanted: &str) -> State {
317    match std::fs::read(&entry.path) {
318        Err(_) => State::Missing,
319        Ok(bytes) => {
320            let actual = content_hash(&bytes);
321            if actual == wanted {
322                State::Current
323            } else if actual == entry.sha256 {
324                State::Stale
325            } else {
326                State::Modified
327            }
328        }
329    }
330}
331
332pub fn status() -> (Vec<StatusRow>, Option<String>) {
333    let (entries, warning) = load_state();
334    let rows = entries
335        .iter()
336        .map(|e| {
337            // An unknown skill name (a state file written by a newer `bb`, or
338            // hand-edited) has no wanted hash to compare against. It must not
339            // be able to reach `Stale` — that state promises "the binary has
340            // newer text, a refresh will fix it", which is untrue here, since
341            // nothing in this binary knows what this entry's content should
342            // be. `Modified` correctly refuses to touch it.
343            let state = match skill_by_name(&e.skill) {
344                Some(skill) => state_of(e, &content_hash(skill.content.as_bytes())),
345                None => State::Modified,
346            };
347            StatusRow {
348                path: e.path.clone(),
349                agent: e.agent.clone(),
350                skill: e.skill.clone(),
351                state,
352            }
353        })
354        .collect();
355    (rows, warning)
356}
357
358/// Distinguishes *why* an entry did not end up removed, so the caller can be
359/// honest about it instead of collapsing "refused because modified" and
360/// "wasn't there to begin with" into the same boolean.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum RemovalOutcome {
363    Removed,
364    RefusedModified,
365    /// Tracked, but `bb` never wrote it — a vendored or hand-placed copy that
366    /// merely matched the embedded text. Left on disk; the entry is dropped.
367    RefusedNotWritten,
368    RefusedUnsafePath,
369    Absent,
370}
371
372impl RemovalOutcome {
373    pub fn as_str(self) -> &'static str {
374        match self {
375            Self::Removed => "removed",
376            Self::RefusedModified => "refused_modified",
377            Self::RefusedNotWritten => "refused_not_written",
378            Self::RefusedUnsafePath => "refused_unsafe_path",
379            Self::Absent => "absent",
380        }
381    }
382}
383
384/// True when the directory *containing* `path` is itself a symlink — the
385/// shape a Claude entry takes when `install_claude_dir` linked it. Checked
386/// against disk rather than trusted from the recorded `kind`, because `kind`
387/// can be wrong: a pre-existing hand-made symlink that `install` finds
388/// `Unchanged` or `SkippedModified` (no prior state entry to inherit from)
389/// used to default to `kind: "file"`, which then bypassed this exact guard.
390fn parent_is_symlink(path: &Path) -> bool {
391    path.parent()
392        .and_then(|p| std::fs::symlink_metadata(p).ok())
393        .map(|m| m.file_type().is_symlink())
394        .unwrap_or(false)
395}
396
397/// Removes what bb recorded. A customized file is left in place unless `force`,
398/// and an untracked file is never touched at all.
399pub fn uninstall(
400    root: Option<&Path>,
401    skills: &[&'static Skill],
402    force: bool,
403) -> Result<Vec<(PathBuf, String, RemovalOutcome)>> {
404    let (entries, warning) = load_state();
405    if let Some(warning) = warning {
406        crate::output::warn(&warning);
407    }
408    let in_scope_skill_names: Vec<&str> = skills.iter().map(|s| s.name).collect();
409    let mut results = Vec::new();
410    let mut keep = Vec::new();
411
412    for entry in entries {
413        let in_scope = root.is_none_or(|r| entry.path.starts_with(r))
414            && in_scope_skill_names.contains(&entry.skill.as_str());
415        if !in_scope {
416            keep.push(entry);
417            continue;
418        }
419        if !is_shaped_like_a_skill_path(&entry.path) {
420            crate::output::warn(&format!(
421                "refusing to touch {} — does not look like a skill path bb would have written",
422                entry.path.display()
423            ));
424            results.push((
425                entry.path.clone(),
426                entry.skill.clone(),
427                RemovalOutcome::RefusedUnsafePath,
428            ));
429            keep.push(entry);
430            continue;
431        }
432        let wanted = skill_by_name(&entry.skill)
433            .map(|s| content_hash(s.content.as_bytes()))
434            .unwrap_or_default();
435        // A symlinked Claude entry's `path` is `SKILL.md` *inside* the linked
436        // directory, so removing it directly would follow the link and delete
437        // the `.agents` copy it points at. The thing actually on disk at the
438        // Claude location is the symlink one level up — remove that instead,
439        // and don't recurse into what it points to. Trusts disk over the
440        // recorded `kind`: a hand-made symlink that predates any bb-recorded
441        // `kind` must still be removed as a link, not followed.
442        let is_symlinked_dir = entry.kind == "symlink" || parent_is_symlink(&entry.path);
443
444        // Never delete a *file* bb did not write. `--force` still overrides, so
445        // someone who vendored a copy and explicitly asks for it gone gets
446        // that; the entry is dropped either way, because "uninstall" means stop
447        // managing it and a refusal that stays tracked is re-reported forever.
448        //
449        // A symlink is exempt: the guard exists to protect content bb did not
450        // author, and a link holds none. Removing a hand-made one — the shape
451        // older docs told users to create by hand — loses nothing, and the
452        // `.agents` file it points at is protected by this same rule under its
453        // own entry.
454        if !entry.created && !is_symlinked_dir && !force {
455            results.push((
456                entry.path.clone(),
457                entry.skill.clone(),
458                RemovalOutcome::RefusedNotWritten,
459            ));
460            continue;
461        }
462        let modified = matches!(state_of(&entry, &wanted), State::Modified);
463        if modified && !force {
464            results.push((
465                entry.path.clone(),
466                entry.skill.clone(),
467                RemovalOutcome::RefusedModified,
468            ));
469            keep.push(entry);
470            continue;
471        }
472        let removal_target: &Path = if is_symlinked_dir {
473            entry.path.parent().unwrap_or(&entry.path)
474        } else {
475            &entry.path
476        };
477        let existed = removal_target.exists() || std::fs::symlink_metadata(removal_target).is_ok();
478        remove_existing(removal_target)?;
479        // A `kind: "file"` Claude fallback can leave an empty
480        // `.claude/skills/<skill name>/` directory behind once `SKILL.md`
481        // inside it is gone. Clean up that one directory — never a parent,
482        // and never one that still has something in it.
483        if !is_symlinked_dir {
484            if let Some(dir) = entry.path.parent() {
485                let is_empty = std::fs::read_dir(dir)
486                    .map(|mut i| i.next().is_none())
487                    .unwrap_or(false);
488                if is_empty {
489                    let _ = std::fs::remove_dir(dir);
490                }
491            }
492        }
493        let outcome = if existed {
494            RemovalOutcome::Removed
495        } else {
496            RemovalOutcome::Absent
497        };
498        results.push((entry.path.clone(), entry.skill.clone(), outcome));
499    }
500
501    save_state(&keep)?;
502    Ok(results)
503}
504
505/// `.cursor/` and `.opencode/` both read `.agents/skills/`, so their presence
506/// asks for the `.agents` write rather than a location of their own.
507pub fn detect_agents(root: &Path) -> Vec<Agent> {
508    let mut found = Vec::new();
509    let shares_agents = [".agents", ".cursor", ".opencode"]
510        .iter()
511        .any(|d| root.join(d).is_dir());
512    if shares_agents {
513        found.push(Agent::Agents);
514    }
515    if root.join(".claude").is_dir() {
516        found.push(Agent::Claude);
517    }
518    found
519}
520
521fn write_file(path: &Path, contents: &str) -> Result<()> {
522    if let Some(parent) = path.parent() {
523        std::fs::create_dir_all(parent).map_err(BbError::Io)?;
524    }
525    std::fs::write(path, contents).map_err(BbError::Io)?;
526    Ok(())
527}
528
529/// Removes whatever is at `path` — file, dir, or symlink — without following a
530/// symlink into its target. `remove_file` handles symlinks-to-files and plain
531/// files; a symlink-to-directory needs `remove_dir_all` refusing to peek inside
532/// on most platforms, but to be safe we check `symlink_metadata` first.
533fn remove_existing(path: &Path) -> Result<()> {
534    match std::fs::symlink_metadata(path) {
535        Ok(meta) => {
536            if meta.file_type().is_symlink() || !meta.is_dir() {
537                std::fs::remove_file(path).map_err(BbError::Io)?;
538            } else {
539                std::fs::remove_dir_all(path).map_err(BbError::Io)?;
540            }
541            Ok(())
542        }
543        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
544        Err(err) => Err(BbError::Io(err)),
545    }
546}
547
548/// Installs the Claude copy as a relative symlink to the `.agents` skill
549/// directory when both are present, falling back to a real file otherwise.
550/// Returns the `kind` that was actually written ("symlink" or "file").
551fn install_claude_dir(root: &Path, agents_installed: bool, skill: &Skill) -> Result<&'static str> {
552    let claude_dir = root.join(".claude").join("skills").join(skill.name);
553    let claude_file = claude_dir.join("SKILL.md");
554
555    if agents_installed {
556        #[cfg(unix)]
557        {
558            if let Some(parent) = claude_dir.parent() {
559                std::fs::create_dir_all(parent).map_err(BbError::Io)?;
560            }
561            remove_existing(&claude_dir)?;
562            let target = Path::new("..")
563                .join("..")
564                .join(".agents")
565                .join("skills")
566                .join(skill.name);
567            if std::os::unix::fs::symlink(&target, &claude_dir).is_ok() {
568                return Ok("symlink");
569            }
570        }
571    }
572
573    remove_existing(&claude_dir)?;
574    write_file(&claude_file, skill.content)?;
575    Ok("file")
576}
577
578pub fn install(
579    root: &Path,
580    agents: &[Agent],
581    skills: &[&'static Skill],
582    force: bool,
583) -> Result<Vec<Outcome>> {
584    let (mut state, _warning) = load_state();
585    let mut outcomes = Vec::new();
586
587    for skill in skills {
588        let wanted = content_hash(skill.content.as_bytes());
589
590        let agents_dir_present = root
591            .join(".agents")
592            .join("skills")
593            .join(skill.name)
594            .join("SKILL.md")
595            .exists()
596            || agents.contains(&Agent::Agents);
597
598        for agent in agents {
599            let path = skill_file(root, *agent, skill);
600            let recorded = state.iter().find(|e| e.path == path).cloned();
601            let on_disk = std::fs::read(&path).ok();
602
603            let action = match (&on_disk, &recorded) {
604                (None, _) => Action::Installed,
605                (Some(bytes), _) if content_hash(bytes) == wanted => Action::Unchanged,
606                // We wrote it and the binary now carries newer text.
607                (Some(bytes), Some(entry)) if content_hash(bytes) == entry.sha256 => {
608                    Action::Refreshed
609                }
610                // Either untracked or edited since we wrote it — someone's own work.
611                (Some(_), _) if force => Action::Refreshed,
612                (Some(_), _) => Action::SkippedModified,
613            };
614
615            // Did *this* call put the file there? `Unchanged` means we wrote
616            // nothing, so ownership can only be inherited from an existing
617            // entry — and when there is none, the file was already on disk and
618            // is somebody else's.
619            let created = match action {
620                Action::Unchanged => recorded.as_ref().map(|e| e.created).unwrap_or(false),
621                _ => true,
622            };
623
624            let mut kind = "file".to_string();
625            if action != Action::SkippedModified && action != Action::Unchanged {
626                if *agent == Agent::Claude {
627                    kind = install_claude_dir(root, agents_dir_present, skill)?.to_string();
628                } else {
629                    write_file(&path, skill.content)?;
630                }
631            } else if let Some(entry) = &recorded {
632                kind = entry.kind.clone();
633            } else if parent_is_symlink(&path) {
634                // No prior state entry to inherit `kind` from — e.g. a hand-made
635                // Claude symlink, exactly what older docs told users to create
636                // themselves — so it must be read off disk rather than defaulted
637                // to `"file"`, or a later uninstall would bypass the symlink
638                // guard and follow the link into whatever it points at.
639                kind = "symlink".to_string();
640            }
641
642            if action != Action::SkippedModified {
643                state.retain(|e| e.path != path);
644                state.push(Entry {
645                    path: path.clone(),
646                    agent: agent.as_str().to_string(),
647                    kind,
648                    sha256: wanted.clone(),
649                    version: env!("CARGO_PKG_VERSION").to_string(),
650                    skill: skill.name.to_string(),
651                    created,
652                });
653            }
654
655            outcomes.push(Outcome {
656                path,
657                agent: agent.as_str().to_string(),
658                skill: skill.name.to_string(),
659                action,
660            });
661        }
662    }
663
664    save_state(&state)?;
665    Ok(outcomes)
666}
667
668/// A tracked Claude entry's recorded `path` is `SKILL.md` *inside* the linked
669/// directory (see `uninstall`'s comment on the same shape), so the project
670/// root sits four components above it: `SKILL.md`, `<skill name>`, `skills`,
671/// `.claude`.
672fn claude_root_from_entry_path(path: &Path) -> Result<&Path> {
673    path.parent()
674        .and_then(Path::parent)
675        .and_then(Path::parent)
676        .and_then(Path::parent)
677        .ok_or_else(|| {
678            BbError::Config(format!(
679                "cannot determine the project root from {}",
680                path.display()
681            ))
682        })
683}
684
685/// Recreates a Claude entry whose recorded `kind` is `"symlink"` but whose
686/// link (or fallback file) is missing from disk. Delegates to
687/// `install_claude_dir` so the same relative-symlink-with-fallback logic that
688/// `install` uses is not duplicated here, and returns the `kind` that was
689/// actually written so the caller can keep the recorded state honest even
690/// when the platform refuses a symlink and falls back to a real file.
691fn restore_claude_link(entry_path: &Path, skill: &Skill) -> Result<String> {
692    if !is_shaped_like_a_skill_path(entry_path) {
693        return Err(BbError::Config(format!(
694            "refusing to touch {} — does not look like a skill path bb would have written",
695            entry_path.display()
696        )));
697    }
698    let root = claude_root_from_entry_path(entry_path)?;
699    let agents_installed = root
700        .join(".agents")
701        .join("skills")
702        .join(skill.name)
703        .join("SKILL.md")
704        .exists();
705    Ok(install_claude_dir(root, agents_installed, skill)?.to_string())
706}
707
708/// Refreshes every tracked entry against the currently-running binary's
709/// embedded text. Driven by the recorded entries rather than a root and an
710/// agent list, so a single call spans every project the user has installed
711/// into. Uses the same drift rules as `install`, via `state_of`: `Stale`
712/// rewrites the file and updates the recorded hash, `Modified` is left
713/// byte-identical and reported as `SkippedModified`, and `Current` is
714/// reported as `Unchanged` without touching anything. `Missing` is rewritten
715/// only under `MissingPolicy::Restore` — `Preserve` (what the pre-command
716/// auto-refresh passes) leaves a deliberately deleted file deleted, reporting
717/// nothing and leaving the entry's version untouched so it is not mistaken
718/// for current.
719///
720/// A single entry's write failing (read-only filesystem, permission denied,
721/// ...) is reported as `Action::Failed` rather than aborting the loop with
722/// `?` — every other entry's refresh still lands, and `save_state` still
723/// persists them, so one unwritable path cannot swallow the whole batch nor
724/// spam the same warning on every future invocation forever. The failed
725/// entry keeps its old version and hash, so it is retried next time rather
726/// than being mistaken for current. `refresh_tracked` itself still returns
727/// `Err` when `save_state` fails, since at that point the whole operation's
728/// work would otherwise be silently lost.
729pub fn refresh_tracked(missing: MissingPolicy) -> Result<Vec<Outcome>> {
730    let (state, warning) = load_state();
731    if let Some(warning) = warning {
732        crate::output::warn(&warning);
733    }
734    let mut outcomes = Vec::new();
735    let mut kept: Vec<Entry> = Vec::new();
736
737    for mut entry in state {
738        if !is_shaped_like_a_skill_path(&entry.path) {
739            crate::output::warn(&format!(
740                "refusing to touch {} — does not look like a skill path bb would have written",
741                entry.path.display()
742            ));
743            // Bookkeeping only: this entry is never rewritten, but stamping the
744            // running version here still keeps `tracked_version_differs` cheap —
745            // without it, one unshaped entry would make every future invocation
746            // believe a refresh is due, forever.
747            entry.version = env!("CARGO_PKG_VERSION").to_string();
748            kept.push(entry);
749            continue;
750        }
751        // A hand-edited or badly-merged state file could name a skill this
752        // binary doesn't know. Leave it untouched here; `status` explicitly
753        // reports an unknown skill name as `Modified`, since this binary has
754        // no wanted content to compare it against or refresh it with.
755        let Some(skill) = skill_by_name(&entry.skill) else {
756            // Same bookkeeping-only stamp as above, for the same reason.
757            entry.version = env!("CARGO_PKG_VERSION").to_string();
758            kept.push(entry);
759            continue;
760        };
761
762        // An entry whose whole directory tree is gone is not a skill waiting to
763        // be restored — it is residue from a temp directory or a deleted
764        // checkout. Recreating it would materialise a file inside a path nobody
765        // asked for, so drop the entry instead. A missing file whose directory
766        // still exists is the opposite case and is restored below. The check
767        // looks two levels up (past the skill-name folder itself, which
768        // `write_file`/`restore_claude_link` happily recreate) so deleting just
769        // the one skill's own folder still restores it — only a vanished parent
770        // tree above that (the agent's whole `skills/` directory, or higher)
771        // counts as residue.
772        if entry
773            .path
774            .parent()
775            .and_then(Path::parent)
776            .is_some_and(ancestor_is_definitely_gone)
777        {
778            outcomes.push(Outcome {
779                path: entry.path.clone(),
780                agent: entry.agent.clone(),
781                skill: entry.skill.clone(),
782                action: Action::Pruned,
783            });
784            continue;
785        }
786
787        let wanted = content_hash(skill.content.as_bytes());
788        let disk_state = state_of(&entry, &wanted);
789
790        // A deliberately deleted file must not come back from an auto-refresh
791        // nobody asked for. Skip it entirely: no write, no outcome, no version
792        // stamp — stamping would make it read as current when it is not.
793        if disk_state == State::Missing && missing == MissingPolicy::Preserve {
794            kept.push(entry);
795            continue;
796        }
797
798        let action = match disk_state {
799            // The link is intact — writing to `entry.path` follows it straight
800            // into the `.agents` file it points at, refreshing the shared
801            // content without disturbing the link itself.
802            State::Stale => match write_file(&entry.path, skill.content) {
803                Ok(()) => {
804                    entry.sha256 = wanted.clone();
805                    Action::Refreshed
806                }
807                Err(_) => Action::Failed,
808            },
809            // The link (or file) itself is gone. A plain `write_file` here
810            // would create a *real* file where a symlink used to be, leaving
811            // state still claiming `"symlink"` while disk disagrees. Restore
812            // the same kind of thing that used to be there instead.
813            State::Missing => {
814                let restored: Result<String> = if entry.kind == "symlink" {
815                    restore_claude_link(&entry.path, skill)
816                } else {
817                    write_file(&entry.path, skill.content).map(|()| "file".to_string())
818                };
819                match restored {
820                    Ok(kind) => {
821                        entry.kind = kind;
822                        entry.sha256 = wanted.clone();
823                        Action::Refreshed
824                    }
825                    Err(_) => Action::Failed,
826                }
827            }
828            State::Modified => Action::SkippedModified,
829            State::Current => Action::Unchanged,
830        };
831
832        // Every entry we looked at records this build, including one we
833        // skipped — except one whose write just failed: its content is
834        // genuinely not current, and stamping the version would hide that
835        // from the next invocation's check.
836        if action != Action::Failed {
837            entry.version = env!("CARGO_PKG_VERSION").to_string();
838        }
839
840        outcomes.push(Outcome {
841            path: entry.path.clone(),
842            agent: entry.agent.clone(),
843            skill: entry.skill.clone(),
844            action,
845        });
846        kept.push(entry);
847    }
848
849    save_state(&kept)?;
850    Ok(outcomes)
851}
852
853#[cfg(test)]
854#[allow(clippy::unwrap_used, clippy::expect_used)]
855mod tests {
856    use super::*;
857
858    fn bb_skill() -> &'static Skill {
859        skill_by_name("bitbucket-cloud").unwrap()
860    }
861
862    /// A packaging regression — an added `exclude` entry in Cargo.toml, or a moved
863    /// file — must fail the build rather than ship an empty skill.
864    #[test]
865    fn embedded_skill_is_present_and_has_frontmatter() {
866        assert!(!bb_skill().content.trim().is_empty());
867        assert!(
868            bb_skill().content.starts_with("---"),
869            "skill must open with yaml frontmatter"
870        );
871        assert!(
872            bb_skill().content.contains("name: bitbucket-cloud"),
873            "frontmatter should name the skill"
874        );
875    }
876
877    #[test]
878    fn content_hash_is_stable_and_distinguishes_content() {
879        assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
880        assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
881        // sha256 hex is 64 chars
882        assert_eq!(content_hash(b"abc").len(), 64);
883    }
884
885    #[test]
886    #[serial_test::serial]
887    fn state_path_prefers_xdg_config_home() {
888        temp_env(
889            &[
890                ("XDG_CONFIG_HOME", Some("/tmp/xdg")),
891                ("HOME", Some("/tmp/home")),
892            ],
893            || {
894                assert_eq!(
895                    state_path(),
896                    std::path::Path::new("/tmp/xdg/bb/skills.json")
897                );
898            },
899        );
900    }
901
902    #[test]
903    #[serial_test::serial]
904    fn state_path_falls_back_to_home_config() {
905        temp_env(
906            &[("XDG_CONFIG_HOME", None), ("HOME", Some("/tmp/home"))],
907            || {
908                assert_eq!(
909                    state_path(),
910                    std::path::Path::new("/tmp/home/.config/bb/skills.json")
911                );
912            },
913        );
914    }
915
916    #[test]
917    #[serial_test::serial]
918    fn saved_state_round_trips() {
919        let dir = tempfile::tempdir().unwrap();
920        temp_env(
921            &[
922                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
923                ("HOME", None),
924            ],
925            || {
926                let entries = vec![Entry {
927                    path: std::path::PathBuf::from("/p/.agents/skills/bitbucket-cloud/SKILL.md"),
928                    agent: "agents".into(),
929                    kind: "file".into(),
930                    sha256: content_hash(bb_skill().content.as_bytes()),
931                    version: env!("CARGO_PKG_VERSION").into(),
932                    skill: "bitbucket-cloud".into(),
933                    created: true,
934                }];
935                save_state(&entries).unwrap();
936                let (loaded, warning) = load_state();
937                assert!(warning.is_none());
938                assert_eq!(loaded.len(), 1);
939                assert_eq!(loaded[0].agent, "agents");
940                assert_eq!(loaded[0].sha256, entries[0].sha256);
941            },
942        );
943    }
944
945    /// A hand-edited or truncated state file must not brick the command.
946    #[test]
947    #[serial_test::serial]
948    fn corrupt_state_is_tolerated_with_a_warning() {
949        let dir = tempfile::tempdir().unwrap();
950        temp_env(
951            &[
952                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
953                ("HOME", None),
954            ],
955            || {
956                let p = state_path();
957                std::fs::create_dir_all(p.parent().unwrap()).unwrap();
958                std::fs::write(&p, "{not json").unwrap();
959                let (loaded, warning) = load_state();
960                assert!(loaded.is_empty());
961                assert!(warning.is_some(), "corrupt state should warn");
962            },
963        );
964    }
965
966    /// Drives `Stale` (and `Current`) through `status()` end to end, not just
967    /// through `install()`'s refresh path. A tracked entry whose sha256 matches
968    /// what's on disk, but not what the binary ships now, is stale; a tracked
969    /// entry whose sha256 matches the binary's current text is current.
970    #[test]
971    #[serial_test::serial]
972    fn status_reports_stale_when_the_binary_shipped_newer_text() {
973        let dir = tempfile::tempdir().unwrap();
974        let root = tempfile::tempdir().unwrap();
975        temp_env(
976            &[
977                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
978                ("HOME", None),
979            ],
980            || {
981                let path = skill_file(root.path(), Agent::Agents, bb_skill());
982                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
983                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
984                std::fs::write(&path, old).unwrap();
985                save_state(&[Entry {
986                    path: path.clone(),
987                    agent: "agents".into(),
988                    kind: "file".into(),
989                    sha256: content_hash(old.as_bytes()),
990                    version: "0.0.1".into(),
991                    skill: "bitbucket-cloud".into(),
992                    created: true,
993                }])
994                .unwrap();
995
996                let (rows, warning) = status();
997                assert!(warning.is_none());
998                assert_eq!(rows.len(), 1);
999                assert_eq!(
1000                    rows[0].state,
1001                    State::Stale,
1002                    "on-disk text matches the recorded sha256, just not the binary's current text"
1003                );
1004
1005                // Same entry, but now the file holds exactly what the binary ships:
1006                // that must read as Current, not Stale.
1007                std::fs::write(&path, bb_skill().content).unwrap();
1008                save_state(&[Entry {
1009                    path: path.clone(),
1010                    agent: "agents".into(),
1011                    kind: "file".into(),
1012                    sha256: content_hash(bb_skill().content.as_bytes()),
1013                    version: env!("CARGO_PKG_VERSION").into(),
1014                    skill: "bitbucket-cloud".into(),
1015                    created: true,
1016                }])
1017                .unwrap();
1018                let (rows, _) = status();
1019                assert_eq!(rows[0].state, State::Current);
1020            },
1021        );
1022    }
1023
1024    /// Uninstalling a symlinked Claude entry must remove the link itself, not
1025    /// follow it into the `.agents` copy it points at. Scopes the uninstall to
1026    /// just the Claude subtree so the `.agents` entry is never itself in scope
1027    /// for removal — the only way to prove the target survives *because* the
1028    /// link wasn't followed, rather than because it was also being deleted on
1029    /// its own account.
1030    #[test]
1031    #[serial_test::serial]
1032    fn uninstall_removes_the_claude_link_without_deleting_its_target() {
1033        let dir = tempfile::tempdir().unwrap();
1034        let cfg = tempfile::tempdir().unwrap();
1035        temp_env(
1036            &[
1037                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1038                ("HOME", None),
1039            ],
1040            || {
1041                install(
1042                    dir.path(),
1043                    &[Agent::Agents, Agent::Claude],
1044                    &[bb_skill()],
1045                    false,
1046                )
1047                .unwrap();
1048                let agents_path = skill_file(dir.path(), Agent::Agents, bb_skill());
1049                let claude_root = dir.path().join(".claude");
1050                assert!(agents_path.is_file(), "sanity: agents copy installed");
1051
1052                let results = uninstall(Some(&claude_root), &[bb_skill()], false).unwrap();
1053                assert_eq!(results.len(), 1, "only the claude entry was in scope");
1054                assert_eq!(
1055                    results[0].2,
1056                    RemovalOutcome::Removed,
1057                    "the claude entry should report removed"
1058                );
1059
1060                let claude_dir = dir.path().join(".claude/skills/bitbucket-cloud");
1061                assert!(
1062                    !claude_dir.exists(),
1063                    "the claude link (or fallback file) should be gone"
1064                );
1065                assert!(
1066                    agents_path.is_file(),
1067                    "removing the claude link must not delete the agents copy it points at"
1068                );
1069
1070                let (remaining, _) = load_state();
1071                assert_eq!(remaining.len(), 1, "the agents entry stays tracked");
1072                assert_eq!(remaining[0].agent, "agents");
1073            },
1074        );
1075    }
1076
1077    #[test]
1078    #[serial_test::serial]
1079    fn missing_state_is_empty_and_silent() {
1080        let dir = tempfile::tempdir().unwrap();
1081        temp_env(
1082            &[
1083                ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
1084                ("HOME", None),
1085            ],
1086            || {
1087                let (loaded, warning) = load_state();
1088                assert!(loaded.is_empty());
1089                assert!(warning.is_none());
1090            },
1091        );
1092    }
1093
1094    /// Restores saved env vars on drop, so a panic inside `temp_env`'s closure
1095    /// still puts `HOME`/`XDG_CONFIG_HOME` back rather than leaking a
1096    /// soon-to-be-dropped tempdir path into whichever `#[serial]` test runs next.
1097    struct EnvGuard {
1098        saved: Vec<(String, Option<String>)>,
1099    }
1100
1101    impl Drop for EnvGuard {
1102        fn drop(&mut self) {
1103            for (k, v) in &self.saved {
1104                match v {
1105                    Some(val) => std::env::set_var(k, val),
1106                    None => std::env::remove_var(k),
1107                }
1108            }
1109        }
1110    }
1111
1112    /// Sets env vars for the closure and restores them afterwards, even if the
1113    /// closure panics. `None` removes. Tests that call this must be `#[serial]`,
1114    /// because process env is global.
1115    fn temp_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
1116        let saved: Vec<(String, Option<String>)> = vars
1117            .iter()
1118            .map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
1119            .collect();
1120        let _guard = EnvGuard { saved };
1121        for (k, v) in vars {
1122            match v {
1123                Some(val) => std::env::set_var(k, val),
1124                None => std::env::remove_var(k),
1125            }
1126        }
1127        f();
1128    }
1129
1130    #[test]
1131    #[serial_test::serial]
1132    fn temp_env_restores_vars_even_if_the_closure_panics() {
1133        std::env::set_var("XDG_CONFIG_HOME", "/before/panic");
1134        std::env::remove_var("HOME");
1135
1136        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1137            temp_env(
1138                &[
1139                    ("XDG_CONFIG_HOME", Some("/tmp/during-panic")),
1140                    ("HOME", Some("/tmp/home")),
1141                ],
1142                || panic!("simulated test failure inside temp_env"),
1143            );
1144        }));
1145        assert!(result.is_err(), "closure should have panicked");
1146
1147        assert_eq!(
1148            std::env::var("XDG_CONFIG_HOME").ok(),
1149            Some("/before/panic".to_string()),
1150            "XDG_CONFIG_HOME must be restored even after a panic"
1151        );
1152        assert_eq!(
1153            std::env::var("HOME").ok(),
1154            None,
1155            "HOME must be restored to unset even after a panic"
1156        );
1157
1158        std::env::remove_var("XDG_CONFIG_HOME");
1159    }
1160
1161    #[test]
1162    #[serial_test::serial]
1163    fn install_writes_the_embedded_content() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let cfg = tempfile::tempdir().unwrap();
1166        temp_env(
1167            &[
1168                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1169                ("HOME", None),
1170            ],
1171            || {
1172                let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1173                assert_eq!(outcomes.len(), 1);
1174                assert!(matches!(outcomes[0].action, Action::Installed));
1175
1176                let written =
1177                    std::fs::read_to_string(skill_file(dir.path(), Agent::Agents, bb_skill()))
1178                        .unwrap();
1179                assert_eq!(
1180                    written,
1181                    bb_skill().content,
1182                    "installed content must equal the embedded skill"
1183                );
1184
1185                let (state, _) = load_state();
1186                assert_eq!(state.len(), 1);
1187                assert_eq!(state[0].sha256, content_hash(bb_skill().content.as_bytes()));
1188            },
1189        );
1190    }
1191
1192    #[test]
1193    #[serial_test::serial]
1194    fn a_second_install_reports_unchanged_and_rewrites_nothing() {
1195        let dir = tempfile::tempdir().unwrap();
1196        let cfg = tempfile::tempdir().unwrap();
1197        temp_env(
1198            &[
1199                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1200                ("HOME", None),
1201            ],
1202            || {
1203                install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1204                let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1205                assert!(
1206                    matches!(outcomes[0].action, Action::Unchanged),
1207                    "{:?}",
1208                    outcomes[0].action
1209                );
1210            },
1211        );
1212    }
1213
1214    /// A local edit is somebody's deliberate customization. It must survive.
1215    #[test]
1216    #[serial_test::serial]
1217    fn a_modified_file_is_refused_and_left_byte_identical() {
1218        let dir = tempfile::tempdir().unwrap();
1219        let cfg = tempfile::tempdir().unwrap();
1220        temp_env(
1221            &[
1222                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1223                ("HOME", None),
1224            ],
1225            || {
1226                install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1227                let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1228                std::fs::write(&path, "# my own notes\n").unwrap();
1229
1230                let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1231                assert!(matches!(outcomes[0].action, Action::SkippedModified));
1232                assert_eq!(std::fs::read_to_string(&path).unwrap(), "# my own notes\n");
1233            },
1234        );
1235    }
1236
1237    #[test]
1238    #[serial_test::serial]
1239    fn force_overwrites_a_modified_file_and_updates_the_hash() {
1240        let dir = tempfile::tempdir().unwrap();
1241        let cfg = tempfile::tempdir().unwrap();
1242        temp_env(
1243            &[
1244                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1245                ("HOME", None),
1246            ],
1247            || {
1248                install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1249                let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1250                std::fs::write(&path, "# my own notes\n").unwrap();
1251
1252                let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], true).unwrap();
1253                assert!(matches!(
1254                    outcomes[0].action,
1255                    Action::Refreshed | Action::Installed
1256                ));
1257                assert_eq!(std::fs::read_to_string(&path).unwrap(), bb_skill().content);
1258                let (state, _) = load_state();
1259                assert_eq!(state[0].sha256, content_hash(bb_skill().content.as_bytes()));
1260            },
1261        );
1262    }
1263
1264    /// Stale means "we wrote it, and the binary has newer text now". It refreshes
1265    /// without asking, because nobody customized it.
1266    #[test]
1267    #[serial_test::serial]
1268    fn a_stale_file_is_refreshed_silently() {
1269        let dir = tempfile::tempdir().unwrap();
1270        let cfg = tempfile::tempdir().unwrap();
1271        temp_env(
1272            &[
1273                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1274                ("HOME", None),
1275            ],
1276            || {
1277                let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1278                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1279                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1280                std::fs::write(&path, old).unwrap();
1281                save_state(&[Entry {
1282                    path: path.clone(),
1283                    agent: "agents".into(),
1284                    kind: "file".into(),
1285                    sha256: content_hash(old.as_bytes()),
1286                    version: "0.0.1".into(),
1287                    skill: "bitbucket-cloud".into(),
1288                    created: true,
1289                }])
1290                .unwrap();
1291
1292                let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1293                assert!(
1294                    matches!(outcomes[0].action, Action::Refreshed),
1295                    "{:?}",
1296                    outcomes[0].action
1297                );
1298                assert_eq!(std::fs::read_to_string(&path).unwrap(), bb_skill().content);
1299            },
1300        );
1301    }
1302
1303    #[test]
1304    #[serial_test::serial]
1305    fn claude_install_links_to_the_agents_copy() {
1306        let dir = tempfile::tempdir().unwrap();
1307        let cfg = tempfile::tempdir().unwrap();
1308        temp_env(
1309            &[
1310                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1311                ("HOME", None),
1312            ],
1313            || {
1314                install(
1315                    dir.path(),
1316                    &[Agent::Agents, Agent::Claude],
1317                    &[bb_skill()],
1318                    false,
1319                )
1320                .unwrap();
1321                let claude = dir.path().join(".claude/skills").join(bb_skill().name);
1322                // Either a symlink resolving to the .agents copy, or a real file with
1323                // the same content when the platform refused a symlink.
1324                let content = std::fs::read_to_string(claude.join("SKILL.md"))
1325                    .or_else(|_| std::fs::read_to_string(&claude))
1326                    .unwrap();
1327                assert_eq!(content, bb_skill().content);
1328            },
1329        );
1330    }
1331
1332    #[test]
1333    fn detect_finds_each_agent_directory() {
1334        let dir = tempfile::tempdir().unwrap();
1335        assert!(
1336            detect_agents(dir.path()).is_empty(),
1337            "nothing present means nothing detected"
1338        );
1339
1340        std::fs::create_dir_all(dir.path().join(".cursor")).unwrap();
1341        assert_eq!(
1342            detect_agents(dir.path()),
1343            vec![Agent::Agents],
1344            "cursor reads .agents"
1345        );
1346
1347        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
1348        let found = detect_agents(dir.path());
1349        assert!(found.contains(&Agent::Agents) && found.contains(&Agent::Claude));
1350    }
1351
1352    /// A deleted Claude symlink with no `.agents` copy to point at falls back
1353    /// to a real file — same as `install_claude_dir` would on a fresh
1354    /// install — and the recorded `kind` must follow disk down to `"file"`,
1355    /// not keep claiming `"symlink"`.
1356    #[test]
1357    #[serial_test::serial]
1358    fn refresh_recreates_a_deleted_symlink_as_a_file_when_no_agents_copy_exists() {
1359        let dir = tempfile::tempdir().unwrap();
1360        let cfg = tempfile::tempdir().unwrap();
1361        temp_env(
1362            &[
1363                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1364                ("HOME", None),
1365            ],
1366            || {
1367                install(
1368                    dir.path(),
1369                    &[Agent::Agents, Agent::Claude],
1370                    &[bb_skill()],
1371                    false,
1372                )
1373                .unwrap();
1374                let claude_path = skill_file(dir.path(), Agent::Claude, bb_skill());
1375                let claude_dir = claude_path.parent().unwrap();
1376
1377                // Only the claude entry is tracked, and its target is gone —
1378                // the state this test wants to force is "link recorded, but
1379                // nothing left to link to".
1380                let (state, _) = load_state();
1381                let claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1382                assert_eq!(claude_entry.kind, "symlink", "sanity: install made a link");
1383                save_state(&[claude_entry]).unwrap();
1384
1385                remove_existing(claude_dir).unwrap();
1386                std::fs::remove_dir_all(dir.path().join(".agents")).unwrap();
1387
1388                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1389                assert_eq!(outcomes.len(), 1);
1390                assert!(matches!(outcomes[0].action, Action::Refreshed));
1391
1392                assert_eq!(
1393                    std::fs::read_to_string(&claude_path).unwrap(),
1394                    bb_skill().content
1395                );
1396                let (state, _) = load_state();
1397                assert_eq!(
1398                    state[0].kind, "file",
1399                    "disk fell back to a real file, so state must say so too"
1400                );
1401            },
1402        );
1403    }
1404
1405    /// A deleted Claude symlink is recreated as a symlink, not a file, when
1406    /// the `.agents` copy it used to point at is still there.
1407    #[test]
1408    #[serial_test::serial]
1409    fn refresh_recreates_a_deleted_symlink_as_a_symlink_when_the_agents_copy_survives() {
1410        let dir = tempfile::tempdir().unwrap();
1411        let cfg = tempfile::tempdir().unwrap();
1412        temp_env(
1413            &[
1414                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1415                ("HOME", None),
1416            ],
1417            || {
1418                install(
1419                    dir.path(),
1420                    &[Agent::Agents, Agent::Claude],
1421                    &[bb_skill()],
1422                    false,
1423                )
1424                .unwrap();
1425                let claude_dir = skill_file(dir.path(), Agent::Claude, bb_skill())
1426                    .parent()
1427                    .unwrap()
1428                    .to_path_buf();
1429
1430                remove_existing(&claude_dir).unwrap();
1431                assert!(!claude_dir.exists(), "sanity: the link is gone");
1432
1433                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1434                let claude_outcome = outcomes.iter().find(|o| o.agent == "claude").unwrap();
1435                assert!(matches!(claude_outcome.action, Action::Refreshed));
1436
1437                assert!(
1438                    std::fs::symlink_metadata(&claude_dir)
1439                        .unwrap()
1440                        .file_type()
1441                        .is_symlink(),
1442                    "the agents copy was still there, so a link should come back, not a file"
1443                );
1444                assert_eq!(
1445                    std::fs::read_to_string(claude_dir.join("SKILL.md")).unwrap(),
1446                    bb_skill().content
1447                );
1448                let (state, _) = load_state();
1449                let claude_entry = state.iter().find(|e| e.agent == "claude").unwrap();
1450                assert_eq!(claude_entry.kind, "symlink");
1451            },
1452        );
1453    }
1454
1455    /// A stale Claude symlink whose link is still intact refreshes the
1456    /// shared `.agents` content in place — `write_file` follows the link
1457    /// rather than replacing it — so the link itself must survive untouched.
1458    #[test]
1459    #[serial_test::serial]
1460    fn refresh_updates_content_through_an_intact_symlink_without_replacing_it() {
1461        let dir = tempfile::tempdir().unwrap();
1462        let cfg = tempfile::tempdir().unwrap();
1463        temp_env(
1464            &[
1465                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1466                ("HOME", None),
1467            ],
1468            || {
1469                install(
1470                    dir.path(),
1471                    &[Agent::Agents, Agent::Claude],
1472                    &[bb_skill()],
1473                    false,
1474                )
1475                .unwrap();
1476                let claude_path = skill_file(dir.path(), Agent::Claude, bb_skill());
1477                let claude_dir = claude_path.parent().unwrap().to_path_buf();
1478
1479                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1480                std::fs::write(&claude_path, old).unwrap();
1481
1482                // Only the claude entry is tracked, so the refresh's only
1483                // write comes from the `Stale` branch on this entry, not from
1484                // an `.agents` entry rewriting the same underlying file.
1485                let (state, _) = load_state();
1486                let mut claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1487                claude_entry.sha256 = content_hash(old.as_bytes());
1488                save_state(&[claude_entry]).unwrap();
1489
1490                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1491                assert_eq!(outcomes.len(), 1);
1492                assert!(matches!(outcomes[0].action, Action::Refreshed));
1493
1494                assert!(
1495                    std::fs::symlink_metadata(&claude_dir)
1496                        .unwrap()
1497                        .file_type()
1498                        .is_symlink(),
1499                    "an intact link must not be replaced by a file just to refresh content"
1500                );
1501                assert_eq!(
1502                    std::fs::read_to_string(&claude_path).unwrap(),
1503                    bb_skill().content
1504                );
1505                let (state, _) = load_state();
1506                assert_eq!(state[0].kind, "symlink");
1507            },
1508        );
1509    }
1510
1511    #[test]
1512    fn shape_guard_accepts_only_the_two_real_skill_locations() {
1513        assert!(is_shaped_like_a_skill_path(Path::new(
1514            "/proj/.agents/skills/bitbucket-cloud/SKILL.md"
1515        )));
1516        assert!(is_shaped_like_a_skill_path(Path::new(
1517            "/proj/.claude/skills/bitbucket-cloud/SKILL.md"
1518        )));
1519        for bad in [
1520            "/proj/src",
1521            "/proj/src/main.rs",
1522            "/proj/.agents/skills/bitbucket-cloud",
1523            "/proj/.agents/skills/some-other-skill/SKILL.md",
1524            "/proj/.opencode/skills/bitbucket-cloud/SKILL.md",
1525            "/etc/passwd",
1526        ] {
1527            assert!(
1528                !is_shaped_like_a_skill_path(Path::new(bad)),
1529                "{bad} should have been refused"
1530            );
1531        }
1532    }
1533
1534    /// Critical 2, reproduced and fixed: a hand-made Claude symlink — exactly
1535    /// what the old README's `ln -s` instructions told users to create —
1536    /// must not be deleted-through by `uninstall` just because no prior state
1537    /// entry existed to tell `install` its `kind` was `"symlink"`.
1538    #[test]
1539    #[serial_test::serial]
1540    fn uninstall_does_not_follow_a_hand_made_symlink_into_its_target() {
1541        let dir = tempfile::tempdir().unwrap();
1542        let cfg = tempfile::tempdir().unwrap();
1543        temp_env(
1544            &[
1545                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1546                ("HOME", None),
1547            ],
1548            || {
1549                // Set up the .agents copy the way a real project would have
1550                // it, then hand-make the Claude symlink exactly as the old
1551                // README instructed, *before* bb has ever recorded anything.
1552                let agents_path = skill_file(dir.path(), Agent::Agents, bb_skill());
1553                std::fs::create_dir_all(agents_path.parent().unwrap()).unwrap();
1554                std::fs::write(&agents_path, bb_skill().content).unwrap();
1555
1556                let claude_dir = dir
1557                    .path()
1558                    .join(".claude")
1559                    .join("skills")
1560                    .join(bb_skill().name);
1561                std::fs::create_dir_all(claude_dir.parent().unwrap()).unwrap();
1562                #[cfg(unix)]
1563                std::os::unix::fs::symlink(
1564                    Path::new("..")
1565                        .join("..")
1566                        .join(".agents")
1567                        .join("skills")
1568                        .join(bb_skill().name),
1569                    &claude_dir,
1570                )
1571                .unwrap();
1572
1573                let outcomes = install(dir.path(), &[Agent::Claude], &[bb_skill()], false).unwrap();
1574                assert!(
1575                    matches!(outcomes[0].action, Action::Unchanged),
1576                    "sanity: content already matches, so install should not rewrite it"
1577                );
1578
1579                let results = uninstall(None, &[bb_skill()], false).unwrap();
1580                assert_eq!(results.len(), 1);
1581                assert_eq!(results[0].2, RemovalOutcome::Removed);
1582
1583                assert!(
1584                    std::fs::read_to_string(&agents_path).unwrap() == bb_skill().content,
1585                    "the .agents copy must survive uninstall of the claude link"
1586                );
1587                assert!(
1588                    std::fs::symlink_metadata(&claude_dir).is_err(),
1589                    "no dangling claude symlink should remain (Path::exists() would \
1590                     wrongly report false for a dangling link, so this checks \
1591                     symlink_metadata instead)"
1592                );
1593            },
1594        );
1595    }
1596
1597    /// Important 3, reproduced and fixed: a state entry pointing at an
1598    /// unrelated directory must be refused, and that directory must survive.
1599    #[test]
1600    #[serial_test::serial]
1601    fn uninstall_refuses_a_state_entry_pointing_outside_the_skill_shape() {
1602        let dir = tempfile::tempdir().unwrap();
1603        let cfg = tempfile::tempdir().unwrap();
1604        temp_env(
1605            &[
1606                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1607                ("HOME", None),
1608            ],
1609            || {
1610                let victim_dir = dir.path().join("src");
1611                std::fs::create_dir_all(&victim_dir).unwrap();
1612                std::fs::write(victim_dir.join("main.rs"), "fn main() {}").unwrap();
1613
1614                let victim_file = dir.path().join("Cargo.toml");
1615                std::fs::write(&victim_file, "[package]").unwrap();
1616
1617                save_state(&[
1618                    Entry {
1619                        path: victim_dir.clone(),
1620                        agent: "agents".into(),
1621                        kind: "file".into(),
1622                        sha256: "deadbeef".into(),
1623                        version: "0.0.1".into(),
1624                        skill: "bitbucket-cloud".into(),
1625                        created: true,
1626                    },
1627                    Entry {
1628                        path: victim_file.clone(),
1629                        agent: "agents".into(),
1630                        kind: "file".into(),
1631                        sha256: "deadbeef".into(),
1632                        version: "0.0.1".into(),
1633                        skill: "bitbucket-cloud".into(),
1634                        created: true,
1635                    },
1636                ])
1637                .unwrap();
1638
1639                let results = uninstall(None, &[bb_skill()], true).unwrap();
1640                assert_eq!(results.len(), 2);
1641                assert!(results
1642                    .iter()
1643                    .all(|(_, _, o)| *o == RemovalOutcome::RefusedUnsafePath));
1644
1645                assert!(victim_dir.is_dir(), "unrelated directory must survive");
1646                assert!(
1647                    victim_dir.join("main.rs").exists(),
1648                    "unrelated directory's contents must survive"
1649                );
1650                assert!(victim_file.is_file(), "unrelated file must survive");
1651
1652                // Refused entries stay tracked rather than being dropped.
1653                let (remaining, _) = load_state();
1654                assert_eq!(remaining.len(), 2);
1655            },
1656        );
1657    }
1658
1659    /// A legitimate entry still works after the shape guard was added.
1660    #[test]
1661    #[serial_test::serial]
1662    fn uninstall_still_removes_a_legitimate_entry() {
1663        let dir = tempfile::tempdir().unwrap();
1664        let cfg = tempfile::tempdir().unwrap();
1665        temp_env(
1666            &[
1667                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1668                ("HOME", None),
1669            ],
1670            || {
1671                install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1672                let results = uninstall(None, &[bb_skill()], false).unwrap();
1673                assert_eq!(
1674                    results,
1675                    vec![(
1676                        skill_file(dir.path(), Agent::Agents, bb_skill()),
1677                        bb_skill().name.to_string(),
1678                        RemovalOutcome::Removed
1679                    )]
1680                );
1681            },
1682        );
1683    }
1684
1685    /// The bug from #50, and the invariant `uninstall`'s own doc comment already
1686    /// claims: "an untracked file is never touched at all".
1687    ///
1688    /// `install` decided what to record from a content hash alone. A hash match
1689    /// proves the bytes are identical, never that `bb` is what put them there,
1690    /// so a `SKILL.md` that already existed and happened to match was reported
1691    /// `Unchanged` — accurate, `bb` wrote nothing — and then recorded as a file
1692    /// `bb` owns. The next `uninstall` deleted it.
1693    ///
1694    /// The sharp case is this crate's own checkout, where `.agents/skills/*/SKILL.md`
1695    /// are the tracked sources `include_str!` compiles in. They match the embedded
1696    /// copies by construction, so `install` claimed all of them and `uninstall`
1697    /// deleted the crate's own sources, breaking the build.
1698    #[test]
1699    #[serial_test::serial]
1700    fn uninstall_leaves_a_file_bb_never_wrote() {
1701        let dir = tempfile::tempdir().unwrap();
1702        let cfg = tempfile::tempdir().unwrap();
1703        temp_env(
1704            &[
1705                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1706                ("HOME", None),
1707            ],
1708            || {
1709                // Someone else's file, byte-identical to what bb ships — a
1710                // vendored copy, or this crate's own source tree.
1711                let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1712                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1713                std::fs::write(&path, bb_skill().content).unwrap();
1714
1715                let installed =
1716                    install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1717                assert_eq!(
1718                    installed[0].action,
1719                    Action::Unchanged,
1720                    "bb wrote nothing, so the action is still Unchanged"
1721                );
1722
1723                let results = uninstall(None, &[bb_skill()], false).unwrap();
1724                assert_eq!(
1725                    results,
1726                    vec![(
1727                        path.clone(),
1728                        bb_skill().name.to_string(),
1729                        RemovalOutcome::RefusedNotWritten
1730                    )]
1731                );
1732                assert!(
1733                    path.exists(),
1734                    "uninstall deleted a file bb never wrote: {}",
1735                    path.display()
1736                );
1737
1738                // "Uninstall" still means stop managing it, so the entry is
1739                // dropped — otherwise every later run re-reports the refusal.
1740                let (entries, _) = load_state();
1741                assert!(
1742                    !entries.iter().any(|e| e.path == path),
1743                    "the adopted entry should be untracked after uninstall"
1744                );
1745            },
1746        );
1747    }
1748
1749    /// The escape hatch. `--force` already means "remove it even though I would
1750    /// normally refuse", and someone who vendored a copy and then asked for it
1751    /// to go, explicitly, gets what they asked for.
1752    #[test]
1753    #[serial_test::serial]
1754    fn force_removes_a_file_bb_never_wrote() {
1755        let dir = tempfile::tempdir().unwrap();
1756        let cfg = tempfile::tempdir().unwrap();
1757        temp_env(
1758            &[
1759                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1760                ("HOME", None),
1761            ],
1762            || {
1763                let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1764                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1765                std::fs::write(&path, bb_skill().content).unwrap();
1766                install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1767
1768                let results = uninstall(None, &[bb_skill()], true).unwrap();
1769                assert_eq!(results[0].2, RemovalOutcome::Removed);
1770                assert!(!path.exists());
1771            },
1772        );
1773    }
1774
1775    /// A file bb wrote itself, then found unchanged on a second install, stays
1776    /// removable. Provenance has to survive the `Unchanged` path, or the fix
1777    /// above would quietly strand every skill after its second install.
1778    #[test]
1779    #[serial_test::serial]
1780    fn a_reinstalled_file_is_still_removable() {
1781        let dir = tempfile::tempdir().unwrap();
1782        let cfg = tempfile::tempdir().unwrap();
1783        temp_env(
1784            &[
1785                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1786                ("HOME", None),
1787            ],
1788            || {
1789                install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1790                let again = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1791                assert_eq!(again[0].action, Action::Unchanged);
1792
1793                let results = uninstall(None, &[bb_skill()], false).unwrap();
1794                assert_eq!(results[0].2, RemovalOutcome::Removed);
1795            },
1796        );
1797    }
1798
1799    /// State files written before this field existed carry no `created`. They
1800    /// were, overwhelmingly, written by `bb` — so they default to true and stay
1801    /// removable, rather than stranding every skill installed before the upgrade.
1802    #[test]
1803    #[serial_test::serial]
1804    fn a_legacy_state_entry_without_the_field_is_still_removable() {
1805        let dir = tempfile::tempdir().unwrap();
1806        let cfg = tempfile::tempdir().unwrap();
1807        temp_env(
1808            &[
1809                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1810                ("HOME", None),
1811            ],
1812            || {
1813                let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1814                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1815                std::fs::write(&path, bb_skill().content).unwrap();
1816
1817                let legacy = serde_json::json!([{
1818                    "path": path,
1819                    "agent": "agents",
1820                    "kind": "file",
1821                    "sha256": content_hash(bb_skill().content.as_bytes()),
1822                    "version": "0.18.0",
1823                    "skill": bb_skill().name,
1824                }]);
1825                std::fs::create_dir_all(cfg.path().join("bb")).unwrap();
1826                std::fs::write(
1827                    cfg.path().join("bb").join("skills.json"),
1828                    serde_json::to_string(&legacy).unwrap(),
1829                )
1830                .unwrap();
1831
1832                let results = uninstall(None, &[bb_skill()], false).unwrap();
1833                assert_eq!(results[0].2, RemovalOutcome::Removed);
1834            },
1835        );
1836    }
1837
1838    /// The design's core claim: one customized skill must not block another
1839    /// tracked skill's refresh, and the skipped one must be named in the
1840    /// output rather than silently dropped.
1841    #[test]
1842    #[serial_test::serial]
1843    fn refresh_rewrites_a_stale_entry_while_leaving_a_modified_one_alone() {
1844        let stale_root = tempfile::tempdir().unwrap();
1845        let modified_root = tempfile::tempdir().unwrap();
1846        let cfg = tempfile::tempdir().unwrap();
1847        temp_env(
1848            &[
1849                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1850                ("HOME", None),
1851            ],
1852            || {
1853                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1854                let stale_path = skill_file(stale_root.path(), Agent::Agents, bb_skill());
1855                std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1856                std::fs::write(&stale_path, old).unwrap();
1857
1858                let modified_path = skill_file(modified_root.path(), Agent::Agents, bb_skill());
1859                std::fs::create_dir_all(modified_path.parent().unwrap()).unwrap();
1860                let ours = "# our own version\n";
1861                std::fs::write(&modified_path, ours).unwrap();
1862
1863                save_state(&[
1864                    Entry {
1865                        path: stale_path.clone(),
1866                        agent: "agents".into(),
1867                        kind: "file".into(),
1868                        sha256: content_hash(old.as_bytes()),
1869                        version: "0.0.1".into(),
1870                        skill: "bitbucket-cloud".into(),
1871                        created: true,
1872                    },
1873                    Entry {
1874                        path: modified_path.clone(),
1875                        agent: "agents".into(),
1876                        kind: "file".into(),
1877                        // Recorded hash disagrees with what's on disk now —
1878                        // someone edited it after bb wrote it.
1879                        sha256: content_hash(old.as_bytes()),
1880                        version: "0.0.1".into(),
1881                        skill: "bitbucket-cloud".into(),
1882                        created: true,
1883                    },
1884                ])
1885                .unwrap();
1886
1887                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1888                assert_eq!(outcomes.len(), 2);
1889
1890                let stale_outcome = outcomes.iter().find(|o| o.path == stale_path).unwrap();
1891                assert!(matches!(stale_outcome.action, Action::Refreshed));
1892                assert_eq!(
1893                    std::fs::read_to_string(&stale_path).unwrap(),
1894                    bb_skill().content
1895                );
1896
1897                let modified_outcome = outcomes.iter().find(|o| o.path == modified_path).unwrap();
1898                assert!(matches!(modified_outcome.action, Action::SkippedModified));
1899                assert_eq!(std::fs::read_to_string(&modified_path).unwrap(), ours);
1900            },
1901        );
1902    }
1903
1904    #[test]
1905    fn both_skills_are_registered_and_well_formed() {
1906        let names: Vec<&str> = SKILLS.iter().map(|s| s.name).collect();
1907        assert!(names.contains(&"bitbucket-cloud"));
1908        assert!(names.contains(&"bbc-daily-brief"));
1909        assert!(names.contains(&"bbc-open-pr"));
1910        assert!(names.contains(&"bbc-report-bug"));
1911        for skill in SKILLS.iter() {
1912            assert!(
1913                skill.content.starts_with("---"),
1914                "{} lacks frontmatter",
1915                skill.name
1916            );
1917            assert!(
1918                skill.content.contains(&format!("name: {}", skill.name)),
1919                "{} frontmatter name does not match",
1920                skill.name
1921            );
1922        }
1923    }
1924
1925    /// The bug-reporting skill drives `gh issue create`, which publishes to a
1926    /// public repository and cannot be undone. Two clauses are what stop it
1927    /// leaking the user's private Bitbucket data or filing uninvited, and both
1928    /// have been deleted by well-meaning edits in similar skills before: the
1929    /// approval gate before the issue is created, and the redaction of
1930    /// workspace and repository names. A skill file is prose, so a test can
1931    /// only hold the load-bearing phrases in place — but that is exactly the
1932    /// part a summarising edit drops first.
1933    #[test]
1934    fn the_bug_report_skill_keeps_its_approval_gate_and_redaction_rules() {
1935        let skill = skill_by_name("bbc-report-bug").expect("bbc-report-bug is registered");
1936
1937        for required in [
1938            // Never files uninvited.
1939            "Explicit invocation only",
1940            // Shows the draft and waits, before anything is created.
1941            "Never create the issue without showing it first",
1942            // Private names never reach a public issue.
1943            "Redact before you draft",
1944            // The one value that must never appear at all.
1945            "never include, redacted or not",
1946            // A single hardcoded target, so it cannot be pointed elsewhere.
1947            "biokraft/bbcloud",
1948        ] {
1949            assert!(
1950                skill.content.contains(required),
1951                "bbc-report-bug lost the {required:?} rule"
1952            );
1953        }
1954    }
1955
1956    #[test]
1957    fn skill_by_name_resolves_known_and_rejects_unknown() {
1958        assert_eq!(
1959            skill_by_name("bbc-daily-brief").map(|s| s.name),
1960            Some("bbc-daily-brief")
1961        );
1962        assert!(skill_by_name("nope").is_none());
1963    }
1964
1965    #[test]
1966    fn every_skill_path_shape_is_accepted_under_both_layouts() {
1967        for skill in SKILLS.iter() {
1968            for dir in [".agents", ".claude"] {
1969                let path = PathBuf::from(format!("/p/{dir}/skills/{}/SKILL.md", skill.name));
1970                assert!(is_shaped_like_a_skill_path(&path), "rejected {path:?}");
1971            }
1972        }
1973    }
1974
1975    #[test]
1976    fn an_unknown_skill_name_is_still_refused() {
1977        assert!(!is_shaped_like_a_skill_path(&PathBuf::from(
1978            "/p/.agents/skills/other-skill/SKILL.md"
1979        )));
1980        assert!(!is_shaped_like_a_skill_path(&PathBuf::from(
1981            "/p/.agents/bbc-daily-brief/SKILL.md"
1982        )));
1983        assert!(!is_shaped_like_a_skill_path(&PathBuf::from(
1984            "/p/.vscode/skills/bbc-daily-brief/SKILL.md"
1985        )));
1986    }
1987
1988    #[test]
1989    fn an_entry_without_a_skill_field_defaults_to_the_first_skill() {
1990        let entry: Entry = serde_json::from_str(
1991            r#"{"path":"/p/.agents/skills/bitbucket-cloud/SKILL.md","agent":"agents",
1992                "kind":"file","sha256":"abc","version":"0.1.0"}"#,
1993        )
1994        .unwrap();
1995        assert_eq!(entry.skill, "bitbucket-cloud");
1996    }
1997
1998    #[test]
1999    fn state_of_compares_against_each_entrys_own_skill() {
2000        // A daily-brief file holding daily-brief content is Current, even though
2001        // it does not match the bitbucket-cloud hash.
2002        let brief = skill_by_name("bbc-daily-brief").unwrap();
2003        let dir = tempfile::tempdir().unwrap();
2004        let path = dir.path().join("SKILL.md");
2005        std::fs::write(&path, brief.content).unwrap();
2006        let entry = Entry {
2007            path: path.clone(),
2008            agent: "agents".into(),
2009            kind: "file".into(),
2010            sha256: content_hash(brief.content.as_bytes()),
2011            version: "0.1.0".into(),
2012            skill: "bbc-daily-brief".into(),
2013            created: true,
2014        };
2015        assert_eq!(
2016            state_of(&entry, &content_hash(brief.content.as_bytes())),
2017            State::Current
2018        );
2019    }
2020
2021    /// An entry naming a skill this binary does not know — a `skills.json`
2022    /// written by a newer `bb`, or a hand-edited file — must never read as
2023    /// `Stale`: that state promises "the binary has newer text, a refresh
2024    /// will fix it", which is not true when there is no wanted content to
2025    /// compare against at all. It must report `Modified` so nothing rewrites
2026    /// or removes it.
2027    #[test]
2028    #[serial_test::serial]
2029    fn status_reports_modified_for_an_unknown_skill_name_even_when_disk_matches_the_recorded_hash()
2030    {
2031        let dir = tempfile::tempdir().unwrap();
2032        let cfg = tempfile::tempdir().unwrap();
2033        temp_env(
2034            &[
2035                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2036                ("HOME", None),
2037            ],
2038            || {
2039                let path = dir.path().join(".agents/skills/some-future-skill/SKILL.md");
2040                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2041                let content = "---\nname: some-future-skill\n---\nfrom a newer bb\n";
2042                std::fs::write(&path, content).unwrap();
2043
2044                save_state(&[Entry {
2045                    path: path.clone(),
2046                    agent: "agents".into(),
2047                    kind: "file".into(),
2048                    // On-disk content hashes equal to the recorded sha256 —
2049                    // exactly what would make a *known* skill read as Stale.
2050                    sha256: content_hash(content.as_bytes()),
2051                    version: "9.9.9".into(),
2052                    skill: "some-future-skill".into(),
2053                    created: true,
2054                }])
2055                .unwrap();
2056
2057                let (rows, warning) = status();
2058                assert!(warning.is_none());
2059                assert_eq!(rows.len(), 1);
2060                assert_eq!(
2061                    rows[0].state,
2062                    State::Modified,
2063                    "an unknown skill name must never be reported as Stale"
2064                );
2065            },
2066        );
2067    }
2068
2069    #[test]
2070    fn tracked_version_differs_only_when_an_entry_is_behind() {
2071        let current = env!("CARGO_PKG_VERSION").to_string();
2072        assert!(
2073            !tracked_version_differs(&[]),
2074            "nothing tracked means nothing to do"
2075        );
2076
2077        let up_to_date = Entry {
2078            path: PathBuf::from("/p/.agents/skills/bitbucket-cloud/SKILL.md"),
2079            agent: "agents".into(),
2080            kind: "file".into(),
2081            sha256: "abc".into(),
2082            version: current.clone(),
2083            skill: "bitbucket-cloud".into(),
2084            created: true,
2085        };
2086        assert!(!tracked_version_differs(std::slice::from_ref(&up_to_date)));
2087
2088        let mut behind = up_to_date.clone();
2089        behind.version = "0.0.1".into();
2090        assert!(tracked_version_differs(&[up_to_date, behind]));
2091    }
2092
2093    #[test]
2094    #[serial_test::serial]
2095    fn refresh_prunes_an_entry_whose_directory_tree_is_gone() {
2096        let cfg = tempfile::tempdir().unwrap();
2097        temp_env(
2098            &[
2099                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2100                ("HOME", None),
2101            ],
2102            || {
2103                // A path under a directory that does not exist — the shape a
2104                // temp-directory install leaves behind once the temp dir is gone.
2105                let gone =
2106                    PathBuf::from("/nonexistent-root-xyz/.agents/skills/bitbucket-cloud/SKILL.md");
2107                save_state(&[Entry {
2108                    path: gone.clone(),
2109                    agent: "agents".into(),
2110                    kind: "file".into(),
2111                    sha256: "abc".into(),
2112                    version: "0.0.1".into(),
2113                    skill: "bitbucket-cloud".into(),
2114                    created: true,
2115                }])
2116                .unwrap();
2117
2118                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2119                assert_eq!(outcomes.len(), 1);
2120                assert_eq!(outcomes[0].action, Action::Pruned);
2121                assert!(!gone.exists(), "pruning must not create the file");
2122
2123                let (state, _) = load_state();
2124                assert!(
2125                    state.is_empty(),
2126                    "the pruned entry must leave the state file"
2127                );
2128            },
2129        );
2130    }
2131
2132    #[test]
2133    #[serial_test::serial]
2134    fn refresh_still_restores_a_missing_file_whose_directory_exists() {
2135        let cfg = tempfile::tempdir().unwrap();
2136        let root = tempfile::tempdir().unwrap();
2137        temp_env(
2138            &[
2139                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2140                ("HOME", None),
2141            ],
2142            || {
2143                let path = skill_file(root.path(), Agent::Agents, bb_skill());
2144                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2145                // Directory exists, file does not: someone deleted a skill and
2146                // wants it back. This must not be confused with a pruned tree.
2147                save_state(&[Entry {
2148                    path: path.clone(),
2149                    agent: "agents".into(),
2150                    kind: "file".into(),
2151                    sha256: content_hash(bb_skill().content.as_bytes()),
2152                    version: "0.0.1".into(),
2153                    skill: "bitbucket-cloud".into(),
2154                    created: true,
2155                }])
2156                .unwrap();
2157
2158                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2159                assert_eq!(outcomes[0].action, Action::Refreshed);
2160                assert_eq!(std::fs::read_to_string(&path).unwrap(), bb_skill().content);
2161            },
2162        );
2163    }
2164
2165    #[test]
2166    #[serial_test::serial]
2167    fn refresh_stamps_the_version_onto_a_skipped_entry_without_touching_the_file() {
2168        let cfg = tempfile::tempdir().unwrap();
2169        let root = tempfile::tempdir().unwrap();
2170        temp_env(
2171            &[
2172                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2173                ("HOME", None),
2174            ],
2175            || {
2176                let path = skill_file(root.path(), Agent::Agents, bb_skill());
2177                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2178                std::fs::write(&path, "locally edited").unwrap();
2179                save_state(&[Entry {
2180                    path: path.clone(),
2181                    agent: "agents".into(),
2182                    kind: "file".into(),
2183                    sha256: content_hash(b"something else"),
2184                    version: "0.0.1".into(),
2185                    skill: "bitbucket-cloud".into(),
2186                    created: true,
2187                }])
2188                .unwrap();
2189
2190                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2191                assert_eq!(outcomes[0].action, Action::SkippedModified);
2192                assert_eq!(
2193                    std::fs::read_to_string(&path).unwrap(),
2194                    "locally edited",
2195                    "a local edit must survive"
2196                );
2197
2198                // The version moves forward even though the file was left alone,
2199                // so the auto-refresh check does not re-fire on every command.
2200                let (state, _) = load_state();
2201                assert_eq!(state[0].version, env!("CARGO_PKG_VERSION"));
2202                assert!(!tracked_version_differs(&state));
2203            },
2204        );
2205    }
2206
2207    #[test]
2208    #[serial_test::serial]
2209    fn refresh_stamps_the_version_onto_an_unshaped_path_without_creating_it() {
2210        let cfg = tempfile::tempdir().unwrap();
2211        let root = tempfile::tempdir().unwrap();
2212        temp_env(
2213            &[
2214                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2215                ("HOME", None),
2216            ],
2217            || {
2218                // A file named `NOTES.md` instead of `SKILL.md` fails
2219                // `is_shaped_like_a_skill_path`'s filename check, so this entry
2220                // hits the shape-guard skip branch.
2221                let path = root
2222                    .path()
2223                    .join(".agents")
2224                    .join("skills")
2225                    .join("bitbucket-cloud")
2226                    .join("NOTES.md");
2227                save_state(&[Entry {
2228                    path: path.clone(),
2229                    agent: "agents".into(),
2230                    kind: "file".into(),
2231                    sha256: "abc".into(),
2232                    version: "0.0.1".into(),
2233                    skill: "bitbucket-cloud".into(),
2234                    created: true,
2235                }])
2236                .unwrap();
2237
2238                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2239                assert_eq!(
2240                    outcomes.len(),
2241                    0,
2242                    "the shape guard never produces an outcome"
2243                );
2244                assert!(!path.exists(), "pruning/stamping must not create the file");
2245
2246                let (state, _) = load_state();
2247                assert_eq!(state.len(), 1, "the unshaped entry stays tracked");
2248                assert_eq!(state[0].version, env!("CARGO_PKG_VERSION"));
2249                assert!(!tracked_version_differs(&state));
2250            },
2251        );
2252    }
2253
2254    #[test]
2255    #[serial_test::serial]
2256    fn refresh_stamps_the_version_onto_an_unknown_skill_name_without_touching_the_file() {
2257        let cfg = tempfile::tempdir().unwrap();
2258        let root = tempfile::tempdir().unwrap();
2259        temp_env(
2260            &[
2261                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2262                ("HOME", None),
2263            ],
2264            || {
2265                // The path's skill-name directory ("bitbucket-cloud") is one
2266                // `is_shaped_like_a_skill_path` recognises, so the entry clears
2267                // the shape guard; it is the `skill` field naming a skill this
2268                // binary has never heard of that routes it into the
2269                // unknown-skill-name skip branch.
2270                let path = skill_file(root.path(), Agent::Agents, bb_skill());
2271                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2272                std::fs::write(&path, "whatever was here").unwrap();
2273                save_state(&[Entry {
2274                    path: path.clone(),
2275                    agent: "agents".into(),
2276                    kind: "file".into(),
2277                    sha256: "abc".into(),
2278                    version: "0.0.1".into(),
2279                    skill: "some-future-skill".into(),
2280                    created: true,
2281                }])
2282                .unwrap();
2283
2284                let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2285                assert_eq!(
2286                    outcomes.len(),
2287                    0,
2288                    "the unknown-skill skip never produces an outcome"
2289                );
2290                assert_eq!(
2291                    std::fs::read_to_string(&path).unwrap(),
2292                    "whatever was here",
2293                    "the file must be left alone"
2294                );
2295
2296                let (state, _) = load_state();
2297                assert_eq!(state.len(), 1, "the unknown-skill entry stays tracked");
2298                assert_eq!(state[0].version, env!("CARGO_PKG_VERSION"));
2299                assert!(!tracked_version_differs(&state));
2300            },
2301        );
2302    }
2303
2304    /// Finding 1: `Path::exists()` reads false both for "truly gone" and for
2305    /// "can't tell" (EACCES on a parent component, an unmounted volume). Only
2306    /// the first must prune. A genuinely absent path is the case the prune
2307    /// path exists for at all.
2308    #[test]
2309    fn ancestor_is_definitely_gone_is_true_only_for_not_found() {
2310        assert!(
2311            ancestor_is_definitely_gone(Path::new("/definitely/does/not/exist/anywhere-xyz")),
2312            "a path with no such component must read as definitely gone"
2313        );
2314
2315        let dir = tempfile::tempdir().unwrap();
2316        assert!(
2317            !ancestor_is_definitely_gone(dir.path()),
2318            "an existing directory must not read as gone"
2319        );
2320    }
2321
2322    /// The "cannot tell" half of finding 1: a permission error on the ancestor
2323    /// itself must not be treated as "gone" — pruning here would drop a still
2324    /// -real entry from the state file while the file stays on disk,
2325    /// untracked forever. Exercised as a real EACCES rather than mocked,
2326    /// since the predicate takes a `Path` and the OS is the one thing that can
2327    /// hand back that exact error kind. Skipped when running as root, since
2328    /// root ignores directory permission bits and the test would otherwise
2329    /// silently pass for the wrong reason.
2330    #[test]
2331    #[cfg(unix)]
2332    fn ancestor_is_definitely_gone_is_false_for_a_permission_error() {
2333        use std::os::unix::fs::PermissionsExt;
2334
2335        let parent = tempfile::tempdir().unwrap();
2336        let locked = parent.path().join("locked");
2337        std::fs::create_dir(&locked).unwrap();
2338        let target = locked.join("child");
2339        std::fs::create_dir(&target).unwrap();
2340
2341        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
2342
2343        // If running as root, permission bits are ignored and the read
2344        // succeeds — in that case there is nothing this test can prove, so
2345        // skip rather than assert something that isn't actually testing the
2346        // permission-denied path.
2347        let stat_result = std::fs::symlink_metadata(&target);
2348        let restore = || {
2349            let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
2350        };
2351        if stat_result.is_ok() {
2352            restore();
2353            return;
2354        }
2355        let is_permission_error = matches!(
2356            &stat_result,
2357            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied
2358        );
2359        if !is_permission_error {
2360            // Some other error (unexpected) — restore perms and bail rather
2361            // than assert on an error shape this test wasn't written for.
2362            restore();
2363            return;
2364        }
2365
2366        assert!(
2367            !ancestor_is_definitely_gone(&target),
2368            "a permission error must not be treated as definitely gone"
2369        );
2370        restore();
2371    }
2372
2373    /// Finding 2: one entry's write failing must not abort the batch, must
2374    /// leave that entry's version and hash untouched (so it is retried), and
2375    /// must not stop `save_state` from persisting the entries that *did*
2376    /// refresh in the same call.
2377    #[test]
2378    #[serial_test::serial]
2379    #[cfg(unix)]
2380    fn refresh_reports_failed_for_an_unwritable_entry_without_blocking_the_rest() {
2381        use std::os::unix::fs::PermissionsExt;
2382
2383        let writable_root = tempfile::tempdir().unwrap();
2384        let locked_root = tempfile::tempdir().unwrap();
2385        let cfg = tempfile::tempdir().unwrap();
2386        temp_env(
2387            &[
2388                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2389                ("HOME", None),
2390            ],
2391            || {
2392                let old = "---\nname: bitbucket-cloud\n---\nold text\n";
2393
2394                let writable_path = skill_file(writable_root.path(), Agent::Agents, bb_skill());
2395                std::fs::create_dir_all(writable_path.parent().unwrap()).unwrap();
2396                std::fs::write(&writable_path, old).unwrap();
2397
2398                let locked_path = skill_file(locked_root.path(), Agent::Agents, bb_skill());
2399                std::fs::create_dir_all(locked_path.parent().unwrap()).unwrap();
2400                std::fs::write(&locked_path, old).unwrap();
2401                // Lock the skill's own directory so writing SKILL.md inside it
2402                // fails with EACCES, without needing root or a real read-only
2403                // filesystem.
2404                std::fs::set_permissions(
2405                    locked_path.parent().unwrap(),
2406                    std::fs::Permissions::from_mode(0o000),
2407                )
2408                .unwrap();
2409
2410                let old_hash = content_hash(old.as_bytes());
2411                save_state(&[
2412                    Entry {
2413                        path: writable_path.clone(),
2414                        agent: "agents".into(),
2415                        kind: "file".into(),
2416                        sha256: old_hash.clone(),
2417                        version: "0.0.1".into(),
2418                        skill: "bitbucket-cloud".into(),
2419                        created: true,
2420                    },
2421                    Entry {
2422                        path: locked_path.clone(),
2423                        agent: "agents".into(),
2424                        kind: "file".into(),
2425                        sha256: old_hash.clone(),
2426                        version: "0.0.1".into(),
2427                        skill: "bitbucket-cloud".into(),
2428                        created: true,
2429                    },
2430                ])
2431                .unwrap();
2432
2433                let restore = || {
2434                    let _ = std::fs::set_permissions(
2435                        locked_path.parent().unwrap(),
2436                        std::fs::Permissions::from_mode(0o755),
2437                    );
2438                };
2439
2440                // Running as root ignores the permission bits entirely, so the
2441                // "locked" write would silently succeed — nothing this test
2442                // can prove in that environment.
2443                if std::fs::write(locked_path.parent().unwrap().join("probe"), "x").is_ok() {
2444                    let _ = std::fs::remove_file(locked_path.parent().unwrap().join("probe"));
2445                    restore();
2446                    return;
2447                }
2448
2449                let result = refresh_tracked(MissingPolicy::Restore);
2450                restore();
2451                let outcomes = result.unwrap();
2452
2453                assert_eq!(outcomes.len(), 2);
2454                let writable_outcome = outcomes.iter().find(|o| o.path == writable_path).unwrap();
2455                assert_eq!(writable_outcome.action, Action::Refreshed);
2456                assert_eq!(
2457                    std::fs::read_to_string(&writable_path).unwrap(),
2458                    bb_skill().content,
2459                    "the writable entry must still refresh despite the other one failing"
2460                );
2461
2462                let locked_outcome = outcomes.iter().find(|o| o.path == locked_path).unwrap();
2463                assert_eq!(locked_outcome.action, Action::Failed);
2464
2465                let (state, _) = load_state();
2466                let writable_entry = state.iter().find(|e| e.path == writable_path).unwrap();
2467                assert_eq!(
2468                    writable_entry.version,
2469                    env!("CARGO_PKG_VERSION"),
2470                    "save_state must have persisted the entry that did succeed"
2471                );
2472                let locked_entry = state.iter().find(|e| e.path == locked_path).unwrap();
2473                assert_eq!(
2474                    locked_entry.version, "0.0.1",
2475                    "a failed write must not stamp the version — it is not current"
2476                );
2477                assert_eq!(
2478                    locked_entry.sha256, old_hash,
2479                    "a failed write must not update the recorded hash either"
2480                );
2481            },
2482        );
2483    }
2484
2485    /// Finding 4, unit-level: `MissingPolicy::Preserve` must not restore a
2486    /// missing file, must not report an outcome for it, and must not stamp
2487    /// its version — all three, or the entry would look "handled" when
2488    /// nothing happened. `Restore` on the same fixture is already proven by
2489    /// `refresh_still_restores_a_missing_file_whose_directory_exists`.
2490    #[test]
2491    #[serial_test::serial]
2492    fn refresh_with_preserve_leaves_a_missing_file_missing() {
2493        let cfg = tempfile::tempdir().unwrap();
2494        let root = tempfile::tempdir().unwrap();
2495        temp_env(
2496            &[
2497                ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2498                ("HOME", None),
2499            ],
2500            || {
2501                let path = skill_file(root.path(), Agent::Agents, bb_skill());
2502                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2503                save_state(&[Entry {
2504                    path: path.clone(),
2505                    agent: "agents".into(),
2506                    kind: "file".into(),
2507                    sha256: content_hash(bb_skill().content.as_bytes()),
2508                    version: "0.0.1".into(),
2509                    skill: "bitbucket-cloud".into(),
2510                    created: true,
2511                }])
2512                .unwrap();
2513
2514                let outcomes = refresh_tracked(MissingPolicy::Preserve).unwrap();
2515                assert!(
2516                    outcomes.is_empty(),
2517                    "a preserved missing entry must produce no outcome"
2518                );
2519                assert!(!path.exists(), "the file must stay deleted");
2520
2521                let (state, _) = load_state();
2522                assert_eq!(state.len(), 1, "the entry stays tracked");
2523                assert_eq!(
2524                    state[0].version, "0.0.1",
2525                    "an untouched entry must not be stamped as current"
2526                );
2527            },
2528        );
2529    }
2530}