1use 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#[derive(Debug, Clone)]
92pub struct CommandsRoot {
93 path: PathBuf,
94}
95
96impl CommandsRoot {
97 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 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 pub fn at(path: impl Into<PathBuf>) -> Self {
121 Self { path: path.into() }
122 }
123
124 pub fn path(&self) -> &Path {
126 &self.path
127 }
128
129 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 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#[derive(Debug, Clone, Serialize)]
176pub struct CommandSummary {
177 pub file_stem: String,
180 pub description: Option<String>,
182 pub argument_hint: Option<String>,
185 pub allowed_tools: Vec<String>,
188 pub model: Option<String>,
191 pub disable_model_invocation: Option<bool>,
194 pub file_path: PathBuf,
196 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#[derive(Debug, Clone, Serialize)]
220pub struct Command {
221 pub file_stem: String,
223 pub description: Option<String>,
225 pub argument_hint: Option<String>,
227 pub allowed_tools: Vec<String>,
229 pub model: Option<String>,
231 pub disable_model_invocation: Option<bool>,
233 pub file_path: PathBuf,
235 pub body: String,
238 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 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}