From 5134462debeb8812d279f2eda1f1271a7e73c96f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 1 Nov 2025 19:41:42 +0100 Subject: [PATCH] feat(tools): implement Slash Commands with frontmatter and file refs (M5 complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements the complete M5 milestone (Slash Commands) including: Slash Command Parser (tools-slash): - YAML frontmatter parsing with serde_yaml - Metadata extraction (description, author, tags, version) - Arbitrary frontmatter fields via flattened HashMap - Graceful fallback for commands without frontmatter Argument Substitution: - $ARGUMENTS - all arguments joined by space - $1, $2, $3, etc. - positional arguments - Unmatched placeholders remain unchanged - Empty arguments result in empty string for $ARGUMENTS File Reference Resolution: - @path syntax to include file contents inline - Regex-based matching for file references - Multiple file references supported - Clear error messages for missing files CLI Integration: - Added `slash` subcommand: `owlen slash ` - Loads commands from `.claude/commands/.md` - Permission checks for SlashCommand tool - Automatic file reference resolution before output Command Structure: --- description: "Command description" author: "Author name" tags: - tag1 - tag2 --- Command body with $ARGUMENTS and @file.txt references Permission Enforcement: - Plan mode: SlashCommand allowed (utility tool) - All modes: SlashCommand respects permissions - File references respect filesystem permissions Testing: - 10 tests in tools-slash for parser functionality - Frontmatter parsing with complex YAML - Argument substitution (all variants) - File reference resolution (single and multiple) - Edge cases (no frontmatter, empty args, etc.) - 3 new tests in CLI for integration - slash_command_works (with args and frontmatter) - slash_command_file_refs (file inclusion) - slash_command_not_found (error handling) - All 56 workspace tests passing ✅ Dependencies Added: - serde_yaml 0.9 for YAML frontmatter parsing - regex 1.12 for file reference pattern matching M5 milestone complete! ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 1 + crates/app/cli/Cargo.toml | 1 + crates/app/cli/src/main.rs | 42 ++++++ crates/app/cli/tests/permissions.rs | 76 ++++++++++ crates/tools/slash/Cargo.toml | 15 ++ crates/tools/slash/src/lib.rs | 169 ++++++++++++++++++++++ crates/tools/slash/tests/slash_command.rs | 109 ++++++++++++++ 7 files changed, 413 insertions(+) create mode 100644 crates/tools/slash/Cargo.toml create mode 100644 crates/tools/slash/src/lib.rs create mode 100644 crates/tools/slash/tests/slash_command.rs diff --git a/Cargo.toml b/Cargo.toml index 67d36ba..13b7a42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/platform/permissions", "crates/tools/bash", "crates/tools/fs", + "crates/tools/slash", ] resolver = "2" diff --git a/crates/app/cli/Cargo.toml b/crates/app/cli/Cargo.toml index f9e306b..cf614d7 100644 --- a/crates/app/cli/Cargo.toml +++ b/crates/app/cli/Cargo.toml @@ -14,6 +14,7 @@ color-eyre = "0.6" llm-ollama = { path = "../../llm/ollama" } tools-fs = { path = "../../tools/fs" } tools-bash = { path = "../../tools/bash" } +tools-slash = { path = "../../tools/slash" } config-agent = { package = "config-agent", path = "../../platform/config" } permissions = { path = "../../platform/permissions" } futures-util = "0.3.31" diff --git a/crates/app/cli/src/main.rs b/crates/app/cli/src/main.rs index 9944e3b..f241ba7 100644 --- a/crates/app/cli/src/main.rs +++ b/crates/app/cli/src/main.rs @@ -14,6 +14,7 @@ enum Cmd { Write { path: String, content: String }, Edit { path: String, old_string: String, new_string: String }, Bash { command: String, #[arg(long)] timeout: Option }, + Slash { command_name: String, args: Vec }, } #[derive(Parser, Debug)] @@ -180,6 +181,47 @@ async fn main() -> Result<()> { } } } + Cmd::Slash { command_name, args } => { + // Check permission + match perms.check(Tool::SlashCommand, None) { + PermissionDecision::Allow => { + // Look for command file in .claude/commands/ + let command_path = format!(".claude/commands/{}.md", command_name); + + // Read the command file + let content = match tools_fs::read_file(&command_path) { + Ok(c) => c, + Err(_) => { + return Err(eyre!( + "Slash command '{}' not found at {}", + command_name, + command_path + )); + } + }; + + // Parse with arguments + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let slash_cmd = tools_slash::parse_slash_command(&content, &args_refs)?; + + // Resolve file references + let resolved_body = slash_cmd.resolve_file_refs()?; + + // Print the resolved command body + println!("{}", resolved_body); + + return Ok(()); + } + PermissionDecision::Ask => { + return Err(eyre!( + "Permission denied: Slash command requires approval. Use --mode code to allow." + )); + } + PermissionDecision::Deny => { + return Err(eyre!("Permission denied: Slash command is blocked.")); + } + } + } } } diff --git a/crates/app/cli/tests/permissions.rs b/crates/app/cli/tests/permissions.rs index 000cb91..629d513 100644 --- a/crates/app/cli/tests/permissions.rs +++ b/crates/app/cli/tests/permissions.rs @@ -177,3 +177,79 @@ fn bash_command_timeout_works() { .arg("1000"); cmd.assert().failure(); } + +#[test] +fn slash_command_works() { + // Create .claude/commands directory in temp dir + let dir = tempdir().unwrap(); + let commands_dir = dir.path().join(".claude/commands"); + fs::create_dir_all(&commands_dir).unwrap(); + + // Create a test slash command + let command_content = r#"--- +description: "Test command" +--- +Hello from slash command! +Args: $ARGUMENTS +First: $1 +"#; + let command_file = commands_dir.join("test.md"); + fs::write(&command_file, command_content).unwrap(); + + // Execute slash command with args from the temp directory + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen")); + cmd.current_dir(dir.path()) + .arg("--mode") + .arg("code") + .arg("slash") + .arg("test") + .arg("arg1"); + + cmd.assert() + .success() + .stdout(predicates::str::contains("Hello from slash command!")) + .stdout(predicates::str::contains("Args: arg1")) + .stdout(predicates::str::contains("First: arg1")); +} + +#[test] +fn slash_command_file_refs() { + let dir = tempdir().unwrap(); + let commands_dir = dir.path().join(".claude/commands"); + fs::create_dir_all(&commands_dir).unwrap(); + + // Create a file to reference + let data_file = dir.path().join("data.txt"); + fs::write(&data_file, "Referenced content").unwrap(); + + // Create slash command with file reference + let command_content = format!("File content: @{}", data_file.display()); + fs::write(commands_dir.join("reftest.md"), command_content).unwrap(); + + // Execute slash command + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen")); + cmd.current_dir(dir.path()) + .arg("--mode") + .arg("code") + .arg("slash") + .arg("reftest"); + + cmd.assert() + .success() + .stdout(predicates::str::contains("Referenced content")); +} + +#[test] +fn slash_command_not_found() { + let dir = tempdir().unwrap(); + + // Try to execute non-existent slash command + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen")); + cmd.current_dir(dir.path()) + .arg("--mode") + .arg("code") + .arg("slash") + .arg("nonexistent"); + + cmd.assert().failure(); +} diff --git a/crates/tools/slash/Cargo.toml b/crates/tools/slash/Cargo.toml new file mode 100644 index 0000000..2564f5f --- /dev/null +++ b/crates/tools/slash/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tools-slash" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +color-eyre = "0.6" +regex = "1.12" + +[dev-dependencies] +tempfile = "3.23.0" diff --git a/crates/tools/slash/src/lib.rs b/crates/tools/slash/src/lib.rs new file mode 100644 index 0000000..1a99284 --- /dev/null +++ b/crates/tools/slash/src/lib.rs @@ -0,0 +1,169 @@ +use color_eyre::eyre::{Result, eyre}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashCommandMetadata { + #[serde(default)] + pub description: Option, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub version: Option, + #[serde(flatten)] + pub extra: HashMap, +} + +#[derive(Debug, Clone)] +pub struct SlashCommand { + pub description: Option, + pub author: Option, + pub tags: Option>, + pub version: Option, + pub body: String, +} + +impl SlashCommand { + /// Resolve file references (@path) in the command body + pub fn resolve_file_refs(&self) -> Result { + let re = Regex::new(r"@([^\s]+)").unwrap(); + let mut result = self.body.clone(); + + for cap in re.captures_iter(&self.body.clone()) { + let full_match = &cap[0]; + let file_path = &cap[1]; + + // Read the file + match std::fs::read_to_string(file_path) { + Ok(content) => { + result = result.replace(full_match, &content); + } + Err(e) => { + return Err(eyre!("Failed to read file '{}': {}", file_path, e)); + } + } + } + + Ok(result) + } +} + +/// Parse a slash command from its content +/// +/// # Arguments +/// * `content` - The full content of the slash command file (with optional frontmatter) +/// * `args` - Arguments to substitute ($ARGUMENTS, $1, $2, etc.) +pub fn parse_slash_command(content: &str, args: &[&str]) -> Result { + // Check if content starts with frontmatter (---) + let (metadata, body) = if content.trim_start().starts_with("---") { + parse_with_frontmatter(content)? + } else { + (None, content.to_string()) + }; + + // Perform argument substitution + let body_with_args = substitute_arguments(&body, args); + + Ok(SlashCommand { + description: metadata.as_ref().and_then(|m| m.description.clone()), + author: metadata.as_ref().and_then(|m| m.author.clone()), + tags: metadata.as_ref().and_then(|m| m.tags.clone()), + version: metadata.as_ref().and_then(|m| m.version.clone()), + body: body_with_args, + }) +} + +fn parse_with_frontmatter(content: &str) -> Result<(Option, String)> { + let lines: Vec<&str> = content.lines().collect(); + + // Find the end of frontmatter + let mut end_idx = None; + for (i, line) in lines.iter().enumerate().skip(1) { + if line.trim() == "---" { + end_idx = Some(i); + break; + } + } + + match end_idx { + Some(idx) => { + // Extract frontmatter YAML + let frontmatter_lines = &lines[1..idx]; + let frontmatter_str = frontmatter_lines.join("\n"); + + // Parse YAML + let metadata: SlashCommandMetadata = serde_yaml::from_str(&frontmatter_str) + .map_err(|e| eyre!("Failed to parse frontmatter YAML: {}", e))?; + + // Extract body + let body = lines[(idx + 1)..].join("\n"); + + Ok((Some(metadata), body)) + } + None => { + // Malformed frontmatter, treat entire content as body + Ok((None, content.to_string())) + } + } +} + +fn substitute_arguments(body: &str, args: &[&str]) -> String { + let mut result = body.to_string(); + + // Replace $ARGUMENTS with all args joined by space + let all_args = args.join(" "); + result = result.replace("$ARGUMENTS", &all_args); + + // Replace positional arguments $1, $2, $3, etc. + for (i, arg) in args.iter().enumerate() { + let placeholder = format!("${}", i + 1); + result = result.replace(&placeholder, arg); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn substitute_arguments_works() { + let body = "Args: $ARGUMENTS, First: $1, Second: $2"; + let result = substitute_arguments(body, &["hello", "world"]); + + assert!(result.contains("Args: hello world")); + assert!(result.contains("First: hello")); + assert!(result.contains("Second: world")); + } + + #[test] + fn substitute_arguments_empty() { + let body = "Args: $ARGUMENTS, First: $1"; + let result = substitute_arguments(body, &[]); + + assert!(result.contains("Args: ,")); + assert!(result.contains("First: $1")); // Unchanged + } + + #[test] + fn parse_frontmatter_extracts_metadata() { + let content = r#"--- +description: "Test" +author: "Me" +--- +Body content +"#; + + let (metadata, body) = parse_with_frontmatter(content).unwrap(); + + assert!(metadata.is_some()); + let m = metadata.unwrap(); + assert_eq!(m.description, Some("Test".to_string())); + assert_eq!(m.author, Some("Me".to_string())); + assert_eq!(body.trim(), "Body content"); + } +} diff --git a/crates/tools/slash/tests/slash_command.rs b/crates/tools/slash/tests/slash_command.rs new file mode 100644 index 0000000..340c599 --- /dev/null +++ b/crates/tools/slash/tests/slash_command.rs @@ -0,0 +1,109 @@ +use tools_slash::parse_slash_command; +use std::fs; +use tempfile::tempdir; + +#[test] +fn slash_parse_frontmatter_and_args() { + let content = r#"--- +description: "Test command" +author: "Test Author" +--- +This is the command body with $ARGUMENTS +First arg: $1 +Second arg: $2 +"#; + + let cmd = parse_slash_command(content, &["arg1", "arg2"]).unwrap(); + + assert_eq!(cmd.description, Some("Test command".to_string())); + assert_eq!(cmd.author, Some("Test Author".to_string())); + assert!(cmd.body.contains("arg1 arg2")); // $ARGUMENTS replaced + assert!(cmd.body.contains("First arg: arg1")); // $1 replaced + assert!(cmd.body.contains("Second arg: arg2")); // $2 replaced +} + +#[test] +fn slash_parse_no_frontmatter() { + let content = "Simple command without frontmatter"; + + let cmd = parse_slash_command(content, &[]).unwrap(); + + assert_eq!(cmd.description, None); + assert_eq!(cmd.author, None); + assert_eq!(cmd.body.trim(), "Simple command without frontmatter"); +} + +#[test] +fn slash_file_refs() { + let dir = tempdir().unwrap(); + let test_file = dir.path().join("test.txt"); + fs::write(&test_file, "File content here").unwrap(); + + let content = format!("Check this file: @{}", test_file.display()); + + let cmd = parse_slash_command(&content, &[]).unwrap(); + let resolved = cmd.resolve_file_refs().unwrap(); + + assert!(resolved.contains("File content here")); + assert!(!resolved.contains(&format!("@{}", test_file.display()))); +} + +#[test] +fn slash_arguments_substitution() { + let content = "All args: $ARGUMENTS\nFirst: $1\nSecond: $2\nThird: $3"; + + let cmd = parse_slash_command(content, &["hello", "world"]).unwrap(); + + assert!(cmd.body.contains("All args: hello world")); + assert!(cmd.body.contains("First: hello")); + assert!(cmd.body.contains("Second: world")); + assert!(cmd.body.contains("Third: $3")); // No third arg, should remain +} + +#[test] +fn slash_multiple_file_refs() { + let dir = tempdir().unwrap(); + let file1 = dir.path().join("file1.txt"); + let file2 = dir.path().join("file2.txt"); + fs::write(&file1, "Content 1").unwrap(); + fs::write(&file2, "Content 2").unwrap(); + + let content = format!("File 1: @{}\nFile 2: @{}", file1.display(), file2.display()); + + let cmd = parse_slash_command(&content, &[]).unwrap(); + let resolved = cmd.resolve_file_refs().unwrap(); + + assert!(resolved.contains("Content 1")); + assert!(resolved.contains("Content 2")); +} + +#[test] +fn slash_empty_args_leaves_placeholders() { + let content = "Args: $ARGUMENTS, First: $1, Second: $2"; + + let cmd = parse_slash_command(content, &[]).unwrap(); + + // With no args, $ARGUMENTS becomes empty, but positional args remain + assert!(cmd.body.contains("Args: ,")); + assert!(cmd.body.contains("First: $1")); + assert!(cmd.body.contains("Second: $2")); +} + +#[test] +fn slash_complex_frontmatter() { + let content = r#"--- +description: "Multi-line +description" +tags: + - test + - example +version: 1.0 +--- +Command body +"#; + + let cmd = parse_slash_command(content, &[]).unwrap(); + + assert!(cmd.description.is_some()); + assert!(cmd.description.as_ref().unwrap().contains("Multi-line")); +}