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:
@@ -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();
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user