use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Clone, Debug, PartialEq)]
pub struct FileCommand {
pub name: String,
pub description: String,
pub body: String,
pub path: PathBuf,
}
#[derive(Default, Deserialize)]
struct CommandFrontmatter {
#[serde(default)]
description: String,
}
pub fn scan_file_commands(cwd: &Path, home: &Path) -> Vec<FileCommand> {
scan_file_commands_in(cwd, home)
}
pub fn scan_file_commands_in(cwd: &Path, user_root: &Path) -> Vec<FileCommand> {
let dirs = [
cwd.join(".agents").join("commands"),
cwd.join(".claude").join("commands"),
user_root.join(".agents").join("commands"),
user_root.join(".claude").join("commands"),
];
let mut commands: Vec<FileCommand> = Vec::new();
for dir in dirs {
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => continue, };
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect();
files.sort();
for path in files {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if stem.is_empty() || stem.contains('/') || commands.iter().any(|c| c.name == stem) {
continue;
}
let Ok(raw) = std::fs::read_to_string(&path) else {
continue; };
let (frontmatter, body) = parse_frontmatter(&raw);
commands.push(FileCommand {
name: stem.to_string(),
description: frontmatter.description,
body,
path,
});
}
}
commands.sort_by(|a, b| a.name.cmp(&b.name));
commands
}
fn parse_frontmatter(content: &str) -> (CommandFrontmatter, String) {
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
if !normalized.starts_with("---") {
return (CommandFrontmatter::default(), normalized.trim().to_string());
}
let Some(end) = normalized[3..].find("\n---") else {
return (CommandFrontmatter::default(), normalized.trim().to_string());
};
let end = end + 3;
let frontmatter = serde_yaml::from_str(&normalized[4..end]).unwrap_or_default();
let body = normalized[end + 4..].trim().to_string();
(frontmatter, body)
}
pub fn expand_file_command(cmd: &FileCommand, args_tail: &str) -> String {
let args: Vec<&str> = args_tail.split_whitespace().collect();
let mut out = cmd.body.replace("$ARGUMENTS", args_tail);
out = out.replace("$ARGS", args_tail);
for (i, arg) in args.iter().enumerate().take(9) {
out = out.replace(&format!("${}", i + 1), arg);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn write_command(root: &Path, dir: &str, name: &str, content: &str) {
let d = root.join(dir);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join(format!("{name}.md")), content).unwrap();
}
#[test]
fn scans_both_formats_with_frontmatter_description() {
let cwd = tempfile::tempdir().unwrap();
let user = tempfile::tempdir().unwrap();
write_command(
cwd.path(),
".claude/commands",
"review",
"---\ndescription: review the diff\n---\nplease review $ARGUMENTS\n",
);
write_command(
cwd.path(),
".agents/commands",
"bare",
"no frontmatter here\n",
);
let cmds = scan_file_commands_in(cwd.path(), user.path());
assert_eq!(cmds.len(), 2);
let review = cmds.iter().find(|c| c.name == "review").unwrap();
assert_eq!(review.description, "review the diff");
assert_eq!(review.body, "please review $ARGUMENTS");
assert!(review.path.ends_with("review.md"));
assert_eq!(cmds[0].name, "bare");
assert_eq!(cmds[1].name, "review");
}
#[test]
fn first_root_wins_on_name_collision() {
let cwd = tempfile::tempdir().unwrap();
let user = tempfile::tempdir().unwrap();
write_command(cwd.path(), ".agents/commands", "commit", "from agents");
write_command(cwd.path(), ".claude/commands", "commit", "from claude");
write_command(
user.path(),
".claude/commands",
"commit",
"from user claude",
);
let cmds = scan_file_commands_in(cwd.path(), user.path());
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].body, "from agents");
assert!(cmds[0].path.starts_with(cwd.path().join(".agents")));
}
#[test]
fn project_wins_over_user() {
let cwd = tempfile::tempdir().unwrap();
let user = tempfile::tempdir().unwrap();
write_command(user.path(), ".claude/commands", "deploy", "user");
write_command(cwd.path(), ".claude/commands", "deploy", "project");
let cmds = scan_file_commands_in(cwd.path(), user.path());
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].body, "project");
}
#[test]
fn missing_dirs_and_junk_files_are_skipped() {
let cwd = tempfile::tempdir().unwrap();
let user = tempfile::tempdir().unwrap();
write_command(cwd.path(), ".claude/commands", "ok", "body");
std::fs::write(cwd.path().join(".claude/commands/notes.txt"), "junk").unwrap();
std::fs::create_dir_all(cwd.path().join(".claude/commands/subdir")).unwrap();
let cmds = scan_file_commands_in(cwd.path(), user.path());
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name, "ok");
}
#[test]
fn expand_substitutes_arguments_and_positionals() {
let cmd = FileCommand {
name: "commit".into(),
description: String::new(),
body: "commit $1 with $2\nrest: $ARGUMENTS\nshort: $ARGS".into(),
path: PathBuf::new(),
};
let out = expand_file_command(&cmd, "one two three");
assert_eq!(
out,
"commit one with two\nrest: one two three\nshort: one two three"
);
let no_args = expand_file_command(&cmd, "");
assert_eq!(no_args, "commit $1 with $2\nrest: \nshort: ");
}
}