diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index d2712e30..47af3005 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -1,5 +1,6 @@ //! Skill Management API +use crate::api::app_state::RemoteWorkspace; use log::info; use regex::Regex; use reqwest::Client; @@ -15,10 +16,19 @@ use tokio::task::JoinSet; use tokio::time::{timeout, Duration}; use crate::api::app_state::AppState; +use bitfun_core::agentic::tools::implementations::skills::mode_overrides::{ + get_disabled_mode_skills_from_document, load_disabled_user_mode_skills, + load_project_mode_skills_document_local, project_mode_skills_path_for_remote, + save_project_mode_skills_document_local, set_mode_skill_disabled_in_document, + set_user_mode_skill_disabled, +}; use bitfun_core::agentic::tools::implementations::skills::{ - SkillData, SkillLocation, SkillRegistry, + ModeSkillInfo, SkillData, SkillInfo, SkillLocation, SkillRegistry, }; +use bitfun_core::agentic::workspace::RemoteWorkspaceFs; use bitfun_core::infrastructure::get_path_manager_arc; +use bitfun_core::service::remote_ssh::{get_remote_workspace_manager, RemoteWorkspaceEntry}; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::runtime::RuntimeManager; use bitfun_core::util::process_manager; @@ -108,59 +118,282 @@ fn workspace_root_from_input(workspace_path: Option<&str>) -> Option { .map(PathBuf::from) } +fn trim_workspace_path(workspace_path: Option<&str>) -> Option { + workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string) +} + +async fn lookup_remote_entry_for_path( + state: &State<'_, AppState>, + path: &str, +) -> Option { + let manager = get_remote_workspace_manager()?; + let preferred = state + .get_remote_workspace_async() + .await + .map(|workspace: RemoteWorkspace| workspace.connection_id); + manager.lookup_connection(path, preferred.as_deref()).await +} + +async fn resolve_remote_workspace( + state: &State<'_, AppState>, + workspace_path: Option<&str>, +) -> Result, String> { + let Some(path) = trim_workspace_path(workspace_path) else { + return Ok(None); + }; + + if !is_remote_path(&path).await { + return Ok(None); + } + + let entry = lookup_remote_entry_for_path(state, &path) + .await + .ok_or_else(|| format!("Remote workspace connection not found for '{}'", path))?; + Ok(Some((path, entry))) +} + +async fn get_all_skills_for_workspace_input( + state: &State<'_, AppState>, + registry: &SkillRegistry, + workspace_path: Option<&str>, +) -> Result, String> { + if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? { + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|e| format!("Remote file service not available: {}", e))?; + let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id, remote_fs); + Ok(registry + .get_all_skills_for_remote_workspace(&remote_workspace_fs, &remote_root) + .await) + } else { + Ok(registry + .get_all_skills_for_workspace(workspace_root_from_input(workspace_path).as_deref()) + .await) + } +} + +async fn get_mode_skill_infos_for_workspace_input( + state: &State<'_, AppState>, + registry: &SkillRegistry, + mode_id: &str, + workspace_path: Option<&str>, +) -> Result, String> { + let all_skills = get_all_skills_for_workspace_input(state, registry, workspace_path).await?; + let disabled_user: HashSet = load_disabled_user_mode_skills(mode_id) + .await + .map_err(|e| format!("Failed to load user skill overrides: {}", e))? + .into_iter() + .collect(); + + let (disabled_project, resolved_skills): (HashSet, Vec) = + if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? { + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|e| format!("Remote file service not available: {}", e))?; + let remote_workspace_fs = + RemoteWorkspaceFs::new(entry.connection_id.clone(), remote_fs.clone()); + let project_config_path = project_mode_skills_path_for_remote(&remote_root); + let project_config = if remote_fs + .exists(&entry.connection_id, &project_config_path) + .await + .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))? + { + let content = remote_fs + .read_file(&entry.connection_id, &project_config_path) + .await + .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; + let content = String::from_utf8(content) + .map_err(|e| format!("Remote project skill overrides are not valid UTF-8: {}", e))?; + serde_json::from_str::(&content) + .map_err(|e| format!("Invalid remote project skill overrides JSON: {}", e))? + } else { + serde_json::json!({}) + }; + + ( + get_disabled_mode_skills_from_document(&project_config, mode_id) + .into_iter() + .collect(), + registry + .get_resolved_skills_for_remote_workspace( + &remote_workspace_fs, + &remote_root, + Some(mode_id), + ) + .await, + ) + } else { + let workspace_root = workspace_root_from_input(workspace_path) + .ok_or_else(|| "Project-level skill overrides require an open workspace".to_string())?; + let project_config = load_project_mode_skills_document_local(&workspace_root) + .await + .map_err(|e| format!("Failed to load project mode skills: {}", e))?; + ( + get_disabled_mode_skills_from_document(&project_config, mode_id) + .into_iter() + .collect(), + registry + .get_resolved_skills_for_workspace(Some(&workspace_root), Some(mode_id)) + .await, + ) + }; + + let resolved_keys: HashSet = resolved_skills.into_iter().map(|skill| skill.key).collect(); + + Ok(all_skills + .into_iter() + .map(|skill| { + let disabled_by_mode = match skill.level { + SkillLocation::User => disabled_user.contains(&skill.key), + SkillLocation::Project => disabled_project.contains(&skill.key), + }; + let selected_for_runtime = resolved_keys.contains(&skill.key); + + ModeSkillInfo { + skill, + disabled_by_mode, + selected_for_runtime, + } + }) + .collect()) +} + #[tauri::command] pub async fn get_skill_configs( - _state: State<'_, AppState>, + state: State<'_, AppState>, force_refresh: Option, workspace_path: Option, ) -> Result { let registry = SkillRegistry::global(); - let workspace_root = workspace_root_from_input(workspace_path.as_deref()); if force_refresh.unwrap_or(false) { - registry - .refresh_for_workspace(workspace_root.as_deref()) - .await; + registry.refresh().await; } - let all_skills = registry - .get_all_skills_for_workspace(workspace_root.as_deref()) - .await; + let all_skills = + get_all_skills_for_workspace_input(&state, registry, workspace_path.as_deref()).await?; serde_json::to_value(all_skills) .map_err(|e| format!("Failed to serialize skill configs: {}", e)) } #[tauri::command] -pub async fn set_skill_enabled( - _state: State<'_, AppState>, - skill_name: String, - enabled: bool, +pub async fn get_mode_skill_configs( + state: State<'_, AppState>, + mode_id: String, + force_refresh: Option, workspace_path: Option, -) -> Result { +) -> Result { let registry = SkillRegistry::global(); - let workspace_root = workspace_root_from_input(workspace_path.as_deref()); - let skill_md_path = registry - .find_skill_path_for_workspace(&skill_name, workspace_root.as_deref()) - .await - .ok_or_else(|| format!("Skill '{}' not found", skill_name))?; + if force_refresh.unwrap_or(false) { + registry.refresh().await; + } - SkillData::set_enabled_and_save( - skill_md_path - .to_str() - .ok_or_else(|| "Invalid path".to_string())?, - enabled, - ) - .map_err(|e| format!("Failed to save skill config: {}", e))?; + let mode_skill_infos = + get_mode_skill_infos_for_workspace_input(&state, registry, &mode_id, workspace_path.as_deref()) + .await?; - registry - .refresh_for_workspace(workspace_root.as_deref()) - .await; + serde_json::to_value(mode_skill_infos) + .map_err(|e| format!("Failed to serialize mode skill configs: {}", e)) +} + +#[tauri::command] +pub async fn set_mode_skill_disabled( + state: State<'_, AppState>, + mode_id: String, + skill_key: String, + disabled: bool, + workspace_path: Option, +) -> Result { + if skill_key.starts_with("user::") { + set_user_mode_skill_disabled(&mode_id, &skill_key, disabled) + .await + .map_err(|e| format!("Failed to update user skill override: {}", e))?; + if let Err(e) = bitfun_core::service::config::reload_global_config().await { + log::warn!( + "Failed to reload global config after user skill override change: mode_id={}, skill_key={}, error={}", + mode_id, + skill_key, + e + ); + } + return Ok(format!( + "Mode '{}' skill '{}' updated successfully", + mode_id, skill_key + )); + } + + if !skill_key.starts_with("project::") { + return Err(format!("Unsupported skill key '{}'", skill_key)); + } + + if let Some((remote_root, entry)) = resolve_remote_workspace(&state, workspace_path.as_deref()).await? { + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|e| format!("Remote file service not available: {}", e))?; + let config_path = project_mode_skills_path_for_remote(&remote_root); + let mut document = if remote_fs + .exists(&entry.connection_id, &config_path) + .await + .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))? + { + let content = remote_fs + .read_file(&entry.connection_id, &config_path) + .await + .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; + let content = String::from_utf8(content) + .map_err(|e| format!("Remote project skill overrides are not valid UTF-8: {}", e))?; + serde_json::from_str::(&content) + .map_err(|e| format!("Invalid remote project skill overrides JSON: {}", e))? + } else { + serde_json::json!({}) + }; + + set_mode_skill_disabled_in_document(&mut document, &mode_id, &skill_key, disabled) + .map_err(|e| format!("Failed to update remote project skill override: {}", e))?; + + let config_dir = config_path + .rsplit_once('/') + .map(|(dir, _)| dir.to_string()) + .ok_or_else(|| format!("Invalid remote project config path '{}'", config_path))?; + + remote_fs + .create_dir_all(&entry.connection_id, &config_dir) + .await + .map_err(|e| format!("Failed to create remote project skill overrides directory: {}", e))?; + remote_fs + .write_file( + &entry.connection_id, + &config_path, + serde_json::to_vec_pretty(&document) + .map_err(|e| format!("Failed to serialize remote project skill overrides: {}", e))? + .as_slice(), + ) + .await + .map_err(|e| format!("Failed to write remote project skill overrides: {}", e))?; + } else { + let workspace_root = workspace_root_from_input(workspace_path.as_deref()) + .ok_or_else(|| "Project-level skill overrides require an open workspace".to_string())?; + let mut document = load_project_mode_skills_document_local(&workspace_root) + .await + .map_err(|e| format!("Failed to load project mode skills: {}", e))?; + set_mode_skill_disabled_in_document(&mut document, &mode_id, &skill_key, disabled) + .map_err(|e| format!("Failed to update project skill override: {}", e))?; + save_project_mode_skills_document_local(&workspace_root, &document) + .await + .map_err(|e| format!("Failed to save project mode skills: {}", e))?; + } Ok(format!( - "Skill '{}' configuration saved successfully", - skill_name + "Mode '{}' skill '{}' updated successfully", + mode_id, skill_key )) } @@ -244,6 +477,12 @@ pub async fn add_skill( let target_dir = if level == "project" { if let Some(workspace_root) = workspace_root_from_input(workspace_path.as_deref()) { + if is_remote_path(&workspace_root.to_string_lossy()).await { + return Err( + "Installing project skills into remote workspaces is not supported yet" + .to_string(), + ); + } workspace_root.join(".bitfun").join("skills") } else { return Err("No workspace open, cannot add project-level Skill".to_string()); @@ -313,17 +552,42 @@ async fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io:: #[tauri::command] pub async fn delete_skill( - _state: State<'_, AppState>, - skill_name: String, + state: State<'_, AppState>, + skill_key: String, workspace_path: Option, ) -> Result { let registry = SkillRegistry::global(); - let workspace_root = workspace_root_from_input(workspace_path.as_deref()); + if let Some((remote_root, entry)) = resolve_remote_workspace(&state, workspace_path.as_deref()).await? { + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|e| format!("Remote file service not available: {}", e))?; + let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id.clone(), remote_fs.clone()); + let skill_info = registry + .find_skill_by_key_for_remote_workspace(&remote_workspace_fs, &remote_root, &skill_key) + .await + .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; + + remote_fs + .remove_dir_all(&entry.connection_id, &skill_info.path) + .await + .map_err(|e| format!("Failed to delete remote skill folder: {}", e))?; + + registry.refresh().await; + + info!( + "Remote skill deleted: key={}, path={}", + skill_key, + skill_info.path + ); + return Ok(format!("Skill '{}' deleted successfully", skill_info.name)); + } + let workspace_root = workspace_root_from_input(workspace_path.as_deref()); let skill_info = registry - .find_skill_for_workspace(&skill_name, workspace_root.as_deref()) + .find_skill_by_key_for_workspace(&skill_key, workspace_root.as_deref()) .await - .ok_or_else(|| format!("Skill '{}' not found", skill_name))?; + .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; let skill_path = std::path::PathBuf::from(&skill_info.path); @@ -338,11 +602,11 @@ pub async fn delete_skill( .await; info!( - "Skill deleted: name={}, path={}", - skill_name, + "Skill deleted: key={}, path={}", + skill_key, skill_path.display() ); - Ok(format!("Skill '{}' deleted successfully", skill_name)) + Ok(format!("Skill '{}' deleted successfully", skill_info.name)) } #[tauri::command] @@ -385,10 +649,15 @@ pub async fn download_skill_market( let level = request.level.unwrap_or(SkillLocation::Project); let workspace_path = if level == SkillLocation::Project { - Some( - workspace_root_from_input(request.workspace_path.as_deref()) - .ok_or_else(|| "No workspace open, cannot add project-level Skill".to_string())?, - ) + let path = trim_workspace_path(request.workspace_path.as_deref()) + .ok_or_else(|| "No workspace open, cannot add project-level Skill".to_string())?; + if is_remote_path(&path).await { + return Err( + "Downloading project skills into remote workspaces is not supported yet" + .to_string(), + ); + } + Some(PathBuf::from(path)) } else { None }; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index ab31a8cb..6a669a73 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -412,10 +412,11 @@ pub async fn run() { list_agent_tool_names, update_subagent_config, get_skill_configs, + get_mode_skill_configs, list_skill_market, search_skill_market, download_skill_market, - set_skill_enabled, + set_mode_skill_disabled, validate_skill_path, add_skill, delete_skill, diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index 6ce47aa1..23e8cb7f 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -69,20 +69,32 @@ Important: .map(|w| w.root_path_string()) .unwrap_or_default(); registry - .get_enabled_skills_xml_for_remote_workspace(fs, &root) + .get_resolved_skills_xml_for_remote_workspace( + fs, + &root, + ctx.agent_type.as_deref(), + ) .await } else { registry - .get_enabled_skills_xml_for_workspace(ctx.workspace_root()) + .get_resolved_skills_xml_for_workspace( + ctx.workspace_root(), + ctx.agent_type.as_deref(), + ) .await } } Some(ctx) => { registry - .get_enabled_skills_xml_for_workspace(ctx.workspace_root()) + .get_resolved_skills_xml_for_workspace( + ctx.workspace_root(), + ctx.agent_type.as_deref(), + ) .await } - None => registry.get_enabled_skills_xml().await, + None => registry + .get_resolved_skills_xml_for_workspace(None, None) + .await, }; self.render_description(available_skills.join("\n")) @@ -195,16 +207,29 @@ impl Tool for SkillTool { .map(|w| w.root_path_string()) .unwrap_or_default(); registry - .find_and_load_skill_for_remote_workspace(skill_name, ws_fs, &root) + .find_and_load_skill_for_remote_workspace( + skill_name, + ws_fs, + &root, + context.agent_type.as_deref(), + ) .await? } else { registry - .find_and_load_skill_for_workspace(skill_name, context.workspace_root()) + .find_and_load_skill_for_workspace( + skill_name, + context.workspace_root(), + context.agent_type.as_deref(), + ) .await? } } else { registry - .find_and_load_skill_for_workspace(skill_name, context.workspace_root()) + .find_and_load_skill_for_workspace( + skill_name, + context.workspace_root(), + context.agent_type.as_deref(), + ) .await? }; diff --git a/src/crates/core/src/agentic/tools/implementations/skills/builtin.rs b/src/crates/core/src/agentic/tools/implementations/skills/builtin.rs index 2350a159..11e32672 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/builtin.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/builtin.rs @@ -5,10 +5,8 @@ use crate::infrastructure::get_path_manager_arc; use crate::util::errors::BitFunResult; -use crate::util::front_matter_markdown::FrontMatterMarkdown; use include_dir::{include_dir, Dir}; use log::{debug, error}; -use serde_yaml::Value; use std::path::{Path, PathBuf}; use tokio::fs; @@ -122,64 +120,7 @@ fn safe_join(root: &Path, relative: &Path) -> BitFunResult { async fn desired_file_content( file: &include_dir::File<'_>, - dest_path: &Path, + _dest_path: &Path, ) -> BitFunResult> { - let source = file.contents(); - if !is_skill_markdown(file.path()) { - return Ok(source.to_vec()); - } - - let source_text = match std::str::from_utf8(source) { - Ok(v) => v, - Err(_) => return Ok(source.to_vec()), - }; - - let enabled = if let Ok(existing) = fs::read_to_string(dest_path).await { - // Preserve user-selected state when file already exists. - extract_enabled_flag(&existing).unwrap_or(true) - } else { - // On first install, respect bundled default (if present), otherwise enable by default. - extract_enabled_flag(source_text).unwrap_or(true) - }; - - let merged = merge_skill_markdown_enabled(source_text, enabled)?; - Ok(merged.into_bytes()) -} - -fn is_skill_markdown(path: &Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n.eq_ignore_ascii_case("SKILL.md")) - .unwrap_or(false) -} - -fn extract_enabled_flag(markdown: &str) -> Option { - let (metadata, _) = FrontMatterMarkdown::load_str(markdown).ok()?; - metadata.get("enabled").and_then(|v| v.as_bool()) -} - -fn merge_skill_markdown_enabled(markdown: &str, enabled: bool) -> BitFunResult { - let (mut metadata, body) = FrontMatterMarkdown::load_str(markdown) - .map_err(|e| crate::util::errors::BitFunError::tool(format!("Invalid SKILL.md: {}", e)))?; - - let map = metadata.as_mapping_mut().ok_or_else(|| { - crate::util::errors::BitFunError::tool( - "Invalid SKILL.md: metadata is not a mapping".to_string(), - ) - })?; - - if enabled { - map.remove(&Value::String("enabled".to_string())); - } else { - map.insert(Value::String("enabled".to_string()), Value::Bool(false)); - } - - let yaml = serde_yaml::to_string(&metadata).map_err(|e| { - crate::util::errors::BitFunError::tool(format!("Failed to serialize SKILL.md: {}", e)) - })?; - Ok(format!( - "---\n{}\n---\n\n{}", - yaml.trim_end(), - body.trim_start() - )) + Ok(file.contents().to_vec()) } diff --git a/src/crates/core/src/agentic/tools/implementations/skills/mod.rs b/src/crates/core/src/agentic/tools/implementations/skills/mod.rs index 69e9268a..53988260 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/mod.rs @@ -3,11 +3,12 @@ //! Provides Skill registry, loading, and configuration management functionality pub mod builtin; +pub mod mode_overrides; pub mod registry; pub mod types; pub use registry::SkillRegistry; -pub use types::{SkillData, SkillInfo, SkillLocation}; +pub use types::{ModeSkillInfo, SkillData, SkillInfo, SkillLocation}; /// Get global Skill registry instance pub fn get_skill_registry() -> &'static SkillRegistry { diff --git a/src/crates/core/src/agentic/tools/implementations/skills/mode_overrides.rs b/src/crates/core/src/agentic/tools/implementations/skills/mode_overrides.rs new file mode 100644 index 00000000..eb5cc18c --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/skills/mode_overrides.rs @@ -0,0 +1,218 @@ +//! Mode-specific skill override helpers. + +use crate::agentic::workspace::WorkspaceFileSystem; +use crate::infrastructure::get_path_manager_arc; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::mode_config_canonicalizer::persist_mode_config_from_value; +use crate::service::config::types::ModeConfig; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +const PROJECT_MODE_SKILLS_FILE_NAME: &str = "mode_skills.json"; +const DISABLED_SKILLS_KEY: &str = "disabled_skills"; + +fn dedupe_skill_keys(keys: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for key in keys { + let trimmed = key.trim(); + if trimmed.is_empty() { + continue; + } + let owned = trimmed.to_string(); + if seen.insert(owned.clone()) { + normalized.push(owned); + } + } + + normalized +} + +pub async fn load_disabled_user_mode_skills(mode_id: &str) -> BitFunResult> { + let config_service = GlobalConfigManager::get_service().await?; + let stored_configs: HashMap = config_service + .get_config(Some("ai.mode_configs")) + .await + .unwrap_or_default(); + + Ok(dedupe_skill_keys( + stored_configs + .get(mode_id) + .map(|config| config.disabled_user_skills.clone()) + .unwrap_or_default(), + )) +} + +pub async fn set_user_mode_skill_disabled( + mode_id: &str, + skill_key: &str, + disabled: bool, +) -> BitFunResult> { + let mut next = load_disabled_user_mode_skills(mode_id).await?; + if disabled { + next.push(skill_key.to_string()); + next = dedupe_skill_keys(next); + } else { + next.retain(|value| value != skill_key); + } + + persist_mode_config_from_value(mode_id, json!({ "disabled_user_skills": next })).await?; + load_disabled_user_mode_skills(mode_id).await +} + +pub fn project_mode_skills_path_for_remote(remote_root: &str) -> String { + format!( + "{}/.bitfun/config/{}", + remote_root.trim_end_matches('/'), + PROJECT_MODE_SKILLS_FILE_NAME + ) +} + +fn normalize_project_document_value(value: Value) -> Value { + match value { + Value::Object(_) => value, + _ => Value::Object(Map::new()), + } +} + +fn mode_skills_object_mut(document: &mut Value) -> BitFunResult<&mut Map> { + if !document.is_object() { + *document = Value::Object(Map::new()); + } + + document + .as_object_mut() + .ok_or_else(|| BitFunError::config("Project mode skills must be a JSON object".to_string())) +} + +fn mode_skills_object(document: &Value) -> Option<&Map> { + document.as_object() +} + +pub fn get_disabled_mode_skills_from_document(document: &Value, mode_id: &str) -> Vec { + let Some(mode_object) = mode_skills_object(document) + .and_then(|map| map.get(mode_id)) + .and_then(Value::as_object) + else { + return Vec::new(); + }; + + let keys = mode_object + .get(DISABLED_SKILLS_KEY) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + + dedupe_skill_keys(keys) +} + +pub fn set_mode_skill_disabled_in_document( + document: &mut Value, + mode_id: &str, + skill_key: &str, + disabled: bool, +) -> BitFunResult> { + let mode_skills = mode_skills_object_mut(document)?; + let mode_entry = mode_skills + .entry(mode_id.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + + if !mode_entry.is_object() { + *mode_entry = Value::Object(Map::new()); + } + + let mode_object = mode_entry + .as_object_mut() + .ok_or_else(|| BitFunError::config("Mode skills entry must be a JSON object".to_string()))?; + + let current = mode_object + .get(DISABLED_SKILLS_KEY) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + + let mut next = dedupe_skill_keys(current); + if disabled { + next.push(skill_key.to_string()); + next = dedupe_skill_keys(next); + } else { + next.retain(|value| value != skill_key); + } + + if next.is_empty() { + mode_object.remove(DISABLED_SKILLS_KEY); + } else { + mode_object.insert( + DISABLED_SKILLS_KEY.to_string(), + serde_json::to_value(&next)?, + ); + } + + if mode_object.is_empty() { + mode_skills.remove(mode_id); + } + + Ok(next) +} + +pub async fn load_project_mode_skills_document_local( + workspace_root: &Path, +) -> BitFunResult { + let path = get_path_manager_arc().project_mode_skills_file(workspace_root); + match tokio::fs::read_to_string(&path).await { + Ok(content) => Ok(normalize_project_document_value(serde_json::from_str(&content)?)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(Value::Object(Map::new())) + } + Err(error) => Err(BitFunError::config(format!( + "Failed to read project skill overrides file '{}': {}", + path.display(), + error + ))), + } +} + +pub async fn save_project_mode_skills_document_local( + workspace_root: &Path, + document: &Value, +) -> BitFunResult<()> { + let path = get_path_manager_arc().project_mode_skills_file(workspace_root); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&path, serde_json::to_vec_pretty(document)?).await?; + Ok(()) +} + +pub async fn load_disabled_mode_skills_local( + workspace_root: &Path, + mode_id: &str, +) -> BitFunResult> { + let document = load_project_mode_skills_document_local(workspace_root).await?; + Ok(get_disabled_mode_skills_from_document(&document, mode_id)) +} + +pub async fn load_disabled_mode_skills_remote( + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + mode_id: &str, +) -> BitFunResult> { + let path = project_mode_skills_path_for_remote(remote_root); + let exists = fs.exists(&path).await.unwrap_or(false); + if !exists { + return Ok(Vec::new()); + } + + let content = fs + .read_file_text(&path) + .await + .map_err(|error| BitFunError::config(format!( + "Failed to read remote project skill overrides: {}", + error + )))?; + let document = normalize_project_document_value(serde_json::from_str(&content)?); + Ok(get_disabled_mode_skills_from_document(&document, mode_id)) +} diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index 56a265c8..26335353 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -1,16 +1,18 @@ //! Skill registry //! -//! Manages Skill loading and enabled/disabled filtering -//! Supports multiple application paths: -//! .bitfun/skills, .claude/skills, .cursor/skills, .codex/skills, .opencode/skills, .agents/skills +//! Manages skill discovery, mode-specific filtering, and loading. use super::builtin::ensure_builtin_skills_installed; +use super::mode_overrides::{ + load_disabled_mode_skills_local, load_disabled_mode_skills_remote, + load_disabled_user_mode_skills, +}; use super::types::{SkillData, SkillInfo, SkillLocation}; use crate::agentic::workspace::WorkspaceFileSystem; use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use tokio::fs; @@ -19,181 +21,288 @@ use tokio::sync::RwLock; /// Global Skill registry instance static SKILL_REGISTRY: OnceLock = OnceLock::new(); -/// Project-level Skill directory names (relative to workspace root) -const PROJECT_SKILL_SUBDIRS: &[(&str, &str)] = &[ - (".bitfun", "skills"), - (".claude", "skills"), - (".codex", "skills"), - (".cursor", "skills"), - (".opencode", "skills"), - (".agents", "skills"), +const USER_PREFIX: &str = "user"; +const PROJECT_PREFIX: &str = "project"; + +/// Project-level skill roots under a workspace. +const PROJECT_SKILL_SLOTS: &[(&str, &str, &str)] = &[ + (".bitfun", "skills", "bitfun"), + (".claude", "skills", "claude"), + (".codex", "skills", "codex"), + (".cursor", "skills", "cursor"), + (".opencode", "skills", "opencode"), + (".agents", "skills", "agents"), ]; -/// Home-directory based user-level Skill paths. -const USER_HOME_SKILL_SUBDIRS: &[(&str, &str)] = &[ - (".claude", "skills"), - (".codex", "skills"), - (".cursor", "skills"), - (".agents", "skills"), +/// Home-directory based user-level skill roots. +const USER_HOME_SKILL_SLOTS: &[(&str, &str, &str)] = &[ + (".claude", "skills", "home.claude"), + (".codex", "skills", "home.codex"), + (".cursor", "skills", "home.cursor"), + (".agents", "skills", "home.agents"), ]; -/// Config-directory based user-level Skill paths. -const USER_CONFIG_SKILL_SUBDIRS: &[(&str, &str)] = &[("opencode", "skills"), ("agents", "skills")]; +/// Config-directory based user-level skill roots. +const USER_CONFIG_SKILL_SLOTS: &[(&str, &str, &str)] = &[ + ("opencode", "skills", "config.opencode"), + ("agents", "skills", "config.agents"), +]; -/// Skill directory entry #[derive(Debug, Clone)] -pub struct SkillDirEntry { - pub path: PathBuf, - pub level: SkillLocation, +struct SkillRootEntry { + path: PathBuf, + level: SkillLocation, + slot: &'static str, + priority: usize, +} + +#[derive(Debug, Clone)] +struct RemoteSkillRootEntry { + path: String, + slot: &'static str, + priority: usize, +} + +#[derive(Debug, Clone)] +struct SkillCandidate { + info: SkillInfo, + priority: usize, +} + +impl SkillCandidate { + fn from_data( + mut data: SkillData, + slot: &str, + key_prefix: &str, + priority: usize, + ) -> Self { + data.source_slot = slot.to_string(); + data.key = build_skill_key(key_prefix, slot, &data.dir_name); + + Self { + info: SkillInfo { + key: data.key, + name: data.name, + description: data.description, + path: data.path, + level: data.location, + source_slot: data.source_slot, + dir_name: data.dir_name, + }, + priority, + } + } +} + +fn build_skill_key(prefix: &str, slot: &str, dir_name: &str) -> String { + format!("{}::{}::{}", prefix, slot, dir_name) +} + +fn normalize_dir_name(path: &Path) -> Option { + path.file_name() + .and_then(|value| value.to_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_remote_dir_name(path: &str) -> Option { + path.trim_end_matches('/') + .rsplit('/') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) +} + +fn dedupe_preserving_order(keys: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for key in keys { + let trimmed = key.trim(); + if trimmed.is_empty() { + continue; + } + + let owned = trimmed.to_string(); + if seen.insert(owned.clone()) { + normalized.push(owned); + } + } + + normalized +} + +fn sort_skills(mut skills: Vec) -> Vec { + skills.sort_by(|a, b| { + let level_order = match a.level { + SkillLocation::Project => 0, + SkillLocation::User => 1, + } + .cmp(&match b.level { + SkillLocation::Project => 0, + SkillLocation::User => 1, + }); + + level_order + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.key.cmp(&b.key)) + }); + skills +} + +fn resolve_visible_skills(candidates: Vec) -> Vec { + let mut by_name: HashMap = HashMap::new(); + for candidate in candidates { + match by_name.get(&candidate.info.name) { + Some(existing) if existing.priority <= candidate.priority => {} + _ => { + by_name.insert(candidate.info.name.clone(), candidate); + } + } + } + + let mut resolved: Vec = by_name.into_values().collect(); + resolved.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then_with(|| a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase())) + }); + resolved.into_iter().map(|candidate| candidate.info).collect() } /// Skill registry -/// -/// Caches scanned skill information to avoid repeated directory scanning pub struct SkillRegistry { - /// Cached skill data, key is skill name - cache: RwLock>, + /// Cached raw user-level skills (no workspace-specific project skills). + cache: RwLock>, } impl SkillRegistry { - fn get_possible_paths_for_workspace(workspace_root: Option<&Path>) -> Vec { + fn new() -> Self { + Self { + cache: RwLock::new(Vec::new()), + } + } + + pub fn global() -> &'static Self { + SKILL_REGISTRY.get_or_init(Self::new) + } + + fn get_possible_paths_for_workspace(workspace_root: Option<&Path>) -> Vec { let mut entries = Vec::new(); + let mut priority = 0usize; if let Some(workspace_path) = workspace_root { - for (parent, sub) in PROJECT_SKILL_SUBDIRS { - let p = workspace_path.join(parent).join(sub); - if p.exists() && p.is_dir() { - entries.push(SkillDirEntry { - path: p, + for (parent, sub, slot) in PROJECT_SKILL_SLOTS { + let path = workspace_path.join(parent).join(sub); + if path.exists() && path.is_dir() { + entries.push(SkillRootEntry { + path, level: SkillLocation::Project, + slot, + priority, }); } + priority += 1; } } - let pm = get_path_manager_arc(); - let bitfun_skills = pm.user_skills_dir(); + let path_manager = get_path_manager_arc(); + let bitfun_skills = path_manager.user_skills_dir(); if bitfun_skills.exists() && bitfun_skills.is_dir() { - entries.push(SkillDirEntry { + entries.push(SkillRootEntry { path: bitfun_skills, level: SkillLocation::User, + slot: "bitfun", + priority, }); } + priority += 1; if let Some(home) = dirs::home_dir() { - for (parent, sub) in USER_HOME_SKILL_SUBDIRS { - let p = home.join(parent).join(sub); - if p.exists() && p.is_dir() { - entries.push(SkillDirEntry { - path: p, + for (parent, sub, slot) in USER_HOME_SKILL_SLOTS { + let path = home.join(parent).join(sub); + if path.exists() && path.is_dir() { + entries.push(SkillRootEntry { + path, level: SkillLocation::User, + slot, + priority, }); } + priority += 1; } } if let Some(config_dir) = dirs::config_dir() { - for (parent, sub) in USER_CONFIG_SKILL_SUBDIRS { - let p = config_dir.join(parent).join(sub); - if p.exists() && p.is_dir() { - entries.push(SkillDirEntry { - path: p, + for (parent, sub, slot) in USER_CONFIG_SKILL_SLOTS { + let path = config_dir.join(parent).join(sub); + if path.exists() && path.is_dir() { + entries.push(SkillRootEntry { + path, level: SkillLocation::User, + slot, + priority, }); } + priority += 1; } } entries } - async fn scan_skill_map_for_workspace( - &self, - workspace_root: Option<&Path>, - ) -> HashMap { - if let Err(e) = ensure_builtin_skills_installed().await { - debug!("Failed to install built-in skills: {}", e); - } - - let mut by_name: HashMap = HashMap::new(); - for entry in Self::get_possible_paths_for_workspace(workspace_root) { - let skills = Self::scan_skills_in_dir(&entry.path, entry.level).await; - for info in skills { - by_name.entry(info.name.clone()).or_insert(info); - } - } - by_name - } - - async fn find_skill_in_map( - &self, - skill_name: &str, - workspace_root: Option<&Path>, - ) -> Option { - self.scan_skill_map_for_workspace(workspace_root) - .await - .remove(skill_name) - } - - /// Create new registry instance - fn new() -> Self { - Self { - cache: RwLock::new(HashMap::new()), + async fn scan_skills_in_dir(entry: &SkillRootEntry) -> Vec { + let mut skills = Vec::new(); + if !entry.path.exists() { + return skills; } - } - /// Get global instance - pub fn global() -> &'static Self { - SKILL_REGISTRY.get_or_init(Self::new) - } + let Ok(mut read_dir) = fs::read_dir(&entry.path).await else { + return skills; + }; - /// Get all possible Skill directory paths - /// - /// Returns existing directories and their levels (project/user) - /// - Project-level: .bitfun/skills, .claude/skills, .cursor/skills, .codex/skills, .opencode/skills, .agents/skills under workspace - /// - User-level: skills under bitfun user config, ~/.claude/skills, ~/.cursor/skills, ~/.codex/skills, ~/.agents/skills, ~/.config/opencode/skills, ~/.config/agents/skills - pub fn get_possible_paths() -> Vec { - Self::get_possible_paths_for_workspace(None) - } + while let Ok(Some(item)) = read_dir.next_entry().await { + let path = item.path(); + if !path.is_dir() { + continue; + } - /// Scan directory to get all skill information - /// enabled status is read from SKILL.md file - async fn scan_skills_in_dir(dir: &Path, level: SkillLocation) -> Vec { - let mut skills = Vec::new(); + let Some(dir_name) = normalize_dir_name(&path) else { + continue; + }; - if !dir.exists() { - return skills; - } + let skill_md_path = path.join("SKILL.md"); + if !skill_md_path.exists() { + continue; + } - if let Ok(mut entries) = fs::read_dir(dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - if path.is_dir() { - let skill_md_path = path.join("SKILL.md"); - if skill_md_path.exists() { - if let Ok(content) = fs::read_to_string(&skill_md_path).await { - match SkillData::from_markdown( - path.to_string_lossy().to_string(), - &content, - level, - false, - ) { - Ok(skill_data) => { - let info = SkillInfo { - name: skill_data.name, - description: skill_data.description, - path: path.to_string_lossy().to_string(), - level, - enabled: skill_data.enabled, - }; - skills.push(info); - } - Err(e) => { - error!("Failed to parse SKILL.md in {}: {}", path.display(), e); - } - } - } + match fs::read_to_string(&skill_md_path).await { + Ok(content) => match SkillData::from_markdown( + path.to_string_lossy().to_string(), + &content, + entry.level, + false, + ) { + Ok(mut skill_data) => { + skill_data.dir_name = dir_name; + let key_prefix = match entry.level { + SkillLocation::User => USER_PREFIX, + SkillLocation::Project => PROJECT_PREFIX, + }; + skills.push(SkillCandidate::from_data( + skill_data, + entry.slot, + key_prefix, + entry.priority, + )); } + Err(error) => { + error!("Failed to parse SKILL.md in {}: {}", path.display(), error); + } + }, + Err(error) => { + debug!("Failed to read {}: {}", skill_md_path.display(), error); } } } @@ -201,348 +310,361 @@ impl SkillRegistry { skills } - /// Refresh cache, rescan all directories - pub async fn refresh(&self) { - if let Err(e) = ensure_builtin_skills_installed().await { - debug!("Failed to install built-in skills: {}", e); + async fn scan_skill_candidates_for_workspace( + &self, + workspace_root: Option<&Path>, + ) -> Vec { + if let Err(error) = ensure_builtin_skills_installed().await { + debug!("Failed to install built-in skills: {}", error); } - let mut by_name: HashMap = HashMap::new(); + let mut skills = Vec::new(); + for entry in Self::get_possible_paths_for_workspace(workspace_root) { + let mut part = Self::scan_skills_in_dir(&entry).await; + skills.append(&mut part); + } + skills + } - for entry in Self::get_possible_paths() { - let skills = Self::scan_skills_in_dir(&entry.path, entry.level).await; - for info in skills { - // Only keep the first skill with the same name (higher priority) - by_name.entry(info.name.clone()).or_insert(info); + async fn scan_remote_project_skills( + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + ) -> Vec { + let mut roots = Vec::new(); + let mut priority = 0usize; + let root = remote_root.trim_end_matches('/'); + for (parent, sub, slot) in PROJECT_SKILL_SLOTS { + let path = format!("{}/{}/{}", root, parent, sub); + if fs.is_dir(&path).await.unwrap_or(false) { + roots.push(RemoteSkillRootEntry { + path, + slot, + priority, + }); } + priority += 1; } - let mut cache = self.cache.write().await; - *cache = by_name; - debug!("SkillRegistry refreshed, {} skills loaded", cache.len()); - } + let mut skills = Vec::new(); + for entry in roots { + let entries = match fs.read_dir(&entry.path).await { + Ok(value) => value, + Err(_) => continue, + }; + + for item in entries { + if !item.is_dir || item.is_symlink { + continue; + } - pub async fn refresh_for_workspace(&self, workspace_root: Option<&Path>) { - let by_name = self.scan_skill_map_for_workspace(workspace_root).await; - let mut cache = self.cache.write().await; - *cache = by_name; - debug!( - "SkillRegistry refreshed for workspace, {} skills loaded", - cache.len() - ); - } + let Some(dir_name) = normalize_remote_dir_name(&item.path) else { + continue; + }; + let skill_md_path = format!("{}/SKILL.md", item.path.trim_end_matches('/')); + if !fs.is_file(&skill_md_path).await.unwrap_or(false) { + continue; + } - /// Ensure cache is initialized - async fn ensure_loaded(&self) { - let cache = self.cache.read().await; - if cache.is_empty() { - drop(cache); - self.refresh().await; + match fs.read_file_text(&skill_md_path).await { + Ok(content) => match SkillData::from_markdown( + item.path.clone(), + &content, + SkillLocation::Project, + false, + ) { + Ok(mut skill_data) => { + skill_data.dir_name = dir_name; + skills.push(SkillCandidate::from_data( + skill_data, + entry.slot, + PROJECT_PREFIX, + entry.priority, + )); + } + Err(error) => { + error!("Failed to parse SKILL.md in {}: {}", item.path, error); + } + }, + Err(error) => { + debug!("Failed to read {}: {}", skill_md_path, error); + } + } + } } + + skills } - /// Get all skill information (including enabled status) - /// - /// Skills with the same name are prioritized by path order: earlier paths have higher priority, later paths won't override already loaded skills with the same name - pub async fn get_all_skills(&self) -> Vec { - self.ensure_loaded().await; - let cache = self.cache.read().await; - cache.values().cloned().collect() + async fn scan_skill_candidates_for_remote_workspace( + &self, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + ) -> Vec { + let mut skills = self.scan_skill_candidates_for_workspace(None).await; + skills.extend(Self::scan_remote_project_skills(fs, remote_root).await); + skills } - pub async fn get_all_skills_for_workspace( + async fn apply_mode_filters_for_workspace( &self, + candidates: Vec, workspace_root: Option<&Path>, - ) -> Vec { - self.scan_skill_map_for_workspace(workspace_root) - .await - .into_values() - .collect() - } + agent_type: Option<&str>, + ) -> Vec { + let Some(mode_id) = agent_type.map(str::trim).filter(|value| !value.is_empty()) else { + return candidates; + }; - /// Get all enabled skills (for tool description) - pub async fn get_enabled_skills(&self) -> Vec { - self.get_all_skills() + let disabled_user = load_disabled_user_mode_skills(mode_id) .await - .into_iter() - .filter(|s| s.enabled) - .collect() - } + .unwrap_or_default(); + let disabled_project = match workspace_root { + Some(root) => load_disabled_mode_skills_local(root, mode_id) + .await + .unwrap_or_default(), + None => Vec::new(), + }; - /// Get XML description list of enabled skills - pub async fn get_enabled_skills_xml(&self) -> Vec { - self.get_enabled_skills() - .await + let disabled_user: HashSet = dedupe_preserving_order(disabled_user).into_iter().collect(); + let disabled_project: HashSet = + dedupe_preserving_order(disabled_project).into_iter().collect(); + + candidates .into_iter() - .map(|s| s.to_xml_desc()) + .filter(|candidate| match candidate.info.level { + SkillLocation::User => !disabled_user.contains(&candidate.info.key), + SkillLocation::Project => !disabled_project.contains(&candidate.info.key), + }) .collect() } - /// Find skill information by name - pub async fn find_skill(&self, skill_name: &str) -> Option { - self.ensure_loaded().await; - { - let cache = self.cache.read().await; - if let Some(info) = cache.get(skill_name) { - return Some(info.clone()); - } - } - - // Skill may have been installed externally (e.g. via `npx skills add`) after cache init. - self.refresh().await; - let cache = self.cache.read().await; - cache.get(skill_name).cloned() - } + async fn apply_mode_filters_for_remote_workspace( + &self, + candidates: Vec, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + agent_type: Option<&str>, + ) -> Vec { + let Some(mode_id) = agent_type.map(str::trim).filter(|value| !value.is_empty()) else { + return candidates; + }; - /// Find SKILL.md path by name - pub async fn find_skill_path(&self, skill_name: &str) -> Option { - self.find_skill(skill_name) + let disabled_user = load_disabled_user_mode_skills(mode_id) .await - .map(|info| PathBuf::from(&info.path).join("SKILL.md")) - } + .unwrap_or_default(); + let disabled_project = load_disabled_mode_skills_remote(fs, remote_root, mode_id) + .await + .unwrap_or_default(); - pub async fn find_skill_for_workspace( - &self, - skill_name: &str, - workspace_root: Option<&Path>, - ) -> Option { - self.find_skill_in_map(skill_name, workspace_root).await - } + let disabled_user: HashSet = dedupe_preserving_order(disabled_user).into_iter().collect(); + let disabled_project: HashSet = + dedupe_preserving_order(disabled_project).into_iter().collect(); - pub async fn find_skill_path_for_workspace( - &self, - skill_name: &str, - workspace_root: Option<&Path>, - ) -> Option { - self.find_skill_for_workspace(skill_name, workspace_root) - .await - .map(|info| PathBuf::from(&info.path).join("SKILL.md")) + candidates + .into_iter() + .filter(|candidate| match candidate.info.level { + SkillLocation::User => !disabled_user.contains(&candidate.info.key), + SkillLocation::Project => !disabled_project.contains(&candidate.info.key), + }) + .collect() } - /// Update skill enabled status in cache - pub async fn update_skill_enabled(&self, skill_name: &str, enabled: bool) { - let mut cache = self.cache.write().await; - if let Some(info) = cache.get_mut(skill_name) { - info.enabled = enabled; + async fn ensure_loaded(&self) { + let cache = self.cache.read().await; + if cache.is_empty() { + drop(cache); + self.refresh().await; } } - /// Remove skill from cache - pub async fn remove_skill(&self, skill_name: &str) { + pub async fn refresh(&self) { + let skills = sort_skills( + self.scan_skill_candidates_for_workspace(None) + .await + .into_iter() + .map(|candidate| candidate.info) + .collect(), + ); let mut cache = self.cache.write().await; - cache.remove(skill_name); + *cache = skills; } - /// Find and load skill (for execution) - /// Only load enabled skills - pub async fn find_and_load_skill(&self, skill_name: &str) -> BitFunResult { - // First search in cache - let skill_info = self.find_skill(skill_name).await; - - if let Some(info) = skill_info { - // Check if enabled - if !info.enabled { - return Err(BitFunError::tool(format!( - "Skill '{}' is disabled", - skill_name - ))); - } - - // Load full content from file - let skill_md_path = PathBuf::from(&info.path).join("SKILL.md"); - let content = fs::read_to_string(&skill_md_path) - .await - .map_err(|e| BitFunError::tool(format!("Failed to read skill file: {}", e)))?; - - let skill_data = - SkillData::from_markdown(info.path.clone(), &content, info.level, true)?; - - debug!( - "SkillRegistry loaded skill '{}' from {}", - skill_name, info.path - ); - return Ok(skill_data); - } + pub async fn refresh_for_workspace(&self, _workspace_root: Option<&Path>) { + self.refresh().await; + } - // Skill not found - Err(BitFunError::tool(format!( - "Skill '{}' not found", - skill_name - ))) + pub async fn get_all_skills(&self) -> Vec { + self.ensure_loaded().await; + let cache = self.cache.read().await; + cache.clone() } - pub async fn get_enabled_skills_xml_for_workspace( + pub async fn get_all_skills_for_workspace( &self, workspace_root: Option<&Path>, - ) -> Vec { - self.scan_skill_map_for_workspace(workspace_root) - .await - .into_values() - .filter(|skill| skill.enabled) - .map(|skill| skill.to_xml_desc()) - .collect() + ) -> Vec { + sort_skills( + self.scan_skill_candidates_for_workspace(workspace_root) + .await + .into_iter() + .map(|candidate| candidate.info) + .collect(), + ) } - /// Remote SSH workspace: merge **client** user-level skills with **server** project skills (via SFTP). - pub async fn get_enabled_skills_xml_for_remote_workspace( + pub async fn get_all_skills_for_remote_workspace( &self, fs: &dyn WorkspaceFileSystem, remote_root: &str, - ) -> Vec { - self.scan_skill_map_for_remote_workspace(fs, remote_root) - .await - .into_values() - .filter(|skill| skill.enabled) - .map(|skill| skill.to_xml_desc()) - .collect() + ) -> Vec { + sort_skills( + self.scan_skill_candidates_for_remote_workspace(fs, remote_root) + .await + .into_iter() + .map(|candidate| candidate.info) + .collect(), + ) } - async fn scan_skills_in_remote_dir( - fs: &dyn WorkspaceFileSystem, - dir: &str, - level: SkillLocation, + pub async fn get_resolved_skills_for_workspace( + &self, + workspace_root: Option<&Path>, + agent_type: Option<&str>, ) -> Vec { - let mut skills = Vec::new(); - let entries = match fs.read_dir(dir).await { - Ok(e) => e, - Err(_) => return skills, - }; - for e in entries { - if !e.is_dir || e.is_symlink { - continue; - } - let skill_md = format!("{}/SKILL.md", e.path.trim_end_matches('/')); - if !fs.is_file(&skill_md).await.unwrap_or(false) { - continue; - } - match fs.read_file_text(&skill_md).await { - Ok(content) => match SkillData::from_markdown( - e.path.clone(), - &content, - level, - false, - ) { - Ok(skill_data) => { - skills.push(SkillInfo { - name: skill_data.name, - description: skill_data.description, - path: skill_data.path, - level, - enabled: skill_data.enabled, - }); - } - Err(err) => { - error!("Failed to parse SKILL.md in {}: {}", e.path, err); - } - }, - Err(err) => { - debug!("Failed to read {}: {}", skill_md, err); - } - } - } - skills + let candidates = self.scan_skill_candidates_for_workspace(workspace_root).await; + let filtered = self + .apply_mode_filters_for_workspace(candidates, workspace_root, agent_type) + .await; + resolve_visible_skills(filtered) } - async fn scan_remote_project_skills( + pub async fn get_resolved_skills_for_remote_workspace( + &self, fs: &dyn WorkspaceFileSystem, remote_root: &str, + agent_type: Option<&str>, ) -> Vec { - let mut skills = Vec::new(); - let root = remote_root.trim_end_matches('/'); - for (parent, sub) in PROJECT_SKILL_SUBDIRS { - let skill_dir = format!("{}/{}/{}", root, parent, sub); - if !fs.is_dir(&skill_dir).await.unwrap_or(false) { - continue; - } - let mut part = - Self::scan_skills_in_remote_dir(fs, &skill_dir, SkillLocation::Project).await; - skills.append(&mut part); - } - skills + let candidates = self + .scan_skill_candidates_for_remote_workspace(fs, remote_root) + .await; + let filtered = self + .apply_mode_filters_for_remote_workspace(candidates, fs, remote_root, agent_type) + .await; + resolve_visible_skills(filtered) } - /// User skills from this machine plus project skills from the remote workspace root. - pub async fn scan_skill_map_for_remote_workspace( + pub async fn find_skill_by_key_for_workspace( + &self, + skill_key: &str, + workspace_root: Option<&Path>, + ) -> Option { + self.get_all_skills_for_workspace(workspace_root) + .await + .into_iter() + .find(|skill| skill.key == skill_key) + } + + pub async fn find_skill_by_key_for_remote_workspace( &self, fs: &dyn WorkspaceFileSystem, remote_root: &str, - ) -> HashMap { - let mut map = self.scan_skill_map_for_workspace(None).await; - for info in Self::scan_remote_project_skills(fs, remote_root).await { - map.insert(info.name.clone(), info); - } - map + skill_key: &str, + ) -> Option { + self.get_all_skills_for_remote_workspace(fs, remote_root) + .await + .into_iter() + .find(|skill| skill.key == skill_key) } pub async fn find_and_load_skill_for_workspace( &self, skill_name: &str, workspace_root: Option<&Path>, + agent_type: Option<&str>, ) -> BitFunResult { - let skill_map = self.scan_skill_map_for_workspace(workspace_root).await; - let info = skill_map - .get(skill_name) + let info = self + .get_resolved_skills_for_workspace(workspace_root, agent_type) + .await + .into_iter() + .find(|skill| skill.name == skill_name) .ok_or_else(|| BitFunError::tool(format!("Skill '{}' not found", skill_name)))?; - if !info.enabled { - return Err(BitFunError::tool(format!( - "Skill '{}' is disabled", - skill_name - ))); - } - let skill_md_path = PathBuf::from(&info.path).join("SKILL.md"); let content = fs::read_to_string(&skill_md_path) .await - .map_err(|e| BitFunError::tool(format!("Failed to read skill file: {}", e)))?; + .map_err(|error| BitFunError::tool(format!("Failed to read skill file: {}", error)))?; - SkillData::from_markdown(info.path.clone(), &content, info.level, true) + let mut data = SkillData::from_markdown(info.path.clone(), &content, info.level, true)?; + data.key = info.key; + data.source_slot = info.source_slot; + data.dir_name = info.dir_name; + Ok(data) } - /// Load skill when the workspace is remote: user-level skills are read from the **client** disk - /// (e.g. Windows `%AppData%`), project-level skills from the **SSH** workspace via [`WorkspaceFileSystem`]. pub async fn find_and_load_skill_for_remote_workspace( &self, skill_name: &str, fs: &dyn WorkspaceFileSystem, remote_root: &str, + agent_type: Option<&str>, ) -> BitFunResult { - let map = self.scan_skill_map_for_remote_workspace(fs, remote_root).await; - let info = map - .get(skill_name) + let info = self + .get_resolved_skills_for_remote_workspace(fs, remote_root, agent_type) + .await + .into_iter() + .find(|skill| skill.name == skill_name) .ok_or_else(|| BitFunError::tool(format!("Skill '{}' not found", skill_name)))?; - if !info.enabled { - return Err(BitFunError::tool(format!( - "Skill '{}' is disabled", - skill_name - ))); - } + let content = Self::read_skill_md_for_remote_merge(&info, fs).await?; + let mut data = SkillData::from_markdown(info.path.clone(), &content, info.level, true)?; + data.key = info.key; + data.source_slot = info.source_slot; + data.dir_name = info.dir_name; + Ok(data) + } - let content = Self::read_skill_md_for_remote_merge(info, fs).await?; + pub async fn get_resolved_skills_xml_for_workspace( + &self, + workspace_root: Option<&Path>, + agent_type: Option<&str>, + ) -> Vec { + self.get_resolved_skills_for_workspace(workspace_root, agent_type) + .await + .into_iter() + .map(|skill| skill.to_xml_desc()) + .collect() + } - SkillData::from_markdown(info.path.clone(), &content, info.level, true) + pub async fn get_resolved_skills_xml_for_remote_workspace( + &self, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + agent_type: Option<&str>, + ) -> Vec { + self.get_resolved_skills_for_remote_workspace(fs, remote_root, agent_type) + .await + .into_iter() + .map(|skill| skill.to_xml_desc()) + .collect() } - /// Merged remote session: [`SkillLocation::User`] paths are host-OS paths (Windows/macOS/Linux); - /// [`SkillLocation::Project`] paths are POSIX paths on the SSH server. async fn read_skill_md_for_remote_merge( info: &SkillInfo, remote_fs: &dyn WorkspaceFileSystem, ) -> BitFunResult { match info.level { SkillLocation::User => { - let p = PathBuf::from(&info.path).join("SKILL.md"); - fs::read_to_string(&p) - .await - .map_err(|e| BitFunError::tool(format!("Failed to read skill file: {}", e))) + let skill_md_path = PathBuf::from(&info.path).join("SKILL.md"); + fs::read_to_string(&skill_md_path).await.map_err(|error| { + BitFunError::tool(format!("Failed to read skill file: {}", error)) + }) } SkillLocation::Project => { let skill_md_path = format!("{}/SKILL.md", info.path.trim_end_matches('/')); remote_fs .read_file_text(&skill_md_path) .await - .map_err(|e| BitFunError::tool(format!("Failed to read skill file: {}", e))) + .map_err(|error| BitFunError::tool(format!("Failed to read skill file: {}", error))) } } } diff --git a/src/crates/core/src/agentic/tools/implementations/skills/types.rs b/src/crates/core/src/agentic/tools/implementations/skills/types.rs index 3d839744..5aadeb5c 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/types.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/types.rs @@ -3,7 +3,6 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::front_matter_markdown::FrontMatterMarkdown; use serde::{Deserialize, Serialize}; -use serde_yaml::Value; /// Skill location #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -26,8 +25,11 @@ impl SkillLocation { /// Complete skill information (for API return) #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SkillInfo { - /// Skill name (read from SKILL.md, used as unique identifier) + /// Runtime-unique identifier derived from source slot + directory name. + pub key: String, + /// Skill name (read from SKILL.md, used by the model to invoke the skill) pub name: String, /// Description (read from SKILL.md) pub description: String, @@ -35,8 +37,10 @@ pub struct SkillInfo { pub path: String, /// Level (project-level/user-level) pub level: SkillLocation, - /// Whether enabled - pub enabled: bool, + /// Source slot that discovered this skill. + pub source_slot: String, + /// Directory name under the slot's `skills/` root. + pub dir_name: String, } impl SkillInfo { @@ -60,16 +64,30 @@ impl SkillInfo { } } +/// Skill information annotated for a specific mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModeSkillInfo { + #[serde(flatten)] + pub skill: SkillInfo, + /// True when this skill key is explicitly disabled in the current mode's config. + pub disabled_by_mode: bool, + /// True when this skill is the one actually selected for runtime after applying + /// mode disables and same-name priority resolution. + pub selected_for_runtime: bool, +} + /// Skill data (contains content, for execution) #[derive(Debug, Clone)] pub struct SkillData { + pub key: String, pub name: String, pub description: String, pub content: String, pub location: SkillLocation, pub path: String, - /// Whether enabled (read from enabled field in SKILL.md, defaults to true if not present) - pub enabled: bool, + pub source_slot: String, + pub dir_name: String, } impl SkillData { @@ -83,7 +101,6 @@ impl SkillData { let (metadata, body) = FrontMatterMarkdown::load_str(content) .map_err(|e| BitFunError::tool(format!("Invalid SKILL.md format: {}", e)))?; - // Extract fields from YAML metadata let name = metadata .get("name") .and_then(|v| v.as_str()) @@ -100,67 +117,22 @@ impl SkillData { BitFunError::tool("Missing required field 'description' in SKILL.md".to_string()) })?; - // enabled field defaults to true if not present - let enabled = metadata - .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let skill_content = if with_content { body } else { String::new() }; + let dir_name = std::path::Path::new(&path) + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| BitFunError::tool(format!("Invalid skill path: {}", path)))? + .to_string(); Ok(SkillData { + key: String::new(), name, description, content: skill_content, location, path, - enabled, + source_slot: String::new(), + dir_name, }) } - - /// Set enabled status and save to SKILL.md file - /// - /// If enabled is true, remove enabled field (use default value) - /// If enabled is false, write enabled: false - pub fn set_enabled_and_save(skill_md_path: &str, enabled: bool) -> BitFunResult<()> { - let (mut metadata, body) = FrontMatterMarkdown::load(skill_md_path) - .map_err(|e| BitFunError::tool(format!("Failed to load SKILL.md: {}", e)))?; - - // Get mutable mapping of metadata - let map = metadata.as_mapping_mut().ok_or_else(|| { - BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string()) - })?; - - if enabled { - // When enabling, remove enabled field (use default value) - map.remove(&Value::String("enabled".to_string())); - } else { - // When disabling, write enabled: false - map.insert(Value::String("enabled".to_string()), Value::Bool(false)); - } - - FrontMatterMarkdown::save(skill_md_path, &metadata, &body) - .map_err(|e| BitFunError::tool(format!("Failed to save SKILL.md: {}", e)))?; - - Ok(()) - } - - /// Convert to XML description - pub fn to_xml_desc(&self) -> String { - format!( - r#" - -{} - - -{} - - -{} - - -"#, - self.name, self.description, self.path - ) - } } diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/filesystem/path_manager.rs index 1ab8c13e..6290d9e8 100644 --- a/src/crates/core/src/infrastructure/filesystem/path_manager.rs +++ b/src/crates/core/src/infrastructure/filesystem/path_manager.rs @@ -360,6 +360,17 @@ impl PathManager { self.project_root(workspace_path).join("config.json") } + /// Get project internal config directory: {project}/.bitfun/config/ + pub fn project_internal_config_dir(&self, workspace_path: &Path) -> PathBuf { + self.project_root(workspace_path).join("config") + } + + /// Get project mode skills file: {project}/.bitfun/config/mode_skills.json + pub fn project_mode_skills_file(&self, workspace_path: &Path) -> PathBuf { + self.project_internal_config_dir(workspace_path) + .join("mode_skills.json") + } + /// Get project .gitignore file: {project}/.bitfun/.gitignore pub fn project_gitignore_file(&self, workspace_path: &Path) -> PathBuf { self.project_root(workspace_path).join(".gitignore") @@ -488,6 +499,7 @@ impl PathManager { pub async fn initialize_project_directories(&self, workspace_path: &Path) -> BitFunResult<()> { let dirs = vec![ self.project_root(workspace_path), + self.project_internal_config_dir(workspace_path), self.project_agents_dir(workspace_path), self.project_rules_dir(workspace_path), self.project_snapshots_dir(workspace_path), diff --git a/src/crates/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/core/src/service/config/mode_config_canonicalizer.rs index 4d757ee9..9ef0196f 100644 --- a/src/crates/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/core/src/service/config/mode_config_canonicalizer.rs @@ -53,8 +53,8 @@ fn normalize_tools(tools: Vec, valid_tools: &HashSet) -> Vec>) -> Option> { - skills.map(dedupe_preserving_order) +fn normalize_skill_keys(keys: Vec) -> Vec { + dedupe_preserving_order(keys) } pub fn resolve_effective_tools( @@ -95,7 +95,7 @@ fn stored_mode_from_enabled_tools( mode_id: &str, enabled: bool, enabled_tools: Vec, - available_skills: Option>, + disabled_user_skills: Vec, default_tools: &[String], valid_tools: &HashSet, ) -> Option { @@ -123,7 +123,7 @@ fn stored_mode_from_enabled_tools( enabled, added_tools, removed_tools, - available_skills, + disabled_user_skills, &default_tools, valid_tools, ) @@ -134,14 +134,14 @@ fn stored_mode_from_overrides( enabled: bool, added_tools: Vec, removed_tools: Vec, - available_skills: Option>, + disabled_user_skills: Vec, default_tools: &[String], valid_tools: &HashSet, ) -> Option { let default_set: HashSet = default_tools.iter().cloned().collect(); let mut added_tools = normalize_tools(added_tools, valid_tools); let mut removed_tools = normalize_tools(removed_tools, valid_tools); - let available_skills = normalize_skills(available_skills); + let disabled_user_skills = normalize_skill_keys(disabled_user_skills); added_tools.retain(|tool| !default_set.contains(tool)); removed_tools.retain(|tool| default_set.contains(tool)); @@ -149,7 +149,11 @@ fn stored_mode_from_overrides( let removed_set: HashSet = removed_tools.iter().cloned().collect(); added_tools.retain(|tool| !removed_set.contains(tool)); - if enabled && added_tools.is_empty() && removed_tools.is_empty() && available_skills.is_none() { + if enabled + && added_tools.is_empty() + && removed_tools.is_empty() + && disabled_user_skills.is_empty() + { return None; } @@ -158,7 +162,7 @@ fn stored_mode_from_overrides( added_tools, removed_tools, enabled, - available_skills, + disabled_user_skills, }) } @@ -171,14 +175,16 @@ fn build_mode_view( let default_tools = normalize_tools(default_tools, valid_tools); let enabled_tools = resolve_effective_tools(&default_tools, mode_config, valid_tools); let enabled = mode_config.map(|config| config.enabled).unwrap_or(true); - let available_skills = mode_config.and_then(|config| config.available_skills.clone()); + let disabled_user_skills = mode_config + .map(|config| normalize_skill_keys(config.disabled_user_skills.clone())) + .unwrap_or_default(); ModeConfigView { mode_id: mode_id.to_string(), enabled_tools, default_tools, enabled, - available_skills, + disabled_user_skills, } } @@ -207,7 +213,7 @@ fn canonicalize_mode_config( stored.enabled, stored.added_tools, stored.removed_tools, - stored.available_skills, + stored.disabled_user_skills, default_tools, valid_tools, )) @@ -289,31 +295,31 @@ pub async fn persist_mode_config_from_value(mode_id: &str, config: Value) -> Bit resolve_effective_tools(default_tools, current, &valid_tools) }; - let available_skills = if config + let disabled_user_skills = if config .as_object() - .map(|obj| obj.contains_key("available_skills")) + .map(|obj| obj.contains_key("disabled_user_skills")) .unwrap_or(false) { - match config.get("available_skills") { - Some(Value::Null) | None => None, - Some(value) => Some( - serde_json::from_value::>(value.clone()).map_err(|error| { - BitFunError::config(format!( - "Invalid available_skills for mode '{}': {}", - mode_id, error - )) - })?, - ), + match config.get("disabled_user_skills") { + Some(Value::Null) | None => Vec::new(), + Some(value) => serde_json::from_value::>(value.clone()).map_err(|error| { + BitFunError::config(format!( + "Invalid disabled_user_skills for mode '{}': {}", + mode_id, error + )) + })?, } } else { - current.and_then(|item| item.available_skills.clone()) + current + .map(|item| item.disabled_user_skills.clone()) + .unwrap_or_default() }; if let Some(canonical) = stored_mode_from_enabled_tools( mode_id, enabled, enabled_tools, - available_skills, + disabled_user_skills, default_tools, &valid_tools, ) { diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index e4bf792f..4a8a7d3f 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -505,10 +505,9 @@ pub struct ModeConfig { #[serde(default = "default_true")] pub enabled: bool, - /// Skill overrides for this mode. - /// `None` means follow the global skill enablement state. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub available_skills: Option>, + /// User-level skills disabled for this mode. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disabled_user_skills: Vec, } /// API view of a mode configuration. @@ -519,8 +518,8 @@ pub struct ModeConfigView { pub enabled_tools: Vec, pub default_tools: Vec, pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub available_skills: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disabled_user_skills: Vec, } fn default_true() -> bool { @@ -548,7 +547,7 @@ impl Default for ModeConfig { added_tools: Vec::new(), removed_tools: Vec::new(), enabled: true, - available_skills: None, + disabled_user_skills: Vec::new(), } } } @@ -560,7 +559,7 @@ impl Default for ModeConfigView { enabled_tools: Vec::new(), default_tools: Vec::new(), enabled: true, - available_skills: None, + disabled_user_skills: Vec::new(), } } } diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 9b3f9d29..41adef9a 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -37,8 +37,36 @@ import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; import { SubagentAPI } from '@/infrastructure/api/service-api/SubagentAPI'; +import type { ModeSkillInfo } from '@/infrastructure/config/types'; import { useNotification } from '@/shared/notification-system'; +function getConfiguredEnabledSkillKeys(skills: ModeSkillInfo[]): string[] { + return skills.filter((skill) => !skill.disabledByMode).map((skill) => skill.key); +} + +function buildDuplicateSkillNameSet(skills: ModeSkillInfo[]): Set { + const counts = new Map(); + for (const skill of skills) { + counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1); + } + return new Set( + [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([name]) => name), + ); +} + +function formatSkillOrigin(skill: ModeSkillInfo): string { + return `${skill.level}/${skill.sourceSlot}`; +} + +function formatSkillDisplayName(skill: ModeSkillInfo, duplicateNames: Set): string { + if (!duplicateNames.has(skill.name)) { + return skill.name; + } + return `${skill.name} [${formatSkillOrigin(skill)}]`; +} + const AgentsHomeView: React.FC = () => { const { t } = useTranslation('scenes/agents'); const notification = useNotification(); @@ -68,7 +96,7 @@ const AgentsHomeView: React.FC = () => { filteredAgents, loading, availableTools, - availableSkills, + getModeSkills, counts, loadAgents, getModeConfig, @@ -134,11 +162,25 @@ const AgentsHomeView: React.FC = () => { () => (selectedAgent?.agentKind === 'mode' ? getModeConfig(selectedAgent.id) : null), [getModeConfig, selectedAgent], ); + const selectedAgentModeSkills = useMemo( + () => (selectedAgent?.agentKind === 'mode' ? getModeSkills(selectedAgent.id) : []), + [getModeSkills, selectedAgent], + ); const selectedAgentTools = selectedAgent?.agentKind === 'mode' ? (selectedAgentModeConfig?.enabled_tools ?? selectedAgent.defaultTools ?? []) : (selectedAgent?.defaultTools ?? []); - const selectedAgentSkills = selectedAgentModeConfig?.available_skills ?? []; - const selectedAgentSkillItems = availableSkills.filter((skill) => selectedAgentSkills.includes(skill.name)); + const selectedAgentSkills = useMemo( + () => getConfiguredEnabledSkillKeys(selectedAgentModeSkills), + [selectedAgentModeSkills], + ); + const selectedAgentSkillItems = useMemo( + () => selectedAgentModeSkills.filter((skill) => !skill.disabledByMode), + [selectedAgentModeSkills], + ); + const selectedAgentDuplicateSkillNames = useMemo( + () => buildDuplicateSkillNameSet(selectedAgentModeSkills), + [selectedAgentModeSkills], + ); const getDisplayedToolCount = useCallback((agent: AgentWithCapabilities): number => { if (agent.agentKind === 'mode') { return getModeConfig(agent.id)?.enabled_tools?.length @@ -275,7 +317,7 @@ const AgentsHomeView: React.FC = () => { index={index} meta={coreAgentMeta[agent.id] ?? { role: agent.name, accentColor: '#6366f1', accentBg: 'rgba(99,102,241,0.10)' }} toolCount={getDisplayedToolCount(agent)} - skillCount={agent.agentKind === 'mode' ? (getModeConfig(agent.id)?.available_skills?.length ?? 0) : 0} + skillCount={agent.agentKind === 'mode' ? getConfiguredEnabledSkillKeys(getModeSkills(agent.id)).length : 0} onOpenDetails={openAgentDetails} /> ))} @@ -359,7 +401,7 @@ const AgentsHomeView: React.FC = () => { index={index} soloEnabled={agentSoloEnabled[agent.id] ?? agent.enabled} toolCount={getDisplayedToolCount(agent)} - skillCount={agent.agentKind === 'mode' ? (getModeConfig(agent.id)?.available_skills?.length ?? 0) : 0} + skillCount={agent.agentKind === 'mode' ? getConfiguredEnabledSkillKeys(getModeSkills(agent.id)).length : 0} onToggleSolo={setAgentSoloEnabled} onOpenDetails={openAgentDetails} /> @@ -545,14 +587,14 @@ const AgentsHomeView: React.FC = () => { ) : null} - {selectedAgent.agentKind === 'mode' && availableSkills.length > 0 ? ( + {selectedAgent.agentKind === 'mode' && selectedAgentModeSkills.length > 0 ? (
{t('agentsOverview.skills')} - {`${(skillsEditing ? (pendingSkills ?? selectedAgentSkills) : selectedAgentSkills).length}/${availableSkills.length}`} + {`${(skillsEditing ? (pendingSkills ?? selectedAgentSkills) : selectedAgentSkills).length}/${selectedAgentModeSkills.length}`}
@@ -607,34 +649,40 @@ const AgentsHomeView: React.FC = () => { {skillsEditing ? (
- {[...availableSkills] + {[...selectedAgentModeSkills] .sort((a, b) => { const draft = pendingSkills ?? selectedAgentSkills; - const aOn = draft.includes(a.name); - const bOn = draft.includes(b.name); + const aOn = draft.includes(a.key); + const bOn = draft.includes(b.key); if (aOn && !bOn) return -1; if (!aOn && bOn) return 1; return 0; }) .map((skill) => { const draft = pendingSkills ?? selectedAgentSkills; - const isOn = draft.includes(skill.name); + const isOn = draft.includes(skill.key); + const displayName = formatSkillDisplayName(skill, selectedAgentDuplicateSkillNames); + const title = [ + skill.description || skill.name, + `key: ${skill.key}`, + !skill.disabledByMode && !skill.selectedForRuntime ? 'shadowed by a higher-priority skill with the same name' : null, + ].filter(Boolean).join('\n'); return ( ); })} @@ -647,8 +695,16 @@ const AgentsHomeView: React.FC = () => { ) : ( selectedAgentSkillItems.map((skill) => ( - - {skill.name} + + {formatSkillDisplayName(skill, selectedAgentDuplicateSkillNames)} )) )} diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index d09cc01b..09ca22a9 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -3,7 +3,7 @@ import type { TFunction } from 'i18next'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { SubagentAPI } from '@/infrastructure/api/service-api/SubagentAPI'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import type { ModeConfigItem, SkillInfo } from '@/infrastructure/config/types'; +import type { ModeConfigItem, ModeSkillInfo } from '@/infrastructure/config/types'; import { useNotification } from '@/shared/notification-system'; import type { AgentWithCapabilities } from '../agentsStore'; import { enrichCapabilities } from '../utils'; @@ -37,7 +37,7 @@ export function useAgentsList({ const [allAgents, setAllAgents] = useState([]); const [loading, setLoading] = useState(true); const [availableTools, setAvailableTools] = useState([]); - const [availableSkills, setAvailableSkills] = useState([]); + const [modeSkills, setModeSkills] = useState>({}); const [modeConfigs, setModeConfigs] = useState>({}); const loadRequestIdRef = useRef(0); @@ -55,13 +55,21 @@ export function useAgentsList({ }; try { - const [modes, subagents, tools, configs, skills] = await Promise.all([ + const [modes, subagents, tools, configs] = await Promise.all([ agentAPI.getAvailableModes().catch(() => []), SubagentAPI.listSubagents({ workspacePath: workspacePath || undefined }).catch(() => []), fetchTools(), configAPI.getModeConfigs().catch(() => ({})), - configAPI.getSkillConfigs({ workspacePath: workspacePath || undefined }).catch(() => []), ]); + const skillEntries = await Promise.all( + modes.map(async (mode) => [ + mode.id, + await configAPI.getModeSkillConfigs({ + modeId: mode.id, + workspacePath: workspacePath || undefined, + }).catch(() => []), + ] as const), + ); if (requestId !== loadRequestIdRef.current) { return; } @@ -90,7 +98,7 @@ export function useAgentsList({ setAllAgents([...modeAgents, ...subAgents]); setAvailableTools(tools); - setAvailableSkills(skills.filter((skill: SkillInfo) => skill.enabled)); + setModeSkills(Object.fromEntries(skillEntries)); setModeConfigs(configs as Record); } finally { if (requestId === loadRequestIdRef.current) { @@ -125,6 +133,10 @@ export function useAgentsList({ }; }, [allAgents, modeConfigs]); + const getModeSkills = useCallback((agentId: string): ModeSkillInfo[] => { + return modeSkills[agentId] ?? []; + }, [modeSkills]); + const saveModeConfig = useCallback(async (agentId: string, updates: Partial) => { const config = getModeConfig(agentId); if (!config) return; @@ -154,7 +166,12 @@ export function useAgentsList({ try { await configAPI.resetModeConfig(agentId); const updated = await configAPI.getModeConfigs(); + const updatedSkills = await configAPI.getModeSkillConfigs({ + modeId: agentId, + workspacePath: workspacePath || undefined, + }); setModeConfigs(updated as Record); + setModeSkills((prev) => ({ ...prev, [agentId]: updatedSkills })); notification.success(t('agentsOverview.toolsResetSuccess', '已重置为默认工具')); try { @@ -166,16 +183,44 @@ export function useAgentsList({ } catch { notification.error(t('agentsOverview.toolToggleFailed', '重置失败')); } - }, [notification, t]); + }, [notification, t, workspacePath]); - const handleSetSkills = useCallback(async (agentId: string, skillNames: string[]) => { + const handleSetSkills = useCallback(async (agentId: string, enabledSkillKeys: string[]) => { try { - const nextSkills = Array.from(new Set(skillNames)); - await saveModeConfig(agentId, { available_skills: nextSkills }); + const currentSkills = getModeSkills(agentId); + const nextEnabled = new Set(enabledSkillKeys); + + for (const skill of currentSkills) { + const shouldBeEnabled = nextEnabled.has(skill.key); + const isEnabled = !skill.disabledByMode; + if (shouldBeEnabled === isEnabled) { + continue; + } + + await configAPI.setModeSkillDisabled({ + modeId: agentId, + skillKey: skill.key, + disabled: !shouldBeEnabled, + workspacePath: workspacePath || undefined, + }); + } + + const updatedSkills = await configAPI.getModeSkillConfigs({ + modeId: agentId, + workspacePath: workspacePath || undefined, + }); + setModeSkills((prev) => ({ ...prev, [agentId]: updatedSkills })); + + try { + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + } catch { + // ignore + } } catch { notification.error(t('agentsOverview.skillToggleFailed', 'Skill 切换失败')); } - }, [notification, saveModeConfig, t]); + }, [getModeSkills, notification, t, workspacePath]); const filteredAgents = useMemo(() => allAgents.filter((agent) => { if (searchQuery) { @@ -217,7 +262,7 @@ export function useAgentsList({ filteredAgents, loading, availableTools, - availableSkills, + getModeSkills, counts, loadAgents, getModeConfig, diff --git a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx index 93c14adc..e0b8328a 100644 --- a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx @@ -15,7 +15,7 @@ import '@/app/components/GalleryLayout/GalleryLayout.scss'; import { Select, Switch, type SelectOption } from '@/component-library'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { AIModelConfig, ModeConfigItem, SkillInfo } from '@/infrastructure/config/types'; +import type { AIModelConfig, ModeConfigItem, ModeSkillInfo } from '@/infrastructure/config/types'; import { MCPAPI, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -29,7 +29,7 @@ interface ToolInfo { name: string; description: string; is_readonly: boolean; } type TemplateDetail = | { type: 'tool'; tool: ToolInfo; isMcp: boolean } | { type: 'mcpServer'; serverId: string } - | { type: 'skill'; skill: SkillInfo }; + | { type: 'skill'; skill: ModeSkillInfo }; type ModelSlot = 'primary' | 'fast'; @@ -88,6 +88,29 @@ interface MockBreakdown { total: number; } +function buildDuplicateSkillNameSet(skills: ModeSkillInfo[]): Set { + const counts = new Map(); + for (const skill of skills) { + counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1); + } + return new Set( + [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([name]) => name), + ); +} + +function formatSkillOrigin(skill: ModeSkillInfo): string { + return `${skill.level}/${skill.sourceSlot}`; +} + +function formatSkillDisplayName(skill: ModeSkillInfo, duplicateNames: Set): string { + if (!duplicateNames.has(skill.name)) { + return skill.name; + } + return `${skill.name} [${formatSkillOrigin(skill)}]`; +} + function buildMockBreakdown( toolCount: number, rulesCount: number, @@ -109,7 +132,7 @@ const TemplateConfigPage: React.FC = () => { const [agenticConfig, setAgenticConfig] = useState(null); const [availableTools, setAvailableTools] = useState([]); const [mcpServers, setMcpServers] = useState([]); - const [skills, setSkills] = useState([]); + const [modeSkills, setModeSkills] = useState([]); const [toolsLoading, setToolsLoading] = useState>({}); const [skillsLoading, setSkillsLoading] = useState>({}); const [loading, setLoading] = useState(true); @@ -121,27 +144,18 @@ const TemplateConfigPage: React.FC = () => { [agenticConfig], ); - // Whether a skill is enabled in this template: - // - If available_skills is undefined, fall back to the skill's global enabled state - // - Otherwise check if the skill name appears in the list - const isSkillEnabled = useCallback( - (skillName: string): boolean => { - if (agenticConfig?.available_skills == null) { - return skills.find((s) => s.name === skillName)?.enabled ?? true; - } - return agenticConfig.available_skills.includes(skillName); - }, - [agenticConfig, skills], - ); - const skillsEnabled = useMemo( - () => skills.filter((s) => isSkillEnabled(s.name)), - [skills, isSkillEnabled], + () => modeSkills.filter((skill) => !skill.disabledByMode), + [modeSkills], ); const skillsDisabled = useMemo( - () => skills.filter((s) => !isSkillEnabled(s.name)), - [skills, isSkillEnabled], + () => modeSkills.filter((skill) => skill.disabledByMode), + [modeSkills], + ); + const duplicateSkillNames = useMemo( + () => buildDuplicateSkillNameSet(modeSkills), + [modeSkills], ); const tokenBreakdown = useMemo( @@ -203,14 +217,14 @@ const TemplateConfigPage: React.FC = () => { configManager.getConfig>('ai.func_agent_models').catch(() => ({} as Record)), configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), - configAPI.getSkillConfigs({}).catch(() => [] as SkillInfo[]), + configAPI.getModeSkillConfigs({ modeId: 'agentic' }).catch(() => [] as ModeSkillInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), ]); setModels(allModels ?? []); setFuncAgentModels(funcModels ?? {}); setAgenticConfig(modeConf); setAvailableTools(tools); - setSkills(skillList ?? []); + setModeSkills(skillList ?? []); setMcpServers(servers ?? []); } catch (e) { log.error('Failed to load template config', e); @@ -289,8 +303,12 @@ const TemplateConfigPage: React.FC = () => { const handleResetTools = useCallback(async () => { try { await configAPI.resetModeConfig('agentic'); - const modeConf = await configAPI.getModeConfig('agentic'); + const [modeConf, skills] = await Promise.all([ + configAPI.getModeConfig('agentic'), + configAPI.getModeSkillConfigs({ modeId: 'agentic' }), + ]); setAgenticConfig(modeConf); + setModeSkills(skills); const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('mode:config:updated'); notificationService.success(t('notifications.resetSuccess')); @@ -320,31 +338,27 @@ const TemplateConfigPage: React.FC = () => { } }, [agenticConfig, t]); - const handleSkillToggle = useCallback(async (skillName: string) => { - if (!agenticConfig) return; - setSkillsLoading((prev) => ({ ...prev, [skillName]: true })); - // Initialise from global state when available_skills is not yet set - const current = - agenticConfig.available_skills ?? - skills.filter((s) => s.enabled).map((s) => s.name); - const isEnabled = current.includes(skillName); - const next = isEnabled - ? current.filter((n) => n !== skillName) - : [...current, skillName]; - const newConfig = { ...agenticConfig, available_skills: next }; - setAgenticConfig(newConfig); + const handleSkillToggle = useCallback(async (skill: ModeSkillInfo) => { + const loadingKey = skill.key; + setSkillsLoading((prev) => ({ ...prev, [loadingKey]: true })); + const nextDisabled = !skill.disabledByMode; try { - await configAPI.setModeConfig('agentic', newConfig); + await configAPI.setModeSkillDisabled({ + modeId: 'agentic', + skillKey: skill.key, + disabled: nextDisabled, + }); + const updatedSkills = await configAPI.getModeSkillConfigs({ modeId: 'agentic' }); + setModeSkills(updatedSkills); const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('mode:config:updated'); } catch (e) { log.error('Failed to toggle skill', e); notificationService.error(t('notifications.toggleFailed')); - setAgenticConfig(agenticConfig); } finally { - setSkillsLoading((prev) => ({ ...prev, [skillName]: false })); + setSkillsLoading((prev) => ({ ...prev, [loadingKey]: false })); } - }, [agenticConfig, skills, t]); + }, [t]); const toggleCollapse = useCallback((id: string) => { setCollapsedGroups((prev) => { @@ -390,9 +404,9 @@ const TemplateConfigPage: React.FC = () => { )); }, []); - const openSkillDetail = useCallback((skill: SkillInfo) => { + const openSkillDetail = useCallback((skill: ModeSkillInfo) => { setDetail((prev) => ( - prev?.type === 'skill' && prev.skill.name === skill.name + prev?.type === 'skill' && prev.skill.key === skill.key ? null : { type: 'skill', skill } )); @@ -459,14 +473,15 @@ const TemplateConfigPage: React.FC = () => {
); - const renderSkillList = (list: SkillInfo[]) => ( + const renderSkillList = (list: ModeSkillInfo[]) => (
{list.map((skill) => { - const on = isSkillEnabled(skill.name); - const selected = detail?.type === 'skill' && detail.skill.name === skill.name; + const on = !skill.disabledByMode; + const selected = detail?.type === 'skill' && detail.skill.key === skill.key; + const displayName = formatSkillDisplayName(skill, duplicateSkillNames); return (
handleSkillToggle(skill.name)} - disabled={skillsLoading[skill.name]} + onChange={() => handleSkillToggle(skill)} + disabled={skillsLoading[skill.key]} size="small" />
@@ -674,7 +689,7 @@ const TemplateConfigPage: React.FC = () => { } const { skill } = detail; - const on = isSkillEnabled(skill.name); + const on = !skill.disabledByMode; return (