diff --git a/code-rs/Cargo.lock b/code-rs/Cargo.lock index 1413ef7d416..6ed0c210fa5 100644 --- a/code-rs/Cargo.lock +++ b/code-rs/Cargo.lock @@ -809,9 +809,8 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +version = "1.2.43" +source = "git+https://github.com/rust-lang/cc-rs?rev=8a45e2b2e99daf9abe45ae404984dc6a65356ded#8a45e2b2e99daf9abe45ae404984dc6a65356ded" dependencies = [ "find-msvc-tools", "jobserver", @@ -1615,6 +1614,7 @@ dependencies = [ "color-eyre", "crossterm", "diffy", + "dirs", "fs2", "futures", "image 0.25.8", @@ -2850,8 +2850,7 @@ dependencies = [ [[package]] name = "find-msvc-tools" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +source = "git+https://github.com/rust-lang/cc-rs?rev=8a45e2b2e99daf9abe45ae404984dc6a65356ded#8a45e2b2e99daf9abe45ae404984dc6a65356ded" [[package]] name = "fixed_decimal" diff --git a/code-rs/Cargo.toml b/code-rs/Cargo.toml index 83e8fa5171b..0be90b32bed 100644 --- a/code-rs/Cargo.toml +++ b/code-rs/Cargo.toml @@ -156,6 +156,7 @@ seccompiler = "0.5.0" serde = "1" serde_json = "1" serde_with = "3.14" +serde_yaml = "0.9" sha1 = "0.10.6" sha2 = "0.10" shlex = "1.3.0" @@ -252,6 +253,7 @@ codegen-units = 1 [patch.crates-io] # ratatui = { path = "../../ratatui" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +cc = { git = "https://github.com/rust-lang/cc-rs", rev = "8a45e2b2e99daf9abe45ae404984dc6a65356ded" } # Custom build profiles used by build-fast.sh [profile.dev-fast] diff --git a/code-rs/core/Cargo.toml b/code-rs/core/Cargo.toml index 897dc22552f..6212e09676f 100644 --- a/code-rs/core/Cargo.toml +++ b/code-rs/core/Cargo.toml @@ -48,7 +48,7 @@ schemars = "0.8.22" serde = { workspace = true, features = ["derive"] } serde_bytes = "0.11" serde_json = { workspace = true } -serde_yaml = "0.9" +serde_yaml = { workspace = true } sha1 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } @@ -66,6 +66,7 @@ tokio = { workspace = true, features = [ "macros", "process", "rt-multi-thread", + "sync", "signal", ] } tokio-util = { workspace = true } @@ -107,6 +108,7 @@ maplit = { workspace = true } once_cell = { workspace = true } serial_test = "3.2.0" pretty_assertions = { workspace = true } +tempfile = { workspace = true } tokio-test = { workspace = true } wiremock = { workspace = true } diff --git a/code-rs/core/src/client.rs b/code-rs/core/src/client.rs index 948dd391888..617d9849f91 100644 --- a/code-rs/core/src/client.rs +++ b/code-rs/core/src/client.rs @@ -30,7 +30,7 @@ use crate::chat_completions::stream_chat_completions; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; -use crate::client_common::ResponsesApiRequest; +use crate::client_common::{ResponsesApiRequest, SkillContainer}; use crate::client_common::create_reasoning_param_for_request; use crate::config::Config; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; @@ -406,6 +406,12 @@ impl ModelClient { (_, None, None) => None, }; + let container = if prompt.skills.is_empty() { + None + } else { + Some(SkillContainer { skills: &prompt.skills }) + }; + // In general, we want to explicitly send `store: false` when using the Responses API, // but in practice, the Azure Responses API rejects `store: false`: // @@ -439,6 +445,7 @@ impl ModelClient { include, // Use a stable per-process cache key (session id). With store=false this is inert. prompt_cache_key: Some(session_id_str.clone()), + container, }; let mut payload_json = serde_json::to_value(&payload)?; diff --git a/code-rs/core/src/client_common.rs b/code-rs/core/src/client_common.rs index f3dfa94e3fa..1e43782c312 100644 --- a/code-rs/core/src/client_common.rs +++ b/code-rs/core/src/client_common.rs @@ -79,6 +79,9 @@ pub struct Prompt { pub log_tag: Option, /// Optional override for session/conversation identifiers used for caching. pub session_id_override: Option, + + /// Enabled skills to advertise to the provider for this turn. + pub skills: Vec, } impl Default for Prompt { @@ -98,6 +101,7 @@ impl Default for Prompt { output_schema: None, log_tag: None, session_id_override: None, + skills: Vec::new(), } } } @@ -426,6 +430,24 @@ pub(crate) struct ResponsesApiRequest<'a> { pub(crate) include: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) container: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SkillRuntimeSpec { + #[serde(rename = "type")] + pub skill_type: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SkillContainer<'a> { + pub(crate) skills: &'a [SkillRuntimeSpec], } pub(crate) fn create_reasoning_param_for_request( @@ -537,6 +559,7 @@ mod tests { include: vec![], prompt_cache_key: None, text: Some(Text { verbosity: OpenAiTextVerbosity::Low, format: None }), + container: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -580,6 +603,7 @@ mod tests { schema: Some(schema.clone()), }), }), + container: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -616,6 +640,7 @@ mod tests { include: vec![], prompt_cache_key: None, text: None, + container: None, }; let v = serde_json::to_value(&req).expect("json"); diff --git a/code-rs/core/src/codex.rs b/code-rs/core/src/codex.rs index ad022b0923a..04dc2ace07a 100644 --- a/code-rs/core/src/codex.rs +++ b/code-rs/core/src/codex.rs @@ -2,8 +2,7 @@ #![allow(clippy::unwrap_used)] use std::borrow::Cow; -use std::collections::HashMap; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::VecDeque; use std::path::Component; use std::path::Path; @@ -60,11 +59,14 @@ use code_protocol::mcp_protocol::AuthMode; use crate::account_usage; use crate::auth_accounts; use crate::agent_defaults::{default_agent_configs, enabled_agent_model_specs}; +use crate::client_common::SkillRuntimeSpec; use code_protocol::models::WebSearchAction; use code_protocol::protocol::RolloutItem; use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; use chrono::Utc; +use crate::skills::{AllowedTool, LocalDirectorySkillLoader, SkillEntry, SkillId, SkillLoader, SkillManifest, SkillRegistry, SkillRegistryError, SkillSource}; +use dirs::home_dir; pub mod compact; use self::compact::build_compacted_history; @@ -933,6 +935,103 @@ where let _ = tokio::task::spawn_blocking(task); } +fn load_skill_registry_for_config(config: &Config) -> Arc { + let registry = Arc::new(SkillRegistry::new()); + let registry_ref = Arc::clone(®istry); + let mut seen = HashSet::new(); + + let mut load_path = |path: PathBuf, source: SkillSource| { + let key = path.canonicalize().unwrap_or_else(|_| path.clone()); + if !seen.insert(key) { + return; + } + + let loader = LocalDirectorySkillLoader::new(path.clone(), source); + match loader.load() { + Ok(entries) => { + for entry in entries { + if let Err(err) = registry_ref.add_skill(entry) { + if !matches!(err, SkillRegistryError::AlreadyExists(_)) { + warn!("failed to register skill from {}: {err}", path.display()); + } + } + } + } + Err(err) => { + warn!("failed to load skills from {}: {err}", path.display()); + } + } + }; + + let home = home_dir(); + + if config.skills.user_paths.is_empty() { + if let Some(home) = home.as_ref() { + let default = home.join(".claude/skills"); + load_path(default, SkillSource::LocalUser { root: PathBuf::new() }); + } + } else { + for user_path in &config.skills.user_paths { + let resolved = if user_path.is_absolute() { + user_path.clone() + } else if let Some(home) = home.as_ref() { + home.join(user_path) + } else { + config.cwd.join(user_path) + }; + load_path(resolved, SkillSource::LocalUser { root: PathBuf::new() }); + } + } + + if config.skills.project_paths.is_empty() { + let default = config.cwd.join(".claude/skills"); + load_path(default, SkillSource::Project { root: PathBuf::new() }); + } else { + for project_path in &config.skills.project_paths { + let resolved = if project_path.is_absolute() { + project_path.clone() + } else { + config.cwd.join(project_path) + }; + load_path(resolved, SkillSource::Project { root: PathBuf::new() }); + } + } + + for identifier in &config.skills.anthropic_skills { + match SkillId::new(identifier.clone()) { + Ok(skill_id) => { + let manifest = SkillManifest { + id: skill_id.clone(), + name: identifier.clone(), + description: "Anthropic catalog skill".to_string(), + allowed_tools: Vec::new(), + metadata: BTreeMap::new(), + body: String::new(), + manifest_path: PathBuf::new(), + root: PathBuf::new(), + }; + let entry = SkillEntry { + manifest, + source: SkillSource::Anthropic { + identifier: identifier.clone(), + version: None, + }, + }; + if let Err(err) = registry_ref.add_skill(entry) { + if !matches!(err, SkillRegistryError::AlreadyExists(_)) { + warn!("failed to register Anthropic skill '{}': {err}", identifier); + } + } + } + Err(err) => { + warn!("invalid Anthropic skill identifier '{}': {err}", identifier); + } + } + } + + registry +} + #[derive(Debug)] struct BackgroundExecState { notify: std::sync::Arc, @@ -999,6 +1098,8 @@ pub(crate) struct Session { hook_guard: AtomicBool, github: Arc>, validation: Arc>, + skills: Arc>, + skill_registry: Arc, self_handle: Weak, active_review: Mutex>, next_turn_text_format: Mutex>, @@ -1160,6 +1261,79 @@ impl Session { } } + pub(crate) fn update_skills_enabled(&self, enable: bool) { + if let Ok(mut cfg) = self.skills.write() { + cfg.enabled = enable; + } + } + + pub(crate) fn update_skill_toggle(&self, skill_id: &str, enable: bool) { + if let Ok(mut cfg) = self.skills.write() { + cfg.per_skill.insert(skill_id.to_string(), enable); + } + } + + pub(crate) fn enabled_skill_specs(&self) -> Vec { + self.enabled_skill_entries() + .into_iter() + .map(|entry| { + let allowed: Vec = entry + .manifest + .allowed_tools + .iter() + .map(|tool| match tool { + AllowedTool::Browser => "browser".to_string(), + AllowedTool::Agents => "agents".to_string(), + AllowedTool::Bash => "bash".to_string(), + AllowedTool::Custom(value) => value.to_ascii_lowercase(), + }) + .collect(); + let mut allowed = allowed; + allowed.sort(); + allowed.dedup(); + + let (skill_type, name, version) = match entry.source { + SkillSource::Anthropic { ref identifier, ref version } => ( + "anthropic".to_string(), + identifier.clone(), + version.clone(), + ), + SkillSource::LocalUser { .. } | SkillSource::Project { .. } => ( + "custom".to_string(), + entry.manifest.id.as_str().to_string(), + None, + ), + }; + + SkillRuntimeSpec { + skill_type, + name, + version, + allowed_tools: if allowed.is_empty() { None } else { Some(allowed) }, + } + }) + .collect() + } + + fn enabled_skill_entries(&self) -> Vec { + let cfg = self.skills.read().unwrap().clone(); + self.skill_registry + .list() + .into_iter() + .filter(|entry| { + let id = entry.manifest.id.as_str(); + cfg.per_skill.get(id).copied().unwrap_or(cfg.enabled) + }) + .collect() + } + + fn skill_entry(&self, skill_id: &str) -> Option { + self.skill_registry + .list() + .into_iter() + .find(|entry| entry.manifest.id.as_str() == skill_id) + } + fn resolve_path(&self, path: Option) -> PathBuf { path.as_ref() .map(PathBuf::from) @@ -3503,6 +3677,7 @@ async fn submission_loop( } } let default_shell = shell::default_user_shell().await; + let skill_registry = load_skill_registry_for_config(&config); let mut tools_config = ToolsConfig::new( &config.model_family, approval_policy, @@ -3535,6 +3710,17 @@ async fn submission_loop( agent_models.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); tools_config.set_agent_models(agent_models); + let has_enabled_skills = { + let cfg = &config.skills; + skill_registry.list().into_iter().any(|entry| { + let id = entry.manifest.id.as_str(); + cfg.per_skill.get(id).copied().unwrap_or(cfg.enabled) + }) + }; + if has_enabled_skills { + tools_config.enable_skill_tool(true); + } + let mut new_session = Arc::new(Session { id: session_id, client, @@ -3567,6 +3753,8 @@ async fn submission_loop( hook_guard: AtomicBool::new(false), github: Arc::new(RwLock::new(config.github.clone())), validation: Arc::new(RwLock::new(config.validation.clone())), + skills: Arc::new(RwLock::new(config.skills.clone())), + skill_registry: Arc::clone(&skill_registry), self_handle: Weak::new(), active_review: Mutex::new(None), next_turn_text_format: Mutex::new(None), @@ -3800,6 +3988,20 @@ async fn submission_loop( send_no_session_event(sub.id).await; } } + Op::UpdateSkillsEnabled { enabled } => { + if let Some(sess) = sess.as_ref() { + sess.update_skills_enabled(enabled); + } else { + send_no_session_event(sub.id).await; + } + } + Op::UpdateSkillToggle { skill_id, enable } => { + if let Some(sess) = sess.as_ref() { + sess.update_skill_toggle(&skill_id, enable); + } else { + send_no_session_event(sub.id).await; + } + } Op::AddToHistory { text } => { // TODO: What should we do if we got AddToHistory before ConfigureSession? // currently, if ConfigureSession has resume path, this history will be ignored @@ -4577,6 +4779,7 @@ async fn run_turn( output_schema: None, log_tag: Some("codex/turn".to_string()), session_id_override: None, + skills: sess.enabled_skill_specs(), }; // Start a new scratchpad for this HTTP attempt @@ -5382,6 +5585,7 @@ async fn handle_function_call( "agent" => handle_agent_tool(sess, &ctx, arguments).await, // unified browser tool "browser" => handle_browser_tool(sess, &ctx, arguments).await, + "skill" => handle_skill_tool(sess, &ctx, arguments).await, "web_fetch" => handle_web_fetch(sess, &ctx, arguments).await, "wait" => handle_wait(sess, &ctx, arguments).await, "kill" => handle_kill(sess, &ctx, arguments).await, @@ -10023,6 +10227,132 @@ where result } +async fn handle_skill_tool(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { + #[derive(serde::Deserialize)] + struct SkillCallPayload { + skill: String, + action: String, + #[serde(default)] + arguments: serde_json::Value, + } + + let call_id = ctx.call_id.clone(); + let payload: SkillCallPayload = match serde_json::from_str(&arguments) { + Ok(payload) => payload, + Err(err) => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("Invalid skill arguments: {err}"), + success: Some(false), + }, + }; + } + }; + + let entry = match sess.skill_entry(&payload.skill) { + Some(entry) => entry, + None => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("Unknown skill '{}'.", payload.skill), + success: Some(false), + }, + }; + } + }; + + let enabled = { + let cfg_guard = sess.skills.read().unwrap(); + cfg_guard + .per_skill + .get(entry.manifest.id.as_str()) + .copied() + .unwrap_or(cfg_guard.enabled) + }; + + if !enabled { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("Skill '{}' is disabled.", payload.skill), + success: Some(false), + }, + }; + } + + let allowed = if entry.manifest.allowed_tools.is_empty() { + true + } else { + let action_lower = payload.action.to_ascii_lowercase(); + entry.manifest.allowed_tools.iter().any(|tool| match tool { + AllowedTool::Browser => action_lower == "browser", + AllowedTool::Agents => action_lower == "agents", + AllowedTool::Bash => action_lower == "bash", + AllowedTool::Custom(value) => action_lower == value.to_ascii_lowercase(), + }) + }; + + if !allowed { + let allowed_list: Vec = if entry.manifest.allowed_tools.is_empty() { + Vec::new() + } else { + entry + .manifest + .allowed_tools + .iter() + .map(|tool| match tool { + AllowedTool::Browser => "browser".to_string(), + AllowedTool::Agents => "agents".to_string(), + AllowedTool::Bash => "bash".to_string(), + AllowedTool::Custom(value) => value.to_string(), + }) + .collect() + }; + + let hint = if allowed_list.is_empty() { + "this skill does not expose any actions in this build".to_string() + } else { + format!("allowed actions: {}", allowed_list.join(", ")) + }; + + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "Action '{}' is not permitted for skill '{}'; {}.", + payload.action, payload.skill, hint + ), + success: Some(false), + }, + }; + } + + let forwarded_args = if payload.arguments.is_null() { + "{}".to_string() + } else { + serde_json::to_string(&payload.arguments).unwrap_or_else(|_| "{}".to_string()) + }; + + match payload.action.to_ascii_lowercase().as_str() { + "browser" => handle_browser_tool(sess, ctx, forwarded_args).await, + "agents" | "agent" => handle_agent_tool(sess, ctx, forwarded_args).await, + _ => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "Skill '{}' action '{}' is not implemented yet. Arguments were: {}", + payload.skill, + payload.action, + serde_json::to_string(&payload.arguments).unwrap_or_else(|_| "{}".to_string()) + ), + success: Some(false), + }, + }, + } +} + async fn handle_browser_tool(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { use serde_json::Value; diff --git a/code-rs/core/src/codex/compact.rs b/code-rs/core/src/codex/compact.rs index 678f4df79e6..b9ecb7d01bd 100644 --- a/code-rs/core/src/codex/compact.rs +++ b/code-rs/core/src/codex/compact.rs @@ -136,6 +136,7 @@ pub(super) async fn perform_compaction( output_schema: None, log_tag: Some("codex/compact".to_string()), session_id_override: None, + skills: sess.enabled_skill_specs(), }; let max_retries = turn_context.client.get_provider().stream_max_retries(); @@ -252,6 +253,7 @@ async fn run_compact_task_inner_inline( output_schema: None, log_tag: Some("codex/compact".to_string()), session_id_override: None, + skills: sess.enabled_skill_specs(), }; let max_retries = turn_context.client.get_provider().stream_max_retries(); diff --git a/code-rs/core/src/config.rs b/code-rs/core/src/config.rs index 0359e7e5283..786a2a624cd 100644 --- a/code-rs/core/src/config.rs +++ b/code-rs/core/src/config.rs @@ -13,6 +13,7 @@ use crate::config_types::CachedTerminalBackground; use crate::config_types::ClientTools; use crate::config_types::History; use crate::config_types::GithubConfig; +use crate::config_types::SkillsConfig; use crate::config_types::ValidationConfig; use crate::config_types::ThemeName; use crate::config_types::ThemeColors; @@ -298,6 +299,9 @@ pub struct Config { /// Validation harness configuration. pub validation: ValidationConfig, + /// Claude skills configuration and overrides. + pub skills: SkillsConfig, + /// Resolved subagent command configurations (including custom ones). /// If a command with name `plan|solve|code` exists here, it overrides /// the built-in defaults for that slash command. @@ -1135,6 +1139,100 @@ pub fn set_validation_tool_enabled( Ok(()) } +/// Persist `[skills].enabled = `. +pub fn set_skills_enabled(code_home: &Path, enabled: bool) -> anyhow::Result<()> { + let config_path = code_home.join(CONFIG_TOML_FILE); + let read_path = resolve_code_path_for_read(code_home, Path::new(CONFIG_TOML_FILE)); + let mut doc = match std::fs::read_to_string(&read_path) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + doc["skills"]["enabled"] = toml_edit::value(enabled); + + std::fs::create_dir_all(code_home)?; + let tmp = NamedTempFile::new_in(code_home)?; + std::fs::write(tmp.path(), doc.to_string())?; + tmp.persist(config_path)?; + Ok(()) +} + +/// Persist per-skill overrides under `[skills.per_skill.""]`. +pub fn set_skill_enabled( + code_home: &Path, + skill: &str, + enabled: bool, +) -> anyhow::Result<()> { + let config_path = code_home.join(CONFIG_TOML_FILE); + let read_path = resolve_code_path_for_read(code_home, Path::new(CONFIG_TOML_FILE)); + let mut doc = match std::fs::read_to_string(&read_path) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + doc["skills"]["per_skill"][skill] = toml_edit::value(enabled); + + std::fs::create_dir_all(code_home)?; + let tmp = NamedTempFile::new_in(code_home)?; + std::fs::write(tmp.path(), doc.to_string())?; + tmp.persist(config_path)?; + Ok(()) +} + +/// Persist path lists under `[skills.user_paths]` or `[skills.project_paths]`. +pub fn set_skills_paths( + code_home: &Path, + key: &str, + paths: &[PathBuf], +) -> anyhow::Result<()> { + let config_path = code_home.join(CONFIG_TOML_FILE); + let read_path = resolve_code_path_for_read(code_home, Path::new(CONFIG_TOML_FILE)); + let mut doc = match std::fs::read_to_string(&read_path) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + let mut array = TomlArray::default(); + for path in paths { + array.push(path.to_string_lossy().to_string()); + } + + doc["skills"][key] = toml_edit::Item::Value(toml_edit::Value::Array(array)); + + std::fs::create_dir_all(code_home)?; + let tmp = NamedTempFile::new_in(code_home)?; + std::fs::write(tmp.path(), doc.to_string())?; + tmp.persist(config_path)?; + Ok(()) +} + +/// Persist `[skills.anthropic_skills] = [..]`. +pub fn set_skills_anthropic_list(code_home: &Path, skills: &[String]) -> anyhow::Result<()> { + let config_path = code_home.join(CONFIG_TOML_FILE); + let read_path = resolve_code_path_for_read(code_home, Path::new(CONFIG_TOML_FILE)); + let mut doc = match std::fs::read_to_string(&read_path) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + let mut array = TomlArray::default(); + for skill in skills { + array.push(skill.clone()); + } + + doc["skills"]["anthropic_skills"] = toml_edit::Item::Value(toml_edit::Value::Array(array)); + + std::fs::create_dir_all(code_home)?; + let tmp = NamedTempFile::new_in(code_home)?; + std::fs::write(tmp.path(), doc.to_string())?; + tmp.persist(config_path)?; + Ok(()) +} + /// Persist per-project access mode under `[projects.""]` with /// `approval_policy` and `sandbox_mode`. pub fn set_project_access_mode( @@ -1777,6 +1875,10 @@ pub struct ConfigToml { /// Validation harness configuration. pub validation: Option, + /// Claude skills configuration. + #[serde(default)] + pub skills: Option, + /// Configuration for subagent commands (built-ins and custom). #[serde(default)] pub subagents: Option, @@ -2357,6 +2459,7 @@ impl Config { using_chatgpt_auth, github: cfg.github.unwrap_or_default(), validation: cfg.validation.unwrap_or_default(), + skills: cfg.skills.unwrap_or_default(), subagent_commands: cfg .subagents .map(|s| s.commands) diff --git a/code-rs/core/src/config_types.rs b/code-rs/core/src/config_types.rs index a61d5fed18d..8870fc64392 100644 --- a/code-rs/core/src/config_types.rs +++ b/code-rs/core/src/config_types.rs @@ -3,7 +3,7 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::time::Duration; use schemars::JsonSchema; @@ -443,6 +443,32 @@ fn default_true() -> bool { true } +#[derive(Deserialize, Debug, Clone, PartialEq)] +pub struct SkillsConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub user_paths: Vec, + #[serde(default)] + pub project_paths: Vec, + #[serde(default)] + pub per_skill: BTreeMap, + #[serde(default)] + pub anthropic_skills: Vec, +} + +impl Default for SkillsConfig { + fn default() -> Self { + Self { + enabled: true, + user_paths: Vec::new(), + project_paths: Vec::new(), + per_skill: BTreeMap::new(), + anthropic_skills: Vec::new(), + } + } +} + /// GitHub integration settings. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct GithubConfig { diff --git a/code-rs/core/src/lib.rs b/code-rs/core/src/lib.rs index 0d1b356fcc2..0a581136ed3 100644 --- a/code-rs/core/src/lib.rs +++ b/code-rs/core/src/lib.rs @@ -80,6 +80,7 @@ mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod skills; pub mod spawn; pub mod terminal; pub mod otel_init; diff --git a/code-rs/core/src/openai_tools.rs b/code-rs/core/src/openai_tools.rs index 5eb1b16cd8a..0a65665f2de 100644 --- a/code-rs/core/src/openai_tools.rs +++ b/code-rs/core/src/openai_tools.rs @@ -88,6 +88,7 @@ pub struct ToolsConfig { pub include_view_image_tool: bool, pub web_search_allowed_domains: Option>, pub agent_model_allowed_values: Vec, + pub skill_tool: bool, } #[allow(dead_code)] @@ -143,6 +144,7 @@ impl ToolsConfig { include_view_image_tool, web_search_allowed_domains: None, agent_model_allowed_values: Vec::new(), + skill_tool: false, } } @@ -170,6 +172,10 @@ impl ToolsConfig { pub fn agent_models(&self) -> &[String] { &self.agent_model_allowed_values } + + pub fn enable_skill_tool(&mut self, enabled: bool) { + self.skill_tool = enabled; + } } /// Whether additional properties are allowed, and if so, any required schema @@ -679,6 +685,10 @@ pub fn get_openai_tools( tools.push(PLAN_TOOL.clone()); } + if config.skill_tool { + tools.push(create_skill_tool()); + } + tools.push(create_browser_tool(browser_enabled)); // Add agent management tool for launching and monitoring asynchronous agents @@ -776,6 +786,43 @@ pub fn create_kill_tool() -> OpenAiTool { }) } +pub fn create_skill_tool() -> OpenAiTool { + let mut properties = BTreeMap::new(); + properties.insert( + "skill".to_string(), + JsonSchema::String { + description: Some("Identifier of the skill to invoke.".to_string()), + allowed_values: None, + }, + ); + properties.insert( + "action".to_string(), + JsonSchema::String { + description: Some("Skill action to run (e.g., browser, agents, bash).".to_string()), + allowed_values: None, + }, + ); + properties.insert( + "arguments".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(true.into()), + }, + ); + + OpenAiTool::Function(ResponsesApiTool { + name: "skill".to_string(), + description: "Invoke a Claude Skill with a specific action. Execution is stubbed in this build.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["skill".to_string(), "action".to_string()]), + additional_properties: Some(true.into()), + }, + }) +} + #[cfg(test)] #[allow(clippy::expect_used)] mod tests { diff --git a/code-rs/core/src/protocol.rs b/code-rs/core/src/protocol.rs index bb5ba4b4c29..4589be10d7b 100644 --- a/code-rs/core/src/protocol.rs +++ b/code-rs/core/src/protocol.rs @@ -180,6 +180,17 @@ pub enum Op { enable: bool, }, + /// Update the global skills enablement flag for this session. + UpdateSkillsEnabled { + enabled: bool, + }, + + /// Update the enablement override for a specific skill id. + UpdateSkillToggle { + skill_id: String, + enable: bool, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has diff --git a/code-rs/core/src/skills/manifest.rs b/code-rs/core/src/skills/manifest.rs new file mode 100644 index 00000000000..c3c15c17772 --- /dev/null +++ b/code-rs/core/src/skills/manifest.rs @@ -0,0 +1,200 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use super::{AllowedTool, AllowedTools, SkillId, SkillIdError}; + +/// Parsed representation of a SKILL.md file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillManifest { + pub id: SkillId, + pub name: String, + pub description: String, + pub allowed_tools: AllowedTools, + pub metadata: BTreeMap, + pub body: String, + pub manifest_path: PathBuf, + pub root: PathBuf, +} + +/// Errors that can arise while parsing a SKILL.md manifest. +#[derive(thiserror::Error, Debug)] +pub enum SkillManifestError { + #[error("skill directory name must be valid UTF-8")] + NonUnicodeDirectoryName, + #[error("missing SKILL.md file in {0}")] + MissingManifest(PathBuf), + #[error("failed to read SKILL.md at {path}: {source}")] + Io { path: PathBuf, source: std::io::Error }, + #[error("SKILL.md must start with YAML frontmatter delimited by --- lines")] + MissingFrontmatter, + #[error("SKILL.md frontmatter is not terminated with ---")] + UnterminatedFrontmatter, + #[error("invalid YAML frontmatter: {0}")] + InvalidYaml(serde_yaml::Error), + #[error("skill manifest is missing required field: {0}")] + MissingRequiredField(&'static str), + #[error("skill name '{manifest_name}' must match directory name '{directory_name}'")] + NameMismatch { manifest_name: String, directory_name: String }, + #[error("invalid skill name: {0}")] + InvalidSkillId(#[from] SkillIdError), +} + +#[derive(Debug, Deserialize)] +struct Frontmatter { + name: Option, + description: Option, + #[serde(rename = "allowed-tools")] + allowed_tools: Option>, + #[serde(default)] + metadata: BTreeMap, +} + +/// Parse the SKILL.md manifest located under `skill_root`. +pub fn parse_skill_manifest_from_path(skill_root: &Path) -> Result { + let directory_name = skill_root + .file_name() + .and_then(|name| name.to_str()) + .ok_or(SkillManifestError::NonUnicodeDirectoryName)? + .to_string(); + + let manifest_path = skill_root.join("SKILL.md"); + if !manifest_path.exists() { + return Err(SkillManifestError::MissingManifest(manifest_path)); + } + + let raw = fs::read_to_string(&manifest_path).map_err(|source| SkillManifestError::Io { + path: manifest_path.clone(), + source, + })?; + + let (frontmatter_raw, body) = extract_frontmatter(&raw)?; + + let frontmatter: Frontmatter = serde_yaml::from_str(&frontmatter_raw) + .map_err(SkillManifestError::InvalidYaml)?; + + let name = frontmatter + .name + .ok_or(SkillManifestError::MissingRequiredField("name"))?; + let description = frontmatter + .description + .ok_or(SkillManifestError::MissingRequiredField("description"))?; + + if name != directory_name { + return Err(SkillManifestError::NameMismatch { + manifest_name: name, + directory_name, + }); + } + + let id = SkillId::try_from(name.clone())?; + let allowed_tools = frontmatter + .allowed_tools + .unwrap_or_default() + .into_iter() + .map(AllowedTool::from_label) + .collect(); + + Ok(SkillManifest { + id, + name, + description, + allowed_tools, + metadata: frontmatter.metadata, + body, + manifest_path, + root: skill_root.to_path_buf(), + }) +} + +fn extract_frontmatter(input: &str) -> Result<(String, String), SkillManifestError> { + let mut lines = input.lines(); + let Some(first_line) = lines.next() else { + return Err(SkillManifestError::MissingFrontmatter); + }; + + if first_line.trim() != "---" { + return Err(SkillManifestError::MissingFrontmatter); + } + + let mut frontmatter_lines = Vec::new(); + let mut found_terminator = false; + + for line in &mut lines { + if line.trim() == "---" { + found_terminator = true; + break; + } + frontmatter_lines.push(line); + } + + if !found_terminator { + return Err(SkillManifestError::UnterminatedFrontmatter); + } + + let body = lines.collect::>().join("\n"); + + Ok((frontmatter_lines.join("\n"), body)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::skills::AllowedTool; + use tempfile::tempdir; + + #[test] + fn parse_valid_manifest() { + let temp = tempdir().unwrap(); + let skill_root = temp.path().join("financial-modeling"); + std::fs::create_dir(&skill_root).unwrap(); + let content = r#"--- +name: financial-modeling +description: Build budgets. +allowed-tools: + - browser + - Bash +metadata: + owner: finance +--- + +# Skill Body +"#; + std::fs::write(skill_root.join("SKILL.md"), content).unwrap(); + + let manifest = parse_skill_manifest_from_path(&skill_root).unwrap(); + + assert_eq!(manifest.id.as_str(), "financial-modeling"); + assert_eq!(manifest.description, "Build budgets."); + assert_eq!(manifest.allowed_tools.len(), 2); + assert_eq!(manifest.allowed_tools[0], AllowedTool::Browser); + assert_eq!(manifest.allowed_tools[1], AllowedTool::Bash); + assert_eq!(manifest.metadata.get("owner").unwrap().as_str().unwrap(), "finance"); + assert!(manifest.body.contains("Skill Body")); + } + + #[test] + fn manifest_name_mismatch_errors() { + let temp = tempdir().unwrap(); + let skill_root = temp.path().join("financial-modeling"); + std::fs::create_dir(&skill_root).unwrap(); + let content = r#"--- +name: wrong-name +description: Something +--- +"#; + std::fs::write(skill_root.join("SKILL.md"), content).unwrap(); + + let err = parse_skill_manifest_from_path(&skill_root).unwrap_err(); + + match err { + SkillManifestError::NameMismatch { manifest_name, directory_name } => { + assert_eq!(manifest_name, "wrong-name"); + assert_eq!(directory_name, "financial-modeling"); + } + other => panic!("unexpected error: {:?}", other), + } + } +} diff --git a/code-rs/core/src/skills/mod.rs b/code-rs/core/src/skills/mod.rs new file mode 100644 index 00000000000..3011feb09ec --- /dev/null +++ b/code-rs/core/src/skills/mod.rs @@ -0,0 +1,121 @@ +//! Skill manifest parsing and in-memory registry primitives. + +mod manifest; +mod store; + +pub use manifest::{parse_skill_manifest_from_path, SkillManifest, SkillManifestError}; +pub use store::{ + LocalDirectorySkillLoader, + SkillEntry, + SkillLoader, + SkillLoaderError, + SkillRegistry, + SkillRegistryEvent, + SkillRegistryError, +}; + +use std::borrow::Cow; +use std::fmt; +use std::path::PathBuf; + +/// Identifier for a Claude skill. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SkillId(String); + +impl SkillId { + /// Create a new skill identifier, validating the provided name. + pub fn new(id: impl Into) -> Result { + let id = id.into(); + if id.trim().is_empty() { + return Err(SkillIdError::Empty); + } + + if !id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' ) + { + return Err(SkillIdError::InvalidCharacters(id)); + } + + Ok(Self(id)) + } + + /// Borrow the identifier as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SkillId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom<&str> for SkillId { + type Error = SkillIdError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom for SkillId { + type Error = SkillIdError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +/// Errors that can arise when validating skill identifiers. +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum SkillIdError { + #[error("skill name cannot be empty")] + Empty, + #[error("skill name contains invalid characters: {0}")] + InvalidCharacters(String), +} + +/// Location a skill originates from. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SkillSource { + /// Skills provided by Anthropic via the Claude catalog. + Anthropic { identifier: String, version: Option }, + /// Skills uploaded by the local user (stored under their profile directory). + LocalUser { root: PathBuf }, + /// Skills checked into the active project. + Project { root: PathBuf }, +} + +/// Tool capabilities a skill declares. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AllowedTool { + Browser, + Agents, + Bash, + Custom(String), +} + +impl AllowedTool { + pub fn from_label(label: impl Into) -> Self { + let label_owned = label.into(); + match label_owned.trim().to_ascii_lowercase().as_str() { + "browser" => Self::Browser, + "agents" => Self::Agents, + "bash" => Self::Bash, + _ => Self::Custom(label_owned), + } + } + + pub fn label(&self) -> Cow<'_, str> { + match self { + Self::Browser => Cow::Borrowed("browser"), + Self::Agents => Cow::Borrowed("agents"), + Self::Bash => Cow::Borrowed("bash"), + Self::Custom(value) => Cow::Borrowed(value.as_str()), + } + } +} + +pub type AllowedTools = Vec; diff --git a/code-rs/core/src/skills/store.rs b/code-rs/core/src/skills/store.rs new file mode 100644 index 00000000000..a8026accfb5 --- /dev/null +++ b/code-rs/core/src/skills/store.rs @@ -0,0 +1,202 @@ +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use tokio::sync::broadcast; +use tracing::warn; + +use super::manifest::parse_skill_manifest_from_path; +use super::{SkillId, SkillManifest, SkillSource}; + +/// Trait implemented by concrete skill loaders (Anthropic catalog, local paths, etc.). +pub trait SkillLoader: Send + Sync { + fn load(&self) -> Result, SkillLoaderError>; +} + +/// Error while loading skills from a source. +#[derive(thiserror::Error, Debug)] +pub enum SkillLoaderError { + #[error("skill loader failed: {0}")] + Message(String), + #[error(transparent)] + Manifest(#[from] super::manifest::SkillManifestError), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// In-memory registry entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillEntry { + pub manifest: SkillManifest, + pub source: SkillSource, +} + +/// Notification describing a registry change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillRegistryEvent { + SkillAdded(SkillEntry), + SkillRemoved { id: SkillId }, +} + +/// Errors returned by the in-memory registry. +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum SkillRegistryError { + #[error("skill '{0}' already exists in the registry")] + AlreadyExists(SkillId), + #[error("skill '{0}' was not found in the registry")] + NotFound(SkillId), +} + +/// Thread-safe registry storing parsed skills and broadcasting change events. +pub struct SkillRegistry { + inner: RwLock>, + notifier: broadcast::Sender, +} + +impl SkillRegistry { + pub fn new() -> Self { + let (notifier, _) = broadcast::channel(32); + Self { + inner: RwLock::new(HashMap::new()), + notifier, + } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.notifier.subscribe() + } + + pub fn list(&self) -> Vec { + let map = self.inner.read().unwrap(); + let mut entries: Vec<_> = map.values().cloned().collect(); + entries.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); + entries + } + + pub fn add_skill(&self, entry: SkillEntry) -> Result<(), SkillRegistryError> { + let id = entry.manifest.id.clone(); + + let mut guard = self.inner.write().unwrap(); + if guard.contains_key(&id) { + return Err(SkillRegistryError::AlreadyExists(id)); + } + guard.insert(id.clone(), entry.clone()); + drop(guard); + + let _ = self.notifier.send(SkillRegistryEvent::SkillAdded(entry)); + Ok(()) + } + + pub fn remove_skill(&self, id: &SkillId) -> Result { + let mut guard = self.inner.write().unwrap(); + let Some(entry) = guard.remove(id) else { + return Err(SkillRegistryError::NotFound(id.clone())); + }; + drop(guard); + + let _ = self + .notifier + .send(SkillRegistryEvent::SkillRemoved { id: id.clone() }); + + Ok(entry) + } +} + +impl Default for SkillRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Loader that scans a local directory for skill bundles. +pub struct LocalDirectorySkillLoader { + root: PathBuf, + source: SkillSource, +} + +impl LocalDirectorySkillLoader { + pub fn new(root: impl Into, source: SkillSource) -> Self { + Self { root: root.into(), source } + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn entry_source(&self, skill_root: &Path) -> SkillSource { + match &self.source { + SkillSource::Anthropic { identifier, version } => SkillSource::Anthropic { + identifier: identifier.clone(), + version: version.clone(), + }, + SkillSource::LocalUser { .. } => SkillSource::LocalUser { + root: skill_root.to_path_buf(), + }, + SkillSource::Project { .. } => SkillSource::Project { + root: skill_root.to_path_buf(), + }, + } + } +} + +impl SkillLoader for LocalDirectorySkillLoader { + fn load(&self) -> Result, SkillLoaderError> { + let mut entries = Vec::new(); + + let root_manifest = self.root.join("SKILL.md"); + if root_manifest.exists() { + match parse_skill_manifest_from_path(&self.root) { + Ok(manifest) => { + let source = self.entry_source(&self.root); + entries.push(SkillEntry { manifest, source }); + } + Err(err) => { + warn!("failed to parse SKILL.md at {}: {err}", root_manifest.display()); + } + } + } + + let read_dir = match fs::read_dir(&self.root) { + Ok(iter) => iter, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(entries), + Err(err) => return Err(SkillLoaderError::Io(err)), + }; + + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + warn!("failed to read entry in {}: {err}", self.root.display()); + continue; + } + }; + + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let manifest_path = path.join("SKILL.md"); + if !manifest_path.exists() { + continue; + } + + match parse_skill_manifest_from_path(&path) { + Ok(manifest) => { + let source = self.entry_source(&path); + entries.push(SkillEntry { manifest, source }); + } + Err(err) => { + warn!( + "failed to parse SKILL.md at {}: {err}", + manifest_path.display() + ); + } + } + } + + Ok(entries) + } +} diff --git a/code-rs/tui/Cargo.toml b/code-rs/tui/Cargo.toml index 08371d3e34a..76aac707c67 100644 --- a/code-rs/tui/Cargo.toml +++ b/code-rs/tui/Cargo.toml @@ -105,6 +105,7 @@ time = { workspace = true, features = ["formatting", "macros", "local-offset"] } # vt100 0.16+ depends on unicode-width 0.2.1 which conflicts with ratatui 0.29.x. # Stick to 0.15.x until ratatui upgrades. vt100 = "0.15.0" +dirs = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/code-rs/tui/src/app.rs b/code-rs/tui/src/app.rs index 0d30a8f706a..e9509c9325f 100644 --- a/code-rs/tui/src/app.rs +++ b/code-rs/tui/src/app.rs @@ -2277,6 +2277,16 @@ impl App<'_> { widget.toggle_validation_group(group, enable); } } + AppEvent::UpdateSkillsEnabled { enabled } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_skills_enabled(enabled); + } + } + AppEvent::UpdateSkillToggle { skill_id, enable } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_skill_toggle(&skill_id, enable); + } + } AppEvent::SetTerminalTitle { title } => { self.terminal_title_override = title; self.apply_terminal_title(); diff --git a/code-rs/tui/src/app_event.rs b/code-rs/tui/src/app_event.rs index 4e0fa5d869e..162377980a0 100644 --- a/code-rs/tui/src/app_event.rs +++ b/code-rs/tui/src/app_event.rs @@ -214,6 +214,10 @@ pub(crate) enum AppEvent { UpdateValidationTool { name: String, enable: bool }, /// Enable/disable an entire validation group UpdateValidationGroup { group: ValidationGroup, enable: bool }, + /// Update the global skills enable flag + UpdateSkillsEnabled { enabled: bool }, + /// Override enablement for a specific skill id + UpdateSkillToggle { skill_id: String, enable: bool }, /// Start installing a validation tool through the terminal overlay RequestValidationToolInstall { name: String, command: String }, diff --git a/code-rs/tui/src/bottom_pane/mod.rs b/code-rs/tui/src/bottom_pane/mod.rs index 786c905a64e..6cc0598c732 100644 --- a/code-rs/tui/src/bottom_pane/mod.rs +++ b/code-rs/tui/src/bottom_pane/mod.rs @@ -51,6 +51,7 @@ pub mod form_text_field; mod theme_selection_view; mod verbosity_selection_view; pub(crate) mod validation_settings_view; +mod skills_settings_view; mod update_settings_view; mod undo_timeline_view; mod notifications_settings_view; @@ -85,6 +86,7 @@ pub(crate) use login_accounts_view::{ pub(crate) use update_settings_view::{UpdateSettingsView, UpdateSharedState}; pub(crate) use notifications_settings_view::{NotificationsMode, NotificationsSettingsView}; pub(crate) use validation_settings_view::ValidationSettingsView; +pub(crate) use skills_settings_view::{SkillDisplay, SkillsSettingsView}; use approval_modal_view::ApprovalModalView; #[cfg(feature = "code-fork")] use approval_ui::ApprovalUi; diff --git a/code-rs/tui/src/bottom_pane/settings_overlay.rs b/code-rs/tui/src/bottom_pane/settings_overlay.rs index d25781c7977..2e9484dabb2 100644 --- a/code-rs/tui/src/bottom_pane/settings_overlay.rs +++ b/code-rs/tui/src/bottom_pane/settings_overlay.rs @@ -7,6 +7,7 @@ pub(crate) enum SettingsSection { AutoDrive, Validation, Github, + Skills, Limits, Chrome, Mcp, @@ -14,7 +15,7 @@ pub(crate) enum SettingsSection { } impl SettingsSection { - pub(crate) const ALL: [SettingsSection; 11] = [ + pub(crate) const ALL: [SettingsSection; 12] = [ SettingsSection::Model, SettingsSection::Theme, SettingsSection::Updates, @@ -22,6 +23,7 @@ impl SettingsSection { SettingsSection::AutoDrive, SettingsSection::Validation, SettingsSection::Github, + SettingsSection::Skills, SettingsSection::Chrome, SettingsSection::Mcp, SettingsSection::Notifications, @@ -37,6 +39,7 @@ impl SettingsSection { SettingsSection::AutoDrive => "Auto Drive", SettingsSection::Validation => "Validation", SettingsSection::Github => "GitHub", + SettingsSection::Skills => "Skills", SettingsSection::Limits => "Limits", SettingsSection::Chrome => "Chrome", SettingsSection::Mcp => "MCP", @@ -53,6 +56,7 @@ impl SettingsSection { SettingsSection::AutoDrive => "Manage Auto Drive defaults for review and cadence.", SettingsSection::Validation => "Toggle validation groups and tool availability.", SettingsSection::Github => "Monitor GitHub workflows after pushes.", + SettingsSection::Skills => "Enable Claude skills, manage discovery paths, and see docs/skills.md for authoring guidance.", SettingsSection::Limits => "Inspect API usage, rate limits, and reset windows.", SettingsSection::Chrome => "Connect to Chrome or switch browser integrations.", SettingsSection::Mcp => "Enable and manage local MCP servers for tooling.", @@ -69,6 +73,7 @@ impl SettingsSection { SettingsSection::AutoDrive => "Auto Drive controls coming soon.", SettingsSection::Validation => "Validation harness controls coming soon.", SettingsSection::Github => "GitHub workflow watcher controls coming soon.", + SettingsSection::Skills => "Toggle skills discovered under ~/.claude/skills or project .claude/skills. See docs/skills.md for manifest details.", SettingsSection::Limits => "Limits usage visualization coming soon.", SettingsSection::Chrome => "Chrome integration settings coming soon.", SettingsSection::Mcp => "MCP server management coming soon.", @@ -85,6 +90,7 @@ impl SettingsSection { "auto" | "autodrive" | "drive" => Some(SettingsSection::AutoDrive), "validation" | "validate" => Some(SettingsSection::Validation), "github" | "gh" => Some(SettingsSection::Github), + "skill" | "skills" => Some(SettingsSection::Skills), "limit" | "limits" | "usage" => Some(SettingsSection::Limits), "chrome" | "browser" => Some(SettingsSection::Chrome), "mcp" => Some(SettingsSection::Mcp), diff --git a/code-rs/tui/src/bottom_pane/skills_settings_view.rs b/code-rs/tui/src/bottom_pane/skills_settings_view.rs new file mode 100644 index 00000000000..24f645fe976 --- /dev/null +++ b/code-rs/tui/src/bottom_pane/skills_settings_view.rs @@ -0,0 +1,283 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::BottomPane; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::prelude::Widget; + +#[derive(Clone, Debug)] +pub(crate) struct SkillDisplay { + pub id: String, + pub description: Option, + pub source_label: String, + pub allowed_tools: Vec, + pub enabled: bool, +} + +enum RowKind { + GlobalToggle, + Skill { index: usize }, + Paths { label: &'static str, values: Vec }, + Message(&'static str), + Spacer, +} + +struct Row { + kind: RowKind, +} + +pub(crate) struct SkillsSettingsView { + skills_enabled: bool, + skills: Vec, + rows: Vec, + selectable: Vec, + selected_idx: usize, + app_event_tx: AppEventSender, + is_complete: bool, +} + +impl SkillsSettingsView { + pub fn new( + skills_enabled: bool, + skills: Vec, + user_paths: Vec, + project_paths: Vec, + app_event_tx: AppEventSender, + ) -> Self { + let mut rows = Vec::new(); + let mut selectable = Vec::new(); + + rows.push(Row { kind: RowKind::GlobalToggle }); + selectable.push(0); + + if !user_paths.is_empty() || !project_paths.is_empty() { + rows.push(Row { kind: RowKind::Spacer }); + } + if !user_paths.is_empty() { + rows.push(Row { + kind: RowKind::Paths { label: "User paths", values: user_paths }, + }); + } + if !project_paths.is_empty() { + rows.push(Row { + kind: RowKind::Paths { label: "Project paths", values: project_paths }, + }); + } + + if !skills.is_empty() { + rows.push(Row { kind: RowKind::Spacer }); + } + + for (idx, _) in skills.iter().enumerate() { + let row_index = rows.len(); + rows.push(Row { kind: RowKind::Skill { index: idx } }); + selectable.push(row_index); + } + + if skills.is_empty() { + rows.push(Row { + kind: RowKind::Message("No skills discovered yet."), + }); + } + + Self { + skills_enabled, + skills, + rows, + selectable, + selected_idx: 0, + app_event_tx, + is_complete: false, + } + } + + fn selected_row_index(&self) -> usize { + self.selectable + .get(self.selected_idx) + .copied() + .unwrap_or(0) + } + + fn move_selection(&mut self, delta: isize) { + if self.selectable.is_empty() { + return; + } + let len = self.selectable.len() as isize; + let mut idx = self.selected_idx as isize + delta; + if idx < 0 { + idx = len - 1; + } + if idx >= len { + idx = 0; + } + self.selected_idx = idx as usize; + } + + fn toggle_selected(&mut self) { + let row_idx = self.selected_row_index(); + match self.rows.get(row_idx).map(|row| &row.kind) { + Some(RowKind::GlobalToggle) => { + self.skills_enabled = !self.skills_enabled; + self.app_event_tx + .send(AppEvent::UpdateSkillsEnabled { enabled: self.skills_enabled }); + } + Some(RowKind::Skill { index }) => { + if let Some(skill) = self.skills.get_mut(*index) { + skill.enabled = !skill.enabled; + self.app_event_tx.send(AppEvent::UpdateSkillToggle { + skill_id: skill.id.clone(), + enable: skill.enabled, + }); + } + } + _ => {} + } + } + + pub fn handle_key_event_direct(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. } => self.move_selection(-1), + KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. } => self.move_selection(1), + KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. } + | KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => { + self.toggle_selected(); + } + KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } + | KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => { + self.toggle_selected(); + } + KeyEvent { code: KeyCode::Esc, .. } => { + self.is_complete = true; + } + _ => {} + } + } + + fn render_row(&self, row: &Row, row_idx: usize) -> Vec> { + let is_selected = row_idx == self.selected_row_index(); + match &row.kind { + RowKind::GlobalToggle => { + let label = if self.skills_enabled { "Enabled" } else { "Disabled" }; + let mut style = Style::default().fg(crate::colors::text()); + if is_selected { + style = style + .bg(crate::colors::selection()) + .add_modifier(Modifier::BOLD); + } + vec![Line::from(vec![ + Span::styled("Claude Skills", Style::default().fg(crate::colors::text_dim())), + Span::raw(": "), + Span::styled(label, style), + ])] + } + RowKind::Skill { index } => { + let skill = &self.skills[*index]; + let mut style = Style::default().fg(crate::colors::text()); + if is_selected { + style = style + .bg(crate::colors::selection()) + .add_modifier(Modifier::BOLD); + } + let status = if skill.enabled { "On" } else { "Off" }; + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + Span::styled(skill.id.clone(), style), + Span::raw(" "), + Span::styled(status, Style::default().fg(if skill.enabled { + crate::colors::success() + } else { + crate::colors::text_dim() + })), + Span::raw(" "), + Span::styled(skill.source_label.clone(), Style::default().fg(crate::colors::text_dim())), + ])); + if let Some(desc) = &skill.description { + lines.push(Line::from(vec![Span::styled( + desc.clone(), + Style::default().fg(crate::colors::dim()), + )])); + } + if !skill.allowed_tools.is_empty() { + lines.push(Line::from(vec![Span::styled( + format!("Tools: {}", skill.allowed_tools.join(", ")), + Style::default().fg(crate::colors::text_dim()), + )])); + } + lines + } + RowKind::Paths { label, values } => { + let mut lines = Vec::new(); + lines.push(Line::from(vec![Span::styled( + format!("{}:", label), + Style::default().fg(crate::colors::text_dim()).add_modifier(Modifier::BOLD), + )])); + if values.is_empty() { + lines.push(Line::from(vec![Span::styled( + " (none)", + Style::default().fg(crate::colors::dim()), + )])); + } else { + for value in values { + lines.push(Line::from(vec![Span::styled( + format!(" {value}"), + Style::default().fg(crate::colors::dim()), + )])); + } + } + lines + } + RowKind::Message(text) => vec![Line::from(vec![Span::styled( + text.to_string(), + Style::default().fg(crate::colors::dim()), + )])], + RowKind::Spacer => vec![Line::from("")], + } + } + + fn render_lines(&self) -> Vec> { + let mut lines = Vec::new(); + for (idx, row) in self.rows.iter().enumerate() { + lines.extend(self.render_row(row, idx)); + } + lines + } + + pub fn is_view_complete(&self) -> bool { + self.is_complete + } +} + +impl<'a> BottomPaneView<'a> for SkillsSettingsView { + fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) { + self.handle_key_event_direct(key_event); + } + + fn is_complete(&self) -> bool { + self.is_complete + } + + fn desired_height(&self, _width: u16) -> u16 { + 12 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(crate::colors::border())) + .style(Style::default().bg(crate::colors::background()).fg(crate::colors::text())) + .title(" Skills "); + let inner = block.inner(area); + block.render(area, buf); + + let lines = self.render_lines(); + Paragraph::new(lines) + .style(Style::default().bg(crate::colors::background()).fg(crate::colors::text())) + .render(inner, buf); + } +} diff --git a/code-rs/tui/src/chatwidget.rs b/code-rs/tui/src/chatwidget.rs index 2dfc7bd9ce1..1c3b39f336c 100644 --- a/code-rs/tui/src/chatwidget.rs +++ b/code-rs/tui/src/chatwidget.rs @@ -5,7 +5,7 @@ use std::collections::hash_map::Entry; use std::collections::HashSet; use std::collections::VecDeque; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::{Rc, Weak}; use std::sync::Arc; use std::sync::mpsc::Sender; @@ -40,6 +40,10 @@ use code_core::config_types::TextVerbosity; use code_core::plan_tool::{PlanItemArg, StepStatus, UpdatePlanArgs}; use code_core::model_family::derive_default_model_family; use code_core::model_family::find_family_for_model; +use code_core::skills::{LocalDirectorySkillLoader, SkillRegistry, SkillRegistryError, SkillSource, SkillLoader}; +use crate::bottom_pane::{SkillsSettingsView, SkillDisplay}; +use crate::chatwidget::settings_overlay::SkillsSettingsContent; +use dirs::home_dir; use code_core::account_usage::{ self, RateLimitWarningScope, @@ -531,8 +535,12 @@ use code_core::config::find_code_home; use code_core::config::resolve_code_path_for_read; use code_core::config::set_github_actionlint_on_patch; use code_core::config::set_github_check_on_push; -use code_core::config::set_validation_group_enabled; -use code_core::config::set_validation_tool_enabled; +use code_core::config::{ + set_skill_enabled, + set_skills_enabled, + set_validation_group_enabled, + set_validation_tool_enabled, +}; use code_file_search::FileMatch; use code_cloud_tasks_client::{ApplyOutcome, CloudTaskError, CreatedTask, TaskSummary}; use code_protocol::models::ContentItem; @@ -779,6 +787,7 @@ pub(crate) struct ChatWidget<'a> { history_snapshot_dirty: bool, history_snapshot_last_flush: Option, config: Config, + skill_registry: Arc, history_debug_events: Option>>, latest_upgrade_version: Option, initial_user_message: Option, @@ -4020,6 +4029,7 @@ impl ChatWidget<'_> { active_exec_cell: None, history_cells, config: config.clone(), + skill_registry: Arc::new(SkillRegistry::new()), history_debug_events: if history_cell_logging_enabled() { Some(RefCell::new(Vec::new())) } else { @@ -4259,6 +4269,7 @@ impl ChatWidget<'_> { w.seed_test_mode_greeting(); } w.maybe_start_auto_upgrade_task(); + w.load_local_skills(); w } @@ -4327,6 +4338,7 @@ impl ChatWidget<'_> { active_exec_cell: None, history_cells, config: config.clone(), + skill_registry: Arc::new(SkillRegistry::new()), history_debug_events: if history_cell_logging_enabled() { Some(RefCell::new(Vec::new())) } else { @@ -4540,6 +4552,7 @@ impl ChatWidget<'_> { w.seed_test_mode_greeting(); } w.maybe_start_auto_upgrade_task(); + w.load_local_skills(); w } @@ -12456,6 +12469,72 @@ impl ChatWidget<'_> { GithubSettingsContent::new(view) } + fn build_skills_settings_content(&self) -> SkillsSettingsContent { + let global_enabled = self.config.skills.enabled; + let per_skill = &self.config.skills.per_skill; + let entries = self.skill_registry.list(); + let skills: Vec = entries + .into_iter() + .map(|entry| { + let manifest = entry.manifest; + let allowed_tools = manifest + .allowed_tools + .iter() + .map(|tool| tool.label().to_string()) + .collect(); + let description = manifest + .description + .trim() + .to_string(); + let description = if description.is_empty() { + None + } else { + Some(description) + }; + let source_label = match entry.source { + SkillSource::Anthropic { identifier, .. } => format!("Anthropic ({identifier})"), + SkillSource::LocalUser { root } => format!("User ({})", root.display()), + SkillSource::Project { root } => format!("Project ({})", root.display()), + }; + let id = manifest.id.as_str().to_string(); + let enabled = per_skill + .get(manifest.id.as_str()) + .copied() + .unwrap_or(global_enabled); + SkillDisplay { + id, + description, + source_label, + allowed_tools, + enabled, + } + }) + .collect(); + + let user_paths = self + .config + .skills + .user_paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + let project_paths = self + .config + .skills + .project_paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + let view = SkillsSettingsView::new( + global_enabled, + skills, + user_paths, + project_paths, + self.app_event_tx.clone(), + ); + SkillsSettingsContent::new(view) + } + fn build_auto_drive_settings_content(&mut self) -> AutoDriveSettingsContent { let review = self.auto_state.review_enabled; let agents = self.auto_state.subagents_enabled; @@ -17452,6 +17531,7 @@ Have we met every part of this goal and is there no further work to do?"# overlay.set_auto_drive_content(self.build_auto_drive_settings_content()); overlay.set_validation_content(self.build_validation_settings_content()); overlay.set_github_content(self.build_github_settings_content()); + overlay.set_skills_content(self.build_skills_settings_content()); overlay.set_limits_content(self.build_limits_settings_content()); overlay.set_chrome_content(self.build_chrome_settings_content(None)); let overview_rows = self.build_settings_overview_rows(); @@ -17730,6 +17810,7 @@ Have we met every part of this goal and is there no further work to do?"# SettingsSection::AutoDrive => self.settings_summary_auto_drive(), SettingsSection::Validation => self.settings_summary_validation(), SettingsSection::Github => self.settings_summary_github(), + SettingsSection::Skills => self.settings_summary_skills(), SettingsSection::Limits => self.settings_summary_limits(), SettingsSection::Chrome => self.settings_summary_chrome(), SettingsSection::Mcp => self.settings_summary_mcp(), @@ -17832,6 +17913,17 @@ Have we met every part of this goal and is there no further work to do?"# )) } + fn settings_summary_skills(&self) -> Option { + let total = self.skill_registry.list().len(); + let overrides = self.config.skills.per_skill.len(); + let mut parts = vec![format!("Global: {}", Self::on_off_label(self.config.skills.enabled))]; + parts.push(format!("Catalog: {}", total)); + if overrides > 0 { + parts.push(format!("Overrides: {}", overrides)); + } + Some(parts.join(" · ")) + } + fn settings_summary_limits(&self) -> Option { if let Some(snapshot) = &self.rate_limit_snapshot { let primary = snapshot.primary_used_percent.clamp(0.0, 100.0).round() as i64; @@ -17975,7 +18067,8 @@ Have we met every part of this goal and is there no further work to do?"# | SettingsSection::Github | SettingsSection::AutoDrive | SettingsSection::Mcp - | SettingsSection::Notifications => false, + | SettingsSection::Notifications + | SettingsSection::Skills => false, SettingsSection::Agents => { self.show_agents_overview_ui(); false @@ -31383,6 +31476,83 @@ impl ChatWidget<'_> { self.refresh_settings_overview_rows(); } + fn display_name_for_skill(&self, skill_id: &str) -> Option { + self + .skill_registry + .list() + .into_iter() + .find(|entry| entry.manifest.id.as_str() == skill_id) + .map(|entry| entry.manifest.name) + } + + pub(crate) fn set_skills_enabled(&mut self, enabled: bool) { + if self.config.skills.enabled == enabled { + return; + } + + self.config.skills.enabled = enabled; + + if let Err(err) = self.code_op_tx.send(Op::UpdateSkillsEnabled { enabled }) { + tracing::warn!("failed to send skills enabled update: {err}"); + } + + let status = if enabled { "Enabled" } else { "Disabled" }; + match find_code_home() { + Ok(home) => match set_skills_enabled(&home, enabled) { + Ok(()) => self.push_background_tail(format!("✅ {status} Claude skills")), + Err(err) => self.push_background_tail(format!( + "⚠️ {status} Claude skills (persist failed: {err})" + )), + }, + Err(err) => { + self.push_background_tail(format!( + "⚠️ {status} Claude skills (persist failed: {err})" + )); + } + } + + self.refresh_settings_overview_rows(); + self.refresh_skills_settings_view(); + self.request_redraw(); + } + + pub(crate) fn set_skill_toggle(&mut self, skill_id: &str, enabled: bool) { + self.config + .skills + .per_skill + .insert(skill_id.to_string(), enabled); + + if let Err(err) = self + .code_op_tx + .send(Op::UpdateSkillToggle { skill_id: skill_id.to_string(), enable: enabled }) + { + tracing::warn!("failed to send skill toggle update: {err}"); + } + + let label = self + .display_name_for_skill(skill_id) + .unwrap_or_else(|| skill_id.to_string()); + let status = if enabled { "Enabled" } else { "Disabled" }; + + match find_code_home() { + Ok(home) => match set_skill_enabled(&home, skill_id, enabled) { + Ok(()) => self.push_background_tail(format!("✅ {status} skill {label}")), + Err(err) => self.push_background_tail(format!( + "⚠️ {status} skill {label} (persist failed: {err})" + )), + }, + Err(err) => { + self.push_background_tail(format!( + "⚠️ {status} skill {label} (persist failed: {err})" + )); + } + } + + self.refresh_settings_overview_rows(); + self.refresh_skills_settings_view(); + self.request_redraw(); + } + pub(crate) fn set_tui_notifications(&mut self, enabled: bool) { let new_state = Notifications::Enabled(enabled); self.config.tui.notifications = new_state.clone(); @@ -31419,6 +31589,84 @@ impl ChatWidget<'_> { self.refresh_settings_overview_rows(); } + fn refresh_skills_settings_view(&mut self) { + let content = self.build_skills_settings_content(); + if let Some(overlay) = self.settings.overlay.as_mut() { + overlay.set_skills_content(content); + self.request_redraw(); + } + } + + fn load_local_skills(&mut self) { + let mut loader_specs: Vec<(PathBuf, SkillSource)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let home = home_dir(); + + let user_paths: Vec = if self.config.skills.user_paths.is_empty() { + home.as_ref() + .map(|home| vec![home.join(".claude/skills")]) + .unwrap_or_default() + } else { + self.config.skills.user_paths.clone() + }; + + for path in user_paths { + let resolved = if path.is_absolute() { + path + } else if let Some(home) = home.as_ref() { + home.join(&path) + } else { + self.config.cwd.join(&path) + }; + + if seen.insert(Self::normalize_path_for_seen(&resolved)) { + loader_specs.push((resolved.clone(), SkillSource::LocalUser { root: resolved })); + } + } + + let project_paths: Vec = if self.config.skills.project_paths.is_empty() { + vec![self.config.cwd.join(".claude/skills")] + } else { + self.config.skills.project_paths.clone() + }; + + for path in project_paths { + let resolved = if path.is_absolute() { + path + } else { + self.config.cwd.join(&path) + }; + if seen.insert(Self::normalize_path_for_seen(&resolved)) { + loader_specs.push((resolved.clone(), SkillSource::Project { root: resolved })); + } + } + + for (root, source) in loader_specs { + let loader = LocalDirectorySkillLoader::new(root.clone(), source); + match loader.load() { + Ok(entries) => { + for entry in entries { + if let Err(err) = self.skill_registry.add_skill(entry) { + if !matches!(err, SkillRegistryError::AlreadyExists(_)) { + warn!("failed to add skill from {}: {err}", root.display()); + } + } + } + } + Err(err) => { + warn!("failed to load skills from {}: {err}", root.display()); + } + } + } + + self.refresh_settings_overview_rows(); + self.refresh_skills_settings_view(); + } + + fn normalize_path_for_seen(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } + fn emit_turn_complete_notification(&self, last_agent_message: Option) { if !self.should_emit_tui_notification("agent-turn-complete") { return; diff --git a/code-rs/tui/src/chatwidget/settings_overlay.rs b/code-rs/tui/src/chatwidget/settings_overlay.rs index cf84a2156a5..05ac3d4b481 100644 --- a/code-rs/tui/src/chatwidget/settings_overlay.rs +++ b/code-rs/tui/src/chatwidget/settings_overlay.rs @@ -19,6 +19,7 @@ use crate::bottom_pane::{ ModelSelectionView, NotificationsSettingsView, SettingsSection, + SkillsSettingsView, ThemeSelectionView, UpdateSettingsView, ValidationSettingsView, @@ -300,6 +301,31 @@ impl SettingsContent for GithubSettingsContent { } } +pub(crate) struct SkillsSettingsContent { + view: SkillsSettingsView, +} + +impl SkillsSettingsContent { + pub(crate) fn new(view: SkillsSettingsView) -> Self { + Self { view } + } +} + +impl SettingsContent for SkillsSettingsContent { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.view.render(area, buf); + } + + fn handle_key(&mut self, key: KeyEvent) -> bool { + self.view.handle_key_event_direct(key); + true + } + + fn is_complete(&self) -> bool { + self.view.is_view_complete() + } +} + pub(crate) struct AutoDriveSettingsContent { view: AutoDriveSettingsView, } @@ -1034,6 +1060,7 @@ pub(crate) struct SettingsOverlayView { agents_content: Option, validation_content: Option, github_content: Option, + skills_content: Option, auto_drive_content: Option, limits_content: Option, chrome_content: Option, @@ -1055,6 +1082,7 @@ impl SettingsOverlayView { agents_content: None, validation_content: None, github_content: None, + skills_content: None, auto_drive_content: None, limits_content: None, chrome_content: None, @@ -1146,6 +1174,10 @@ impl SettingsOverlayView { self.github_content = Some(content); } + pub(crate) fn set_skills_content(&mut self, content: SkillsSettingsContent) { + self.skills_content = Some(content); + } + pub(crate) fn set_auto_drive_content(&mut self, content: AutoDriveSettingsContent) { self.auto_drive_content = Some(content); } @@ -1614,6 +1646,7 @@ impl SettingsOverlayView { SettingsSection::AutoDrive => "Auto Drive Settings", SettingsSection::Validation => "Validation Settings", SettingsSection::Github => "GitHub Settings", + SettingsSection::Skills => "Claude Skills", SettingsSection::Limits => "Rate Limits", SettingsSection::Chrome => "Chrome Launch Options", SettingsSection::Notifications => "Notifications", @@ -1942,6 +1975,13 @@ impl SettingsOverlayView { } self.render_placeholder(area, buf, SettingsSection::Github.placeholder()); } + SettingsSection::Skills => { + if let Some(content) = self.skills_content.as_ref() { + content.render(area, buf); + return; + } + self.render_placeholder(area, buf, SettingsSection::Skills.placeholder()); + } SettingsSection::Limits => { if let Some(content) = self.limits_content.as_ref() { content.render(area, buf); @@ -2014,6 +2054,10 @@ impl SettingsOverlayView { .github_content .as_mut() .map(|content| content as &mut dyn SettingsContent), + SettingsSection::Skills => self + .skills_content + .as_mut() + .map(|content| content as &mut dyn SettingsContent), SettingsSection::Limits => self .limits_content .as_mut() @@ -2060,6 +2104,11 @@ impl SettingsOverlayView { content.on_close(); } } + SettingsSection::Skills => { + if let Some(content) = self.skills_content.as_mut() { + content.on_close(); + } + } _ => {} } } diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 00000000000..28c38a2ddf2 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,99 @@ +# Claude Skills in Code + +Code can load Claude Skills authored in Markdown and expose them to the agent runtime. Each skill +is a directory that contains a `SKILL.md` manifest plus any supporting assets. + +## Authoring `SKILL.md` + +A manifest is Markdown with a YAML frontmatter block. The frontmatter **must** include: + +```markdown +--- +name: financial-modeling +description: Build 3-statement financial models and scenario analyses. +allowed-tools: + - browser + - bash +metadata: + owner: finance +--- + +# Financial Modeling Skill + +Step-by-step instructions for the agent… +``` + +Key rules: + +- `name` must match the directory name exactly and contain only lowercase letters, digits, or + hyphens. +- `description` should be a concise sentence the model can read in the skill picker. +- `allowed-tools` is optional. If present, it restricts which Code tools the skill may call. + Supported entries today are `browser`, `agents`, `bash`, or a custom label that downstream logic + can inspect. +- `metadata` is an optional map for your own bookkeeping. + +The Markdown body can include playbooks, command snippets, or references to bundled scripts. Code +keeps these files on disk and streams content only when requested. + +## Skill discovery paths + +On startup Code scans these locations for skills: + +- `~/.claude/skills/…` +- `/.claude/skills/…` + +Extend the search paths via `config.toml`: + +```toml +[skills] +user_paths = ["/opt/skills", "~/work/skills"] +project_paths = [".dev/skills"] +per_skill."financial-modeling" = true +anthropic_skills = ["pdf", "xlsx"] +``` + +`user_paths` resolve relative to `$HOME` when not absolute; `project_paths` resolve relative to the +workspace root. + +### Example manifest + +A starter skill lives under `examples/skills/hello-web/`. Copy the entire `hello-web` directory into +`~/.claude/skills/` (user scope) or `/.claude/skills/` (project scope) to try it: + +```shell +mkdir -p ~/.claude/skills +cp -r examples/skills/hello-web ~/.claude/skills/ +``` + +Restart Code or reload `/settings` → Skills and the example will appear with a browser-only action. + +## Enabling and using skills + +Open `/settings` → **Skills** to toggle the global skills switch or individual manifests. Enabled +skills are advertised in Anthropic requests via `container.skills`, allowing Claude to auto-select +them when relevant. + +When the model calls the `skill` tool, Code enforces the manifest’s `allowed-tools` and delegates +actions to existing surfaces: + +| Skill action | Delegates to | +|--------------|-------------------------------| +| `browser` | Unified browser tooling | +| `agents` | Agent orchestration subsystem | +| `bash` | (reserved for future work) | + +Unknown actions return a clear “not implemented” response. + +## Validation checklist + +- `SKILL.md` exists, parses, and `name` equals the folder name. +- `allowed-tools` only lists supported entries (`browser`, `agents`, `bash`, or documented custom + labels). +- The Skills settings pane lists the skill and toggles persist across restarts. +- Invoking the `skill` tool with a browser action routes through the browser handler without + violating `allowed-tools`. +- Disabled or malformed skills fail gracefully and log helpful warnings. + +For deeper integrations (remote catalogs, execution engines) start with +`code-rs/core/src/skills/` and extend as needed. diff --git a/examples/skills/hello-web/SKILL.md b/examples/skills/hello-web/SKILL.md new file mode 100644 index 00000000000..c6ac8f28199 --- /dev/null +++ b/examples/skills/hello-web/SKILL.md @@ -0,0 +1,29 @@ +--- +name: hello-web +description: Open example.com in the browser and capture a screenshot. +allowed-tools: + - browser +metadata: + owner: examples +--- + +# Hello Web Skill + +## Overview + +Use this skill when you need to quickly browse to a URL, take a screenshot, and summarise the +page. + +## Workflow + +1. Call the `browser` action with `{"action":"open","url":"https://example.com"}`. +2. Once the page loads, capture a screenshot: `{"action":"screenshot"}`. +3. Optionally use `{"action":"type","text":"..."}` and `{"action":"click",...}` for + basic navigation. +4. Close the browser with `{"action":"close"}` when finished. + +## Notes + +- This manifest demonstrates the required YAML frontmatter plus instructional Markdown. +- Copy the entire `hello-web` directory into `~/.claude/skills/` or your project’s + `.claude/skills/` directory so Code can discover it.