diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index d48bda18f6..d8813e23d7 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -425,7 +425,9 @@ async fn load_requirements_from_legacy_scheme( /// empty array, which indicates that root detection should be disabled). /// - Returns an error if `project_root_markers` is specified but is not an /// array of strings. -fn project_root_markers_from_config(config: &TomlValue) -> io::Result>> { +pub(crate) fn project_root_markers_from_config( + config: &TomlValue, +) -> io::Result>> { let Some(table) = config.as_table() else { return Ok(None); }; @@ -454,7 +456,7 @@ fn project_root_markers_from_config(config: &TomlValue) -> io::Result Vec { +pub(crate) fn default_project_root_markers() -> Vec { DEFAULT_PROJECT_ROOT_MARKERS .iter() .map(ToString::to_string) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 32f1b8706e..a3c7731a6e 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,6 +1,9 @@ use crate::config::Config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; +use crate::config_loader::default_project_root_markers; +use crate::config_loader::merge_toml_values; +use crate::config_loader::project_root_markers_from_config; use crate::skills::model::SkillDependencies; use crate::skills::model::SkillError; use crate::skills::model::SkillInterface; @@ -20,6 +23,7 @@ use std::fs; use std::path::Component; use std::path::Path; use std::path::PathBuf; +use toml::Value as TomlValue; use tracing::error; #[derive(Debug, Deserialize)] @@ -72,6 +76,7 @@ struct DependencyTool { } const SKILLS_FILENAME: &str = "SKILL.md"; +const AGENTS_DIR_NAME: &str = ".agents"; const SKILLS_METADATA_DIR: &str = "agents"; const SKILLS_METADATA_FILENAME: &str = "openai.yaml"; const SKILLS_DIR_NAME: &str = "skills"; @@ -209,15 +214,104 @@ fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) -> } fn skill_roots(config: &Config) -> Vec { - skill_roots_from_layer_stack_inner(&config.config_layer_stack) + skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd) } +#[cfg(test)] pub(crate) fn skill_roots_from_layer_stack( config_layer_stack: &ConfigLayerStack, ) -> Vec { skill_roots_from_layer_stack_inner(config_layer_stack) } +pub(crate) fn skill_roots_from_layer_stack_with_agents( + config_layer_stack: &ConfigLayerStack, + cwd: &Path, +) -> Vec { + let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack); + roots.extend(repo_agents_skill_roots(config_layer_stack, cwd)); + dedupe_skill_roots_by_path(&mut roots); + roots +} + +fn dedupe_skill_roots_by_path(roots: &mut Vec) { + let mut seen: HashSet = HashSet::new(); + roots.retain(|root| seen.insert(root.path.clone())); +} + +fn repo_agents_skill_roots(config_layer_stack: &ConfigLayerStack, cwd: &Path) -> Vec { + let project_root_markers = project_root_markers_from_stack(config_layer_stack); + let project_root = find_project_root(cwd, &project_root_markers); + let dirs = dirs_between_project_root_and_cwd(cwd, &project_root); + let mut roots = Vec::new(); + for dir in dirs { + let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME); + if agents_skills.is_dir() { + roots.push(SkillRoot { + path: agents_skills, + scope: SkillScope::Repo, + }); + } + } + roots +} + +fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec { + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in + config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + { + if matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; + } + merge_toml_values(&mut merged, &layer.config); + } + + match project_root_markers_from_config(&merged) { + Ok(Some(markers)) => markers, + Ok(None) => default_project_root_markers(), + Err(err) => { + tracing::warn!("invalid project_root_markers: {err}"); + default_project_root_markers() + } + } +} + +fn find_project_root(cwd: &Path, project_root_markers: &[String]) -> PathBuf { + if project_root_markers.is_empty() { + return cwd.to_path_buf(); + } + + for ancestor in cwd.ancestors() { + for marker in project_root_markers { + let marker_path = ancestor.join(marker); + if marker_path.exists() { + return ancestor.to_path_buf(); + } + } + } + + cwd.to_path_buf() +} + +fn dirs_between_project_root_and_cwd(cwd: &Path, project_root: &Path) -> Vec { + let mut dirs = cwd + .ancestors() + .scan(false, |done, a| { + if *done { + None + } else { + if a == project_root { + *done = true; + } + Some(a.to_path_buf()) + } + }) + .collect::>(); + dirs.reverse(); + dirs +} + fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) { let Ok(root) = canonicalize_path(root) else { return; @@ -1615,6 +1709,40 @@ interface: ); } + #[tokio::test] + async fn loads_skills_from_agents_dir_without_codex_dir() { + let codex_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(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "agents-skill".to_string(), + description: "from agents".to_string(), + short_description: None, + interface: None, + dependencies: None, + path: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); + } + #[tokio::test] async fn loads_skills_from_all_codex_dirs_under_project_root() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 3b100991f7..85e0bf20eb 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -15,7 +15,7 @@ use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::skills::SkillLoadOutcome; use crate::skills::loader::load_skills_from_roots; -use crate::skills::loader::skill_roots_from_layer_stack; +use crate::skills::loader::skill_roots_from_layer_stack_with_agents; use crate::skills::system::install_system_skills; pub struct SkillsManager { @@ -47,7 +47,8 @@ impl SkillsManager { return outcome; } - let roots = skill_roots_from_layer_stack(&config.config_layer_stack); + let roots = + skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd); let mut outcome = load_skills_from_roots(roots); outcome.disabled_paths = disabled_paths_from_stack(&config.config_layer_stack); match self.cache_by_cwd.write() { @@ -105,7 +106,7 @@ impl SkillsManager { } }; - let roots = skill_roots_from_layer_stack(&config_layer_stack); + let roots = skill_roots_from_layer_stack_with_agents(&config_layer_stack, cwd); let mut outcome = load_skills_from_roots(roots); outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack); match self.cache_by_cwd.write() {