diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index a556655e5..e7fbda62b 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -1874,6 +1874,12 @@ async fn run_interactive( // Set up terminal let mut terminal = setup_terminal(live_config.mouse_capture_enabled())?; let mut app = App::new(live_config.clone(), cost_tracker.clone()); + // Discover skill slash commands once at startup so they appear in + // autocomplete without re-running discovery per keystroke. + app.discovered_slash_commands = claurst_commands::all_slash_command_names( + &tool_ctx.working_dir, + &live_config.skills, + ); if let Some(error) = settings_load_error { app.invalid_config_dialog = claurst_tui::InvalidConfigDialogState::show_settings_error(&error); diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 723a09910..5ebfc0a6c 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -1453,6 +1453,37 @@ pub fn commands_from_discovered_skills( .collect() } +/// Return all slash command names and descriptions (built-in + discovered +/// skills), suitable for autocomplete and the command palette. Discovered +/// skills whose name clashes with a built-in are excluded. +/// +/// The built-in list is returned as owned `String`s because `all_commands()` +/// returns `Box` with borrowed `&str` fields — we need +/// owned strings to mix with discovered skill names in one `Vec`. +pub fn all_slash_command_names( + cwd: &std::path::Path, + skills_config: &claurst_core::SkillsConfig, +) -> Vec<(String, String)> { + // Built-in commands. + let cmds = all_commands(); + let mut result: Vec<(String, String)> = cmds + .iter() + .map(|c| (c.name().to_string(), c.description().to_string())) + .collect(); + // Discovered skills (excluding name collisions with built-ins). + let builtin_names: std::collections::HashSet = result + .iter() + .map(|(name, _)| name.clone()) + .collect(); + let discovered = claurst_core::discover_skills(cwd, skills_config); + for skill in discovered.values() { + if !builtin_names.contains(&skill.name) { + result.push((skill.name.clone(), skill.description.clone())); + } + } + result +} + /// Execute a slash command string (with leading /). pub async fn execute_command( input: &str, diff --git a/src-rust/crates/core/src/skill_discovery.rs b/src-rust/crates/core/src/skill_discovery.rs index 2b49ab43f..06652de00 100644 --- a/src-rust/crates/core/src/skill_discovery.rs +++ b/src-rust/crates/core/src/skill_discovery.rs @@ -70,10 +70,21 @@ pub fn parse_skill_file(content: &str, path: &Path) -> Option { }; let name = name.unwrap_or_else(|| { - path.file_stem() + let stem = path + .file_stem() .and_then(|s| s.to_str()) - .unwrap_or("unnamed") - .to_string() + .unwrap_or("unnamed"); + // When the file is named `SKILL.md` (directory-based skill), use the + // parent directory's name as the skill name. + if stem.eq_ignore_ascii_case("skill") { + path.parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or(stem) + .to_string() + } else { + stem.to_string() + } }); let description = description.unwrap_or_else(|| "Custom skill".to_string()); @@ -93,13 +104,35 @@ pub fn parse_skill_file(content: &str, path: &Path) -> Option { // Directory scanning // --------------------------------------------------------------------------- -/// Scan a single directory for `*.md` skill files. +/// Scan a single directory for skill files: +/// - Flat `*.md` files directly in `dir` +/// - Subdirectories containing `SKILL.md` (e.g. `dir//SKILL.md`) +/// - `dir` itself if it directly contains `SKILL.md` +/// +/// Collects all matches rather than returning early, so a directory with a +/// root `SKILL.md` plus sibling flat `.md` files keeps the siblings. fn scan_dir(dir: &Path) -> Vec { let mut skills = Vec::new(); if !dir.is_dir() { return skills; } + // If `dir` directly contains a SKILL.md file, parse it as a single skill. + // Only check the canonical casing — on case-insensitive filesystems + // (macOS, Windows) both `SKILL.md` and `skill.md` resolve to the same + // file, so checking both would double-count. + for skill_file in ["SKILL.md", "skill.md"] { + let candidate = dir.join(skill_file); + if candidate.is_file() { + if let Ok(content) = std::fs::read_to_string(&candidate) { + if let Some(skill) = parse_skill_file(&content, &candidate) { + skills.push(skill); + } + } + break; + } + } + let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(err) => { @@ -110,15 +143,48 @@ fn scan_dir(dir: &Path) -> Vec { for entry in entries.flatten() { let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("md") { - match std::fs::read_to_string(&path) { - Ok(content) => { - if let Some(skill) = parse_skill_file(&content, &path) { - skills.push(skill); + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // Skip hidden files/directories (e.g. .git, .DS_Store). + if file_name_str.starts_with('.') { + continue; + } + + if path.is_file() { + // Skip SKILL.md / skill.md — already handled by the dedicated + // check above, so we don't double-count them. + if file_name_str.eq_ignore_ascii_case("skill.md") { + continue; + } + if path.extension().and_then(|e| e.to_str()) == Some("md") { + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Some(skill) = parse_skill_file(&content, &path) { + skills.push(skill); + } + } + Err(err) => { + tracing::debug!(path = %path.display(), error = %err, "skill_discovery: read failed"); } } - Err(err) => { - tracing::debug!(path = %path.display(), error = %err, "skill_discovery: read failed"); + } + } else if path.is_dir() { + // Check subdirectories for SKILL.md / skill.md. + for skill_file in ["SKILL.md", "skill.md"] { + let candidate = path.join(skill_file); + if candidate.is_file() { + match std::fs::read_to_string(&candidate) { + Ok(content) => { + if let Some(skill) = parse_skill_file(&content, &candidate) { + skills.push(skill); + } + } + Err(err) => { + tracing::debug!(path = %candidate.display(), error = %err, "skill_discovery: read failed"); + } + } + break; } } } @@ -127,6 +193,32 @@ fn scan_dir(dir: &Path) -> Vec { skills } +/// Resolve a configured path string, expanding `~` to the user's home +/// directory. Handles both `/` and `\\` separators so it works on Windows. +/// `~user` paths (e.g. `~alice/skills`) are left unexpanded rather than +/// silently resolving to the wrong home. +fn resolve_path(path_str: &str, cwd: &Path) -> PathBuf { + let trimmed = path_str.trim(); + if trimmed == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) + } else if let Some(rest) = trimmed.strip_prefix("~/") { + dirs::home_dir().map(|h| h.join(rest)).unwrap_or_else(|| PathBuf::from(trimmed)) + } else if let Some(rest) = trimmed.strip_prefix("~\\\\") { + dirs::home_dir().map(|h| h.join(rest)).unwrap_or_else(|| PathBuf::from(trimmed)) + } else if trimmed.starts_with('~') { + // `~user/...` — we can't resolve another user's home reliably. + // Leave the path unexpanded rather than guessing wrong. + PathBuf::from(trimmed) + } else { + let p = Path::new(trimmed); + if p.is_absolute() { + p.to_path_buf() + } else { + cwd.join(p) + } + } +} + // --------------------------------------------------------------------------- // Top-level discovery // --------------------------------------------------------------------------- @@ -176,18 +268,18 @@ pub fn discover_skills( &crate::config::Settings::config_dir().join("skills"), )); - // ---- 3. Configured extra paths ------------------------------------------ + // ---- 3. Global .agents skills: ~/.agents/skills/ ------------------------- + if let Some(home) = dirs::home_dir() { + add(scan_dir(&home.join(".agents").join("skills"))); + } + + // ---- 4. Configured extra paths (with ~ expansion) ----------------------- for path_str in &config_skills.paths { - let path = Path::new(path_str); - let path = if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - }; + let path = resolve_path(path_str, cwd); add(scan_dir(&path)); } - // ---- 4. Git URL skills (cached) ----------------------------------------- + // ---- 5. Git URL skills (cached) ----------------------------------------- for url in &config_skills.urls { if let Some(git_skills) = fetch_git_skills(url) { add(git_skills); @@ -402,4 +494,107 @@ mod tests { // Project-level wins over extra path. assert_eq!(discovered["dup"].description, "project"); } + + // ---- directory-based skills (SKILL.md in subdirs) ----------------------- + + #[test] + fn test_parse_skill_md_in_subdir_uses_parent_dir_name() { + let content = "Use clean code principles."; + let path = PathBuf::from("/home/user/.agents/skills/clean-code/SKILL.md"); + let skill = parse_skill_file(content, &path).unwrap(); + assert_eq!(skill.name, "clean-code"); + assert_eq!(skill.description, "Custom skill"); + assert_eq!(skill.template, "Use clean code principles."); + } + + #[test] + fn test_scan_dir_finds_subdirectories_with_skill_md() { + let tmp = make_temp_dir(); + // Flat file + write_file(tmp.path(), "flat.md", "---\nname: flat\n---\nFlat template."); + // Subdirectory with SKILL.md + write_file( + tmp.path(), + "brainstorming/SKILL.md", + "---\ndescription: Brainstorm ideas\n---\nBrainstorm $ARGUMENTS", + ); + // Subdirectory with lowercase skill.md + write_file( + tmp.path(), + "git-master/skill.md", + "---\nname: git-guru\ndescription: Git helpers\n---\nGit commands", + ); + // Ignored non-skill dir + write_file(tmp.path(), "other_folder/readme.txt", "not a skill"); + + let skills = scan_dir(tmp.path()); + assert_eq!(skills.len(), 3); + let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"flat")); + assert!(names.contains(&"brainstorming")); + assert!(names.contains(&"git-guru")); + } + + #[test] + fn test_scan_dir_direct_skill_folder() { + let tmp = make_temp_dir(); + let skill_dir = tmp.path().join("my-custom-skill"); + write_file( + &skill_dir, + "SKILL.md", + "---\ndescription: Direct folder\n---\nDo work", + ); + + let skills = scan_dir(&skill_dir); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "my-custom-skill"); + assert_eq!(skills[0].description, "Direct folder"); + } + + // ---- resolve_path ------------------------------------------------------- + + #[test] + fn test_resolve_path() { + let cwd = PathBuf::from("/workspace/project"); + // Absolute path + assert_eq!( + resolve_path("/Users/test/.agents/skills", &cwd), + PathBuf::from("/Users/test/.agents/skills") + ); + // Relative path + assert_eq!( + resolve_path("custom/skills", &cwd), + PathBuf::from("/workspace/project/custom/skills") + ); + // Tilde path + if let Some(home) = dirs::home_dir() { + assert_eq!( + resolve_path("~/.agents/skills", &cwd), + home.join(".agents/skills") + ); + assert_eq!(resolve_path("~", &cwd), home); + } + // ~user path should NOT be expanded to $HOME + let unexpanded = resolve_path("~alice/skills", &cwd); + assert_eq!(unexpanded, PathBuf::from("~alice/skills")); + } + + // ---- discover_skills with directory-based skills ------------------------ + + #[test] + fn test_discover_from_project_agents_skills_subdir() { + let tmp = make_temp_dir(); + let skills_dir = tmp.path().join(".agents").join("skills").join("sub-skill"); + std::fs::create_dir_all(&skills_dir).unwrap(); + write_file( + &skills_dir, + "SKILL.md", + "---\ndescription: Sub agent skill\n---\nSub agent prompt.", + ); + + let config = crate::config::SkillsConfig::default(); + let discovered = discover_skills(tmp.path(), &config); + assert!(discovered.contains_key("sub-skill")); + assert_eq!(discovered["sub-skill"].description, "Sub agent skill"); + } } diff --git a/src-rust/crates/tui/src/app/mod.rs b/src-rust/crates/tui/src/app/mod.rs index 71c2195a0..88956afff 100644 --- a/src-rust/crates/tui/src/app/mod.rs +++ b/src-rust/crates/tui/src/app/mod.rs @@ -246,6 +246,11 @@ pub struct App { pub remote_session_url: Option, /// Live MCP manager snapshot source when available. pub mcp_manager: Option>, + /// Cached list of discovered skill slash commands (name, description), + /// built once at startup and refreshed when the config changes. Used to + /// augment the autocomplete and command palette without re-running + /// discovery on every keystroke. + pub discovered_slash_commands: Vec<(String, String)>, /// Queued request for a real MCP reconnect from the interactive loop. pub pending_mcp_reconnect: bool, /// Set after an in-session provider connection (e.g. a Claude Pro/Max OAuth @@ -647,6 +652,7 @@ impl App { session_title: None, remote_session_url: None, mcp_manager: None, + discovered_slash_commands: Vec::new(), pending_mcp_reconnect: false, pending_provider_reload: false, pending_mcp_panel_auth: None, diff --git a/src-rust/crates/tui/src/app/prompt.rs b/src-rust/crates/tui/src/app/prompt.rs index 7de2588a6..cf785557d 100644 --- a/src-rust/crates/tui/src/app/prompt.rs +++ b/src-rust/crates/tui/src/app/prompt.rs @@ -54,7 +54,13 @@ impl App { } let file_autocomplete_limit = self.config.file_autocomplete_limit; let file_autocomplete_show_hidden = self.config.file_autocomplete_show_hidden_files; - self.prompt_input.update_suggestions(PROMPT_SLASH_COMMANDS, file_autocomplete_limit, file_autocomplete_show_hidden); + // Merge built-in slash commands with discovered skill commands + // so skills appear in autocomplete and the command palette. + let mut all_cmds: Vec<(&str, &str)> = PROMPT_SLASH_COMMANDS.to_vec(); + for (name, desc) in &self.discovered_slash_commands { + all_cmds.push((name.as_str(), desc.as_str())); + } + self.prompt_input.update_suggestions(&all_cmds, file_autocomplete_limit, file_autocomplete_show_hidden); self.sync_legacy_prompt_fields(); }