feat(commands): unified slash-command namespace with template substitution (closes #435)

Three sources share the /foo namespace with clear precedence:
  1. Native built-ins (match block in mod.rs)
  2. User-config commands (~/.deepseek/commands/*.md) — checked first
  3. Skills (~/.deepseek/skills/) — new fallback in the _ arm

Template substitution: $1, $2, $ARGUMENTS are replaced in user-command
and skill content before the message is sent. Existing exact-match and
alias behavior is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangfeng
2026-05-04 16:39:28 -07:00
parent 3cff070570
commit e577db47e4
3 changed files with 30 additions and 3 deletions
+5
View File
@@ -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!(
+12
View File
@@ -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<CommandResult> {
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(),
+13 -3
View File
@@ -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<CommandResult> {
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)));
}
}