Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions src-rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn SlashCommand>` 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<String> = 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,
Expand Down
233 changes: 214 additions & 19 deletions src-rust/crates/core/src/skill_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,21 @@ pub fn parse_skill_file(content: &str, path: &Path) -> Option<DiscoveredSkill> {
};

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());

Expand All @@ -93,13 +104,35 @@ pub fn parse_skill_file(content: &str, path: &Path) -> Option<DiscoveredSkill> {
// 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-name>/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<DiscoveredSkill> {
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) => {
Expand All @@ -110,15 +143,48 @@ fn scan_dir(dir: &Path) -> Vec<DiscoveredSkill> {

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;
}
}
}
Expand All @@ -127,6 +193,32 @@ fn scan_dir(dir: &Path) -> Vec<DiscoveredSkill> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
}
6 changes: 6 additions & 0 deletions src-rust/crates/tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ pub struct App {
pub remote_session_url: Option<String>,
/// Live MCP manager snapshot source when available.
pub mcp_manager: Option<Arc<claurst_mcp::McpManager>>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src-rust/crates/tui/src/app/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down