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//! Note the dashes in `argument-hint` / `allowed-tools` /
59//! `disable-model-invocation`: that's how Claude Code spells the
60//! keys on disk. The typed fields use Rust-friendly snake_case
61//! names.
62//!
63//! # Example
64//!
65//! ```no_run
66//! use claude_wrapper::commands::CommandsRoot;
67//!
68//! # fn example() -> claude_wrapper::Result<()> {
69//! let root = CommandsRoot::user()?;
70//! for summary in root.list()? {
71//!     println!("/{}: {}", summary.file_stem,
72//!         summary.description.as_deref().unwrap_or(""));
73//! }
74//! # Ok(()) }
75//! ```
76
77use std::collections::BTreeMap;
78use std::fs;
79use std::path::{Path, PathBuf};
80
81use serde::Serialize;
82
83use crate::artifacts::split_frontmatter;
84use crate::error::{Error, Result};
85
86/// Root directory of one set of slash command definitions
87/// (`<root>/<stem>.md`). Use [`Self::user`] for the user-level root
88/// at `~/.claude/commands`, [`Self::project`] for a project's
89/// `<dir>/.claude/commands`, or [`Self::at`] to point at an
90/// arbitrary directory for tests.
91#[derive(Debug, Clone)]
92pub struct CommandsRoot {
93    path: PathBuf,
94}
95
96impl CommandsRoot {
97    /// Resolve the user-level commands root at `~/.claude/commands`.
98    /// Errors if `$HOME` cannot be determined.
99    pub fn user() -> Result<Self> {
100        let home = home_dir().ok_or_else(|| Error::Artifacts {
101            message: "could not determine user home directory".to_string(),
102        })?;
103        Ok(Self {
104            path: home.join(".claude").join("commands"),
105        })
106    }
107
108    /// Resolve a project-level commands root at
109    /// `<project_dir>/.claude/commands`. The `project_dir` is the
110    /// project root itself (the `.claude/commands` suffix is
111    /// appended internally).
112    pub fn project(project_dir: impl Into<PathBuf>) -> Self {
113        let mut p: PathBuf = project_dir.into();
114        p.push(".claude");
115        p.push("commands");
116        Self { path: p }
117    }
118
119    /// Use a specific path as the commands root. Useful for tests.
120    pub fn at(path: impl Into<PathBuf>) -> Self {
121        Self { path: path.into() }
122    }
123
124    /// The configured root directory.
125    pub fn path(&self) -> &Path {
126        &self.path
127    }
128
129    /// List every `*.md` command at the root, sorted by file stem.
130    ///
131    /// Returns an empty vec if the root directory doesn't exist (a
132    /// project or user without custom commands). Files that fail
133    /// to parse contribute a tracing warning and are skipped.
134    pub fn list(&self) -> Result<Vec<CommandSummary>> {
135        let entries = match fs::read_dir(&self.path) {
136            Ok(it) => it,
137            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
138            Err(e) => return Err(e.into()),
139        };
140
141        let mut out = Vec::new();
142        for entry in entries.flatten() {
143            let path = entry.path();
144            if path.extension().and_then(|s| s.to_str()) != Some("md") {
145                continue;
146            }
147            let stem = match path.file_stem().and_then(|s| s.to_str()) {
148                Some(s) => s.to_string(),
149                None => continue,
150            };
151            match parse_command_file(&path, &stem) {
152                Ok(cmd) => out.push(CommandSummary::from_command(&cmd)),
153                Err(e) => tracing::warn!(?path, "skipping command: {e}"),
154            }
155        }
156        out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
157        Ok(out)
158    }
159
160    /// Read one command by file stem. Errors if no such file exists
161    /// or it fails to parse.
162    pub fn get(&self, file_stem: &str) -> Result<Command> {
163        let path = self.path.join(format!("{file_stem}.md"));
164        if !path.exists() {
165            return Err(Error::Artifacts {
166                message: format!("no command at {}", path.display()),
167            });
168        }
169        parse_command_file(&path, file_stem)
170    }
171}
172
173/// Lightweight metadata for one slash command, returned by
174/// [`CommandsRoot::list`].
175#[derive(Debug, Clone, Serialize)]
176pub struct CommandSummary {
177    /// Filename stem (`<stem>.md`). This is the slash command name --
178    /// `/<stem>` is what users type.
179    pub file_stem: String,
180    /// Frontmatter `description` if present.
181    pub description: Option<String>,
182    /// Frontmatter `argument-hint` if present -- placeholder text
183    /// shown next to `$ARGUMENTS` in the UI.
184    pub argument_hint: Option<String>,
185    /// Frontmatter `allowed-tools` parsed as a comma-separated list.
186    /// Empty when absent.
187    pub allowed_tools: Vec<String>,
188    /// Frontmatter `model` if present (model override for this
189    /// command).
190    pub model: Option<String>,
191    /// Frontmatter `disable-model-invocation` if present and `true`.
192    /// `None` when absent.
193    pub disable_model_invocation: Option<bool>,
194    /// Absolute path to the source file.
195    pub file_path: PathBuf,
196    /// File size in bytes; useful for cheap UI hints.
197    pub size_bytes: u64,
198}
199
200impl CommandSummary {
201    fn from_command(c: &Command) -> Self {
202        let size_bytes = fs::metadata(&c.file_path)
203            .map(|m| m.len())
204            .unwrap_or_default();
205        Self {
206            file_stem: c.file_stem.clone(),
207            description: c.description.clone(),
208            argument_hint: c.argument_hint.clone(),
209            allowed_tools: c.allowed_tools.clone(),
210            model: c.model.clone(),
211            disable_model_invocation: c.disable_model_invocation,
212            file_path: c.file_path.clone(),
213            size_bytes,
214        }
215    }
216}
217
218/// Full command record returned by [`CommandsRoot::get`].
219#[derive(Debug, Clone, Serialize)]
220pub struct Command {
221    /// Filename stem (`<stem>.md`). The slash command name.
222    pub file_stem: String,
223    /// Frontmatter `description` if present.
224    pub description: Option<String>,
225    /// Frontmatter `argument-hint` if present.
226    pub argument_hint: Option<String>,
227    /// Frontmatter `allowed-tools` parsed as a comma-separated list.
228    pub allowed_tools: Vec<String>,
229    /// Frontmatter `model` if present.
230    pub model: Option<String>,
231    /// Frontmatter `disable-model-invocation` if present.
232    pub disable_model_invocation: Option<bool>,
233    /// Absolute path to the source file.
234    pub file_path: PathBuf,
235    /// Markdown body after the frontmatter block (trimmed). The
236    /// prompt template; supports `$ARGUMENTS` substitution.
237    pub body: String,
238    /// Frontmatter keys other than the typed ones. Preserves
239    /// unknown future fields verbatim as raw strings.
240    pub extra: BTreeMap<String, String>,
241}
242
243fn parse_command_file(path: &Path, file_stem: &str) -> Result<Command> {
244    let raw = fs::read_to_string(path)?;
245    let (frontmatter, body) = split_frontmatter(&raw);
246
247    let mut description = None;
248    let mut argument_hint = None;
249    let mut allowed_tools = Vec::new();
250    let mut model = None;
251    let mut disable_model_invocation = None;
252    let mut extra = BTreeMap::new();
253
254    if let Some(fm) = frontmatter {
255        for line in fm.lines() {
256            let trimmed = line.trim();
257            if trimmed.is_empty() {
258                continue;
259            }
260            let Some((k, v)) = trimmed.split_once(':') else {
261                continue;
262            };
263            let key = k.trim();
264            let value = v.trim().to_string();
265            match key {
266                "description" if !value.is_empty() => description = Some(value),
267                "argument-hint" if !value.is_empty() => argument_hint = Some(value),
268                "allowed-tools" if !value.is_empty() => {
269                    allowed_tools = value
270                        .split(',')
271                        .map(|t| t.trim().to_string())
272                        .filter(|t| !t.is_empty())
273                        .collect();
274                }
275                "model" if !value.is_empty() => model = Some(value),
276                "disable-model-invocation" if !value.is_empty() => {
277                    disable_model_invocation = Some(matches!(
278                        value.to_ascii_lowercase().as_str(),
279                        "true" | "yes" | "1"
280                    ));
281                }
282                _ if !key.is_empty() => {
283                    extra.insert(key.to_string(), value);
284                }
285                _ => {}
286            }
287        }
288    }
289
290    Ok(Command {
291        file_stem: file_stem.to_string(),
292        description,
293        argument_hint,
294        allowed_tools,
295        model,
296        disable_model_invocation,
297        file_path: path.to_path_buf(),
298        body: body.trim().to_string(),
299        extra,
300    })
301}
302
303fn home_dir() -> Option<PathBuf> {
304    if let Ok(h) = std::env::var("HOME")
305        && !h.is_empty()
306    {
307        return Some(PathBuf::from(h));
308    }
309    if let Ok(h) = std::env::var("USERPROFILE")
310        && !h.is_empty()
311    {
312        return Some(PathBuf::from(h));
313    }
314    None
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::io::Write;
321
322    fn write_command(dir: &Path, file_stem: &str, contents: &str) -> PathBuf {
323        let path = dir.join(format!("{file_stem}.md"));
324        let mut f = fs::File::create(&path).expect("create md");
325        f.write_all(contents.as_bytes()).expect("write md");
326        path
327    }
328
329    fn fixture_root() -> tempfile::TempDir {
330        let tmp = tempfile::tempdir().expect("tempdir");
331        write_command(
332            tmp.path(),
333            "open-pr",
334            "---\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",
335        );
336        write_command(
337            tmp.path(),
338            "no-frontmatter",
339            "Just a body, no frontmatter at all.\n",
340        );
341        write_command(
342            tmp.path(),
343            "weird",
344            "---\ndescription: has extras\ncustom_key: custom_value\ndisable-model-invocation: true\n---\nbody\n",
345        );
346        // Non-md file ignored.
347        fs::write(tmp.path().join("README.txt"), "ignore").expect("write txt");
348        tmp
349    }
350
351    #[test]
352    fn list_returns_only_md_files_sorted() {
353        let tmp = fixture_root();
354        let root = CommandsRoot::at(tmp.path());
355        let cmds = root.list().expect("list");
356        let stems: Vec<&str> = cmds.iter().map(|c| c.file_stem.as_str()).collect();
357        assert_eq!(stems, ["no-frontmatter", "open-pr", "weird"]);
358    }
359
360    #[test]
361    fn list_missing_root_returns_empty() {
362        let tmp = tempfile::tempdir().expect("tempdir");
363        let root = CommandsRoot::at(tmp.path().join("does-not-exist"));
364        assert!(root.list().expect("list").is_empty());
365    }
366
367    #[test]
368    fn list_typed_metadata() {
369        let tmp = fixture_root();
370        let root = CommandsRoot::at(tmp.path());
371        let cmds = root.list().expect("list");
372        let pr = cmds.iter().find(|c| c.file_stem == "open-pr").unwrap();
373        assert_eq!(
374            pr.description.as_deref(),
375            Some("Open a PR for the current branch")
376        );
377        assert_eq!(pr.argument_hint.as_deref(), Some("<pr title>"));
378        assert_eq!(pr.allowed_tools, vec!["Bash(git *)", "Bash(gh *)"]);
379        assert_eq!(pr.model.as_deref(), Some("sonnet"));
380        assert!(pr.disable_model_invocation.is_none());
381        assert!(pr.size_bytes > 0);
382    }
383
384    #[test]
385    fn list_no_frontmatter_parses_clean() {
386        let tmp = fixture_root();
387        let root = CommandsRoot::at(tmp.path());
388        let cmds = root.list().expect("list");
389        let nf = cmds
390            .iter()
391            .find(|c| c.file_stem == "no-frontmatter")
392            .unwrap();
393        assert!(nf.description.is_none());
394        assert!(nf.allowed_tools.is_empty());
395    }
396
397    #[test]
398    fn get_returns_full_command_with_body() {
399        let tmp = fixture_root();
400        let root = CommandsRoot::at(tmp.path());
401        let cmd = root.get("open-pr").expect("get");
402        assert_eq!(cmd.file_stem, "open-pr");
403        assert!(cmd.body.starts_with("Open a pull request"));
404    }
405
406    #[test]
407    fn get_no_frontmatter_returns_full_body() {
408        let tmp = fixture_root();
409        let root = CommandsRoot::at(tmp.path());
410        let cmd = root.get("no-frontmatter").expect("get");
411        assert_eq!(cmd.body, "Just a body, no frontmatter at all.");
412    }
413
414    #[test]
415    fn get_unknown_id_errors() {
416        let tmp = fixture_root();
417        let root = CommandsRoot::at(tmp.path());
418        let err = root.get("nope").unwrap_err();
419        assert!(err.to_string().to_lowercase().contains("no command"));
420    }
421
422    #[test]
423    fn extras_round_trip() {
424        let tmp = fixture_root();
425        let root = CommandsRoot::at(tmp.path());
426        let cmd = root.get("weird").expect("get");
427        assert_eq!(
428            cmd.extra.get("custom_key").map(String::as_str),
429            Some("custom_value")
430        );
431    }
432
433    #[test]
434    fn disable_model_invocation_parses_bool() {
435        let tmp = fixture_root();
436        let root = CommandsRoot::at(tmp.path());
437        let cmd = root.get("weird").expect("get");
438        assert_eq!(cmd.disable_model_invocation, Some(true));
439    }
440
441    #[test]
442    fn project_helper_appends_dot_claude_commands() {
443        let p = CommandsRoot::project("/tmp/repo");
444        assert!(p.path().ends_with(".claude/commands"));
445        assert!(p.path().starts_with("/tmp/repo"));
446    }
447}