Skip to main content

claude_wrapper/
commands.rs

1//! Read-side access to Claude Code's on-disk **custom slash command**
2//! definitions.
3//!
4//! Claude Code resolves custom slash commands from `*.md` files at:
5//!
6//! - `~/.claude/commands/<name>.md` -- user-level
7//! - `<project>/.claude/commands/<name>.md` -- project-level
8//!
9//! Plus plugin-provided commands under
10//! `~/.claude/plugins/<plugin>/commands/`, which this module does
11//! not enumerate (the plugin feature surfaces those separately).
12//!
13//! # What's NOT covered
14//!
15//! **Built-in slash commands** like `/help`, `/clear`, `/config`,
16//! `/init` are hardcoded in the `claude` binary. They have no disk
17//! representation, are not listed by `claude --help`, and aren't
18//! introspectable from the CLI. Consumers learn about them from
19//! Claude Code's own UX. We deliberately do not attempt to mirror
20//! a built-in list here -- doing so would silently rot every time
21//! Claude Code adds or renames one.
22//!
23//! **Skills** also surface as slash commands (`/recall`,
24//! `/draft-pr-first`, etc.) but they're a separate on-disk artifact
25//! type and are not loaded by this module. A consumer that wants
26//! "the full slash command universe at this moment" combines this
27//! list with a separate skills enumeration (and accepts that
28//! built-ins aren't represented).
29//!
30//! # Two levels of granularity
31//!
32//! - [`CommandsRoot::list`] -- enumerate every `*.md` command at
33//!   the root with summary metadata.
34//! - [`CommandsRoot::get`] -- read one command's full record
35//!   including the prompt body and any unknown frontmatter keys.
36//!
37//! # Frontmatter format
38//!
39//! Real-world commands look like:
40//!
41//! ```text
42//! ---
43//! description: Open a PR for the current branch
44//! argument-hint: <pr title>
45//! allowed-tools: Bash(git *), Bash(gh *)
46//! model: sonnet
47//! ---
48//!
49//! Open a pull request titled "$ARGUMENTS" ...
50//! ```
51//!
52//! The parser is permissive: only `description`, `argument-hint`,
53//! `allowed-tools`, `model`, and `disable-model-invocation` are
54//! typed. Any other `key: value` pairs land in [`Command::extra`].
55//! Frontmatter is optional -- a body-only file parses fine, with
56//! `description` left `None`.
57//!
58//! YAML block scalars (`>`, `>-`, `>+`, `|`, `|-`, `|+`) are
59//! supported for any key, which is how multi-line descriptions are
60//! usually written. `>` folds the block into one line, `|` preserves
61//! the line breaks, and the chomping indicator controls the trailing
62//! newline. Continuation lines are part of the value even when they
63//! contain a colon.
64//!
65//! Note the dashes in `argument-hint` / `allowed-tools` /
66//! `disable-model-invocation`: that's how Claude Code spells the
67//! keys on disk. The typed fields use Rust-friendly snake_case
68//! names.
69//!
70//! # Example
71//!
72//! ```no_run
73//! use claude_wrapper::commands::CommandsRoot;
74//!
75//! # fn example() -> claude_wrapper::Result<()> {
76//! let root = CommandsRoot::user()?;
77//! for summary in root.list()? {
78//!     println!("/{}: {}", summary.file_stem,
79//!         summary.description.as_deref().unwrap_or(""));
80//! }
81//! # Ok(()) }
82//! ```
83
84use std::collections::BTreeMap;
85use std::fs;
86use std::path::{Path, PathBuf};
87
88use serde::Serialize;
89
90use crate::artifacts::{frontmatter_entries, split_frontmatter, split_list};
91use crate::error::{Error, Result};
92
93/// Root directory of one set of slash command definitions
94/// (`<root>/<stem>.md`). Use [`Self::user`] for the user-level root
95/// at `~/.claude/commands`, [`Self::project`] for a project's
96/// `<dir>/.claude/commands`, or [`Self::at`] to point at an
97/// arbitrary directory for tests.
98#[derive(Debug, Clone)]
99pub struct CommandsRoot {
100    path: PathBuf,
101}
102
103impl CommandsRoot {
104    /// Resolve the user-level commands root at `~/.claude/commands`.
105    /// Errors if `$HOME` cannot be determined.
106    pub fn user() -> Result<Self> {
107        let home = home_dir().ok_or_else(|| Error::Artifacts {
108            message: "could not determine user home directory".to_string(),
109        })?;
110        Ok(Self {
111            path: home.join(".claude").join("commands"),
112        })
113    }
114
115    /// Resolve a project-level commands root at
116    /// `<project_dir>/.claude/commands`. The `project_dir` is the
117    /// project root itself (the `.claude/commands` suffix is
118    /// appended internally).
119    pub fn project(project_dir: impl Into<PathBuf>) -> Self {
120        let mut p: PathBuf = project_dir.into();
121        p.push(".claude");
122        p.push("commands");
123        Self { path: p }
124    }
125
126    /// Use a specific path as the commands root. Useful for tests.
127    pub fn at(path: impl Into<PathBuf>) -> Self {
128        Self { path: path.into() }
129    }
130
131    /// The configured root directory.
132    pub fn path(&self) -> &Path {
133        &self.path
134    }
135
136    /// List every `*.md` command at the root, sorted by file stem.
137    ///
138    /// Returns an empty vec if the root directory doesn't exist (a
139    /// project or user without custom commands). Files that fail
140    /// to parse contribute a tracing warning and are skipped.
141    pub fn list(&self) -> Result<Vec<CommandSummary>> {
142        let entries = match fs::read_dir(&self.path) {
143            Ok(it) => it,
144            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
145            Err(e) => return Err(e.into()),
146        };
147
148        let mut out = Vec::new();
149        for entry in entries.flatten() {
150            let path = entry.path();
151            if path.extension().and_then(|s| s.to_str()) != Some("md") {
152                continue;
153            }
154            let stem = match path.file_stem().and_then(|s| s.to_str()) {
155                Some(s) => s.to_string(),
156                None => continue,
157            };
158            match parse_command_file(&path, &stem) {
159                Ok(cmd) => out.push(CommandSummary::from_command(&cmd)),
160                Err(e) => tracing::warn!(?path, "skipping command: {e}"),
161            }
162        }
163        out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
164        Ok(out)
165    }
166
167    /// Read one command by file stem. Errors if no such file exists
168    /// or it fails to parse.
169    pub fn get(&self, file_stem: &str) -> Result<Command> {
170        let path = self.path.join(format!("{file_stem}.md"));
171        if !path.exists() {
172            return Err(Error::Artifacts {
173                message: format!("no command at {}", path.display()),
174            });
175        }
176        parse_command_file(&path, file_stem)
177    }
178}
179
180/// Lightweight metadata for one slash command, returned by
181/// [`CommandsRoot::list`].
182#[derive(Debug, Clone, Serialize)]
183pub struct CommandSummary {
184    /// Filename stem (`<stem>.md`). This is the slash command name --
185    /// `/<stem>` is what users type.
186    pub file_stem: String,
187    /// Frontmatter `description` if present.
188    pub description: Option<String>,
189    /// Frontmatter `argument-hint` if present -- placeholder text
190    /// shown next to `$ARGUMENTS` in the UI.
191    pub argument_hint: Option<String>,
192    /// Frontmatter `allowed-tools` parsed as a comma-separated list.
193    /// Empty when absent.
194    pub allowed_tools: Vec<String>,
195    /// Frontmatter `model` if present (model override for this
196    /// command).
197    pub model: Option<String>,
198    /// Frontmatter `disable-model-invocation` if present and `true`.
199    /// `None` when absent.
200    pub disable_model_invocation: Option<bool>,
201    /// Absolute path to the source file.
202    pub file_path: PathBuf,
203    /// File size in bytes; useful for cheap UI hints.
204    pub size_bytes: u64,
205}
206
207impl CommandSummary {
208    fn from_command(c: &Command) -> Self {
209        let size_bytes = fs::metadata(&c.file_path)
210            .map(|m| m.len())
211            .unwrap_or_default();
212        Self {
213            file_stem: c.file_stem.clone(),
214            description: c.description.clone(),
215            argument_hint: c.argument_hint.clone(),
216            allowed_tools: c.allowed_tools.clone(),
217            model: c.model.clone(),
218            disable_model_invocation: c.disable_model_invocation,
219            file_path: c.file_path.clone(),
220            size_bytes,
221        }
222    }
223}
224
225/// Full command record returned by [`CommandsRoot::get`].
226#[derive(Debug, Clone, Serialize)]
227pub struct Command {
228    /// Filename stem (`<stem>.md`). The slash command name.
229    pub file_stem: String,
230    /// Frontmatter `description` if present.
231    pub description: Option<String>,
232    /// Frontmatter `argument-hint` if present.
233    pub argument_hint: Option<String>,
234    /// Frontmatter `allowed-tools` parsed as a comma-separated list.
235    pub allowed_tools: Vec<String>,
236    /// Frontmatter `model` if present.
237    pub model: Option<String>,
238    /// Frontmatter `disable-model-invocation` if present.
239    pub disable_model_invocation: Option<bool>,
240    /// Absolute path to the source file.
241    pub file_path: PathBuf,
242    /// Markdown body after the frontmatter block (trimmed). The
243    /// prompt template; supports `$ARGUMENTS` substitution.
244    pub body: String,
245    /// Frontmatter keys other than the typed ones. Preserves
246    /// unknown future fields verbatim as raw strings.
247    pub extra: BTreeMap<String, String>,
248}
249
250fn parse_command_file(path: &Path, file_stem: &str) -> Result<Command> {
251    let raw = fs::read_to_string(path)?;
252    let (frontmatter, body) = split_frontmatter(&raw);
253
254    let mut description = None;
255    let mut argument_hint = None;
256    let mut allowed_tools = Vec::new();
257    let mut model = None;
258    let mut disable_model_invocation = None;
259    let mut extra = BTreeMap::new();
260
261    if let Some(fm) = frontmatter {
262        for (key, value) in frontmatter_entries(fm) {
263            match key.as_str() {
264                "description" if !value.is_empty() => description = Some(value),
265                "argument-hint" if !value.is_empty() => argument_hint = Some(value),
266                "allowed-tools" if !value.is_empty() => allowed_tools = split_list(&value),
267                "model" if !value.is_empty() => model = Some(value),
268                "disable-model-invocation" if !value.is_empty() => {
269                    disable_model_invocation = Some(matches!(
270                        value.trim().to_ascii_lowercase().as_str(),
271                        "true" | "yes" | "1"
272                    ));
273                }
274                _ => {
275                    extra.insert(key, value);
276                }
277            }
278        }
279    }
280
281    Ok(Command {
282        file_stem: file_stem.to_string(),
283        description,
284        argument_hint,
285        allowed_tools,
286        model,
287        disable_model_invocation,
288        file_path: path.to_path_buf(),
289        body: body.trim().to_string(),
290        extra,
291    })
292}
293
294fn home_dir() -> Option<PathBuf> {
295    if let Ok(h) = std::env::var("HOME")
296        && !h.is_empty()
297    {
298        return Some(PathBuf::from(h));
299    }
300    if let Ok(h) = std::env::var("USERPROFILE")
301        && !h.is_empty()
302    {
303        return Some(PathBuf::from(h));
304    }
305    None
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::io::Write;
312
313    fn write_command(dir: &Path, file_stem: &str, contents: &str) -> PathBuf {
314        let path = dir.join(format!("{file_stem}.md"));
315        let mut f = fs::File::create(&path).expect("create md");
316        f.write_all(contents.as_bytes()).expect("write md");
317        path
318    }
319
320    fn fixture_root() -> tempfile::TempDir {
321        let tmp = tempfile::tempdir().expect("tempdir");
322        write_command(
323            tmp.path(),
324            "open-pr",
325            "---\ndescription: Open a PR for the current branch\nargument-hint: <pr title>\nallowed-tools: Bash(git *), Bash(gh *)\nmodel: sonnet\n---\n\nOpen a pull request titled \"$ARGUMENTS\".\n",
326        );
327        write_command(
328            tmp.path(),
329            "no-frontmatter",
330            "Just a body, no frontmatter at all.\n",
331        );
332        write_command(
333            tmp.path(),
334            "weird",
335            "---\ndescription: has extras\ncustom_key: custom_value\ndisable-model-invocation: true\n---\nbody\n",
336        );
337        // Non-md file ignored.
338        fs::write(tmp.path().join("README.txt"), "ignore").expect("write txt");
339        tmp
340    }
341
342    #[test]
343    fn list_returns_only_md_files_sorted() {
344        let tmp = fixture_root();
345        let root = CommandsRoot::at(tmp.path());
346        let cmds = root.list().expect("list");
347        let stems: Vec<&str> = cmds.iter().map(|c| c.file_stem.as_str()).collect();
348        assert_eq!(stems, ["no-frontmatter", "open-pr", "weird"]);
349    }
350
351    #[test]
352    fn list_missing_root_returns_empty() {
353        let tmp = tempfile::tempdir().expect("tempdir");
354        let root = CommandsRoot::at(tmp.path().join("does-not-exist"));
355        assert!(root.list().expect("list").is_empty());
356    }
357
358    #[test]
359    fn list_typed_metadata() {
360        let tmp = fixture_root();
361        let root = CommandsRoot::at(tmp.path());
362        let cmds = root.list().expect("list");
363        let pr = cmds.iter().find(|c| c.file_stem == "open-pr").unwrap();
364        assert_eq!(
365            pr.description.as_deref(),
366            Some("Open a PR for the current branch")
367        );
368        assert_eq!(pr.argument_hint.as_deref(), Some("<pr title>"));
369        assert_eq!(pr.allowed_tools, vec!["Bash(git *)", "Bash(gh *)"]);
370        assert_eq!(pr.model.as_deref(), Some("sonnet"));
371        assert!(pr.disable_model_invocation.is_none());
372        assert!(pr.size_bytes > 0);
373    }
374
375    #[test]
376    fn list_no_frontmatter_parses_clean() {
377        let tmp = fixture_root();
378        let root = CommandsRoot::at(tmp.path());
379        let cmds = root.list().expect("list");
380        let nf = cmds
381            .iter()
382            .find(|c| c.file_stem == "no-frontmatter")
383            .unwrap();
384        assert!(nf.description.is_none());
385        assert!(nf.allowed_tools.is_empty());
386    }
387
388    #[test]
389    fn get_returns_full_command_with_body() {
390        let tmp = fixture_root();
391        let root = CommandsRoot::at(tmp.path());
392        let cmd = root.get("open-pr").expect("get");
393        assert_eq!(cmd.file_stem, "open-pr");
394        assert!(cmd.body.starts_with("Open a pull request"));
395    }
396
397    #[test]
398    fn get_no_frontmatter_returns_full_body() {
399        let tmp = fixture_root();
400        let root = CommandsRoot::at(tmp.path());
401        let cmd = root.get("no-frontmatter").expect("get");
402        assert_eq!(cmd.body, "Just a body, no frontmatter at all.");
403    }
404
405    #[test]
406    fn get_unknown_id_errors() {
407        let tmp = fixture_root();
408        let root = CommandsRoot::at(tmp.path());
409        let err = root.get("nope").unwrap_err();
410        assert!(err.to_string().to_lowercase().contains("no command"));
411    }
412
413    #[test]
414    fn extras_round_trip() {
415        let tmp = fixture_root();
416        let root = CommandsRoot::at(tmp.path());
417        let cmd = root.get("weird").expect("get");
418        assert_eq!(
419            cmd.extra.get("custom_key").map(String::as_str),
420            Some("custom_value")
421        );
422    }
423
424    #[test]
425    fn disable_model_invocation_parses_bool() {
426        let tmp = fixture_root();
427        let root = CommandsRoot::at(tmp.path());
428        let cmd = root.get("weird").expect("get");
429        assert_eq!(cmd.disable_model_invocation, Some(true));
430    }
431
432    #[test]
433    fn folded_description_with_colons_is_one_value() {
434        let tmp = tempfile::tempdir().expect("tempdir");
435        write_command(
436            tmp.path(),
437            "folded",
438            concat!(
439                "---\n",
440                "description: >-\n",
441                "  Open a PR for the current branch. Note: pushes first, then\n",
442                "  opens the PR as a draft.\n",
443                "allowed-tools: Bash(git *), Bash(gh *)\n",
444                "disable-model-invocation: true\n",
445                "---\n\nBody.\n",
446            ),
447        );
448        let root = CommandsRoot::at(tmp.path());
449        let cmd = root.get("folded").expect("get");
450        assert_eq!(
451            cmd.description.as_deref(),
452            Some(
453                "Open a PR for the current branch. Note: pushes first, then opens the PR as a draft."
454            )
455        );
456        assert!(cmd.extra.is_empty(), "extra: {:?}", cmd.extra);
457        assert_eq!(cmd.allowed_tools, vec!["Bash(git *)", "Bash(gh *)"]);
458        assert_eq!(cmd.disable_model_invocation, Some(true));
459        assert_eq!(cmd.body, "Body.");
460    }
461
462    #[test]
463    fn literal_description_preserves_newlines() {
464        let tmp = tempfile::tempdir().expect("tempdir");
465        write_command(
466            tmp.path(),
467            "lit",
468            "---\ndescription: |-\n  one\n  two: three\nmodel: sonnet\n---\nbody\n",
469        );
470        let root = CommandsRoot::at(tmp.path());
471        let cmd = root.get("lit").expect("get");
472        assert_eq!(cmd.description.as_deref(), Some("one\ntwo: three"));
473        assert_eq!(cmd.model.as_deref(), Some("sonnet"));
474    }
475
476    #[test]
477    fn project_helper_appends_dot_claude_commands() {
478        let p = CommandsRoot::project("/tmp/repo");
479        assert!(p.path().ends_with(".claude/commands"));
480        assert!(p.path().starts_with("/tmp/repo"));
481    }
482}