Skip to main content

byob_plugins/
commands.rs

1//! Slash commands: a plugin's `commands/<name>.md`, in Claude Code's format. The file's
2//! body is a prompt template; the user types `/<name> some arguments` and the model gets
3//! the template with `$ARGUMENTS` (all of them) and `$1`…`$9` (split on whitespace) filled
4//! in. Optional frontmatter: `description` (shown in the `/` menu) and `argument-hint`.
5//! Other keys (`allowed-tools`, `model`) are kept but not used.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::LoadError;
12use crate::skills::{frontmatter, read_capped, skill_name_ok};
13
14/// Biggest command file read.
15pub const MAX_COMMAND_BYTES: u64 = 64 * 1024;
16/// Most command files read from one folder.
17pub const MAX_COMMANDS_PER_DIR: usize = 100;
18
19/// One slash command.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Command {
22    /// `[a-z0-9][a-z0-9-]{0,63}`, from the file name; what the user types after `/`.
23    pub name: String,
24    pub description: String,
25    /// What to type after the name, e.g. `<file> [focus]`.
26    pub argument_hint: String,
27    /// The prompt template, frontmatter removed.
28    pub body: String,
29    pub file: PathBuf,
30    /// Other frontmatter keys, kept as text.
31    pub meta: BTreeMap<String, String>,
32}
33
34impl Command {
35    /// The prompt for `/name args`: `$ARGUMENTS` and `$1`…`$9` filled in. Arguments the
36    /// template doesn't mention are added at the end, as Claude Code does.
37    pub fn expand(&self, args: &str) -> String {
38        let args = args.trim();
39        let words: Vec<&str> = args.split_whitespace().collect();
40        let mut out = String::with_capacity(self.body.len() + args.len());
41        let mut used = false;
42        let mut rest = self.body.as_str();
43        while let Some(i) = rest.find('$') {
44            out.push_str(rest.get(..i).unwrap_or_default());
45            let after = rest.get(i + 1..).unwrap_or_default();
46            if let Some(tail) = after.strip_prefix("ARGUMENTS") {
47                out.push_str(args);
48                used = true;
49                rest = tail;
50            } else if let Some(d) = after.chars().next().and_then(|c| c.to_digit(10)).filter(|d| *d >= 1) {
51                let n = usize::try_from(d).unwrap_or(1);
52                out.push_str(words.get(n - 1).copied().unwrap_or_default());
53                used = true;
54                rest = after.get(1..).unwrap_or_default();
55            } else {
56                out.push('$');
57                rest = after;
58            }
59        }
60        out.push_str(rest);
61        let mut out = out.trim().to_owned();
62        if !used && !args.is_empty() {
63            out.push_str("\n\nARGUMENTS: ");
64            out.push_str(args);
65        }
66        out
67    }
68}
69
70/// Parse a command file (`file` gives the name: `review-pr.md` → `review-pr`).
71pub fn parse_command(text: &str, file: &Path) -> Result<Command, String> {
72    let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
73    let shown = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
74    let name = stem.to_lowercase();
75    if !skill_name_ok(&name) {
76        return Err(format!("commands/{shown}: name the file with lowercase words and dashes, like \"review-pr.md\""));
77    }
78    let (mut meta, body) = frontmatter(text);
79    let body = body.trim().to_owned();
80    if body.is_empty() {
81        return Err(format!("commands/{shown}: the file has no prompt"));
82    }
83    let description = meta.remove("description").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| first_line(&body));
84    let argument_hint = meta.remove("argument-hint").unwrap_or_default();
85    Ok(Command { name, description, argument_hint, body, file: file.to_path_buf(), meta })
86}
87
88/// The first non-empty line, without Markdown heading marks, at most 120 characters.
89fn first_line(body: &str) -> String {
90    let line = body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default();
91    line.trim_start_matches('#').trim().chars().take(120).collect()
92}
93
94/// Every `<dir>/*.md`, sorted by name. Bad files become errors; the others still load. A
95/// missing `dir` is simply empty.
96pub fn load_commands_dir(dir: &Path) -> (Vec<Command>, Vec<LoadError>) {
97    let mut commands: Vec<Command> = Vec::new();
98    let mut errors = Vec::new();
99    let Ok(rd) = fs::read_dir(dir) else { return (commands, errors) };
100    let mut files: Vec<PathBuf> = rd
101        .flatten()
102        .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
103        .map(|e| e.path())
104        .filter(|p| p.is_file() && p.extension().is_some_and(|x| x.eq_ignore_ascii_case("md")))
105        .collect();
106    files.sort();
107    if files.len() > MAX_COMMANDS_PER_DIR {
108        errors.push(LoadError { path: dir.to_path_buf(), message: format!("only the first {MAX_COMMANDS_PER_DIR} command files are read") });
109        files.truncate(MAX_COMMANDS_PER_DIR);
110    }
111    for file in files {
112        match read_capped(&file, MAX_COMMAND_BYTES).and_then(|text| parse_command(&text, &file)) {
113            Ok(c) if commands.iter().any(|d| d.name == c.name) => {
114                errors.push(LoadError { path: file, message: format!("command \"{}\" is defined twice", c.name) });
115            }
116            Ok(c) => commands.push(c),
117            Err(message) => errors.push(LoadError { path: file, message }),
118        }
119    }
120    (commands, errors)
121}
122
123/// `"/name some args"` → `("name", "some args")`; `None` if the text isn't a slash command
124/// (no leading `/`, or not a valid name: `/usr/bin` is a path, not a command).
125pub fn split_slash(text: &str) -> Option<(&str, &str)> {
126    let rest = text.trim_start().strip_prefix('/')?;
127    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
128    let (name, args) = rest.split_at(end);
129    skill_name_ok(name).then_some((name, args.trim()))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn cmd(body: &str) -> Command {
137        parse_command(body, Path::new("/p/commands/review.md")).unwrap()
138    }
139
140    #[test]
141    fn templates_fill_in_arguments() {
142        let c = cmd("---\ndescription: Review a file\nargument-hint: <file> [focus]\n---\nReview $1 with a focus on $2.\nAll: $ARGUMENTS; cost $5 stays.");
143        assert_eq!((c.name.as_str(), c.description.as_str(), c.argument_hint.as_str()), ("review", "Review a file", "<file> [focus]"));
144        assert_eq!(c.expand(" src/a.rs  errors "), "Review src/a.rs with a focus on errors.\nAll: src/a.rs  errors; cost  stays.");
145        // No placeholders: the arguments go at the end; none given, nothing is added.
146        let plain = cmd("# Summarize the chat\nBe brief.");
147        assert_eq!(plain.description, "Summarize the chat");
148        assert_eq!(plain.expand("in French"), "# Summarize the chat\nBe brief.\n\nARGUMENTS: in French");
149        assert_eq!(plain.expand(""), "# Summarize the chat\nBe brief.");
150        assert_eq!(cmd("price: $").expand("x"), "price: $\n\nARGUMENTS: x");
151        assert!(parse_command("---\ndescription: x\n---\n", Path::new("/p/commands/empty.md")).is_err());
152        assert!(parse_command("hi", Path::new("/p/commands/Bad Name.md")).is_err());
153    }
154
155    #[test]
156    fn slash_text_splits_into_name_and_arguments() {
157        assert_eq!(split_slash("/review src/a.rs now"), Some(("review", "src/a.rs now")));
158        assert_eq!(split_slash("  /compact"), Some(("compact", "")));
159        assert_eq!(split_slash("/review\nmore lines"), Some(("review", "more lines")));
160        assert_eq!(split_slash("/usr/bin/env is a path"), None);
161        assert_eq!(split_slash("no slash"), None);
162        assert_eq!(split_slash("/"), None);
163    }
164
165    #[test]
166    fn a_folder_loads_good_files_and_reports_bad_ones() {
167        let d = tempfile::tempdir().unwrap();
168        fs::write(d.path().join("b-one.md"), "Do B.").unwrap();
169        fs::write(d.path().join("a-one.md"), "---\ndescription: A\n---\nDo A with $ARGUMENTS").unwrap();
170        fs::write(d.path().join("Bad Name.md"), "x").unwrap();
171        fs::write(d.path().join("notes.txt"), "ignored").unwrap();
172        let (cmds, errors) = load_commands_dir(d.path());
173        assert_eq!(cmds.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["a-one", "b-one"]);
174        assert_eq!(errors.len(), 1, "{errors:?}");
175        assert!(load_commands_dir(&d.path().join("missing")).0.is_empty());
176    }
177}