feat: implement custom slash commands system

Adds a comprehensive slash command system that allows users to create and manage custom commands:

- Backend implementation in Rust for discovering, loading, and managing slash commands
- Support for both user-level (~/.claude/commands/) and project-level (.claude/commands/) commands
- YAML frontmatter support for command metadata (description, allowed-tools)
- Command namespacing with directory structure (e.g., /namespace:command)
- Detection of special features: bash commands (\!), file references (@), and arguments ($ARGUMENTS)

Frontend enhancements:
- SlashCommandPicker component with autocomplete UI and keyboard navigation
- SlashCommandsManager component for CRUD operations on commands
- Integration with FloatingPromptInput to trigger picker on "/" input
- Visual indicators for command features (bash, files, arguments)
- Grouped display by namespace with search functionality

API additions:
- slash_commands_list: Discover all available commands
- slash_command_get: Retrieve specific command by ID
- slash_command_save: Create or update commands
- slash_command_delete: Remove commands

This implementation provides a foundation for users to create reusable command templates and workflows. Commands are stored as markdown files with optional YAML frontmatter for metadata.

Addresses #127 and #134
This commit is contained in:
Mufeed VH
2025-07-06 22:51:08 +05:30
parent 985de02404
commit 8af922944b
12 changed files with 1753 additions and 4 deletions

20
src-tauri/Cargo.lock generated
View File

@@ -635,6 +635,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"sha2",
"tauri",
"tauri-build",
@@ -4243,6 +4244,19 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.9.0",
"itoa 1.0.15",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.1"
@@ -5480,6 +5494,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"

View File

@@ -48,6 +48,7 @@ sha2 = "0.10"
zstd = "0.13"
uuid = { version = "1.6", features = ["v4", "serde"] }
walkdir = "2"
serde_yaml = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]

View File

@@ -3,3 +3,4 @@ pub mod claude;
pub mod mcp;
pub mod usage;
pub mod storage;
pub mod slash_commands;

View File

@@ -0,0 +1,405 @@
use anyhow::{Context, Result};
use dirs;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Represents a custom slash command
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlashCommand {
/// Unique identifier for the command (derived from file path)
pub id: String,
/// Command name (without prefix)
pub name: String,
/// Full command with prefix (e.g., "/project:optimize")
pub full_command: String,
/// Command scope: "project" or "user"
pub scope: String,
/// Optional namespace (e.g., "frontend" in "/project:frontend:component")
pub namespace: Option<String>,
/// Path to the markdown file
pub file_path: String,
/// Command content (markdown body)
pub content: String,
/// Optional description from frontmatter
pub description: Option<String>,
/// Allowed tools from frontmatter
pub allowed_tools: Vec<String>,
/// Whether the command has bash commands (!)
pub has_bash_commands: bool,
/// Whether the command has file references (@)
pub has_file_references: bool,
/// Whether the command uses $ARGUMENTS placeholder
pub accepts_arguments: bool,
}
/// YAML frontmatter structure
#[derive(Debug, Deserialize)]
struct CommandFrontmatter {
#[serde(rename = "allowed-tools")]
allowed_tools: Option<Vec<String>>,
description: Option<String>,
}
/// Parse a markdown file with optional YAML frontmatter
fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option<CommandFrontmatter>, String)> {
let lines: Vec<&str> = content.lines().collect();
// Check if the file starts with YAML frontmatter
if lines.is_empty() || lines[0] != "---" {
// No frontmatter
return Ok((None, content.to_string()));
}
// Find the end of frontmatter
let mut frontmatter_end = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if *line == "---" {
frontmatter_end = Some(i);
break;
}
}
if let Some(end) = frontmatter_end {
// Extract frontmatter
let frontmatter_content = lines[1..end].join("\n");
let body_content = lines[(end + 1)..].join("\n");
// Parse YAML
match serde_yaml::from_str::<CommandFrontmatter>(&frontmatter_content) {
Ok(frontmatter) => Ok((Some(frontmatter), body_content)),
Err(e) => {
debug!("Failed to parse frontmatter: {}", e);
// Return full content if frontmatter parsing fails
Ok((None, content.to_string()))
}
}
} else {
// Malformed frontmatter, treat as regular content
Ok((None, content.to_string()))
}
}
/// Extract command name and namespace from file path
fn extract_command_info(file_path: &Path, base_path: &Path) -> Result<(String, Option<String>)> {
let relative_path = file_path
.strip_prefix(base_path)
.context("Failed to get relative path")?;
// Remove .md extension
let path_without_ext = relative_path
.with_extension("")
.to_string_lossy()
.to_string();
// Split into components
let components: Vec<&str> = path_without_ext.split('/').collect();
if components.is_empty() {
return Err(anyhow::anyhow!("Invalid command path"));
}
if components.len() == 1 {
// No namespace
Ok((components[0].to_string(), None))
} else {
// Last component is the command name, rest is namespace
let command_name = components.last().unwrap().to_string();
let namespace = components[..components.len() - 1].join(":");
Ok((command_name, Some(namespace)))
}
}
/// Load a single command from a markdown file
fn load_command_from_file(
file_path: &Path,
base_path: &Path,
scope: &str,
) -> Result<SlashCommand> {
debug!("Loading command from: {:?}", file_path);
// Read file content
let content = fs::read_to_string(file_path)
.context("Failed to read command file")?;
// Parse frontmatter
let (frontmatter, body) = parse_markdown_with_frontmatter(&content)?;
// Extract command info
let (name, namespace) = extract_command_info(file_path, base_path)?;
// Build full command (no scope prefix, just /command or /namespace:command)
let full_command = match &namespace {
Some(ns) => format!("/{ns}:{name}"),
None => format!("/{name}"),
};
// Generate unique ID
let id = format!("{}-{}", scope, file_path.to_string_lossy().replace('/', "-"));
// Check for special content
let has_bash_commands = body.contains("!`");
let has_file_references = body.contains('@');
let accepts_arguments = body.contains("$ARGUMENTS");
// Extract metadata from frontmatter
let (description, allowed_tools) = if let Some(fm) = frontmatter {
(fm.description, fm.allowed_tools.unwrap_or_default())
} else {
(None, Vec::new())
};
Ok(SlashCommand {
id,
name,
full_command,
scope: scope.to_string(),
namespace,
file_path: file_path.to_string_lossy().to_string(),
content: body,
description,
allowed_tools,
has_bash_commands,
has_file_references,
accepts_arguments,
})
}
/// Recursively find all markdown files in a directory
fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
// Skip hidden files/directories
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
}
if path.is_dir() {
find_markdown_files(&path, files)?;
} else if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "md" {
files.push(path);
}
}
}
}
Ok(())
}
/// Discover all custom slash commands
#[tauri::command]
pub async fn slash_commands_list(
project_path: Option<String>,
) -> Result<Vec<SlashCommand>, String> {
info!("Discovering slash commands");
let mut commands = Vec::new();
// Load project commands if project path is provided
if let Some(proj_path) = project_path {
let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands");
if project_commands_dir.exists() {
debug!("Scanning project commands at: {:?}", project_commands_dir);
let mut md_files = Vec::new();
if let Err(e) = find_markdown_files(&project_commands_dir, &mut md_files) {
error!("Failed to find project command files: {}", e);
} else {
for file_path in md_files {
match load_command_from_file(&file_path, &project_commands_dir, "project") {
Ok(cmd) => {
debug!("Loaded project command: {}", cmd.full_command);
commands.push(cmd);
}
Err(e) => {
error!("Failed to load command from {:?}: {}", file_path, e);
}
}
}
}
}
}
// Load user commands
if let Some(home_dir) = dirs::home_dir() {
let user_commands_dir = home_dir.join(".claude").join("commands");
if user_commands_dir.exists() {
debug!("Scanning user commands at: {:?}", user_commands_dir);
let mut md_files = Vec::new();
if let Err(e) = find_markdown_files(&user_commands_dir, &mut md_files) {
error!("Failed to find user command files: {}", e);
} else {
for file_path in md_files {
match load_command_from_file(&file_path, &user_commands_dir, "user") {
Ok(cmd) => {
debug!("Loaded user command: {}", cmd.full_command);
commands.push(cmd);
}
Err(e) => {
error!("Failed to load command from {:?}: {}", file_path, e);
}
}
}
}
}
}
info!("Found {} slash commands", commands.len());
Ok(commands)
}
/// Get a single slash command by ID
#[tauri::command]
pub async fn slash_command_get(command_id: String) -> Result<SlashCommand, String> {
debug!("Getting slash command: {}", command_id);
// Parse the ID to determine scope and reconstruct file path
let parts: Vec<&str> = command_id.split('-').collect();
if parts.len() < 2 {
return Err("Invalid command ID".to_string());
}
// The actual implementation would need to reconstruct the path and reload the command
// For now, we'll list all commands and find the matching one
let commands = slash_commands_list(None).await?;
commands
.into_iter()
.find(|cmd| cmd.id == command_id)
.ok_or_else(|| format!("Command not found: {}", command_id))
}
/// Create or update a slash command
#[tauri::command]
pub async fn slash_command_save(
scope: String,
name: String,
namespace: Option<String>,
content: String,
description: Option<String>,
allowed_tools: Vec<String>,
project_path: Option<String>,
) -> Result<SlashCommand, String> {
info!("Saving slash command: {} in scope: {}", name, scope);
// Validate inputs
if name.is_empty() {
return Err("Command name cannot be empty".to_string());
}
if !["project", "user"].contains(&scope.as_str()) {
return Err("Invalid scope. Must be 'project' or 'user'".to_string());
}
// Determine base directory
let base_dir = if scope == "project" {
if let Some(proj_path) = project_path {
PathBuf::from(proj_path).join(".claude").join("commands")
} else {
return Err("Project path required for project scope".to_string());
}
} else {
dirs::home_dir()
.ok_or_else(|| "Could not find home directory".to_string())?
.join(".claude")
.join("commands")
};
// Build file path
let mut file_path = base_dir.clone();
if let Some(ns) = &namespace {
for component in ns.split(':') {
file_path = file_path.join(component);
}
}
// Create directories if needed
fs::create_dir_all(&file_path)
.map_err(|e| format!("Failed to create directories: {}", e))?;
// Add filename
file_path = file_path.join(format!("{}.md", name));
// Build content with frontmatter
let mut full_content = String::new();
// Add frontmatter if we have metadata
if description.is_some() || !allowed_tools.is_empty() {
full_content.push_str("---\n");
if let Some(desc) = &description {
full_content.push_str(&format!("description: {}\n", desc));
}
if !allowed_tools.is_empty() {
full_content.push_str("allowed-tools:\n");
for tool in &allowed_tools {
full_content.push_str(&format!(" - {}\n", tool));
}
}
full_content.push_str("---\n\n");
}
full_content.push_str(&content);
// Write file
fs::write(&file_path, &full_content)
.map_err(|e| format!("Failed to write command file: {}", e))?;
// Load and return the saved command
load_command_from_file(&file_path, &base_dir, &scope)
.map_err(|e| format!("Failed to load saved command: {}", e))
}
/// Delete a slash command
#[tauri::command]
pub async fn slash_command_delete(command_id: String) -> Result<String, String> {
info!("Deleting slash command: {}", command_id);
// Get the command to find its file path
let command = slash_command_get(command_id.clone()).await?;
// Delete the file
fs::remove_file(&command.file_path)
.map_err(|e| format!("Failed to delete command file: {}", e))?;
// Clean up empty directories
if let Some(parent) = Path::new(&command.file_path).parent() {
let _ = remove_empty_dirs(parent);
}
Ok(format!("Deleted command: {}", command.full_command))
}
/// Remove empty directories recursively
fn remove_empty_dirs(dir: &Path) -> Result<()> {
if !dir.exists() {
return Ok(());
}
// Check if directory is empty
let is_empty = fs::read_dir(dir)?.next().is_none();
if is_empty {
fs::remove_dir(dir)?;
// Try to remove parent if it's also empty
if let Some(parent) = dir.parent() {
let _ = remove_empty_dirs(parent);
}
}
Ok(())
}

View File

@@ -189,6 +189,12 @@ fn main() {
storage_insert_row,
storage_execute_sql,
storage_reset_database,
// Slash Commands
commands::slash_commands::slash_commands_list,
commands::slash_commands::slash_command_get,
commands::slash_commands::slash_command_save,
commands::slash_commands::slash_command_delete,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");