feat(commands): scan workspace-local .deepseek/.cursor/.claude commands

Extend load_user_commands() to scan workspace-local command directories
in addition to the global ~/.deepseek/commands/. Precedence model
mirrors skills_directories(): project-local shadows global by name.

Scanned directories (in precedence order):
  1. <workspace>/.deepseek/commands/
  2. <workspace>/.claude/commands/   (Claude Code interop)
  3. <workspace>/.cursor/commands/   (Cursor interop)
  4. ~/.deepseek/commands/           (user-global fallback)

Workspace context threaded through:
  - try_dispatch_user_command (has App reference)
  - user_commands_matching (new workspace parameter)
  - all_command_names_matching (new workspace parameter)
  - slash_completion_hints (new workspace parameter)

Closes #1259
This commit is contained in:
Hunter Bown
2026-05-09 00:11:28 -05:00
parent 15f62e3e93
commit 8dcb467bf5
4 changed files with 255 additions and 43 deletions
+8 -2
View File
@@ -719,7 +719,13 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
/// Get all command names matching a prefix, including both built-in
/// static commands and user-defined commands, formatted as `/name`.
pub fn all_command_names_matching(prefix: &str) -> Vec<String> {
///
/// `workspace` is used to also scan workspace-local command directories;
/// pass `None` when no workspace context is available.
pub fn all_command_names_matching(
prefix: &str,
workspace: Option<&std::path::Path>,
) -> Vec<String> {
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
let mut result: Vec<String> = COMMANDS
.iter()
@@ -730,7 +736,7 @@ pub fn all_command_names_matching(prefix: &str) -> Vec<String> {
.collect();
// Add user-defined commands
result.extend(user_commands::user_commands_matching(&prefix));
result.extend(user_commands::user_commands_matching(&prefix, workspace));
result.sort();
result.dedup();
+223 -30
View File
@@ -1,36 +1,54 @@
//! User-defined slash commands from `~/.deepseek/commands/<name>.md`.
//! User-defined slash commands from `~/.deepseek/commands/<name>.md` and
//! workspace-local `<workspace>/.deepseek/commands/<name>.md`.
//!
//! Users drop `.md` files into `~/.deepseek/commands/` and the filename
//! Users drop `.md` files into a commands directory and the filename
//! (without `.md` extension) becomes a slash command. When invoked via
//! `/name`, the file contents are sent as a user message.
//!
//! ## Precedence
//!
//! Workspace-local directories shadow user-global by name:
//!
//! 1. `<workspace>/.deepseek/commands/` (project-local, highest)
//! 2. `<workspace>/.claude/commands/` (Claude Code interop)
//! 3. `<workspace>/.cursor/commands/` (Cursor interop)
//! 4. `~/.deepseek/commands/` (user-global, lowest)
use std::path::PathBuf;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::tui::app::{App, AppAction};
use super::CommandResult;
/// Path to the user commands directory: `~/.deepseek/commands/`.
fn commands_dir() -> PathBuf {
/// Path to the global user commands directory: `~/.deepseek/commands/`.
fn global_commands_dir() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
home.join(".deepseek").join("commands")
}
/// Scan `~/.deepseek/commands/` for `.md` files and return `(name, content)` pairs.
///
/// The name is the filename without the `.md` extension, normalized to
/// lowercase. Files that fail to read are silently skipped. The directory
/// is re-scanned on every call so newly-added commands show up immediately
/// without requiring a restart.
pub fn load_user_commands() -> Vec<(String, String)> {
let dir = commands_dir();
/// Return all candidate commands directories in precedence order.
fn commands_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(ws) = workspace {
dirs.push(ws.join(".deepseek").join("commands"));
dirs.push(ws.join(".claude").join("commands"));
dirs.push(ws.join(".cursor").join("commands"));
}
dirs.push(global_commands_dir());
dirs
}
/// Scan a single commands directory for `.md` files and return
/// `(name, content)` pairs. Errors are silently skipped.
fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> {
let mut commands: Vec<(String, String)> = Vec::new();
if !dir.exists() {
if !dir.is_dir() {
return commands;
}
let entries = match std::fs::read_dir(&dir) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return commands,
};
@@ -51,6 +69,27 @@ pub fn load_user_commands() -> Vec<(String, String)> {
commands.push((stem, content));
}
commands
}
/// Scan every candidate commands directory and return merged
/// `(name, content)` pairs. Workspace-local directories shadow
/// user-global by name — the first occurrence of a name wins.
///
/// Pass `None` for the workspace to scan only the global directory
/// (backward-compatible with callers that don't have workspace context).
pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
let mut seen: HashSet<String> = HashSet::new();
let mut commands: Vec<(String, String)> = Vec::new();
for dir in commands_dirs(workspace) {
for (name, content) in load_commands_from_dir(&dir) {
if seen.insert(name.clone()) {
commands.push((name, content));
}
}
}
// Sort by name for deterministic ordering.
commands.sort_by(|a, b| a.0.cmp(&b.0));
commands
@@ -72,13 +111,13 @@ fn apply_template(template: &str, args: &str) -> String {
result
}
pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option<CommandResult> {
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();
let user_commands = load_user_commands(Some(&app.workspace));
for (name, content) in &user_commands {
if name == command {
@@ -94,9 +133,12 @@ pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option<CommandR
///
/// The prefix should be the command name portion only (after `/`).
/// Returns entries formatted as `/name`.
pub fn user_commands_matching(prefix: &str) -> Vec<String> {
///
/// `workspace` is used to also scan workspace-local command directories;
/// pass `None` when no workspace context is available.
pub fn user_commands_matching(prefix: &str, workspace: Option<&Path>) -> Vec<String> {
let prefix = prefix.to_lowercase();
load_user_commands()
load_user_commands(workspace)
.into_iter()
.filter(|(name, _)| name.starts_with(&prefix))
.map(|(name, _)| format!("/{}", name))
@@ -106,10 +148,11 @@ pub fn user_commands_matching(prefix: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_commands_dir_contains_deepseek_commands() {
let dir = commands_dir();
fn test_global_commands_dir_contains_deepseek_commands() {
let dir = global_commands_dir();
let parts: Vec<_> = dir
.components()
.filter_map(|component| component.as_os_str().to_str())
@@ -124,13 +167,9 @@ mod tests {
}
#[test]
fn test_load_user_commands_when_dir_absent() {
// Use a temp dir that definitely doesn't have a commands dir.
let _tmp = std::env::temp_dir().join("deepseek-test-nonexistent");
// Temporarily override the home for this test by checking the
// function with a non-existent directory path.
let cmds = load_user_commands();
// Should not panic; returns empty vec when dir doesn't exist.
fn test_load_user_commands_when_no_dir_exists() {
let cmds = load_user_commands(None);
// Should not panic; returns empty vec when no directories exist.
assert!(cmds.is_empty() || !cmds.is_empty());
}
@@ -166,8 +205,162 @@ mod tests {
}
#[test]
fn test_user_commands_matching_with_prefix() {
let matches = user_commands_matching("zzzznotfound");
fn test_user_commands_matching_with_prefix_no_workspace() {
let matches = user_commands_matching("zzzznotfound", None);
assert!(matches.is_empty());
}
// ── Workspace-local commands tests ─────────────────────────────────
fn write_command(dir: &Path, name: &str, body: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
}
#[test]
fn load_user_commands_scans_workspace_local_dir() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path();
let cmds_dir = ws.join(".deepseek").join("commands");
write_command(&cmds_dir, "hello", "echo hi");
let cmds = load_user_commands(Some(ws));
let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"hello"),
"expected 'hello' in workspace-local commands: {names:?}"
);
}
#[test]
fn load_user_commands_scans_claude_and_cursor_dirs() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path();
write_command(
&ws.join(".claude").join("commands"),
"claude-cmd",
"claude body",
);
write_command(
&ws.join(".cursor").join("commands"),
"cursor-cmd",
"cursor body",
);
let cmds = load_user_commands(Some(ws));
let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"claude-cmd"),
"expected 'claude-cmd': {names:?}"
);
assert!(
names.contains(&"cursor-cmd"),
"expected 'cursor-cmd': {names:?}"
);
}
#[test]
fn workspace_local_shadows_global_by_name() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path();
// Workspace-local version
write_command(
&ws.join(".deepseek").join("commands"),
"shared",
"workspace version",
);
// Global version — simulate by putting it in a "global" temp dir.
// Since we can't easily override `dirs::home_dir()`, we test the
// first-match-wins semantics by putting the same name in both
// workspace-scanned dirs. The first dir in precedence order wins.
write_command(
&ws.join(".claude").join("commands"),
"shared",
"claude version",
);
let cmds = load_user_commands(Some(ws));
let shared = cmds
.iter()
.find(|(n, _)| n == "shared")
.expect("shared present");
assert_eq!(
shared.1, "workspace version",
"workspace-local (.deepseek) must shadow later dirs"
);
}
#[test]
fn load_user_commands_without_workspace_falls_back_to_global_only() {
// When no workspace is passed, only the global ~/.deepseek/commands/
// is scanned. On test machines this dir often doesn't exist, so we
// just verify we don't panic.
let cmds = load_user_commands(None);
// This should not panic; can be empty or have user's real commands.
let _ = cmds;
}
#[test]
fn try_dispatch_uses_workspace_local_command() {
use crate::config::Config;
use crate::tui::app::TuiOptions;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
"hello",
"Hello, $ARGUMENTS!",
);
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: ws.clone(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
initial_input: None,
};
let mut app = App::new(options, &Config::default());
let result = try_dispatch_user_command(&mut app, "/hello world");
assert!(result.is_some());
let cmd_result = result.unwrap();
match cmd_result.action {
Some(AppAction::SendMessage(msg)) => {
assert!(msg.contains("Hello, world!"), "got: {msg}");
}
other => panic!("expected SendMessage action, got: {other:?}"),
}
}
#[test]
fn user_commands_matching_with_workspace() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path();
write_command(
&ws.join(".deepseek").join("commands"),
"project-cmd",
"body",
);
let matches = user_commands_matching("project", Some(ws));
assert!(
matches.contains(&"/project-cmd".to_string()),
"got: {matches:?}"
);
}
}
+17 -5
View File
@@ -20,7 +20,13 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<SlashMenuEntry
if app.slash_menu_hidden {
return Vec::new();
}
slash_completion_hints(&app.input, limit, &app.cached_skills, app.ui_locale)
slash_completion_hints(
&app.input,
limit,
&app.cached_skills,
app.ui_locale,
Some(&app.workspace),
)
}
/// Apply the currently-selected slash menu entry to the composer input.
@@ -63,10 +69,16 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
return false;
}
let candidates = slash_completion_hints(&app.input, 128, &app.cached_skills, app.ui_locale)
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
let candidates = slash_completion_hints(
&app.input,
128,
&app.cached_skills,
app.ui_locale,
Some(&app.workspace),
)
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
if candidates.is_empty() {
return false;
+7 -6
View File
@@ -1953,6 +1953,7 @@ pub(crate) fn slash_completion_hints(
limit: usize,
cached_skills: &[(String, String)],
locale: crate::localization::Locale,
workspace: Option<&std::path::Path>,
) -> Vec<SlashMenuEntry> {
if !input.starts_with('/') {
return Vec::new();
@@ -1970,7 +1971,7 @@ pub(crate) fn slash_completion_hints(
// built-in ones from the static registry and use a generic label for
// user-defined commands.
if completing_skill_arg.is_none() {
for name in commands::all_command_names_matching(prefix) {
for name in commands::all_command_names_matching(prefix, workspace) {
let command_key = name.trim_start_matches('/');
let description = if let Some(info) = commands::get_command_info(command_key) {
info.description_for(locale).to_string()
@@ -2359,14 +2360,14 @@ mod tests {
#[test]
fn slash_completion_hints_include_links_and_config() {
let hints = slash_completion_hints("/", 128, &[], Locale::En);
let hints = slash_completion_hints("/", 128, &[], Locale::En, None);
assert!(hints.iter().any(|hint| hint.name == "/config"));
assert!(hints.iter().any(|hint| hint.name == "/links"));
}
#[test]
fn slash_completion_hints_exclude_set_and_deepseek_commands() {
let hints = slash_completion_hints("/", 128, &[], Locale::En);
let hints = slash_completion_hints("/", 128, &[], Locale::En, None);
assert!(!hints.iter().any(|hint| hint.name == "/set"));
assert!(!hints.iter().any(|hint| hint.name == "/deepseek"));
}
@@ -2377,7 +2378,7 @@ mod tests {
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En);
let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En, None);
assert!(
hints
.iter()
@@ -2396,7 +2397,7 @@ mod tests {
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En);
let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En, None);
assert!(
hints
.iter()
@@ -2411,7 +2412,7 @@ mod tests {
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En);
let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En, None);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].name, "/skill my-review");
assert!(hints[0].is_skill);