use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SkillRootId(String);
impl SkillRootId {
#[must_use]
#[allow(dead_code)] pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SkillRootId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CompatibleHarness {
Agents,
Claude,
Cursor,
OpenCode,
Codex,
DeepSeekLegacy,
FlatProjectSkills,
}
impl CompatibleHarness {
#[must_use]
#[allow(dead_code)] pub fn label(self) -> &'static str {
match self {
Self::Agents => "agents",
Self::Claude => "claude",
Self::Cursor => "cursor",
Self::OpenCode => "opencode",
Self::Codex => "codex",
Self::DeepSeekLegacy => "deepseek",
Self::FlatProjectSkills => "flat-skills",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(dead_code)] pub enum SkillRootKind {
CodeWhaleProject,
CodeWhaleGlobal,
CompatibleProject(CompatibleHarness),
CompatibleGlobal(CompatibleHarness),
Configured,
BuiltIn,
ReviewedPluginSnapshot,
RegistryCache,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(dead_code)] pub enum SkillRootAccess {
WritableOwned,
ReadOnlyExternal,
Immutable,
CacheOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkillScope {
Project,
Global,
Logical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRootDescriptor {
pub id: SkillRootId,
pub kind: SkillRootKind,
pub access: SkillRootAccess,
pub scope: SkillScope,
pub path: PathBuf,
pub canonical_path: Option<PathBuf>,
pub precedence: Option<usize>,
pub active_for_runtime: bool,
pub active_for_audit: bool,
}
impl SkillRootDescriptor {
#[must_use]
pub fn is_writable_owned(&self) -> bool {
self.access == SkillRootAccess::WritableOwned
}
#[must_use]
#[allow(dead_code)] pub fn safe_display_path(&self, workspace: Option<&Path>, home: Option<&Path>) -> String {
safe_display_path(&self.path, workspace, home)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRootCatalog {
roots: Vec<SkillRootDescriptor>,
}
impl SkillRootCatalog {
#[must_use]
pub fn build(
workspace: &Path,
home_dir: Option<&Path>,
configured_skills_dir: Option<&Path>,
) -> Self {
let mut roots = Vec::new();
let mut precedence = 0usize;
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleProject(CompatibleHarness::Agents),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
workspace.join(".agents").join("skills"),
true,
true,
"project-agents",
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleProject(CompatibleHarness::FlatProjectSkills),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
workspace.join("skills"),
true,
true,
"project-flat-skills",
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleProject(CompatibleHarness::OpenCode),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
workspace.join(".opencode").join("skills"),
true,
true,
"project-opencode",
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleProject(CompatibleHarness::Claude),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
workspace.join(".claude").join("skills"),
true,
true,
"project-claude",
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleProject(CompatibleHarness::Cursor),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
workspace.join(".cursor").join("skills"),
true,
true,
"project-cursor",
);
let project_owned = workspace.join(".codewhale").join("skills");
push_descriptor(
&mut roots,
&mut precedence,
SkillRootKind::CodeWhaleProject,
SkillRootAccess::WritableOwned,
SkillScope::Project,
project_owned,
true,
true,
"project-codewhale",
true, );
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleProject(CompatibleHarness::Codex),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
workspace.join(".codex").join("skills"),
false,
true,
"project-codex",
);
if let Some(home) = home_dir {
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleGlobal(CompatibleHarness::Agents),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Global,
home.join(".agents").join("skills"),
true,
true,
"global-agents",
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleGlobal(CompatibleHarness::Claude),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Global,
home.join(".claude").join("skills"),
true,
true,
"global-claude",
);
let global_owned = home.join(".codewhale").join("skills");
push_descriptor(
&mut roots,
&mut precedence,
SkillRootKind::CodeWhaleGlobal,
SkillRootAccess::WritableOwned,
SkillScope::Global,
global_owned,
true,
true,
"global-codewhale",
true,
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleGlobal(CompatibleHarness::DeepSeekLegacy),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Global,
home.join(".deepseek").join("skills"),
true,
true,
"global-deepseek",
);
push_existing(
&mut roots,
&mut precedence,
SkillRootKind::CompatibleGlobal(CompatibleHarness::Codex),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Global,
home.join(".codex").join("skills"),
false,
true,
"global-codex",
);
let cache = home.join(".codewhale").join("cache").join("skills");
push_descriptor(
&mut roots,
&mut precedence,
SkillRootKind::RegistryCache,
SkillRootAccess::CacheOnly,
SkillScope::Logical,
cache,
false,
false,
"registry-cache",
false,
);
} else {
push_descriptor(
&mut roots,
&mut precedence,
SkillRootKind::CodeWhaleGlobal,
SkillRootAccess::WritableOwned,
SkillScope::Global,
PathBuf::from("/tmp/codewhale/skills"),
true,
true,
"global-codewhale-fallback",
true,
);
}
if let Some(configured) = configured_skills_dir {
insert_configured_root(&mut roots, workspace, home_dir, configured, &mut precedence);
}
Self { roots }
}
#[must_use]
#[allow(dead_code)] pub fn roots(&self) -> &[SkillRootDescriptor] {
&self.roots
}
#[must_use]
pub fn runtime_directories(
&self,
workspace: &Path,
mode: super::SkillDiscoveryMode,
) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for root in &self.roots {
if !root.active_for_runtime {
continue;
}
match mode {
super::SkillDiscoveryMode::Compatible => {}
super::SkillDiscoveryMode::CodeWhaleOnly => {
if !matches!(
root.kind,
SkillRootKind::CodeWhaleProject
| SkillRootKind::CodeWhaleGlobal
| SkillRootKind::Configured
) {
continue;
}
if root.kind == SkillRootKind::CodeWhaleProject
&& !codewhale_project_root_is_inside_workspace(workspace, &root.path)
{
continue;
}
}
}
if !path_is_existing_dir(&root.path) {
continue;
}
let Ok(canonical) = fs::canonicalize(&root.path) else {
continue;
};
if !canonical.is_dir() || !seen.insert(canonical) {
continue;
}
out.push(root.path.clone());
}
out
}
#[must_use]
pub fn owned_writable_roots(&self) -> Vec<&SkillRootDescriptor> {
self.roots
.iter()
.filter(|r| r.is_writable_owned())
.collect()
}
#[must_use]
#[allow(dead_code)] pub fn audit_owned_directories(&self) -> Vec<&SkillRootDescriptor> {
self.roots
.iter()
.filter(|r| {
r.is_writable_owned() && r.active_for_audit && path_is_existing_dir(&r.path)
})
.collect()
}
#[must_use]
pub fn audit_compatible_directories(&self) -> Vec<&SkillRootDescriptor> {
self.roots
.iter()
.filter(|r| {
r.active_for_audit
&& !matches!(
r.kind,
SkillRootKind::RegistryCache
| SkillRootKind::BuiltIn
| SkillRootKind::ReviewedPluginSnapshot
)
&& path_is_existing_dir(&r.path)
})
.collect()
}
}
#[must_use]
pub fn skills_directories_with_home_and_mode(
workspace: &Path,
home_dir: Option<&Path>,
mode: super::SkillDiscoveryMode,
) -> Vec<PathBuf> {
SkillRootCatalog::build(workspace, home_dir, None).runtime_directories(workspace, mode)
}
#[must_use]
pub fn codewhale_workspace_skills_dir(workspace: &Path) -> Option<PathBuf> {
let skills_dir = workspace.join(".codewhale").join("skills");
codewhale_project_root_is_inside_workspace(workspace, &skills_dir).then_some(skills_dir)
}
#[cfg(test)]
#[must_use]
pub fn existing_skill_dirs(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for path in candidates {
let Ok(canonical_path) = fs::canonicalize(&path) else {
continue;
};
if canonical_path.is_dir() && seen.insert(canonical_path) {
out.push(path);
}
}
out
}
#[must_use]
pub fn classify_configured_skills_dir(
workspace: &Path,
home_dir: Option<&Path>,
skills_dir: &Path,
) -> (SkillRootKind, SkillRootAccess, SkillScope) {
let project_owned = workspace.join(".codewhale").join("skills");
if paths_refer_to_same_dir(&project_owned, skills_dir) {
return (
SkillRootKind::CodeWhaleProject,
SkillRootAccess::WritableOwned,
SkillScope::Project,
);
}
if let Some(home) = home_dir {
let global_owned = home.join(".codewhale").join("skills");
if paths_refer_to_same_dir(&global_owned, skills_dir) {
return (
SkillRootKind::CodeWhaleGlobal,
SkillRootAccess::WritableOwned,
SkillScope::Global,
);
}
}
if let Some(harness) = match_compatible_project(workspace, skills_dir) {
return (
SkillRootKind::CompatibleProject(harness),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Project,
);
}
if let Some(home) = home_dir
&& let Some(harness) = match_compatible_global(home, skills_dir)
{
return (
SkillRootKind::CompatibleGlobal(harness),
SkillRootAccess::ReadOnlyExternal,
SkillScope::Global,
);
}
let scope = fs::canonicalize(workspace)
.ok()
.map_or(SkillScope::Global, |root| {
fs::canonicalize(skills_dir)
.ok()
.filter(|p| p.starts_with(&root))
.map_or(SkillScope::Global, |_| SkillScope::Project)
});
(
SkillRootKind::Configured,
SkillRootAccess::ReadOnlyExternal,
scope,
)
}
#[must_use]
pub fn safe_display_path(path: &Path, workspace: Option<&Path>, home: Option<&Path>) -> String {
if let Some(workspace) = workspace
&& let Ok(stripped) = path.strip_prefix(workspace)
{
return format!("<workspace>/{}", stripped.display()).replace('\\', "/");
}
if let Some(home) = home
&& let Ok(stripped) = path.strip_prefix(home)
{
return format!("~/{}", stripped.display()).replace('\\', "/");
}
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
#[must_use]
pub fn paths_refer_to_same_dir(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn codewhale_project_root_is_inside_workspace(workspace: &Path, skills_dir: &Path) -> bool {
let Ok(canonical_workspace) = fs::canonicalize(workspace) else {
return false;
};
let Ok(canonical_skills) = fs::canonicalize(skills_dir) else {
return false;
};
canonical_skills.is_dir() && canonical_skills.starts_with(canonical_workspace)
}
fn path_is_existing_dir(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
fs::canonicalize(path).ok().is_some_and(|p| p.is_dir())
}
Ok(meta) => meta.is_dir(),
Err(_) => false,
}
}
fn match_compatible_project(workspace: &Path, skills_dir: &Path) -> Option<CompatibleHarness> {
let candidates = [
(
CompatibleHarness::Agents,
workspace.join(".agents").join("skills"),
),
(
CompatibleHarness::FlatProjectSkills,
workspace.join("skills"),
),
(
CompatibleHarness::OpenCode,
workspace.join(".opencode").join("skills"),
),
(
CompatibleHarness::Claude,
workspace.join(".claude").join("skills"),
),
(
CompatibleHarness::Cursor,
workspace.join(".cursor").join("skills"),
),
(
CompatibleHarness::Codex,
workspace.join(".codex").join("skills"),
),
];
for (harness, candidate) in candidates {
if paths_refer_to_same_dir(&candidate, skills_dir) {
return Some(harness);
}
}
None
}
fn match_compatible_global(home: &Path, skills_dir: &Path) -> Option<CompatibleHarness> {
let candidates = [
(
CompatibleHarness::Agents,
home.join(".agents").join("skills"),
),
(
CompatibleHarness::Claude,
home.join(".claude").join("skills"),
),
(
CompatibleHarness::DeepSeekLegacy,
home.join(".deepseek").join("skills"),
),
(CompatibleHarness::Codex, home.join(".codex").join("skills")),
];
for (harness, candidate) in candidates {
if paths_refer_to_same_dir(&candidate, skills_dir) {
return Some(harness);
}
}
None
}
#[allow(clippy::too_many_arguments)] fn push_existing(
roots: &mut Vec<SkillRootDescriptor>,
precedence: &mut usize,
kind: SkillRootKind,
access: SkillRootAccess,
scope: SkillScope,
path: PathBuf,
active_for_runtime: bool,
active_for_audit: bool,
id: &str,
) {
push_descriptor(
roots,
precedence,
kind,
access,
scope,
path,
active_for_runtime,
active_for_audit,
id,
false,
);
}
#[allow(clippy::too_many_arguments)] fn push_descriptor(
roots: &mut Vec<SkillRootDescriptor>,
precedence: &mut usize,
kind: SkillRootKind,
access: SkillRootAccess,
scope: SkillScope,
path: PathBuf,
active_for_runtime: bool,
active_for_audit: bool,
id: &str,
include_missing: bool,
) {
let exists = path_is_existing_dir(&path);
if !include_missing && !exists {
return;
}
let canonical_path = fs::canonicalize(&path).ok();
let slot = *precedence;
*precedence += 1;
roots.push(SkillRootDescriptor {
id: SkillRootId(id.to_string()),
kind,
access,
scope,
path,
canonical_path,
precedence: Some(slot),
active_for_runtime,
active_for_audit,
});
}
fn insert_configured_root(
roots: &mut Vec<SkillRootDescriptor>,
workspace: &Path,
home_dir: Option<&Path>,
skills_dir: &Path,
precedence: &mut usize,
) {
if !path_is_existing_dir(skills_dir) {
return;
}
if roots
.iter()
.any(|root| paths_refer_to_same_dir(&root.path, skills_dir))
{
return;
}
let (kind, access, scope) = classify_configured_skills_dir(workspace, home_dir, skills_dir);
let workspace_root = fs::canonicalize(workspace).ok();
let insert_at = workspace_root
.as_ref()
.and_then(|root| {
roots.iter().position(|dir| {
fs::canonicalize(&dir.path).map_or(true, |dir| !dir.starts_with(root))
})
})
.unwrap_or(roots.len());
let canonical_path = fs::canonicalize(skills_dir).ok();
let slot = *precedence;
*precedence += 1;
let descriptor = SkillRootDescriptor {
id: SkillRootId(format!("configured-{slot}")),
kind,
access,
scope,
path: skills_dir.to_path_buf(),
canonical_path,
precedence: Some(slot),
active_for_runtime: true,
active_for_audit: true,
};
roots.insert(insert_at, descriptor);
for (idx, root) in roots.iter_mut().enumerate() {
root.precedence = Some(idx);
}
*precedence = roots.len();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::SkillDiscoveryMode;
use tempfile::TempDir;
fn write_dir(path: &Path) {
std::fs::create_dir_all(path).unwrap();
}
#[test]
fn runtime_compatible_preserves_historical_workspace_order() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
write_dir(&workspace.join(".agents").join("skills"));
write_dir(&workspace.join("skills"));
write_dir(&workspace.join(".claude").join("skills"));
write_dir(&workspace.join(".cursor").join("skills"));
write_dir(&workspace.join(".codewhale").join("skills"));
write_dir(&workspace.join(".codex").join("skills"));
write_dir(&home.join(".codewhale").join("skills"));
let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
let dirs = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible);
assert_eq!(
dirs,
vec![
workspace.join(".agents").join("skills"),
workspace.join("skills"),
workspace.join(".claude").join("skills"),
workspace.join(".cursor").join("skills"),
workspace.join(".codewhale").join("skills"),
home.join(".codewhale").join("skills"),
]
);
assert!(
!dirs
.iter()
.any(|p| p == &workspace.join(".codex").join("skills")),
"codex must not activate for runtime"
);
}
#[test]
fn audit_compatible_includes_codex_without_runtime_activation() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
write_dir(&workspace.join(".codewhale").join("skills"));
write_dir(&workspace.join(".codex").join("skills"));
write_dir(&home.join(".codewhale").join("skills"));
write_dir(&home.join(".codex").join("skills"));
let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
let audit: Vec<_> = catalog
.audit_compatible_directories()
.into_iter()
.map(|r| r.path.clone())
.collect();
assert!(audit.contains(&workspace.join(".codex").join("skills")));
assert!(audit.contains(&home.join(".codex").join("skills")));
let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible);
assert!(!runtime.contains(&workspace.join(".codex").join("skills")));
assert!(!runtime.contains(&home.join(".codex").join("skills")));
}
#[test]
fn owned_roots_are_writable_and_codewhale_only() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
write_dir(&workspace.join(".agents").join("skills"));
write_dir(&workspace.join(".codewhale").join("skills"));
write_dir(&home.join(".codewhale").join("skills"));
write_dir(&home.join(".agents").join("skills"));
let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
let owned = catalog.owned_writable_roots();
assert_eq!(owned.len(), 2);
assert!(owned.iter().all(|r| r.is_writable_owned()));
let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::CodeWhaleOnly);
assert_eq!(
runtime,
vec![
workspace.join(".codewhale").join("skills"),
home.join(".codewhale").join("skills"),
]
);
}
#[test]
fn configured_compatible_path_stays_read_only() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
let agents = workspace.join(".agents").join("skills");
write_dir(&workspace);
write_dir(&agents);
let (kind, access, scope) =
classify_configured_skills_dir(&workspace, Some(&home), &agents);
assert_eq!(
kind,
SkillRootKind::CompatibleProject(CompatibleHarness::Agents)
);
assert_eq!(access, SkillRootAccess::ReadOnlyExternal);
assert_eq!(scope, SkillScope::Project);
}
#[test]
fn safe_display_path_prefers_home_then_workspace() {
let home = PathBuf::from("/home/user");
let workspace = home.join("proj");
let path = home.join(".codewhale").join("skills");
assert_eq!(
safe_display_path(&path, Some(&workspace), Some(&home)),
"~/.codewhale/skills"
);
let project = workspace.join(".codewhale").join("skills");
assert_eq!(
safe_display_path(&project, Some(&workspace), Some(&home)),
"<workspace>/.codewhale/skills"
);
}
}