mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(core/skills): add .agents and admin discovery roots
Add Codex-compatible skill discovery for repo and user .agents paths, keep legacy .codex compatibility, and include optional /etc/codex/skills. Follow symlinked skill directories for repo/user/admin roots with cycle-safe bounded traversal, and add regression tests for roots, precedence, symlinks, and legacy compatibility. fixes #540
This commit is contained in:
@@ -1133,6 +1133,9 @@ pub(super) async fn submission_loop(
|
||||
crate::skills::model::SkillScope::System => {
|
||||
code_protocol::protocol::SkillScope::System
|
||||
}
|
||||
crate::skills::model::SkillScope::Admin => {
|
||||
code_protocol::protocol::SkillScope::Admin
|
||||
}
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
@@ -26,9 +26,13 @@ struct SkillFrontmatter {
|
||||
|
||||
const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
const AGENTS_DIR_NAME: &str = ".agents";
|
||||
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
|
||||
const ADMIN_SKILLS_ROOT: &str = "/etc/codex/skills";
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
const MAX_SCAN_DEPTH: usize = 6;
|
||||
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SkillParseError {
|
||||
@@ -98,6 +102,14 @@ pub(crate) fn user_skills_root(config: &Config) -> SkillRoot {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn home_agents_skills_root() -> Option<SkillRoot> {
|
||||
let home = dirs::home_dir()?;
|
||||
Some(SkillRoot {
|
||||
path: home.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn system_skills_root(config: &Config) -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: system_cache_root_dir(&config.code_home),
|
||||
@@ -105,49 +117,77 @@ pub(crate) fn system_skills_root(config: &Config) -> SkillRoot {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
|
||||
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
|
||||
pub(crate) fn admin_skills_root() -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: PathBuf::from(ADMIN_SKILLS_ROOT),
|
||||
scope: SkillScope::Admin,
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_search_dirs(cwd: &Path) -> Vec<PathBuf> {
|
||||
let Some(base) = (if cwd.is_dir() { Some(cwd) } else { cwd.parent() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base = normalize_path(base).unwrap_or_else(|_| base.to_path_buf());
|
||||
|
||||
let repo_root =
|
||||
resolve_root_git_project_for_trust(&base).map(|root| normalize_path(&root).unwrap_or(root));
|
||||
|
||||
let scope = SkillScope::Repo;
|
||||
if let Some(repo_root) = repo_root.as_deref() {
|
||||
let mut dirs = Vec::new();
|
||||
for dir in base.ancestors() {
|
||||
let skills_root = dir.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
if skills_root.is_dir() {
|
||||
return Some(SkillRoot {
|
||||
path: skills_root,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
dirs.push(dir.to_path_buf());
|
||||
|
||||
if dir == repo_root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
return dirs;
|
||||
}
|
||||
|
||||
let skills_root = base.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
skills_root.is_dir().then_some(SkillRoot {
|
||||
path: skills_root,
|
||||
scope,
|
||||
})
|
||||
vec![base]
|
||||
}
|
||||
|
||||
fn repo_skills_roots_for_config_dir(cwd: &Path, config_dir: &str) -> Vec<SkillRoot> {
|
||||
repo_search_dirs(cwd)
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(config_dir).join(SKILLS_DIR_NAME))
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::Repo,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
roots.retain(|root| {
|
||||
let normalized = normalize_path(&root.path).unwrap_or_else(|_| root.path.clone());
|
||||
seen.insert(normalized)
|
||||
});
|
||||
}
|
||||
|
||||
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Some(repo_root) = repo_skills_root(&config.cwd) {
|
||||
roots.push(repo_root);
|
||||
roots.extend(repo_skills_roots_for_config_dir(&config.cwd, AGENTS_DIR_NAME));
|
||||
roots.extend(repo_skills_roots_for_config_dir(
|
||||
&config.cwd,
|
||||
REPO_ROOT_CONFIG_DIR_NAME,
|
||||
));
|
||||
|
||||
if let Some(home_root) = home_agents_skills_root() {
|
||||
roots.push(home_root);
|
||||
}
|
||||
|
||||
// Load order matters: we dedupe by name, keeping the first occurrence.
|
||||
// This makes repo/user skills win over system skills.
|
||||
// This makes repo/user skills win over system/admin skills.
|
||||
roots.push(user_skills_root(config));
|
||||
roots.push(system_skills_root(config));
|
||||
roots.push(admin_skills_root());
|
||||
|
||||
dedupe_skill_roots_by_path(&mut roots);
|
||||
|
||||
roots
|
||||
}
|
||||
@@ -161,8 +201,33 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
return;
|
||||
}
|
||||
|
||||
let mut queue: VecDeque<PathBuf> = VecDeque::from([root]);
|
||||
while let Some(dir) = queue.pop_front() {
|
||||
fn enqueue_dir(
|
||||
queue: &mut VecDeque<(PathBuf, usize)>,
|
||||
visited_dirs: &mut HashSet<PathBuf>,
|
||||
truncated_by_dir_limit: &mut bool,
|
||||
path: PathBuf,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > MAX_SCAN_DEPTH {
|
||||
return;
|
||||
}
|
||||
if visited_dirs.len() >= MAX_SKILLS_DIRS_PER_ROOT {
|
||||
*truncated_by_dir_limit = true;
|
||||
return;
|
||||
}
|
||||
if visited_dirs.insert(path.clone()) {
|
||||
queue.push_back((path, depth));
|
||||
}
|
||||
}
|
||||
|
||||
let follow_symlinks = matches!(scope, SkillScope::Repo | SkillScope::User | SkillScope::Admin);
|
||||
|
||||
let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
|
||||
visited_dirs.insert(root.clone());
|
||||
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
|
||||
let mut truncated_by_dir_limit = false;
|
||||
|
||||
while let Some((dir, depth)) = queue.pop_front() {
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
@@ -171,7 +236,10 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let mut entries: Vec<fs::DirEntry> = entries.flatten().collect();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let file_name = match path.file_name().and_then(|f| f.to_str()) {
|
||||
Some(name) => name,
|
||||
@@ -187,11 +255,47 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
};
|
||||
|
||||
if file_type.is_symlink() {
|
||||
if !follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"failed to stat skills entry {} (symlink): {e:#}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
let Ok(resolved_dir) = normalize_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_dir() {
|
||||
queue.push_back(path);
|
||||
let Ok(resolved_dir) = normalize_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -212,6 +316,14 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if truncated_by_dir_limit {
|
||||
tracing::warn!(
|
||||
"skills scan truncated after {} directories (root: {})",
|
||||
MAX_SKILLS_DIRS_PER_ROOT,
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, SkillParseError> {
|
||||
@@ -282,3 +394,342 @@ fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
|
||||
Some(frontmatter_lines.join("\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
const AGENTS_DIR_NAME: &str = ".agents";
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.previous.as_ref() {
|
||||
Some(value) => {
|
||||
// SAFETY: tests that mutate process env are serialised.
|
||||
unsafe { std::env::set_var(self.key, value) }
|
||||
}
|
||||
None => {
|
||||
// SAFETY: tests that mutate process env are serialised.
|
||||
unsafe { std::env::remove_var(self.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_env_var(key: &'static str, value: &Path) -> EnvVarGuard {
|
||||
let previous = std::env::var_os(key);
|
||||
// SAFETY: tests that mutate process env are serialised.
|
||||
unsafe { std::env::set_var(key, value) };
|
||||
EnvVarGuard { key, previous }
|
||||
}
|
||||
|
||||
fn make_config_for_cwd(code_home: &Path, cwd: &Path) -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides {
|
||||
cwd: Some(cwd.to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
code_home.to_path_buf(),
|
||||
)
|
||||
.expect("build config")
|
||||
}
|
||||
|
||||
fn mark_as_git_repo(dir: &Path) {
|
||||
let output = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("run git init");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git init failed: status={:?}",
|
||||
output.status.code()
|
||||
);
|
||||
}
|
||||
|
||||
fn write_skill_at(skills_root: &Path, dir: &str, name: &str, description: &str) -> PathBuf {
|
||||
let skill_dir = skills_root.join(dir);
|
||||
fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
let skill_path = skill_dir.join(SKILLS_FILENAME);
|
||||
fs::write(
|
||||
&skill_path,
|
||||
format!(
|
||||
"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
|
||||
),
|
||||
)
|
||||
.expect("write skill file");
|
||||
skill_path
|
||||
}
|
||||
|
||||
fn normalized(path: &Path) -> PathBuf {
|
||||
normalize_path(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_from_agents_dir_without_codex_dir() {
|
||||
let code_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let skill_path = write_skill_at(
|
||||
&repo_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
"agents",
|
||||
"agents-skill",
|
||||
"from agents",
|
||||
);
|
||||
let cfg = make_config_for_cwd(code_home.path(), repo_dir.path());
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert!(
|
||||
outcome.skills.iter().any(|skill| {
|
||||
skill.name == "agents-skill"
|
||||
&& skill.description == "from agents"
|
||||
&& skill.path == normalized(&skill_path)
|
||||
&& skill.scope == SkillScope::Repo
|
||||
}),
|
||||
"expected repo .agents skill in outcome: {:?}",
|
||||
outcome.skills
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn loads_skills_from_home_agents_dir_for_user_scope() {
|
||||
let home_dir = tempfile::tempdir().expect("tempdir");
|
||||
let _home_guard = set_env_var("HOME", home_dir.path());
|
||||
let code_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let skill_path = write_skill_at(
|
||||
&home_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
"home",
|
||||
"home-agents-skill",
|
||||
"from home agents",
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(code_home.path(), cwd.path());
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert!(
|
||||
outcome.skills.iter().any(|skill| {
|
||||
skill.name == "home-agents-skill"
|
||||
&& skill.description == "from home agents"
|
||||
&& skill.path == normalized(&skill_path)
|
||||
&& skill.scope == SkillScope::User
|
||||
}),
|
||||
"expected home .agents user skill in outcome: {:?}",
|
||||
outcome.skills
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn follows_symlinked_subdir_for_user_scope() {
|
||||
let root_dir = tempfile::tempdir().expect("tempdir");
|
||||
let shared_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let skill_path = write_skill_at(
|
||||
shared_dir.path(),
|
||||
"shared",
|
||||
"symlinked-user-skill",
|
||||
"from symlink",
|
||||
);
|
||||
|
||||
std::os::unix::fs::symlink(shared_dir.path(), root_dir.path().join("shared"))
|
||||
.expect("create symlink");
|
||||
|
||||
let outcome = load_skills_from_roots(vec![SkillRoot {
|
||||
path: root_dir.path().to_path_buf(),
|
||||
scope: SkillScope::User,
|
||||
}]);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert!(
|
||||
outcome.skills.iter().any(|skill| {
|
||||
skill.name == "symlinked-user-skill"
|
||||
&& skill.description == "from symlink"
|
||||
&& skill.path == normalized(&skill_path)
|
||||
&& skill.scope == SkillScope::User
|
||||
}),
|
||||
"expected symlinked user skill in outcome: {:?}",
|
||||
outcome.skills
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_from_agents_dirs_between_cwd_and_repo_root() {
|
||||
let code_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let nested_dir = repo_dir.path().join("nested/inner");
|
||||
fs::create_dir_all(&nested_dir).expect("create nested dir");
|
||||
|
||||
let root_skill_path = write_skill_at(
|
||||
&repo_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
"root",
|
||||
"root-agents-skill",
|
||||
"from root agents",
|
||||
);
|
||||
let nested_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join("nested")
|
||||
.join(AGENTS_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"nested",
|
||||
"nested-agents-skill",
|
||||
"from nested agents",
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(code_home.path(), &nested_dir);
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert!(
|
||||
outcome.skills.iter().any(|skill| {
|
||||
skill.name == "root-agents-skill"
|
||||
&& skill.path == normalized(&root_skill_path)
|
||||
&& skill.scope == SkillScope::Repo
|
||||
}),
|
||||
"expected root .agents skill in outcome: {:?}",
|
||||
outcome.skills
|
||||
);
|
||||
assert!(
|
||||
outcome.skills.iter().any(|skill| {
|
||||
skill.name == "nested-agents-skill"
|
||||
&& skill.path == normalized(&nested_skill_path)
|
||||
&& skill.scope == SkillScope::Repo
|
||||
}),
|
||||
"expected nested .agents skill in outcome: {:?}",
|
||||
outcome.skills
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn prefers_repo_agents_over_user_and_legacy_for_duplicate_name() {
|
||||
let home_dir = tempfile::tempdir().expect("tempdir");
|
||||
let _home_guard = set_env_var("HOME", home_dir.path());
|
||||
let code_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let nested_dir = repo_dir.path().join("nested/inner");
|
||||
fs::create_dir_all(&nested_dir).expect("create nested dir");
|
||||
|
||||
let repo_agents_path = write_skill_at(
|
||||
&repo_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
"dup",
|
||||
"collision-skill",
|
||||
"from repo agents",
|
||||
);
|
||||
write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"dup",
|
||||
"collision-skill",
|
||||
"from repo codex",
|
||||
);
|
||||
write_skill_at(
|
||||
&home_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
"dup",
|
||||
"collision-skill",
|
||||
"from home agents",
|
||||
);
|
||||
write_skill_at(
|
||||
&code_home.path().join(SKILLS_DIR_NAME),
|
||||
"dup",
|
||||
"collision-skill",
|
||||
"from legacy user",
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(code_home.path(), &nested_dir);
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
|
||||
let matching: Vec<&SkillMetadata> = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.filter(|skill| skill.name == "collision-skill")
|
||||
.collect();
|
||||
assert_eq!(matching.len(), 1, "expected one deduped collision skill");
|
||||
assert_eq!(matching[0].description, "from repo agents");
|
||||
assert_eq!(matching[0].scope, SkillScope::Repo);
|
||||
assert_eq!(matching[0].path, normalized(&repo_agents_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_loads_legacy_user_skills_root() {
|
||||
let code_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let legacy_path = write_skill_at(
|
||||
&code_home.path().join(SKILLS_DIR_NAME),
|
||||
"legacy",
|
||||
"legacy-user-skill",
|
||||
"from legacy user",
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(code_home.path(), cwd.path());
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert!(
|
||||
outcome.skills.iter().any(|skill| {
|
||||
skill.name == "legacy-user-skill"
|
||||
&& skill.description == "from legacy user"
|
||||
&& skill.path == normalized(&legacy_path)
|
||||
&& skill.scope == SkillScope::User
|
||||
}),
|
||||
"expected legacy user skill in outcome: {:?}",
|
||||
outcome.skills
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_root_uses_expected_path() {
|
||||
let root = admin_skills_root();
|
||||
assert_eq!(root.path, PathBuf::from(ADMIN_SKILLS_ROOT));
|
||||
assert_eq!(root.scope, SkillScope::Admin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub enum SkillScope {
|
||||
Repo,
|
||||
User,
|
||||
System,
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
+12
-2
@@ -19,8 +19,18 @@ Skills are behind the experimental `skills` feature flag and are enabled by defa
|
||||
|
||||
## Where skills live
|
||||
|
||||
- Location (v1): `~/.codex/skills/**/SKILL.md` (recursive). Hidden entries and symlinks are skipped. Only files named exactly `SKILL.md` count.
|
||||
- Sorting: rendered by name, then path for stability.
|
||||
- Discovery roots (highest precedence first):
|
||||
- Repo: `.agents/skills/**/SKILL.md` from the current working directory up to the repo root.
|
||||
- Repo (legacy): `.codex/skills/**/SKILL.md` from the current working directory up to the repo root.
|
||||
- User: `$HOME/.agents/skills/**/SKILL.md`.
|
||||
- User (legacy): `$CODEX_HOME/skills/**/SKILL.md` (usually `~/.code/skills`; legacy `~/.codex/skills` is still read for compatibility).
|
||||
- System: bundled skills under `$CODEX_HOME/skills/.system/**/SKILL.md`.
|
||||
- Admin (optional): `/etc/codex/skills/**/SKILL.md`.
|
||||
- Discovery is recursive and only files named exactly `SKILL.md` count.
|
||||
- Hidden entries are skipped.
|
||||
- Symlinked directories are followed for repo, user, and admin roots. Symlinked files are ignored.
|
||||
- Duplicate skill names are resolved by root precedence above (first match wins).
|
||||
- Skills are rendered by name, then path for stability.
|
||||
|
||||
## File format
|
||||
|
||||
|
||||
Reference in New Issue
Block a user