返回 CodeWhale
roots.rs
根目录 / crates / tui / src / skills / roots.rs
1 //! Single source of truth for skill root enumeration, ownership, scope, and
2 //! runtime precedence.
3 //!
4 //! Runtime discovery and (later) audit/mutation share this catalog so
5 //! precedence cannot drift between modules. Discovery directories are not
6 //! write targets: only explicitly owned CodeWhale roots are writable.
7
8 use std::collections::HashSet;
9 use std::fs;
10 use std::path::{Path, PathBuf};
11
12 /// Stable identifier for a skill root within a catalog snapshot.
13 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
14 pub struct SkillRootId(String);
15
16 impl std::fmt::Display for SkillRootId {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 f.write_str(&self.0)
19 }
20 }
21
22 /// External harness layouts that CodeWhale can discover/audit but never owns.
23 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24 pub enum CompatibleHarness {
25 Agents,
26 Claude,
27 Cursor,
28 OpenCode,
29 Codex,
30 DeepSeekLegacy,
31 /// Flat `<workspace>/skills` layout.
32 FlatProjectSkills,
33 }
34
35 impl CompatibleHarness {
36 #[must_use]
37 pub fn label(self) -> &'static str {
38 match self {
39 Self::Agents => "agents",
40 Self::Claude => "claude",
41 Self::Cursor => "cursor",
42 Self::OpenCode => "opencode",
43 Self::Codex => "codex",
44 Self::DeepSeekLegacy => "deepseek",
45 Self::FlatProjectSkills => "flat-skills",
46 }
47 }
48 }
49
50 /// Kind of skill root on disk (or logical source).
51 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52 pub enum SkillRootKind {
53 CodeWhaleProject,
54 CodeWhaleGlobal,
55 CompatibleProject(CompatibleHarness),
56 CompatibleGlobal(CompatibleHarness),
57 /// Explicitly configured `skills_dir` that is not one of the owned roots.
58 Configured,
59 // Matched by the extensions UI + audit provenance, never constructed:
60 // no discovery path produces these roots yet (#4651 follow-up never came).
61 #[allow(dead_code)]
62 BuiltIn,
63 #[allow(dead_code)]
64 ReviewedPluginSnapshot,
65 RegistryCache,
66 }
67
68 /// Whether CodeWhale may mutate files under this root.
69 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70 pub enum SkillRootAccess {
71 /// CodeWhale-owned project/global install targets.
72 WritableOwned,
73 /// Compatible harness roots and unclassified configured dirs — read only.
74 ReadOnlyExternal,
75 /// Built-in / reviewed plugin snapshot content.
76 // Never constructed: no discovery path assigns it yet (#4651 follow-up
77 // never came). Kept because the access taxonomy is meaningless without it.
78 #[allow(dead_code)]
79 Immutable,
80 /// Registry download cache — not an active install target.
81 CacheOnly,
82 }
83
84 /// Project vs global scope for owned and compatible roots.
85 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86 pub enum SkillScope {
87 Project,
88 Global,
89 /// Logical / non-filesystem sources (built-in, plugin snapshot, cache).
90 Logical,
91 }
92
93 /// One enumerated skill root with ownership and precedence metadata.
94 #[derive(Debug, Clone, PartialEq, Eq)]
95 pub struct SkillRootDescriptor {
96 pub id: SkillRootId,
97 pub kind: SkillRootKind,
98 pub access: SkillRootAccess,
99 pub scope: SkillScope,
100 pub path: PathBuf,
101 pub canonical_path: Option<PathBuf>,
102 /// Lower value = higher precedence for first-wins runtime merge.
103 pub precedence: Option<usize>,
104 /// When true, runtime skill discovery includes this root.
105 pub active_for_runtime: bool,
106 /// When true, owned-only / compatible audit may include this root.
107 pub active_for_audit: bool,
108 }
109
110 impl SkillRootDescriptor {
111 #[must_use]
112 pub fn is_writable_owned(&self) -> bool {
113 self.access == SkillRootAccess::WritableOwned
114 }
115 }
116
117 /// Catalog of skill roots for a workspace (+ optional HOME override for tests).
118 #[derive(Debug, Clone, PartialEq, Eq)]
119 pub struct SkillRootCatalog {
120 roots: Vec<SkillRootDescriptor>,
121 }
122
123 impl SkillRootCatalog {
124 /// Build the full catalog: owned + compatible (including Codex audit-only)
125 /// plus optional configured dir and logical sources.
126 #[must_use]
127 pub fn build(
128 workspace: &Path,
129 home_dir: Option<&Path>,
130 configured_skills_dir: Option<&Path>,
131 ) -> Self {
132 let mut roots = Vec::new();
133 let mut precedence = 0usize;
134
135 // Runtime-compatible workspace roots (existing order — do not reorder).
136 push_existing(
137 &mut roots,
138 &mut precedence,
139 SkillRootKind::CompatibleProject(CompatibleHarness::Agents),
140 SkillRootAccess::ReadOnlyExternal,
141 SkillScope::Project,
142 workspace.join(".agents").join("skills"),
143 true,
144 true,
145 "project-agents",
146 );
147 push_existing(
148 &mut roots,
149 &mut precedence,
150 SkillRootKind::CompatibleProject(CompatibleHarness::FlatProjectSkills),
151 SkillRootAccess::ReadOnlyExternal,
152 SkillScope::Project,
153 workspace.join("skills"),
154 true,
155 true,
156 "project-flat-skills",
157 );
158 push_existing(
159 &mut roots,
160 &mut precedence,
161 SkillRootKind::CompatibleProject(CompatibleHarness::OpenCode),
162 SkillRootAccess::ReadOnlyExternal,
163 SkillScope::Project,
164 workspace.join(".opencode").join("skills"),
165 true,
166 true,
167 "project-opencode",
168 );
169 push_existing(
170 &mut roots,
171 &mut precedence,
172 SkillRootKind::CompatibleProject(CompatibleHarness::Claude),
173 SkillRootAccess::ReadOnlyExternal,
174 SkillScope::Project,
175 workspace.join(".claude").join("skills"),
176 true,
177 true,
178 "project-claude",
179 );
180 push_existing(
181 &mut roots,
182 &mut precedence,
183 SkillRootKind::CompatibleProject(CompatibleHarness::Cursor),
184 SkillRootAccess::ReadOnlyExternal,
185 SkillScope::Project,
186 workspace.join(".cursor").join("skills"),
187 true,
188 true,
189 "project-cursor",
190 );
191
192 // CodeWhale project root — always listed for ownership; runtime
193 // CodeWhale-only mode additionally requires the path stay inside the
194 // workspace (symlink escape check happens in path selection helpers).
195 let project_owned = workspace.join(".codewhale").join("skills");
196 push_descriptor(
197 &mut roots,
198 &mut precedence,
199 SkillRootKind::CodeWhaleProject,
200 SkillRootAccess::WritableOwned,
201 SkillScope::Project,
202 project_owned,
203 true,
204 true,
205 "project-codewhale",
206 true, // include even if missing — owned target may be created later
207 );
208
209 // Codex project: audit-compatible only; never active for runtime in #4651.
210 push_existing(
211 &mut roots,
212 &mut precedence,
213 SkillRootKind::CompatibleProject(CompatibleHarness::Codex),
214 SkillRootAccess::ReadOnlyExternal,
215 SkillScope::Project,
216 workspace.join(".codex").join("skills"),
217 false,
218 true,
219 "project-codex",
220 );
221
222 if let Some(home) = home_dir {
223 push_existing(
224 &mut roots,
225 &mut precedence,
226 SkillRootKind::CompatibleGlobal(CompatibleHarness::Agents),
227 SkillRootAccess::ReadOnlyExternal,
228 SkillScope::Global,
229 home.join(".agents").join("skills"),
230 true,
231 true,
232 "global-agents",
233 );
234 push_existing(
235 &mut roots,
236 &mut precedence,
237 SkillRootKind::CompatibleGlobal(CompatibleHarness::Claude),
238 SkillRootAccess::ReadOnlyExternal,
239 SkillScope::Global,
240 home.join(".claude").join("skills"),
241 true,
242 true,
243 "global-claude",
244 );
245
246 let global_owned = home.join(".codewhale").join("skills");
247 push_descriptor(
248 &mut roots,
249 &mut precedence,
250 SkillRootKind::CodeWhaleGlobal,
251 SkillRootAccess::WritableOwned,
252 SkillScope::Global,
253 global_owned,
254 true,
255 true,
256 "global-codewhale",
257 true,
258 );
259
260 push_existing(
261 &mut roots,
262 &mut precedence,
263 SkillRootKind::CompatibleGlobal(CompatibleHarness::DeepSeekLegacy),
264 SkillRootAccess::ReadOnlyExternal,
265 SkillScope::Global,
266 home.join(".deepseek").join("skills"),
267 true,
268 true,
269 "global-deepseek",
270 );
271
272 // Codex global: audit-compatible only.
273 push_existing(
274 &mut roots,
275 &mut precedence,
276 SkillRootKind::CompatibleGlobal(CompatibleHarness::Codex),
277 SkillRootAccess::ReadOnlyExternal,
278 SkillScope::Global,
279 home.join(".codex").join("skills"),
280 false,
281 true,
282 "global-codex",
283 );
284
285 // Registry cache is never an active skill root.
286 let cache = home.join(".codewhale").join("cache").join("skills");
287 push_descriptor(
288 &mut roots,
289 &mut precedence,
290 SkillRootKind::RegistryCache,
291 SkillRootAccess::CacheOnly,
292 SkillScope::Logical,
293 cache,
294 false,
295 false,
296 "registry-cache",
297 false,
298 );
299 } else {
300 // Match legacy fallback when HOME is unavailable.
301 push_descriptor(
302 &mut roots,
303 &mut precedence,
304 SkillRootKind::CodeWhaleGlobal,
305 SkillRootAccess::WritableOwned,
306 SkillScope::Global,
307 PathBuf::from("/tmp/codewhale/skills"),
308 true,
309 true,
310 "global-codewhale-fallback",
311 true,
312 );
313 }
314
315 if let Some(configured) = configured_skills_dir {
316 insert_configured_root(&mut roots, workspace, home_dir, configured, &mut precedence);
317 }
318
319 Self { roots }
320 }
321
322 /// Paths used by runtime discovery for the given mode (existing dirs only,
323 /// first-wins order preserved). CodeWhale-only applies the workspace
324 /// containment check for the project owned root.
325 #[must_use]
326 pub fn runtime_directories(
327 &self,
328 workspace: &Path,
329 mode: super::SkillDiscoveryMode,
330 ) -> Vec<PathBuf> {
331 let mut out = Vec::new();
332 let mut seen = HashSet::new();
333
334 for root in &self.roots {
335 if !root.active_for_runtime {
336 continue;
337 }
338 match mode {
339 super::SkillDiscoveryMode::Compatible => {}
340 super::SkillDiscoveryMode::CodeWhaleOnly => {
341 if !matches!(
342 root.kind,
343 SkillRootKind::CodeWhaleProject
344 | SkillRootKind::CodeWhaleGlobal
345 | SkillRootKind::Configured
346 ) {
347 continue;
348 }
349 if root.kind == SkillRootKind::CodeWhaleProject
350 && !codewhale_project_root_is_inside_workspace(workspace, &root.path)
351 {
352 continue;
353 }
354 }
355 }
356
357 if !path_is_existing_dir(&root.path) {
358 continue;
359 }
360 let Ok(canonical) = fs::canonicalize(&root.path) else {
361 continue;
362 };
363 if !canonical.is_dir() || !seen.insert(canonical) {
364 continue;
365 }
366 out.push(root.path.clone());
367 }
368 out
369 }
370
371 /// Owned CodeWhale project + global roots (may not exist yet).
372 #[must_use]
373 pub fn owned_writable_roots(&self) -> Vec<&SkillRootDescriptor> {
374 self.roots
375 .iter()
376 .filter(|r| r.is_writable_owned())
377 .collect()
378 }
379
380 /// Roots eligible for owned-only audit (writable owned roots that exist).
381 #[must_use]
382 pub fn audit_owned_directories(&self) -> Vec<&SkillRootDescriptor> {
383 self.roots
384 .iter()
385 .filter(|r| {
386 r.is_writable_owned() && r.active_for_audit && path_is_existing_dir(&r.path)
387 })
388 .collect()
389 }
390
391 /// Owned + compatible roots for explicit `--compatible` audit, including
392 /// Codex. Does not change runtime activation.
393 #[must_use]
394 pub fn audit_compatible_directories(&self) -> Vec<&SkillRootDescriptor> {
395 self.roots
396 .iter()
397 .filter(|r| {
398 r.active_for_audit
399 && !matches!(
400 r.kind,
401 SkillRootKind::RegistryCache
402 | SkillRootKind::BuiltIn
403 | SkillRootKind::ReviewedPluginSnapshot
404 )
405 && path_is_existing_dir(&r.path)
406 })
407 .collect()
408 }
409 }
410
411 /// Resolve candidate skill directories for runtime discovery (existing paths
412 /// only), preserving historical precedence.
413 #[must_use]
414 pub fn skills_directories_with_home_and_mode(
415 workspace: &Path,
416 home_dir: Option<&Path>,
417 mode: super::SkillDiscoveryMode,
418 ) -> Vec<PathBuf> {
419 SkillRootCatalog::build(workspace, home_dir, None).runtime_directories(workspace, mode)
420 }
421
422 /// CodeWhale project skills dir when it exists and stays inside the workspace.
423 #[must_use]
424 pub fn codewhale_workspace_skills_dir(workspace: &Path) -> Option<PathBuf> {
425 let skills_dir = workspace.join(".codewhale").join("skills");
426 codewhale_project_root_is_inside_workspace(workspace, &skills_dir).then_some(skills_dir)
427 }
428
429 /// Filter candidate paths to existing directories, preserving order and
430 /// de-duplicating by canonical path.
431 #[cfg(test)]
432 #[must_use]
433 pub fn existing_skill_dirs(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
434 let mut out = Vec::new();
435 let mut seen = HashSet::new();
436 for path in candidates {
437 let Ok(canonical_path) = fs::canonicalize(&path) else {
438 continue;
439 };
440 if canonical_path.is_dir() && seen.insert(canonical_path) {
441 out.push(path);
442 }
443 }
444 out
445 }
446
447 /// Classify a configured `skills_dir`: owned only when it is exactly a
448 /// CodeWhale project/global root; compatible harness paths stay read-only.
449 #[must_use]
450 pub fn classify_configured_skills_dir(
451 workspace: &Path,
452 home_dir: Option<&Path>,
453 skills_dir: &Path,
454 ) -> (SkillRootKind, SkillRootAccess, SkillScope) {
455 let project_owned = workspace.join(".codewhale").join("skills");
456 if paths_refer_to_same_dir(&project_owned, skills_dir) {
457 return (
458 SkillRootKind::CodeWhaleProject,
459 SkillRootAccess::WritableOwned,
460 SkillScope::Project,
461 );
462 }
463 if let Some(home) = home_dir {
464 let global_owned = home.join(".codewhale").join("skills");
465 if paths_refer_to_same_dir(&global_owned, skills_dir) {
466 return (
467 SkillRootKind::CodeWhaleGlobal,
468 SkillRootAccess::WritableOwned,
469 SkillScope::Global,
470 );
471 }
472 }
473
474 if let Some(harness) = match_compatible_project(workspace, skills_dir) {
475 return (
476 SkillRootKind::CompatibleProject(harness),
477 SkillRootAccess::ReadOnlyExternal,
478 SkillScope::Project,
479 );
480 }
481 if let Some(home) = home_dir
482 && let Some(harness) = match_compatible_global(home, skills_dir)
483 {
484 return (
485 SkillRootKind::CompatibleGlobal(harness),
486 SkillRootAccess::ReadOnlyExternal,
487 SkillScope::Global,
488 );
489 }
490
491 // Unknown configured path: treat as external until an explicit owned-root
492 // marker exists (Issue #4651 first cut — do not guess writability).
493 let scope = fs::canonicalize(workspace)
494 .ok()
495 .map_or(SkillScope::Global, |root| {
496 fs::canonicalize(skills_dir)
497 .ok()
498 .filter(|p| p.starts_with(&root))
499 .map_or(SkillScope::Global, |_| SkillScope::Project)
500 });
501 (
502 SkillRootKind::Configured,
503 SkillRootAccess::ReadOnlyExternal,
504 scope,
505 )
506 }
507
508 #[must_use]
509 pub fn safe_display_path(path: &Path, workspace: Option<&Path>, home: Option<&Path>) -> String {
510 // Prefer workspace when both apply so project roots stay distinct from
511 // `~/...` global paths that happen to live under the same home tree.
512 if let Some(workspace) = workspace
513 && let Ok(stripped) = path.strip_prefix(workspace)
514 {
515 return format!("<workspace>/{}", stripped.display()).replace('\\', "/");
516 }
517 if let Some(home) = home
518 && let Ok(stripped) = path.strip_prefix(home)
519 {
520 return format!("~/{}", stripped.display()).replace('\\', "/");
521 }
522 // Last resort: basename chain without expanding unrelated absolute parents.
523 path.file_name()
524 .map(|name| name.to_string_lossy().into_owned())
525 .unwrap_or_else(|| path.display().to_string())
526 }
527
528 #[must_use]
529 pub fn paths_refer_to_same_dir(left: &Path, right: &Path) -> bool {
530 if left == right {
531 return true;
532 }
533 match (fs::canonicalize(left), fs::canonicalize(right)) {
534 (Ok(left), Ok(right)) => left == right,
535 _ => false,
536 }
537 }
538
539 fn codewhale_project_root_is_inside_workspace(workspace: &Path, skills_dir: &Path) -> bool {
540 let Ok(canonical_workspace) = fs::canonicalize(workspace) else {
541 return false;
542 };
543 let Ok(canonical_skills) = fs::canonicalize(skills_dir) else {
544 return false;
545 };
546 canonical_skills.is_dir() && canonical_skills.starts_with(canonical_workspace)
547 }
548
549 fn path_is_existing_dir(path: &Path) -> bool {
550 match fs::symlink_metadata(path) {
551 Ok(meta) if meta.file_type().is_symlink() => {
552 fs::canonicalize(path).ok().is_some_and(|p| p.is_dir())
553 }
554 Ok(meta) => meta.is_dir(),
555 Err(_) => false,
556 }
557 }
558
559 fn match_compatible_project(workspace: &Path, skills_dir: &Path) -> Option<CompatibleHarness> {
560 let candidates = [
561 (
562 CompatibleHarness::Agents,
563 workspace.join(".agents").join("skills"),
564 ),
565 (
566 CompatibleHarness::FlatProjectSkills,
567 workspace.join("skills"),
568 ),
569 (
570 CompatibleHarness::OpenCode,
571 workspace.join(".opencode").join("skills"),
572 ),
573 (
574 CompatibleHarness::Claude,
575 workspace.join(".claude").join("skills"),
576 ),
577 (
578 CompatibleHarness::Cursor,
579 workspace.join(".cursor").join("skills"),
580 ),
581 (
582 CompatibleHarness::Codex,
583 workspace.join(".codex").join("skills"),
584 ),
585 ];
586 for (harness, candidate) in candidates {
587 if paths_refer_to_same_dir(&candidate, skills_dir) {
588 return Some(harness);
589 }
590 }
591 None
592 }
593
594 fn match_compatible_global(home: &Path, skills_dir: &Path) -> Option<CompatibleHarness> {
595 let candidates = [
596 (
597 CompatibleHarness::Agents,
598 home.join(".agents").join("skills"),
599 ),
600 (
601 CompatibleHarness::Claude,
602 home.join(".claude").join("skills"),
603 ),
604 (
605 CompatibleHarness::DeepSeekLegacy,
606 home.join(".deepseek").join("skills"),
607 ),
608 (CompatibleHarness::Codex, home.join(".codex").join("skills")),
609 ];
610 for (harness, candidate) in candidates {
611 if paths_refer_to_same_dir(&candidate, skills_dir) {
612 return Some(harness);
613 }
614 }
615 None
616 }
617
618 #[allow(clippy::too_many_arguments)] // catalog rows keep ownership flags explicit at call sites
619 fn push_existing(
620 roots: &mut Vec<SkillRootDescriptor>,
621 precedence: &mut usize,
622 kind: SkillRootKind,
623 access: SkillRootAccess,
624 scope: SkillScope,
625 path: PathBuf,
626 active_for_runtime: bool,
627 active_for_audit: bool,
628 id: &str,
629 ) {
630 push_descriptor(
631 roots,
632 precedence,
633 kind,
634 access,
635 scope,
636 path,
637 active_for_runtime,
638 active_for_audit,
639 id,
640 false,
641 );
642 }
643
644 #[allow(clippy::too_many_arguments)] // shared constructor for the explicit catalog table above
645 fn push_descriptor(
646 roots: &mut Vec<SkillRootDescriptor>,
647 precedence: &mut usize,
648 kind: SkillRootKind,
649 access: SkillRootAccess,
650 scope: SkillScope,
651 path: PathBuf,
652 active_for_runtime: bool,
653 active_for_audit: bool,
654 id: &str,
655 include_missing: bool,
656 ) {
657 let exists = path_is_existing_dir(&path);
658 if !include_missing && !exists {
659 return;
660 }
661 let canonical_path = fs::canonicalize(&path).ok();
662 let slot = *precedence;
663 *precedence += 1;
664 roots.push(SkillRootDescriptor {
665 id: SkillRootId(id.to_string()),
666 kind,
667 access,
668 scope,
669 path,
670 canonical_path,
671 precedence: Some(slot),
672 active_for_runtime,
673 active_for_audit,
674 });
675 }
676
677 fn insert_configured_root(
678 roots: &mut Vec<SkillRootDescriptor>,
679 workspace: &Path,
680 home_dir: Option<&Path>,
681 skills_dir: &Path,
682 precedence: &mut usize,
683 ) {
684 if !path_is_existing_dir(skills_dir) {
685 return;
686 }
687 if roots
688 .iter()
689 .any(|root| paths_refer_to_same_dir(&root.path, skills_dir))
690 {
691 return;
692 }
693
694 let (kind, access, scope) = classify_configured_skills_dir(workspace, home_dir, skills_dir);
695 let workspace_root = fs::canonicalize(workspace).ok();
696 let insert_at = workspace_root
697 .as_ref()
698 .and_then(|root| {
699 roots.iter().position(|dir| {
700 fs::canonicalize(&dir.path).map_or(true, |dir| !dir.starts_with(root))
701 })
702 })
703 .unwrap_or(roots.len());
704
705 let canonical_path = fs::canonicalize(skills_dir).ok();
706 let slot = *precedence;
707 *precedence += 1;
708 let descriptor = SkillRootDescriptor {
709 id: SkillRootId(format!("configured-{slot}")),
710 kind,
711 access,
712 scope,
713 path: skills_dir.to_path_buf(),
714 canonical_path,
715 precedence: Some(slot),
716 active_for_runtime: true,
717 active_for_audit: true,
718 };
719 roots.insert(insert_at, descriptor);
720 // Re-number precedence after insertion so catalog order stays consistent.
721 for (idx, root) in roots.iter_mut().enumerate() {
722 root.precedence = Some(idx);
723 }
724 *precedence = roots.len();
725 }
726
727 #[cfg(test)]
728 mod tests {
729 use super::*;
730 use crate::skills::SkillDiscoveryMode;
731 use tempfile::TempDir;
732
733 fn write_dir(path: &Path) {
734 std::fs::create_dir_all(path).unwrap();
735 }
736
737 #[test]
738 fn runtime_compatible_preserves_historical_workspace_order() {
739 let tmp = TempDir::new().unwrap();
740 let workspace = tmp.path().join("ws");
741 let home = tmp.path().join("home");
742 write_dir(&workspace.join(".agents").join("skills"));
743 write_dir(&workspace.join("skills"));
744 write_dir(&workspace.join(".claude").join("skills"));
745 write_dir(&workspace.join(".cursor").join("skills"));
746 write_dir(&workspace.join(".codewhale").join("skills"));
747 write_dir(&workspace.join(".codex").join("skills"));
748 write_dir(&home.join(".codewhale").join("skills"));
749
750 let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
751 let dirs = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible);
752
753 assert_eq!(
754 dirs,
755 vec![
756 workspace.join(".agents").join("skills"),
757 workspace.join("skills"),
758 workspace.join(".claude").join("skills"),
759 workspace.join(".cursor").join("skills"),
760 workspace.join(".codewhale").join("skills"),
761 home.join(".codewhale").join("skills"),
762 ]
763 );
764 assert!(
765 !dirs
766 .iter()
767 .any(|p| p == &workspace.join(".codex").join("skills")),
768 "codex must not activate for runtime"
769 );
770 }
771
772 #[test]
773 fn audit_compatible_includes_codex_without_runtime_activation() {
774 let tmp = TempDir::new().unwrap();
775 let workspace = tmp.path().join("ws");
776 let home = tmp.path().join("home");
777 write_dir(&workspace.join(".codewhale").join("skills"));
778 write_dir(&workspace.join(".codex").join("skills"));
779 write_dir(&home.join(".codewhale").join("skills"));
780 write_dir(&home.join(".codex").join("skills"));
781
782 let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
783 let audit: Vec<_> = catalog
784 .audit_compatible_directories()
785 .into_iter()
786 .map(|r| r.path.clone())
787 .collect();
788 assert!(audit.contains(&workspace.join(".codex").join("skills")));
789 assert!(audit.contains(&home.join(".codex").join("skills")));
790
791 let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible);
792 assert!(!runtime.contains(&workspace.join(".codex").join("skills")));
793 assert!(!runtime.contains(&home.join(".codex").join("skills")));
794 }
795
796 #[test]
797 fn owned_roots_are_writable_and_codewhale_only() {
798 let tmp = TempDir::new().unwrap();
799 let workspace = tmp.path().join("ws");
800 let home = tmp.path().join("home");
801 write_dir(&workspace.join(".agents").join("skills"));
802 write_dir(&workspace.join(".codewhale").join("skills"));
803 write_dir(&home.join(".codewhale").join("skills"));
804 write_dir(&home.join(".agents").join("skills"));
805
806 let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
807 let owned = catalog.owned_writable_roots();
808 assert_eq!(owned.len(), 2);
809 assert!(owned.iter().all(|r| r.is_writable_owned()));
810
811 let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::CodeWhaleOnly);
812 assert_eq!(
813 runtime,
814 vec![
815 workspace.join(".codewhale").join("skills"),
816 home.join(".codewhale").join("skills"),
817 ]
818 );
819 }
820
821 #[test]
822 fn configured_compatible_path_stays_read_only() {
823 let tmp = TempDir::new().unwrap();
824 let workspace = tmp.path().join("ws");
825 let home = tmp.path().join("home");
826 let agents = workspace.join(".agents").join("skills");
827 write_dir(&workspace);
828 write_dir(&agents);
829
830 let (kind, access, scope) =
831 classify_configured_skills_dir(&workspace, Some(&home), &agents);
832 assert_eq!(
833 kind,
834 SkillRootKind::CompatibleProject(CompatibleHarness::Agents)
835 );
836 assert_eq!(access, SkillRootAccess::ReadOnlyExternal);
837 assert_eq!(scope, SkillScope::Project);
838 }
839
840 #[test]
841 fn safe_display_path_prefers_home_then_workspace() {
842 let home = PathBuf::from("/home/user");
843 let workspace = home.join("proj");
844 let path = home.join(".codewhale").join("skills");
845 assert_eq!(
846 safe_display_path(&path, Some(&workspace), Some(&home)),
847 "~/.codewhale/skills"
848 );
849 let project = workspace.join(".codewhale").join("skills");
850 assert_eq!(
851 safe_display_path(&project, Some(&workspace), Some(&home)),
852 "<workspace>/.codewhale/skills"
853 );
854 }
855 }
856
856 lines RUST