feat(tools): implement Slash Commands with frontmatter and file refs (M5 complete)
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 <command> <args...>` - Loads commands from `.claude/commands/<name>.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 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/platform/permissions",
|
"crates/platform/permissions",
|
||||||
"crates/tools/bash",
|
"crates/tools/bash",
|
||||||
"crates/tools/fs",
|
"crates/tools/fs",
|
||||||
|
"crates/tools/slash",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ color-eyre = "0.6"
|
|||||||
llm-ollama = { path = "../../llm/ollama" }
|
llm-ollama = { path = "../../llm/ollama" }
|
||||||
tools-fs = { path = "../../tools/fs" }
|
tools-fs = { path = "../../tools/fs" }
|
||||||
tools-bash = { path = "../../tools/bash" }
|
tools-bash = { path = "../../tools/bash" }
|
||||||
|
tools-slash = { path = "../../tools/slash" }
|
||||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||||
permissions = { path = "../../platform/permissions" }
|
permissions = { path = "../../platform/permissions" }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ enum Cmd {
|
|||||||
Write { path: String, content: String },
|
Write { path: String, content: String },
|
||||||
Edit { path: String, old_string: String, new_string: String },
|
Edit { path: String, old_string: String, new_string: String },
|
||||||
Bash { command: String, #[arg(long)] timeout: Option<u64> },
|
Bash { command: String, #[arg(long)] timeout: Option<u64> },
|
||||||
|
Slash { command_name: String, args: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,3 +177,79 @@ fn bash_command_timeout_works() {
|
|||||||
.arg("1000");
|
.arg("1000");
|
||||||
cmd.assert().failure();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
15
crates/tools/slash/Cargo.toml
Normal file
15
crates/tools/slash/Cargo.toml
Normal file
@@ -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"
|
||||||
169
crates/tools/slash/src/lib.rs
Normal file
169
crates/tools/slash/src/lib.rs
Normal file
@@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: HashMap<String, serde_yaml::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SlashCommand {
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommand {
|
||||||
|
/// Resolve file references (@path) in the command body
|
||||||
|
pub fn resolve_file_refs(&self) -> Result<String> {
|
||||||
|
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<SlashCommand> {
|
||||||
|
// 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<SlashCommandMetadata>, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/tools/slash/tests/slash_command.rs
Normal file
109
crates/tools/slash/tests/slash_command.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user