1use crate::error::{BbError, Result};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6pub const SKILL_NAME: &str = "bitbucket-cloud";
7
8pub const SKILL_MD: &str = include_str!("../.agents/skills/bitbucket-cloud/SKILL.md");
13
14pub fn content_hash(bytes: &[u8]) -> String {
15 let mut hasher = Sha256::new();
16 hasher.update(bytes);
17 format!("{:x}", hasher.finalize())
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Agent {
22 Agents,
24 Claude,
26}
27
28impl Agent {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::Agents => "agents",
32 Self::Claude => "claude",
33 }
34 }
35
36 pub fn all() -> [Agent; 2] {
37 [Agent::Agents, Agent::Claude]
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Entry {
43 pub path: PathBuf,
44 pub agent: String,
45 pub kind: String,
48 pub sha256: String,
51 pub version: String,
52}
53
54pub fn state_path() -> PathBuf {
55 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
56 if !xdg.is_empty() {
57 return PathBuf::from(xdg).join("bb").join("skills.json");
58 }
59 }
60 let home = std::env::var_os("HOME").unwrap_or_default();
61 PathBuf::from(home)
62 .join(".config")
63 .join("bb")
64 .join("skills.json")
65}
66
67pub fn load_state() -> (Vec<Entry>, Option<String>) {
71 let path = state_path();
72 let raw = match std::fs::read_to_string(&path) {
73 Ok(text) => text,
74 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (Vec::new(), None),
75 Err(err) => {
76 return (
77 Vec::new(),
78 Some(format!("could not read {}: {err}", path.display())),
79 )
80 }
81 };
82 if raw.trim().is_empty() {
83 return (Vec::new(), None);
84 }
85 match serde_json::from_str::<Vec<Entry>>(&raw) {
86 Ok(entries) => (entries, None),
87 Err(err) => (
88 Vec::new(),
89 Some(format!("ignoring unreadable {}: {err}", path.display())),
90 ),
91 }
92}
93
94pub fn save_state(entries: &[Entry]) -> Result<()> {
95 let path = state_path();
96 if let Some(parent) = path.parent() {
97 std::fs::create_dir_all(parent).map_err(BbError::Io)?;
98 }
99 let json = serde_json::to_string_pretty(entries)?;
100 std::fs::write(&path, json).map_err(BbError::Io)?;
101 Ok(())
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Action {
106 Installed,
107 Refreshed,
108 Unchanged,
109 SkippedModified,
110}
111
112impl Action {
113 pub fn as_str(self) -> &'static str {
114 match self {
115 Self::Installed => "installed",
116 Self::Refreshed => "refreshed",
117 Self::Unchanged => "unchanged",
118 Self::SkippedModified => "skipped_modified",
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
124pub struct Outcome {
125 pub path: PathBuf,
126 pub agent: String,
127 pub action: Action,
128}
129
130pub fn skill_file(root: &Path, agent: Agent) -> PathBuf {
132 let base = match agent {
133 Agent::Agents => root.join(".agents").join("skills"),
134 Agent::Claude => root.join(".claude").join("skills"),
135 };
136 base.join(SKILL_NAME).join("SKILL.md")
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum State {
141 Current,
142 Stale,
143 Modified,
144 Missing,
145}
146
147impl State {
148 pub fn as_str(self) -> &'static str {
149 match self {
150 Self::Current => "current",
151 Self::Stale => "stale",
152 Self::Modified => "modified",
153 Self::Missing => "missing",
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
159pub struct StatusRow {
160 pub path: PathBuf,
161 pub agent: String,
162 pub state: State,
163}
164
165fn is_shaped_like_a_skill_path(path: &Path) -> bool {
174 let mut components: Vec<_> = path.components().collect();
175 let Some(file) = components.pop() else {
176 return false;
177 };
178 if file.as_os_str() != "SKILL.md" {
179 return false;
180 }
181 let Some(skill_dir) = components.pop() else {
182 return false;
183 };
184 if skill_dir.as_os_str() != SKILL_NAME {
185 return false;
186 }
187 let Some(skills_dir) = components.pop() else {
188 return false;
189 };
190 if skills_dir.as_os_str() != "skills" {
191 return false;
192 }
193 matches!(
194 components.pop().map(|c| c.as_os_str().to_owned()),
195 Some(agents_dir) if agents_dir == ".agents" || agents_dir == ".claude"
196 )
197}
198
199fn state_of(entry: &Entry, wanted: &str) -> State {
200 match std::fs::read(&entry.path) {
201 Err(_) => State::Missing,
202 Ok(bytes) => {
203 let actual = content_hash(&bytes);
204 if actual == wanted {
205 State::Current
206 } else if actual == entry.sha256 {
207 State::Stale
208 } else {
209 State::Modified
210 }
211 }
212 }
213}
214
215pub fn status() -> (Vec<StatusRow>, Option<String>) {
216 let (entries, warning) = load_state();
217 let wanted = content_hash(SKILL_MD.as_bytes());
218 let rows = entries
219 .iter()
220 .map(|e| StatusRow {
221 path: e.path.clone(),
222 agent: e.agent.clone(),
223 state: state_of(e, &wanted),
224 })
225 .collect();
226 (rows, warning)
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum RemovalOutcome {
234 Removed,
235 RefusedModified,
236 RefusedUnsafePath,
237 Absent,
238}
239
240impl RemovalOutcome {
241 pub fn as_str(self) -> &'static str {
242 match self {
243 Self::Removed => "removed",
244 Self::RefusedModified => "refused_modified",
245 Self::RefusedUnsafePath => "refused_unsafe_path",
246 Self::Absent => "absent",
247 }
248 }
249}
250
251fn parent_is_symlink(path: &Path) -> bool {
258 path.parent()
259 .and_then(|p| std::fs::symlink_metadata(p).ok())
260 .map(|m| m.file_type().is_symlink())
261 .unwrap_or(false)
262}
263
264pub fn uninstall(root: Option<&Path>, force: bool) -> Result<Vec<(PathBuf, RemovalOutcome)>> {
267 let (entries, warning) = load_state();
268 if let Some(warning) = warning {
269 crate::output::warn(&warning);
270 }
271 let wanted = content_hash(SKILL_MD.as_bytes());
272 let mut results = Vec::new();
273 let mut keep = Vec::new();
274
275 for entry in entries {
276 let in_scope = root.is_none_or(|r| entry.path.starts_with(r));
277 if !in_scope {
278 keep.push(entry);
279 continue;
280 }
281 if !is_shaped_like_a_skill_path(&entry.path) {
282 crate::output::warn(&format!(
283 "refusing to touch {} — does not look like a skill path bb would have written",
284 entry.path.display()
285 ));
286 results.push((entry.path.clone(), RemovalOutcome::RefusedUnsafePath));
287 keep.push(entry);
288 continue;
289 }
290 let modified = matches!(state_of(&entry, &wanted), State::Modified);
291 if modified && !force {
292 results.push((entry.path.clone(), RemovalOutcome::RefusedModified));
293 keep.push(entry);
294 continue;
295 }
296 let is_symlinked_dir = entry.kind == "symlink" || parent_is_symlink(&entry.path);
304 let removal_target: &Path = if is_symlinked_dir {
305 entry.path.parent().unwrap_or(&entry.path)
306 } else {
307 &entry.path
308 };
309 let existed = removal_target.exists() || std::fs::symlink_metadata(removal_target).is_ok();
310 remove_existing(removal_target)?;
311 if !is_symlinked_dir {
316 if let Some(dir) = entry.path.parent() {
317 let is_empty = std::fs::read_dir(dir)
318 .map(|mut i| i.next().is_none())
319 .unwrap_or(false);
320 if is_empty {
321 let _ = std::fs::remove_dir(dir);
322 }
323 }
324 }
325 let outcome = if existed {
326 RemovalOutcome::Removed
327 } else {
328 RemovalOutcome::Absent
329 };
330 results.push((entry.path.clone(), outcome));
331 }
332
333 save_state(&keep)?;
334 Ok(results)
335}
336
337pub fn detect_agents(root: &Path) -> Vec<Agent> {
340 let mut found = Vec::new();
341 let shares_agents = [".agents", ".cursor", ".opencode"]
342 .iter()
343 .any(|d| root.join(d).is_dir());
344 if shares_agents {
345 found.push(Agent::Agents);
346 }
347 if root.join(".claude").is_dir() {
348 found.push(Agent::Claude);
349 }
350 found
351}
352
353fn write_file(path: &Path, contents: &str) -> Result<()> {
354 if let Some(parent) = path.parent() {
355 std::fs::create_dir_all(parent).map_err(BbError::Io)?;
356 }
357 std::fs::write(path, contents).map_err(BbError::Io)?;
358 Ok(())
359}
360
361fn remove_existing(path: &Path) -> Result<()> {
366 match std::fs::symlink_metadata(path) {
367 Ok(meta) => {
368 if meta.file_type().is_symlink() || !meta.is_dir() {
369 std::fs::remove_file(path).map_err(BbError::Io)?;
370 } else {
371 std::fs::remove_dir_all(path).map_err(BbError::Io)?;
372 }
373 Ok(())
374 }
375 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
376 Err(err) => Err(BbError::Io(err)),
377 }
378}
379
380fn install_claude_dir(root: &Path, agents_installed: bool) -> Result<&'static str> {
384 let claude_dir = root.join(".claude").join("skills").join(SKILL_NAME);
385 let claude_file = claude_dir.join("SKILL.md");
386
387 if agents_installed {
388 #[cfg(unix)]
389 {
390 if let Some(parent) = claude_dir.parent() {
391 std::fs::create_dir_all(parent).map_err(BbError::Io)?;
392 }
393 remove_existing(&claude_dir)?;
394 let target = Path::new("..")
395 .join("..")
396 .join(".agents")
397 .join("skills")
398 .join(SKILL_NAME);
399 if std::os::unix::fs::symlink(&target, &claude_dir).is_ok() {
400 return Ok("symlink");
401 }
402 }
403 }
404
405 remove_existing(&claude_dir)?;
406 write_file(&claude_file, SKILL_MD)?;
407 Ok("file")
408}
409
410pub fn install(root: &Path, agents: &[Agent], force: bool) -> Result<Vec<Outcome>> {
411 let (mut state, _warning) = load_state();
412 let wanted = content_hash(SKILL_MD.as_bytes());
413 let mut outcomes = Vec::new();
414
415 let agents_dir_present = root
416 .join(".agents")
417 .join("skills")
418 .join(SKILL_NAME)
419 .join("SKILL.md")
420 .exists()
421 || agents.contains(&Agent::Agents);
422
423 for agent in agents {
424 let path = skill_file(root, *agent);
425 let recorded = state.iter().find(|e| e.path == path).cloned();
426 let on_disk = std::fs::read(&path).ok();
427
428 let action = match (&on_disk, &recorded) {
429 (None, _) => Action::Installed,
430 (Some(bytes), _) if content_hash(bytes) == wanted => Action::Unchanged,
431 (Some(bytes), Some(entry)) if content_hash(bytes) == entry.sha256 => Action::Refreshed,
433 (Some(_), _) if force => Action::Refreshed,
435 (Some(_), _) => Action::SkippedModified,
436 };
437
438 let mut kind = "file".to_string();
439 if action != Action::SkippedModified && action != Action::Unchanged {
440 if *agent == Agent::Claude {
441 kind = install_claude_dir(root, agents_dir_present)?.to_string();
442 } else {
443 write_file(&path, SKILL_MD)?;
444 }
445 } else if let Some(entry) = &recorded {
446 kind = entry.kind.clone();
447 } else if parent_is_symlink(&path) {
448 kind = "symlink".to_string();
454 }
455
456 if action != Action::SkippedModified {
457 state.retain(|e| e.path != path);
458 state.push(Entry {
459 path: path.clone(),
460 agent: agent.as_str().to_string(),
461 kind,
462 sha256: wanted.clone(),
463 version: env!("CARGO_PKG_VERSION").to_string(),
464 });
465 }
466
467 outcomes.push(Outcome {
468 path,
469 agent: agent.as_str().to_string(),
470 action,
471 });
472 }
473
474 save_state(&state)?;
475 Ok(outcomes)
476}
477
478fn claude_root_from_entry_path(path: &Path) -> Result<&Path> {
483 path.parent()
484 .and_then(Path::parent)
485 .and_then(Path::parent)
486 .and_then(Path::parent)
487 .ok_or_else(|| {
488 BbError::Config(format!(
489 "cannot determine the project root from {}",
490 path.display()
491 ))
492 })
493}
494
495fn restore_claude_link(entry_path: &Path) -> Result<String> {
502 if !is_shaped_like_a_skill_path(entry_path) {
503 return Err(BbError::Config(format!(
504 "refusing to touch {} — does not look like a skill path bb would have written",
505 entry_path.display()
506 )));
507 }
508 let root = claude_root_from_entry_path(entry_path)?;
509 let agents_installed = root
510 .join(".agents")
511 .join("skills")
512 .join(SKILL_NAME)
513 .join("SKILL.md")
514 .exists();
515 Ok(install_claude_dir(root, agents_installed)?.to_string())
516}
517
518pub fn refresh_tracked() -> Result<Vec<Outcome>> {
526 let (mut state, warning) = load_state();
527 if let Some(warning) = warning {
528 crate::output::warn(&warning);
529 }
530 let wanted = content_hash(SKILL_MD.as_bytes());
531 let mut outcomes = Vec::new();
532
533 for entry in &mut state {
534 if !is_shaped_like_a_skill_path(&entry.path) {
535 crate::output::warn(&format!(
536 "refusing to touch {} — does not look like a skill path bb would have written",
537 entry.path.display()
538 ));
539 continue;
540 }
541 let action = match state_of(entry, &wanted) {
542 State::Stale => {
546 write_file(&entry.path, SKILL_MD)?;
547 entry.sha256 = wanted.clone();
548 entry.version = env!("CARGO_PKG_VERSION").to_string();
549 Action::Refreshed
550 }
551 State::Missing => {
556 entry.kind = if entry.kind == "symlink" {
557 restore_claude_link(&entry.path)?
558 } else {
559 write_file(&entry.path, SKILL_MD)?;
560 "file".to_string()
561 };
562 entry.sha256 = wanted.clone();
563 entry.version = env!("CARGO_PKG_VERSION").to_string();
564 Action::Refreshed
565 }
566 State::Modified => Action::SkippedModified,
567 State::Current => Action::Unchanged,
568 };
569
570 outcomes.push(Outcome {
571 path: entry.path.clone(),
572 agent: entry.agent.clone(),
573 action,
574 });
575 }
576
577 save_state(&state)?;
578 Ok(outcomes)
579}
580
581#[cfg(test)]
582#[allow(clippy::unwrap_used, clippy::expect_used)]
583mod tests {
584 use super::*;
585
586 #[test]
589 fn embedded_skill_is_present_and_has_frontmatter() {
590 assert!(!SKILL_MD.trim().is_empty());
591 assert!(
592 SKILL_MD.starts_with("---"),
593 "skill must open with yaml frontmatter"
594 );
595 assert!(
596 SKILL_MD.contains("name: bitbucket-cloud"),
597 "frontmatter should name the skill"
598 );
599 }
600
601 #[test]
602 fn content_hash_is_stable_and_distinguishes_content() {
603 assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
604 assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
605 assert_eq!(content_hash(b"abc").len(), 64);
607 }
608
609 #[test]
610 #[serial_test::serial]
611 fn state_path_prefers_xdg_config_home() {
612 temp_env(
613 &[
614 ("XDG_CONFIG_HOME", Some("/tmp/xdg")),
615 ("HOME", Some("/tmp/home")),
616 ],
617 || {
618 assert_eq!(
619 state_path(),
620 std::path::Path::new("/tmp/xdg/bb/skills.json")
621 );
622 },
623 );
624 }
625
626 #[test]
627 #[serial_test::serial]
628 fn state_path_falls_back_to_home_config() {
629 temp_env(
630 &[("XDG_CONFIG_HOME", None), ("HOME", Some("/tmp/home"))],
631 || {
632 assert_eq!(
633 state_path(),
634 std::path::Path::new("/tmp/home/.config/bb/skills.json")
635 );
636 },
637 );
638 }
639
640 #[test]
641 #[serial_test::serial]
642 fn saved_state_round_trips() {
643 let dir = tempfile::tempdir().unwrap();
644 temp_env(
645 &[
646 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
647 ("HOME", None),
648 ],
649 || {
650 let entries = vec![Entry {
651 path: std::path::PathBuf::from("/p/.agents/skills/bitbucket-cloud/SKILL.md"),
652 agent: "agents".into(),
653 kind: "file".into(),
654 sha256: content_hash(SKILL_MD.as_bytes()),
655 version: env!("CARGO_PKG_VERSION").into(),
656 }];
657 save_state(&entries).unwrap();
658 let (loaded, warning) = load_state();
659 assert!(warning.is_none());
660 assert_eq!(loaded.len(), 1);
661 assert_eq!(loaded[0].agent, "agents");
662 assert_eq!(loaded[0].sha256, entries[0].sha256);
663 },
664 );
665 }
666
667 #[test]
669 #[serial_test::serial]
670 fn corrupt_state_is_tolerated_with_a_warning() {
671 let dir = tempfile::tempdir().unwrap();
672 temp_env(
673 &[
674 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
675 ("HOME", None),
676 ],
677 || {
678 let p = state_path();
679 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
680 std::fs::write(&p, "{not json").unwrap();
681 let (loaded, warning) = load_state();
682 assert!(loaded.is_empty());
683 assert!(warning.is_some(), "corrupt state should warn");
684 },
685 );
686 }
687
688 #[test]
693 #[serial_test::serial]
694 fn status_reports_stale_when_the_binary_shipped_newer_text() {
695 let dir = tempfile::tempdir().unwrap();
696 let root = tempfile::tempdir().unwrap();
697 temp_env(
698 &[
699 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
700 ("HOME", None),
701 ],
702 || {
703 let path = skill_file(root.path(), Agent::Agents);
704 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
705 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
706 std::fs::write(&path, old).unwrap();
707 save_state(&[Entry {
708 path: path.clone(),
709 agent: "agents".into(),
710 kind: "file".into(),
711 sha256: content_hash(old.as_bytes()),
712 version: "0.0.1".into(),
713 }])
714 .unwrap();
715
716 let (rows, warning) = status();
717 assert!(warning.is_none());
718 assert_eq!(rows.len(), 1);
719 assert_eq!(
720 rows[0].state,
721 State::Stale,
722 "on-disk text matches the recorded sha256, just not the binary's current text"
723 );
724
725 std::fs::write(&path, SKILL_MD).unwrap();
728 save_state(&[Entry {
729 path: path.clone(),
730 agent: "agents".into(),
731 kind: "file".into(),
732 sha256: content_hash(SKILL_MD.as_bytes()),
733 version: env!("CARGO_PKG_VERSION").into(),
734 }])
735 .unwrap();
736 let (rows, _) = status();
737 assert_eq!(rows[0].state, State::Current);
738 },
739 );
740 }
741
742 #[test]
749 #[serial_test::serial]
750 fn uninstall_removes_the_claude_link_without_deleting_its_target() {
751 let dir = tempfile::tempdir().unwrap();
752 let cfg = tempfile::tempdir().unwrap();
753 temp_env(
754 &[
755 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
756 ("HOME", None),
757 ],
758 || {
759 install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
760 let agents_path = skill_file(dir.path(), Agent::Agents);
761 let claude_root = dir.path().join(".claude");
762 assert!(agents_path.is_file(), "sanity: agents copy installed");
763
764 let results = uninstall(Some(&claude_root), false).unwrap();
765 assert_eq!(results.len(), 1, "only the claude entry was in scope");
766 assert_eq!(
767 results[0].1,
768 RemovalOutcome::Removed,
769 "the claude entry should report removed"
770 );
771
772 let claude_dir = dir.path().join(".claude/skills/bitbucket-cloud");
773 assert!(
774 !claude_dir.exists(),
775 "the claude link (or fallback file) should be gone"
776 );
777 assert!(
778 agents_path.is_file(),
779 "removing the claude link must not delete the agents copy it points at"
780 );
781
782 let (remaining, _) = load_state();
783 assert_eq!(remaining.len(), 1, "the agents entry stays tracked");
784 assert_eq!(remaining[0].agent, "agents");
785 },
786 );
787 }
788
789 #[test]
790 #[serial_test::serial]
791 fn missing_state_is_empty_and_silent() {
792 let dir = tempfile::tempdir().unwrap();
793 temp_env(
794 &[
795 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
796 ("HOME", None),
797 ],
798 || {
799 let (loaded, warning) = load_state();
800 assert!(loaded.is_empty());
801 assert!(warning.is_none());
802 },
803 );
804 }
805
806 struct EnvGuard {
810 saved: Vec<(String, Option<String>)>,
811 }
812
813 impl Drop for EnvGuard {
814 fn drop(&mut self) {
815 for (k, v) in &self.saved {
816 match v {
817 Some(val) => std::env::set_var(k, val),
818 None => std::env::remove_var(k),
819 }
820 }
821 }
822 }
823
824 fn temp_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
828 let saved: Vec<(String, Option<String>)> = vars
829 .iter()
830 .map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
831 .collect();
832 let _guard = EnvGuard { saved };
833 for (k, v) in vars {
834 match v {
835 Some(val) => std::env::set_var(k, val),
836 None => std::env::remove_var(k),
837 }
838 }
839 f();
840 }
841
842 #[test]
843 #[serial_test::serial]
844 fn temp_env_restores_vars_even_if_the_closure_panics() {
845 std::env::set_var("XDG_CONFIG_HOME", "/before/panic");
846 std::env::remove_var("HOME");
847
848 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
849 temp_env(
850 &[
851 ("XDG_CONFIG_HOME", Some("/tmp/during-panic")),
852 ("HOME", Some("/tmp/home")),
853 ],
854 || panic!("simulated test failure inside temp_env"),
855 );
856 }));
857 assert!(result.is_err(), "closure should have panicked");
858
859 assert_eq!(
860 std::env::var("XDG_CONFIG_HOME").ok(),
861 Some("/before/panic".to_string()),
862 "XDG_CONFIG_HOME must be restored even after a panic"
863 );
864 assert_eq!(
865 std::env::var("HOME").ok(),
866 None,
867 "HOME must be restored to unset even after a panic"
868 );
869
870 std::env::remove_var("XDG_CONFIG_HOME");
871 }
872
873 #[test]
874 #[serial_test::serial]
875 fn install_writes_the_embedded_content() {
876 let dir = tempfile::tempdir().unwrap();
877 let cfg = tempfile::tempdir().unwrap();
878 temp_env(
879 &[
880 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
881 ("HOME", None),
882 ],
883 || {
884 let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
885 assert_eq!(outcomes.len(), 1);
886 assert!(matches!(outcomes[0].action, Action::Installed));
887
888 let written =
889 std::fs::read_to_string(skill_file(dir.path(), Agent::Agents)).unwrap();
890 assert_eq!(
891 written, SKILL_MD,
892 "installed content must equal the embedded skill"
893 );
894
895 let (state, _) = load_state();
896 assert_eq!(state.len(), 1);
897 assert_eq!(state[0].sha256, content_hash(SKILL_MD.as_bytes()));
898 },
899 );
900 }
901
902 #[test]
903 #[serial_test::serial]
904 fn a_second_install_reports_unchanged_and_rewrites_nothing() {
905 let dir = tempfile::tempdir().unwrap();
906 let cfg = tempfile::tempdir().unwrap();
907 temp_env(
908 &[
909 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
910 ("HOME", None),
911 ],
912 || {
913 install(dir.path(), &[Agent::Agents], false).unwrap();
914 let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
915 assert!(
916 matches!(outcomes[0].action, Action::Unchanged),
917 "{:?}",
918 outcomes[0].action
919 );
920 },
921 );
922 }
923
924 #[test]
926 #[serial_test::serial]
927 fn a_modified_file_is_refused_and_left_byte_identical() {
928 let dir = tempfile::tempdir().unwrap();
929 let cfg = tempfile::tempdir().unwrap();
930 temp_env(
931 &[
932 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
933 ("HOME", None),
934 ],
935 || {
936 install(dir.path(), &[Agent::Agents], false).unwrap();
937 let path = skill_file(dir.path(), Agent::Agents);
938 std::fs::write(&path, "# my own notes\n").unwrap();
939
940 let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
941 assert!(matches!(outcomes[0].action, Action::SkippedModified));
942 assert_eq!(std::fs::read_to_string(&path).unwrap(), "# my own notes\n");
943 },
944 );
945 }
946
947 #[test]
948 #[serial_test::serial]
949 fn force_overwrites_a_modified_file_and_updates_the_hash() {
950 let dir = tempfile::tempdir().unwrap();
951 let cfg = tempfile::tempdir().unwrap();
952 temp_env(
953 &[
954 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
955 ("HOME", None),
956 ],
957 || {
958 install(dir.path(), &[Agent::Agents], false).unwrap();
959 let path = skill_file(dir.path(), Agent::Agents);
960 std::fs::write(&path, "# my own notes\n").unwrap();
961
962 let outcomes = install(dir.path(), &[Agent::Agents], true).unwrap();
963 assert!(matches!(
964 outcomes[0].action,
965 Action::Refreshed | Action::Installed
966 ));
967 assert_eq!(std::fs::read_to_string(&path).unwrap(), SKILL_MD);
968 let (state, _) = load_state();
969 assert_eq!(state[0].sha256, content_hash(SKILL_MD.as_bytes()));
970 },
971 );
972 }
973
974 #[test]
977 #[serial_test::serial]
978 fn a_stale_file_is_refreshed_silently() {
979 let dir = tempfile::tempdir().unwrap();
980 let cfg = tempfile::tempdir().unwrap();
981 temp_env(
982 &[
983 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
984 ("HOME", None),
985 ],
986 || {
987 let path = skill_file(dir.path(), Agent::Agents);
988 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
989 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
990 std::fs::write(&path, old).unwrap();
991 save_state(&[Entry {
992 path: path.clone(),
993 agent: "agents".into(),
994 kind: "file".into(),
995 sha256: content_hash(old.as_bytes()),
996 version: "0.0.1".into(),
997 }])
998 .unwrap();
999
1000 let outcomes = install(dir.path(), &[Agent::Agents], false).unwrap();
1001 assert!(
1002 matches!(outcomes[0].action, Action::Refreshed),
1003 "{:?}",
1004 outcomes[0].action
1005 );
1006 assert_eq!(std::fs::read_to_string(&path).unwrap(), SKILL_MD);
1007 },
1008 );
1009 }
1010
1011 #[test]
1012 #[serial_test::serial]
1013 fn claude_install_links_to_the_agents_copy() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let cfg = tempfile::tempdir().unwrap();
1016 temp_env(
1017 &[
1018 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1019 ("HOME", None),
1020 ],
1021 || {
1022 install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1023 let claude = dir.path().join(".claude/skills").join(SKILL_NAME);
1024 let content = std::fs::read_to_string(claude.join("SKILL.md"))
1027 .or_else(|_| std::fs::read_to_string(&claude))
1028 .unwrap();
1029 assert_eq!(content, SKILL_MD);
1030 },
1031 );
1032 }
1033
1034 #[test]
1035 fn detect_finds_each_agent_directory() {
1036 let dir = tempfile::tempdir().unwrap();
1037 assert!(
1038 detect_agents(dir.path()).is_empty(),
1039 "nothing present means nothing detected"
1040 );
1041
1042 std::fs::create_dir_all(dir.path().join(".cursor")).unwrap();
1043 assert_eq!(
1044 detect_agents(dir.path()),
1045 vec![Agent::Agents],
1046 "cursor reads .agents"
1047 );
1048
1049 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
1050 let found = detect_agents(dir.path());
1051 assert!(found.contains(&Agent::Agents) && found.contains(&Agent::Claude));
1052 }
1053
1054 #[test]
1059 #[serial_test::serial]
1060 fn refresh_recreates_a_deleted_symlink_as_a_file_when_no_agents_copy_exists() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let cfg = tempfile::tempdir().unwrap();
1063 temp_env(
1064 &[
1065 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1066 ("HOME", None),
1067 ],
1068 || {
1069 install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1070 let claude_path = skill_file(dir.path(), Agent::Claude);
1071 let claude_dir = claude_path.parent().unwrap();
1072
1073 let (state, _) = load_state();
1077 let claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1078 assert_eq!(claude_entry.kind, "symlink", "sanity: install made a link");
1079 save_state(&[claude_entry]).unwrap();
1080
1081 remove_existing(claude_dir).unwrap();
1082 std::fs::remove_dir_all(dir.path().join(".agents")).unwrap();
1083
1084 let outcomes = refresh_tracked().unwrap();
1085 assert_eq!(outcomes.len(), 1);
1086 assert!(matches!(outcomes[0].action, Action::Refreshed));
1087
1088 assert_eq!(std::fs::read_to_string(&claude_path).unwrap(), SKILL_MD);
1089 let (state, _) = load_state();
1090 assert_eq!(
1091 state[0].kind, "file",
1092 "disk fell back to a real file, so state must say so too"
1093 );
1094 },
1095 );
1096 }
1097
1098 #[test]
1101 #[serial_test::serial]
1102 fn refresh_recreates_a_deleted_symlink_as_a_symlink_when_the_agents_copy_survives() {
1103 let dir = tempfile::tempdir().unwrap();
1104 let cfg = tempfile::tempdir().unwrap();
1105 temp_env(
1106 &[
1107 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1108 ("HOME", None),
1109 ],
1110 || {
1111 install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1112 let claude_dir = skill_file(dir.path(), Agent::Claude)
1113 .parent()
1114 .unwrap()
1115 .to_path_buf();
1116
1117 remove_existing(&claude_dir).unwrap();
1118 assert!(!claude_dir.exists(), "sanity: the link is gone");
1119
1120 let outcomes = refresh_tracked().unwrap();
1121 let claude_outcome = outcomes.iter().find(|o| o.agent == "claude").unwrap();
1122 assert!(matches!(claude_outcome.action, Action::Refreshed));
1123
1124 assert!(
1125 std::fs::symlink_metadata(&claude_dir)
1126 .unwrap()
1127 .file_type()
1128 .is_symlink(),
1129 "the agents copy was still there, so a link should come back, not a file"
1130 );
1131 assert_eq!(
1132 std::fs::read_to_string(claude_dir.join("SKILL.md")).unwrap(),
1133 SKILL_MD
1134 );
1135 let (state, _) = load_state();
1136 let claude_entry = state.iter().find(|e| e.agent == "claude").unwrap();
1137 assert_eq!(claude_entry.kind, "symlink");
1138 },
1139 );
1140 }
1141
1142 #[test]
1146 #[serial_test::serial]
1147 fn refresh_updates_content_through_an_intact_symlink_without_replacing_it() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let cfg = tempfile::tempdir().unwrap();
1150 temp_env(
1151 &[
1152 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1153 ("HOME", None),
1154 ],
1155 || {
1156 install(dir.path(), &[Agent::Agents, Agent::Claude], false).unwrap();
1157 let claude_path = skill_file(dir.path(), Agent::Claude);
1158 let claude_dir = claude_path.parent().unwrap().to_path_buf();
1159
1160 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1161 std::fs::write(&claude_path, old).unwrap();
1162
1163 let (state, _) = load_state();
1167 let mut claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1168 claude_entry.sha256 = content_hash(old.as_bytes());
1169 save_state(&[claude_entry]).unwrap();
1170
1171 let outcomes = refresh_tracked().unwrap();
1172 assert_eq!(outcomes.len(), 1);
1173 assert!(matches!(outcomes[0].action, Action::Refreshed));
1174
1175 assert!(
1176 std::fs::symlink_metadata(&claude_dir)
1177 .unwrap()
1178 .file_type()
1179 .is_symlink(),
1180 "an intact link must not be replaced by a file just to refresh content"
1181 );
1182 assert_eq!(std::fs::read_to_string(&claude_path).unwrap(), SKILL_MD);
1183 let (state, _) = load_state();
1184 assert_eq!(state[0].kind, "symlink");
1185 },
1186 );
1187 }
1188
1189 #[test]
1190 fn shape_guard_accepts_only_the_two_real_skill_locations() {
1191 assert!(is_shaped_like_a_skill_path(Path::new(
1192 "/proj/.agents/skills/bitbucket-cloud/SKILL.md"
1193 )));
1194 assert!(is_shaped_like_a_skill_path(Path::new(
1195 "/proj/.claude/skills/bitbucket-cloud/SKILL.md"
1196 )));
1197 for bad in [
1198 "/proj/src",
1199 "/proj/src/main.rs",
1200 "/proj/.agents/skills/bitbucket-cloud",
1201 "/proj/.agents/skills/some-other-skill/SKILL.md",
1202 "/proj/.opencode/skills/bitbucket-cloud/SKILL.md",
1203 "/etc/passwd",
1204 ] {
1205 assert!(
1206 !is_shaped_like_a_skill_path(Path::new(bad)),
1207 "{bad} should have been refused"
1208 );
1209 }
1210 }
1211
1212 #[test]
1217 #[serial_test::serial]
1218 fn uninstall_does_not_follow_a_hand_made_symlink_into_its_target() {
1219 let dir = tempfile::tempdir().unwrap();
1220 let cfg = tempfile::tempdir().unwrap();
1221 temp_env(
1222 &[
1223 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1224 ("HOME", None),
1225 ],
1226 || {
1227 let agents_path = skill_file(dir.path(), Agent::Agents);
1231 std::fs::create_dir_all(agents_path.parent().unwrap()).unwrap();
1232 std::fs::write(&agents_path, SKILL_MD).unwrap();
1233
1234 let claude_dir = dir.path().join(".claude").join("skills").join(SKILL_NAME);
1235 std::fs::create_dir_all(claude_dir.parent().unwrap()).unwrap();
1236 #[cfg(unix)]
1237 std::os::unix::fs::symlink(
1238 Path::new("..")
1239 .join("..")
1240 .join(".agents")
1241 .join("skills")
1242 .join(SKILL_NAME),
1243 &claude_dir,
1244 )
1245 .unwrap();
1246
1247 let outcomes = install(dir.path(), &[Agent::Claude], false).unwrap();
1248 assert!(
1249 matches!(outcomes[0].action, Action::Unchanged),
1250 "sanity: content already matches, so install should not rewrite it"
1251 );
1252
1253 let results = uninstall(None, false).unwrap();
1254 assert_eq!(results.len(), 1);
1255 assert_eq!(results[0].1, RemovalOutcome::Removed);
1256
1257 assert!(
1258 std::fs::read_to_string(&agents_path).unwrap() == SKILL_MD,
1259 "the .agents copy must survive uninstall of the claude link"
1260 );
1261 assert!(
1262 std::fs::symlink_metadata(&claude_dir).is_err(),
1263 "no dangling claude symlink should remain (Path::exists() would \
1264 wrongly report false for a dangling link, so this checks \
1265 symlink_metadata instead)"
1266 );
1267 },
1268 );
1269 }
1270
1271 #[test]
1274 #[serial_test::serial]
1275 fn uninstall_refuses_a_state_entry_pointing_outside_the_skill_shape() {
1276 let dir = tempfile::tempdir().unwrap();
1277 let cfg = tempfile::tempdir().unwrap();
1278 temp_env(
1279 &[
1280 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1281 ("HOME", None),
1282 ],
1283 || {
1284 let victim_dir = dir.path().join("src");
1285 std::fs::create_dir_all(&victim_dir).unwrap();
1286 std::fs::write(victim_dir.join("main.rs"), "fn main() {}").unwrap();
1287
1288 let victim_file = dir.path().join("Cargo.toml");
1289 std::fs::write(&victim_file, "[package]").unwrap();
1290
1291 save_state(&[
1292 Entry {
1293 path: victim_dir.clone(),
1294 agent: "agents".into(),
1295 kind: "file".into(),
1296 sha256: "deadbeef".into(),
1297 version: "0.0.1".into(),
1298 },
1299 Entry {
1300 path: victim_file.clone(),
1301 agent: "agents".into(),
1302 kind: "file".into(),
1303 sha256: "deadbeef".into(),
1304 version: "0.0.1".into(),
1305 },
1306 ])
1307 .unwrap();
1308
1309 let results = uninstall(None, true).unwrap();
1310 assert_eq!(results.len(), 2);
1311 assert!(results
1312 .iter()
1313 .all(|(_, o)| *o == RemovalOutcome::RefusedUnsafePath));
1314
1315 assert!(victim_dir.is_dir(), "unrelated directory must survive");
1316 assert!(
1317 victim_dir.join("main.rs").exists(),
1318 "unrelated directory's contents must survive"
1319 );
1320 assert!(victim_file.is_file(), "unrelated file must survive");
1321
1322 let (remaining, _) = load_state();
1324 assert_eq!(remaining.len(), 2);
1325 },
1326 );
1327 }
1328
1329 #[test]
1331 #[serial_test::serial]
1332 fn uninstall_still_removes_a_legitimate_entry() {
1333 let dir = tempfile::tempdir().unwrap();
1334 let cfg = tempfile::tempdir().unwrap();
1335 temp_env(
1336 &[
1337 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1338 ("HOME", None),
1339 ],
1340 || {
1341 install(dir.path(), &[Agent::Agents], false).unwrap();
1342 let results = uninstall(None, false).unwrap();
1343 assert_eq!(
1344 results,
1345 vec![(
1346 skill_file(dir.path(), Agent::Agents),
1347 RemovalOutcome::Removed
1348 )]
1349 );
1350 },
1351 );
1352 }
1353
1354 #[test]
1358 #[serial_test::serial]
1359 fn refresh_rewrites_a_stale_entry_while_leaving_a_modified_one_alone() {
1360 let stale_root = tempfile::tempdir().unwrap();
1361 let modified_root = tempfile::tempdir().unwrap();
1362 let cfg = tempfile::tempdir().unwrap();
1363 temp_env(
1364 &[
1365 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1366 ("HOME", None),
1367 ],
1368 || {
1369 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1370 let stale_path = skill_file(stale_root.path(), Agent::Agents);
1371 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1372 std::fs::write(&stale_path, old).unwrap();
1373
1374 let modified_path = skill_file(modified_root.path(), Agent::Agents);
1375 std::fs::create_dir_all(modified_path.parent().unwrap()).unwrap();
1376 let ours = "# our own version\n";
1377 std::fs::write(&modified_path, ours).unwrap();
1378
1379 save_state(&[
1380 Entry {
1381 path: stale_path.clone(),
1382 agent: "agents".into(),
1383 kind: "file".into(),
1384 sha256: content_hash(old.as_bytes()),
1385 version: "0.0.1".into(),
1386 },
1387 Entry {
1388 path: modified_path.clone(),
1389 agent: "agents".into(),
1390 kind: "file".into(),
1391 sha256: content_hash(old.as_bytes()),
1394 version: "0.0.1".into(),
1395 },
1396 ])
1397 .unwrap();
1398
1399 let outcomes = refresh_tracked().unwrap();
1400 assert_eq!(outcomes.len(), 2);
1401
1402 let stale_outcome = outcomes.iter().find(|o| o.path == stale_path).unwrap();
1403 assert!(matches!(stale_outcome.action, Action::Refreshed));
1404 assert_eq!(std::fs::read_to_string(&stale_path).unwrap(), SKILL_MD);
1405
1406 let modified_outcome = outcomes.iter().find(|o| o.path == modified_path).unwrap();
1407 assert!(matches!(modified_outcome.action, Action::SkippedModified));
1408 assert_eq!(std::fs::read_to_string(&modified_path).unwrap(), ours);
1409 },
1410 );
1411 }
1412}