From 57255be585a82bdca980491b2dc610994f511345 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 27 May 2026 22:20:37 -0400 Subject: [PATCH] feat: support manual-only skill config --- code-rs/core/src/config.rs | 135 +++++++++++++++++++++++++++ code-rs/core/src/project_doc.rs | 31 ++++++- code-rs/core/src/skills/loader.rs | 147 +++++++++++++++++++++++++++++- docs/config.md | 25 +++++ 4 files changed, 336 insertions(+), 2 deletions(-) diff --git a/code-rs/core/src/config.rs b/code-rs/core/src/config.rs index abf92540d4e..c0446558460 100644 --- a/code-rs/core/src/config.rs +++ b/code-rs/core/src/config.rs @@ -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. @@ -937,6 +939,9 @@ pub struct ConfigToml { /// Experimental feature toggles. pub features: Option, + /// Skill discovery and implicit invocation configuration. + pub skills: Option, + /// Memory subsystem configuration. pub memories: Option, @@ -1041,6 +1046,98 @@ pub struct FeaturesToml { pub memories: Option, } +#[derive(Deserialize, Debug, Clone, Default)] +pub struct SkillsToml { + /// Whether turns receive the automatic skills instructions block. + #[serde(default)] + pub include_instructions: Option, + + /// Upstream-compatible per-skill selectors. An entry with `enabled = false` + /// leaves the skill discoverable but disables implicit prompt injection. + #[serde(default)] + pub config: Vec, +} + +#[derive(Deserialize, Debug, Clone, Default)] +pub struct SkillConfigToml { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub path: Option, + #[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, + pub config: Vec, +} + +#[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> for SkillsConfig { + fn from(value: Option) -> 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 { + 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)] @@ -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() @@ -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, @@ -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::( + 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#" diff --git a/code-rs/core/src/project_doc.rs b/code-rs/core/src/project_doc.rs index 14e7150585d..834dab8cf4d 100644 --- a/code-rs/core/src/project_doc.rs +++ b/code-rs/core/src/project_doc.rs @@ -37,7 +37,12 @@ pub(crate) async fn get_user_instructions( config: &Config, skills: Option<&[SkillMetadata]>, ) -> Option { - 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 { @@ -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. diff --git a/code-rs/core/src/skills/loader.rs b/code-rs/core/src/skills/loader.rs index 7dba2986c16..b8ed00a4c5c 100644 --- a/code-rs/core/src/skills/loader.rs +++ b/code-rs/core/src/skills/loader.rs @@ -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; @@ -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 { @@ -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"); diff --git a/docs/config.md b/docs/config.md index cd4d0824084..9157970f538 100644 --- a/docs/config.md +++ b/docs/config.md @@ -158,6 +158,31 @@ model_provider = "ollama" model = "mistral" ``` +## skills + +Skills are discovered by default and included in the model-visible skills block +unless a skill or config rule marks them manual-only. + +```toml +[skills] +include_instructions = true + +[[skills.config]] +name = "release-notes" +enabled = false + +[[skills.config]] +path = "/Users/me/.code/skills/archive/SKILL.md" +enabled = false +``` + +`enabled = false` keeps a skill installed and visible in skill listing surfaces, +but removes it from implicit skill routing and the default skills prompt. Explicit +turn requests such as `$release-notes` can still load the skill body. + +Set `include_instructions = false` to omit the default skills prompt block for +all skills while keeping discovery available. + ## approval_policy Determines when the user should be prompted to approve whether Code can execute a command: