From 2a30816095ed435fb345b6f2bc124fc6b72be47c Mon Sep 17 00:00:00 2001 From: Prabhat Agarwal Date: Sat, 21 Mar 2026 09:09:42 -0700 Subject: [PATCH] Add skills.roots config for explicit skill roots Add skills.roots to config so users can specify absolute skill folders. Use configured roots as the local root set when provided; otherwise keep default local roots. Add loader/config tests and regenerate the committed config schema fixture. Co-authored-by: Codex --- codex-rs/core/config.schema.json | 7 ++++ codex-rs/core/src/config/config_tests.rs | 1 + codex-rs/core/src/config/types.rs | 6 +++ codex-rs/core/src/skills/loader.rs | 39 ++++++++++++++++++- codex-rs/core/src/skills/loader_tests.rs | 48 ++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 009ef57369..f4568a4039 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1545,6 +1545,13 @@ "$ref": "#/definitions/SkillConfig" }, "type": "array" + }, + "roots": { + "description": "Additional absolute skill root directories to scan for `SKILL.md`.\n\nWhen non-empty, these roots override default local skill roots.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" } }, "type": "object" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1c78759ec4..46f66673eb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -176,6 +176,7 @@ enabled = false cfg.skills, Some(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), + roots: Vec::new(), config: Vec::new(), }) ); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 3b20779cd5..ff8edcaacf 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -821,6 +821,12 @@ pub struct SkillsConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub bundled: Option, + /// Additional absolute skill root directories to scan for `SKILL.md`. + /// + /// When non-empty, these roots override default local skill roots. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roots: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub config: Vec, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 5672bdb0a3..17d25dd4a0 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -3,6 +3,7 @@ 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::config::types::SkillsConfig; use crate::plugins::plugin_namespace_for_skill_path; use crate::skills::model::SkillDependencies; use crate::skills::model::SkillError; @@ -34,6 +35,7 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; use tracing::error; +use tracing::warn; #[cfg(test)] use crate::config::Config; @@ -234,16 +236,49 @@ fn skill_roots_with_home_dir( home_dir: Option<&Path>, plugin_skill_roots: Vec, ) -> Vec { - let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir); + let configured_roots = configured_skill_roots_from_stack(config_layer_stack); + let mut roots = if configured_roots.is_empty() { + let mut defaults = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir); + defaults.extend(repo_agents_skill_roots(config_layer_stack, cwd)); + defaults + } else { + configured_roots + }; roots.extend(plugin_skill_roots.into_iter().map(|path| SkillRoot { path, scope: SkillScope::User, })); - roots.extend(repo_agents_skill_roots(config_layer_stack, cwd)); dedupe_skill_roots_by_path(&mut roots); roots } +fn configured_skill_roots_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec { + let effective_config = config_layer_stack.effective_config(); + let Some(skills_value) = effective_config + .as_table() + .and_then(|table| table.get("skills")) + else { + return Vec::new(); + }; + + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + return Vec::new(); + } + }; + + skills + .roots + .into_iter() + .map(|path| SkillRoot { + path: path.as_path().to_path_buf(), + scope: SkillScope::User, + }) + .collect() +} + fn skill_roots_from_layer_stack_inner( config_layer_stack: &ConfigLayerStack, home_dir: Option<&Path>, diff --git a/codex-rs/core/src/skills/loader_tests.rs b/codex-rs/core/src/skills/loader_tests.rs index 7f758735cb..d43158be02 100644 --- a/codex-rs/core/src/skills/loader_tests.rs +++ b/codex-rs/core/src/skills/loader_tests.rs @@ -198,6 +198,54 @@ fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Re Ok(()) } +#[test] +fn skill_roots_from_layer_stack_can_override_defaults_with_explicit_roots() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let system_folder = tmp.path().join("etc/codex"); + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + let explicit_root = tmp.path().join("custom-skills"); + fs::create_dir_all(&system_folder)?; + fs::create_dir_all(&user_folder)?; + fs::create_dir_all(&explicit_root)?; + + let system_file = AbsolutePathBuf::from_absolute_path(system_folder.join("config.toml"))?; + let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; + + let mut skills_table = toml::map::Map::new(); + skills_table.insert( + "roots".to_string(), + TomlValue::Array(vec![TomlValue::String( + explicit_root.to_string_lossy().to_string(), + )]), + ); + let mut user_table = toml::map::Map::new(); + user_table.insert("skills".to_string(), TomlValue::Table(skills_table)); + let user_config = TomlValue::Table(user_table); + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::System { file: system_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new(ConfigLayerSource::User { file: user_file }, user_config), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let got = skill_roots_from_layer_stack(&stack, Some(&home_folder)) + .into_iter() + .map(|root| (root.scope, root.path)) + .collect::>(); + + assert_eq!(got, vec![(SkillScope::User, explicit_root)]); + Ok(()) +} + #[test] fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { let tmp = tempfile::tempdir()?;