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>
256 lines
7.3 KiB
Rust
256 lines
7.3 KiB
Rust
use assert_cmd::Command;
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn plan_mode_allows_read_operations() {
|
|
// Create a temp file to read
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "hello world").unwrap();
|
|
|
|
// Read operation should work in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("read").arg(file.to_str().unwrap());
|
|
cmd.assert().success().stdout("hello world\n");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_allows_glob_operations() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("a.txt"), "test").unwrap();
|
|
fs::write(dir.path().join("b.txt"), "test").unwrap();
|
|
|
|
let pattern = format!("{}/*.txt", dir.path().display());
|
|
|
|
// Glob operation should work in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("glob").arg(&pattern);
|
|
cmd.assert().success();
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_allows_grep_operations() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("test.txt"), "hello world\nfoo bar").unwrap();
|
|
|
|
// Grep operation should work in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("grep").arg(dir.path().to_str().unwrap()).arg("hello");
|
|
cmd.assert().success();
|
|
}
|
|
|
|
#[test]
|
|
fn mode_override_via_cli_flag() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "content").unwrap();
|
|
|
|
// Test with --mode code (should also allow read)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("read")
|
|
.arg(file.to_str().unwrap());
|
|
cmd.assert().success().stdout("content\n");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_blocks_write_operations() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("new.txt");
|
|
|
|
// Write operation should be blocked in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("write").arg(file.to_str().unwrap()).arg("content");
|
|
cmd.assert().failure();
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_blocks_edit_operations() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "old content").unwrap();
|
|
|
|
// Edit operation should be blocked in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("edit")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("old")
|
|
.arg("new");
|
|
cmd.assert().failure();
|
|
}
|
|
|
|
#[test]
|
|
fn accept_edits_mode_allows_write() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("new.txt");
|
|
|
|
// Write operation should work in acceptEdits mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("acceptEdits")
|
|
.arg("write")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("new content");
|
|
cmd.assert().success();
|
|
|
|
// Verify file was written
|
|
assert_eq!(fs::read_to_string(&file).unwrap(), "new content");
|
|
}
|
|
|
|
#[test]
|
|
fn accept_edits_mode_allows_edit() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "line 1\nline 2\nline 3").unwrap();
|
|
|
|
// Edit operation should work in acceptEdits mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("acceptEdits")
|
|
.arg("edit")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("line 2")
|
|
.arg("modified line");
|
|
cmd.assert().success();
|
|
|
|
// Verify file was edited
|
|
assert_eq!(
|
|
fs::read_to_string(&file).unwrap(),
|
|
"line 1\nmodified line\nline 3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_allows_all_operations() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
|
|
// Write in code mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("write")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("initial content");
|
|
cmd.assert().success();
|
|
|
|
// Edit in code mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("edit")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("initial")
|
|
.arg("modified");
|
|
cmd.assert().success();
|
|
|
|
assert_eq!(fs::read_to_string(&file).unwrap(), "modified content");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_blocks_bash_operations() {
|
|
// Bash operation should be blocked in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("bash").arg("echo hello");
|
|
cmd.assert().failure();
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_allows_bash() {
|
|
// Bash operation should work in code mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode").arg("code").arg("bash").arg("echo hello");
|
|
cmd.assert().success().stdout("hello\n");
|
|
}
|
|
|
|
#[test]
|
|
fn bash_command_timeout_works() {
|
|
// Test that timeout works
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("bash")
|
|
.arg("sleep 10")
|
|
.arg("--timeout")
|
|
.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();
|
|
}
|