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 <noreply@openai.com>
This commit is contained in:
Prabhat Agarwal
2026-03-21 09:09:42 -07:00
parent 06e06ab173
commit 2a30816095
5 changed files with 99 additions and 2 deletions

View File

@@ -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"

View File

@@ -176,6 +176,7 @@ enabled = false
cfg.skills,
Some(SkillsConfig {
bundled: Some(BundledSkillsConfig { enabled: false }),
roots: Vec::new(),
config: Vec::new(),
})
);

View File

@@ -821,6 +821,12 @@ pub struct SkillsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundled: Option<BundledSkillsConfig>,
/// 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<AbsolutePathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<SkillConfig>,
}

View File

@@ -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<PathBuf>,
) -> Vec<SkillRoot> {
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<SkillRoot> {
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>,

View File

@@ -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::<Vec<_>>();
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()?;