diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index ffa7fd77..553d308a 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::convert::Infallible; use std::fs; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path as FsPath, PathBuf}; use std::process::Command; use std::sync::Arc; use std::time::Duration; @@ -961,10 +961,9 @@ async fn list_skills( State(state): State, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = - crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); + let registry = crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let skill_state = state.skill_state.lock().await; - let directories = crate::skills::skills_directories(&state.workspace); + let directories = skills_search_directories(&state.workspace, &skills_dir); let skills = registry .list() .iter() @@ -990,12 +989,12 @@ async fn set_skill_enabled( Json(req): Json, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = - crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); + let registry = crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let exists = registry.list().iter().any(|skill| skill.name == name); if !exists { return Err(ApiError::not_found(format!( - "skill '{name}' not found" + "skill '{name}' not found in searched directories: {}", + format_skill_search_paths(&skills_search_directories(&state.workspace, &skills_dir)) ))); } @@ -1771,6 +1770,25 @@ fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf { config.skills_dir() } +fn skills_search_directories(workspace: &FsPath, skills_dir: &FsPath) -> Vec { + let mut directories = crate::skills::skills_directories(workspace); + if skills_dir.is_dir() && !directories.iter().any(|path| path == skills_dir) { + directories.push(skills_dir.to_path_buf()); + } + directories +} + +fn format_skill_search_paths(directories: &[PathBuf]) -> String { + if directories.is_empty() { + return "".to_string(); + } + directories + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") +} + fn load_mcp_config_or_default(path: &std::path::Path) -> Result { crate::mcp::load_config(path) .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}"))) @@ -3890,6 +3908,24 @@ mod tests { assert_eq!(resolved, expected); } + #[test] + fn skills_search_directories_includes_custom_skills_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let custom_skills = tmp.path().join("custom-skills"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&custom_skills).expect("create custom skills"); + + let directories = skills_search_directories(&workspace, &custom_skills); + + assert!( + directories.iter().any(|dir| dir == &custom_skills), + "custom skills_dir must be reported when discovery searches it" + ); + let message = format_skill_search_paths(&directories); + assert!(message.contains("custom-skills")); + } + /// A `skills` symlink that points outside the workspace must NOT be /// returned as the resolved skills directory. Containment check ensures /// the canonicalized candidate stays under the canonicalized workspace