diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index cdfcb3f6..31276b2e 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -567,6 +567,11 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { ), _ => { + // Third source: skills (lowest precedence after native and user-config). + // Try to run a skill whose name matches the command. + if skills::run_skill_by_name(app, command, arg).is_some() { + return skills::run_skill_by_name(app, command, arg).unwrap(); + } let suggestions = suggest_command_names(command, 3); if suggestions.is_empty() { CommandResult::error(format!( diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index 65b264e1..c8c0e3b3 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -78,6 +78,18 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { /// Run a specific skill — activates skill for next user message, or /// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`). +/// Try to run a skill by exact name (used for unified slash-command namespace, #435). +/// Returns None when no skill with that name exists, so the caller can try other sources. +pub fn run_skill_by_name(app: &mut App, name: &str, _arg: Option<&str>) -> Option { + let skills_dir = app.skills_dir.clone(); + let registry = crate::skills::SkillRegistry::discover(&skills_dir); + if registry.get(name).is_some() { + Some(activate_skill(app, name)) + } else { + None + } +} + pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { let raw = match name { Some(n) => n.trim(), diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 29bb41b9..65b93c6d 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -62,18 +62,28 @@ pub fn load_user_commands() -> Vec<(String, String)> { /// The `input` should be the full command string including the `/` /// prefix (e.g. `/mycmd` or `/mycmd with args`). Only exact matches /// on the command name are considered (no partial/alias matching). +/// Substitute $1, $2, $ARGUMENTS placeholders in a command template. +fn apply_template(template: &str, args: &str) -> String { + let positional: Vec<&str> = args.split_whitespace().collect(); + let mut result = template.replace("$ARGUMENTS", args); + for (i, arg) in positional.iter().enumerate() { + result = result.replace(&format!("${}", i + 1), arg); + } + result +} + pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option { let parts: Vec<&str> = input.trim().splitn(2, ' ').collect(); let command = parts[0].to_lowercase(); let command = command.strip_prefix('/').unwrap_or(&command); + let args = parts.get(1).copied().unwrap_or("").trim(); let user_commands = load_user_commands(); for (name, content) in &user_commands { if name == command { - return Some(CommandResult::action(AppAction::SendMessage( - content.clone(), - ))); + let message = apply_template(content, args); + return Some(CommandResult::action(AppAction::SendMessage(message))); } }