Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions code-rs/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,8 @@ pub struct Config {

/// Experimental: enable discovery and injection of skills.
pub skills_enabled: bool,
/// Per-skill configuration overrides for implicit invocation.
pub skills: SkillsConfig,
/// Upstream-aligned memory feature gate.
pub memories_enabled: bool,
/// Upstream-aligned memories runtime settings.
Expand Down Expand Up @@ -937,6 +939,9 @@ pub struct ConfigToml {
/// Experimental feature toggles.
pub features: Option<FeaturesToml>,

/// Skill discovery and implicit invocation configuration.
pub skills: Option<SkillsToml>,

/// Memory subsystem configuration.
pub memories: Option<MemoriesToml>,

Expand Down Expand Up @@ -1041,6 +1046,98 @@ pub struct FeaturesToml {
pub memories: Option<bool>,
}

#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillsToml {
/// Whether turns receive the automatic skills instructions block.
#[serde(default)]
pub include_instructions: Option<bool>,

/// Upstream-compatible per-skill selectors. An entry with `enabled = false`
/// leaves the skill discoverable but disables implicit prompt injection.
#[serde(default)]
pub config: Vec<SkillConfigToml>,
}

#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillConfigToml {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default = "default_skill_config_enabled")]
pub enabled: bool,
}

fn default_skill_config_enabled() -> bool {
true
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SkillsConfig {
pub include_instructions: Option<bool>,
pub config: Vec<SkillConfigRule>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillConfigRule {
pub selector: SkillConfigRuleSelector,
pub enabled: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillConfigRuleSelector {
Name(String),
Path(PathBuf),
}

impl From<Option<SkillsToml>> for SkillsConfig {
fn from(value: Option<SkillsToml>) -> Self {
let Some(value) = value else {
return Self::default();
};
Self {
include_instructions: value.include_instructions,
config: value
.config
.into_iter()
.filter_map(SkillConfigRule::from_toml)
.collect(),
}
}
}

impl SkillConfigRule {
fn from_toml(value: SkillConfigToml) -> Option<Self> {
match (value.name, value.path) {
(Some(name), None) => {
let name = name.trim();
if name.is_empty() {
tracing::warn!("ignoring skills.config entry with empty name selector");
return None;
}
Some(Self {
selector: SkillConfigRuleSelector::Name(name.to_string()),
enabled: value.enabled,
})
}
(None, Some(path)) => Some(Self {
selector: SkillConfigRuleSelector::Path(
dunce::canonicalize(&path).unwrap_or(path),
),
enabled: value.enabled,
}),
(Some(_), Some(_)) => {
tracing::warn!("ignoring skills.config entry with both name and path selectors");
None
}
(None, None) => {
tracing::warn!("ignoring skills.config entry without a name or path selector");
None
}
}
}
}

impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
#[cfg(test)]
Expand Down Expand Up @@ -1383,6 +1480,7 @@ impl Config {
.as_ref()
.and_then(|features| features.skills)
.unwrap_or(true);
let skills: SkillsConfig = cfg.skills.clone().into();
let memories_enabled = cfg
.features
.as_ref()
Expand Down Expand Up @@ -1823,6 +1921,7 @@ impl Config {
.unwrap_or(false),
include_view_image_tool: include_view_image_tool_flag,
skills_enabled,
skills,
memories_enabled,
memories,
env_ctx_v2: env_ctx_v2_flag,
Expand Down Expand Up @@ -2029,6 +2128,42 @@ mod tests {
assert!(parse_automation_origin("not json").is_none());
}

#[test]
fn skills_config_parses_upstream_compatible_selectors() {
let cfg = toml::from_str::<ConfigToml>(
r#"
[skills]
include_instructions = false

[[skills.config]]
name = "manual"
enabled = false

[[skills.config]]
path = "/tmp/auto/SKILL.md"
"#,
)
.expect("TOML deserialization should succeed");
let skills = SkillsConfig::from(cfg.skills);

assert_eq!(skills.include_instructions, Some(false));
assert_eq!(skills.config.len(), 2);
assert_eq!(
skills.config[0],
SkillConfigRule {
selector: SkillConfigRuleSelector::Name("manual".to_string()),
enabled: false,
}
);
assert_eq!(
skills.config[1],
SkillConfigRule {
selector: SkillConfigRuleSelector::Path(PathBuf::from("/tmp/auto/SKILL.md")),
enabled: true,
}
);
}

#[test]
fn test_toml_parsing() {
let history_with_persistence = r#"
Expand Down
31 changes: 30 additions & 1 deletion code-rs/core/src/project_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ pub(crate) async fn get_user_instructions(
config: &Config,
skills: Option<&[SkillMetadata]>,
) -> Option<String> {
let skills_section = skills.and_then(render_skills_section);
let skills_section = config
.skills
.include_instructions
.unwrap_or(true)
.then(|| skills.and_then(render_skills_section))
.flatten();
let skills_section_key = skills_section.as_ref().map(|section| section.trim().to_string());

let project_doc_parts = match read_project_doc_parts(config).await {
Expand Down Expand Up @@ -480,6 +485,30 @@ mod tests {
assert_eq!(recomposed.matches("### Available skills").count(), 1);
}

#[tokio::test]
async fn skills_include_instructions_false_omits_default_skills_section() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_path = tmp.path().join("skills").join("demo").join("SKILL.md");
let skills = vec![SkillMetadata {
name: "demo".to_string(),
description: "Demo skill".to_string(),
short_description: None,
path: skill_path,
scope: SkillScope::User,
content: String::new(),
policy: None,
}];
let mut config = make_config(&tmp, 4096, Some("base"));
config.skills.include_instructions = Some(false);

let rendered = get_user_instructions(&config, Some(&skills))
.await
.expect("instructions expected");

assert_eq!(rendered, "base");
assert!(!rendered.contains("### Available skills"));
}

/// If the base instructions match the root doc but there is additional
/// project-specific guidance deeper in the tree, only the new content is
/// appended after the separator.
Expand Down
147 changes: 146 additions & 1 deletion code-rs/core/src/skills/loader.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::config::Config;
use crate::config::SkillConfigRuleSelector;
use crate::config::resolve_code_path_for_read;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::skills::model::SkillError;
Expand Down Expand Up @@ -81,7 +82,31 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome {
if let Err(err) = install_system_skills(&config.code_home) {
tracing::error!("failed to install system skills: {err}");
}
load_skills_from_roots(skill_roots(config))
let mut outcome = load_skills_from_roots(skill_roots(config));
apply_skill_config_rules(&mut outcome.skills, config);
outcome
}

fn apply_skill_config_rules(skills: &mut [SkillMetadata], config: &Config) {
for rule in &config.skills.config {
match &rule.selector {
SkillConfigRuleSelector::Name(name) => {
for skill in skills.iter_mut().filter(|skill| skill.name == *name) {
set_allow_implicit_invocation(skill, rule.enabled);
}
}
SkillConfigRuleSelector::Path(path) => {
for skill in skills.iter_mut().filter(|skill| skill.path == *path) {
set_allow_implicit_invocation(skill, rule.enabled);
}
}
}
}
}

fn set_allow_implicit_invocation(skill: &mut SkillMetadata, enabled: bool) {
let policy = skill.policy.get_or_insert_with(SkillPolicy::default);
policy.allow_implicit_invocation = Some(enabled);
}

pub(crate) struct SkillRoot {
Expand Down Expand Up @@ -553,6 +578,126 @@ mod tests {
assert!(!skill.allow_implicit_invocation());
}

#[test]
fn config_name_selector_disables_implicit_invocation() {
let skills_root = tempfile::tempdir().expect("tempdir");
write_skill_at(
skills_root.path(),
"manual",
"manual-skill",
"Manual skill",
);

let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
skills: Some(crate::config::SkillsToml {
config: vec![crate::config::SkillConfigToml {
name: Some("manual-skill".to_string()),
enabled: false,
..Default::default()
}],
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempfile::tempdir().expect("code home").path().to_path_buf(),
)
.expect("config");
let mut outcome = load_skills_from_roots(vec![SkillRoot {
path: skills_root.path().to_path_buf(),
scope: SkillScope::User,
}]);

apply_skill_config_rules(&mut outcome.skills, &cfg);

assert_eq!(outcome.skills.len(), 1);
assert!(!outcome.skills[0].allow_implicit_invocation());
}

#[test]
fn config_path_selector_disables_implicit_invocation() {
let skills_root = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill_at(
skills_root.path(),
"manual",
"manual-skill",
"Manual skill",
);
let normalized_skill_path = normalized(&skill_path);

let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
skills: Some(crate::config::SkillsToml {
config: vec![crate::config::SkillConfigToml {
path: Some(skill_path),
enabled: false,
..Default::default()
}],
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempfile::tempdir().expect("code home").path().to_path_buf(),
)
.expect("config");
let mut outcome = load_skills_from_roots(vec![SkillRoot {
path: skills_root.path().to_path_buf(),
scope: SkillScope::User,
}]);

apply_skill_config_rules(&mut outcome.skills, &cfg);

assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].path, normalized_skill_path);
assert!(!outcome.skills[0].allow_implicit_invocation());
}

#[test]
fn later_skill_config_rule_can_re_enable_matching_skill() {
let skills_root = tempfile::tempdir().expect("tempdir");
write_skill_at(
skills_root.path(),
"enabled",
"enabled-skill",
"Enabled skill",
);

let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
skills: Some(crate::config::SkillsToml {
config: vec![
crate::config::SkillConfigToml {
name: Some("enabled-skill".to_string()),
enabled: false,
..Default::default()
},
crate::config::SkillConfigToml {
name: Some("enabled-skill".to_string()),
enabled: true,
..Default::default()
},
],
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempfile::tempdir().expect("code home").path().to_path_buf(),
)
.expect("config");
let mut outcome = load_skills_from_roots(vec![SkillRoot {
path: skills_root.path().to_path_buf(),
scope: SkillScope::User,
}]);

apply_skill_config_rules(&mut outcome.skills, &cfg);

assert_eq!(outcome.skills.len(), 1);
assert!(outcome.skills[0].allow_implicit_invocation());
}

#[test]
fn loads_optional_short_description_from_metadata_frontmatter() {
let skills_root = tempfile::tempdir().expect("tempdir");
Expand Down
Loading