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>
110 lines
3.1 KiB
Rust
110 lines
3.1 KiB
Rust
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"));
|
|
}
|