1use 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#[derive(Debug, Clone)]
99pub struct CommandsRoot {
100 path: PathBuf,
101}
102
103impl CommandsRoot {
104 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 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 pub fn at(path: impl Into<PathBuf>) -> Self {
128 Self { path: path.into() }
129 }
130
131 pub fn path(&self) -> &Path {
133 &self.path
134 }
135
136 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 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#[derive(Debug, Clone, Serialize)]
183pub struct CommandSummary {
184 pub file_stem: String,
187 pub description: Option<String>,
189 pub argument_hint: Option<String>,
192 pub allowed_tools: Vec<String>,
195 pub model: Option<String>,
198 pub disable_model_invocation: Option<bool>,
201 pub file_path: PathBuf,
203 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#[derive(Debug, Clone, Serialize)]
227pub struct Command {
228 pub file_stem: String,
230 pub description: Option<String>,
232 pub argument_hint: Option<String>,
234 pub allowed_tools: Vec<String>,
236 pub model: Option<String>,
238 pub disable_model_invocation: Option<bool>,
240 pub file_path: PathBuf,
242 pub body: String,
245 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 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}