1use std::collections::BTreeMap;
66use std::fs;
67use std::path::{Path, PathBuf};
68
69use serde::Serialize;
70
71use crate::error::{Error, Result};
72
73#[derive(Debug, Clone)]
77pub struct AgentsRoot {
78 path: PathBuf,
79}
80
81impl AgentsRoot {
82 pub fn home() -> Result<Self> {
85 let home = home_dir().ok_or_else(|| Error::Artifacts {
86 message: "could not determine user home directory".to_string(),
87 })?;
88 Ok(Self {
89 path: home.join(".claude").join("agents"),
90 })
91 }
92
93 pub fn at(path: impl Into<PathBuf>) -> Self {
96 Self { path: path.into() }
97 }
98
99 pub fn path(&self) -> &Path {
101 &self.path
102 }
103
104 pub fn list(&self) -> Result<Vec<AgentSummary>> {
111 let entries = match fs::read_dir(&self.path) {
112 Ok(it) => it,
113 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
114 Err(e) => return Err(e.into()),
115 };
116
117 let mut out = Vec::new();
118 for entry in entries.flatten() {
119 let path = entry.path();
120 if path.extension().and_then(|s| s.to_str()) != Some("md") {
121 continue;
122 }
123 let stem = match path.file_stem().and_then(|s| s.to_str()) {
124 Some(s) => s.to_string(),
125 None => continue,
126 };
127 match parse_agent_file(&path, &stem) {
128 Ok(agent) => out.push(AgentSummary::from_agent(&agent)),
129 Err(e) => tracing::warn!(?path, "skipping agent: {e}"),
130 }
131 }
132 out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
133 Ok(out)
134 }
135
136 pub fn get(&self, file_stem: &str) -> Result<Agent> {
140 let path = self.path.join(format!("{file_stem}.md"));
141 if !path.exists() {
142 return Err(Error::Artifacts {
143 message: format!("no agent at {}", path.display()),
144 });
145 }
146 parse_agent_file(&path, file_stem)
147 }
148
149 pub fn write(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
161 self.write_inner(file_stem, input, true)
162 }
163
164 pub fn write_new(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
168 self.write_inner(file_stem, input, false)
169 }
170
171 fn write_inner(
172 &self,
173 file_stem: &str,
174 input: AgentWriteInput,
175 allow_overwrite: bool,
176 ) -> Result<()> {
177 validate_stem(file_stem)?;
178 fs::create_dir_all(&self.path)?;
179 let path = self.path.join(format!("{file_stem}.md"));
180 if !allow_overwrite && path.exists() {
181 return Err(Error::Artifacts {
182 message: format!("agent already exists at {}", path.display()),
183 });
184 }
185
186 let markdown = render_agent_markdown(file_stem, &input);
187
188 let tmp = self.path.join(format!(".{file_stem}.md.tmp"));
192 fs::write(&tmp, markdown)?;
193 if let Err(e) = fs::rename(&tmp, &path) {
194 let _ = fs::remove_file(&tmp);
196 return Err(e.into());
197 }
198 Ok(())
199 }
200
201 pub fn delete(&self, file_stem: &str) -> Result<()> {
204 validate_stem(file_stem)?;
205 let path = self.path.join(format!("{file_stem}.md"));
206 if !path.exists() {
207 return Err(Error::Artifacts {
208 message: format!("no agent at {}", path.display()),
209 });
210 }
211 fs::remove_file(&path)?;
212 Ok(())
213 }
214}
215
216#[derive(Debug, Clone, Default)]
223pub struct AgentWriteInput {
224 pub name: Option<String>,
227 pub description: Option<String>,
229 pub tools: Vec<String>,
232 pub model: Option<String>,
234 pub body: String,
237 pub extra: BTreeMap<String, String>,
240}
241
242fn render_agent_markdown(file_stem: &str, input: &AgentWriteInput) -> String {
243 let name = input.name.as_deref().unwrap_or(file_stem);
244 let mut out = String::from("---\n");
245 out.push_str(&format!("name: {name}\n"));
246 if let Some(desc) = &input.description {
247 out.push_str(&format!("description: {desc}\n"));
248 }
249 if !input.tools.is_empty() {
250 out.push_str(&format!("tools: {}\n", input.tools.join(", ")));
251 }
252 if let Some(model) = &input.model {
253 out.push_str(&format!("model: {model}\n"));
254 }
255 for (k, v) in &input.extra {
256 out.push_str(&format!("{k}: {v}\n"));
257 }
258 out.push_str("---\n\n");
259 out.push_str(input.body.trim());
260 out.push('\n');
261 out
262}
263
264fn validate_stem(stem: &str) -> Result<()> {
265 if stem.is_empty() {
266 return Err(Error::Artifacts {
267 message: "file_stem cannot be empty".into(),
268 });
269 }
270 if stem == "." || stem == ".." {
271 return Err(Error::Artifacts {
272 message: format!("file_stem cannot be {stem:?}"),
273 });
274 }
275 if stem.contains('/') || stem.contains('\\') || stem.contains('\0') {
276 return Err(Error::Artifacts {
277 message: format!("file_stem contains invalid characters: {stem:?}"),
278 });
279 }
280 Ok(())
281}
282
283#[derive(Debug, Clone, Serialize)]
286pub struct AgentSummary {
287 pub file_stem: String,
289 pub name: String,
291 pub description: Option<String>,
293 pub tools: Vec<String>,
295 pub model: Option<String>,
297 pub file_path: PathBuf,
299 pub size_bytes: u64,
301}
302
303impl AgentSummary {
304 fn from_agent(a: &Agent) -> Self {
305 let size_bytes = fs::metadata(&a.file_path)
306 .map(|m| m.len())
307 .unwrap_or_default();
308 Self {
309 file_stem: a.file_stem.clone(),
310 name: a.name.clone(),
311 description: a.description.clone(),
312 tools: a.tools.clone(),
313 model: a.model.clone(),
314 file_path: a.file_path.clone(),
315 size_bytes,
316 }
317 }
318}
319
320#[derive(Debug, Clone, Serialize)]
322pub struct Agent {
323 pub file_stem: String,
325 pub name: String,
327 pub description: Option<String>,
329 pub tools: Vec<String>,
331 pub model: Option<String>,
333 pub file_path: PathBuf,
335 pub body: String,
338 pub extra: BTreeMap<String, String>,
341}
342
343fn parse_agent_file(path: &Path, file_stem: &str) -> Result<Agent> {
344 let raw = fs::read_to_string(path)?;
345 let (frontmatter, body) = split_frontmatter(&raw);
346
347 let mut name = file_stem.to_string();
348 let mut description = None;
349 let mut tools = Vec::new();
350 let mut model = None;
351 let mut extra = BTreeMap::new();
352
353 if let Some(fm) = frontmatter {
354 for line in fm.lines() {
355 let trimmed = line.trim();
356 if trimmed.is_empty() {
357 continue;
358 }
359 let Some((k, v)) = trimmed.split_once(':') else {
360 continue;
361 };
362 let key = k.trim();
363 let value = v.trim().to_string();
364 match key {
365 "name" if !value.is_empty() => name = value,
366 "description" if !value.is_empty() => description = Some(value),
367 "tools" if !value.is_empty() => {
368 tools = value
369 .split(',')
370 .map(|t| t.trim().to_string())
371 .filter(|t| !t.is_empty())
372 .collect();
373 }
374 "model" if !value.is_empty() => model = Some(value),
375 _ if !key.is_empty() => {
376 extra.insert(key.to_string(), value);
377 }
378 _ => {}
379 }
380 }
381 }
382
383 Ok(Agent {
384 file_stem: file_stem.to_string(),
385 name,
386 description,
387 tools,
388 model,
389 file_path: path.to_path_buf(),
390 body: body.trim().to_string(),
391 extra,
392 })
393}
394
395pub(crate) fn split_frontmatter(raw: &str) -> (Option<&str>, &str) {
400 let mut lines = raw.split_inclusive('\n');
401 let Some(first) = lines.next() else {
402 return (None, raw);
403 };
404 if first.trim_end_matches(['\n', '\r']) != "---" {
405 return (None, raw);
406 }
407 let after_first = first.len();
408 let mut cursor = after_first;
409 for line in lines {
410 let len = line.len();
411 if line.trim_end_matches(['\n', '\r']) == "---" {
412 let fm = &raw[after_first..cursor];
413 let body_start = cursor + len;
414 let body = &raw[body_start..];
415 return (Some(fm), body);
416 }
417 cursor += len;
418 }
419 (None, raw)
420}
421
422fn home_dir() -> Option<PathBuf> {
423 if let Ok(h) = std::env::var("HOME")
424 && !h.is_empty()
425 {
426 return Some(PathBuf::from(h));
427 }
428 if let Ok(h) = std::env::var("USERPROFILE")
429 && !h.is_empty()
430 {
431 return Some(PathBuf::from(h));
432 }
433 None
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use std::io::Write;
440
441 fn write_agent(dir: &Path, file_stem: &str, contents: &str) -> PathBuf {
442 let path = dir.join(format!("{file_stem}.md"));
443 let mut f = fs::File::create(&path).expect("create md");
444 f.write_all(contents.as_bytes()).expect("write md");
445 path
446 }
447
448 fn fixture_root() -> tempfile::TempDir {
449 let tmp = tempfile::tempdir().expect("tempdir");
450 write_agent(
451 tmp.path(),
452 "rust-qa",
453 "---\nname: rust-qa\ndescription: Rust quality gate\ntools: Read, Grep, Bash\nmodel: sonnet\n---\n\nYou are a Rust quality gate.\n",
454 );
455 write_agent(
456 tmp.path(),
457 "no-frontmatter",
458 "Just a body, no frontmatter at all.\n",
459 );
460 write_agent(
461 tmp.path(),
462 "minimal",
463 "---\nname: minimal\ndescription: Minimal agent\n---\nBody here.\n",
464 );
465 write_agent(
467 tmp.path(),
468 "weird",
469 "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
470 );
471 let other = tmp.path().join("README.txt");
473 fs::write(&other, "ignore me").expect("write txt");
474 tmp
475 }
476
477 #[test]
478 fn list_returns_only_md_files_sorted() {
479 let tmp = fixture_root();
480 let root = AgentsRoot::at(tmp.path());
481 let agents = root.list().expect("list");
482 let stems: Vec<&str> = agents.iter().map(|a| a.file_stem.as_str()).collect();
483 assert_eq!(stems, ["minimal", "no-frontmatter", "rust-qa", "weird"]);
484 }
485
486 #[test]
487 fn list_missing_root_returns_empty() {
488 let tmp = tempfile::tempdir().expect("tempdir");
489 let root = AgentsRoot::at(tmp.path().join("does-not-exist"));
490 let agents = root.list().expect("list");
491 assert!(agents.is_empty());
492 }
493
494 #[test]
495 fn list_typed_metadata() {
496 let tmp = fixture_root();
497 let root = AgentsRoot::at(tmp.path());
498 let agents = root.list().expect("list");
499 let rust_qa = agents
500 .iter()
501 .find(|a| a.file_stem == "rust-qa")
502 .expect("rust-qa");
503 assert_eq!(rust_qa.name, "rust-qa");
504 assert_eq!(rust_qa.description.as_deref(), Some("Rust quality gate"));
505 assert_eq!(rust_qa.tools, vec!["Read", "Grep", "Bash"]);
506 assert_eq!(rust_qa.model.as_deref(), Some("sonnet"));
507 assert!(rust_qa.size_bytes > 0);
508 }
509
510 #[test]
511 fn list_no_frontmatter_falls_back_to_stem() {
512 let tmp = fixture_root();
513 let root = AgentsRoot::at(tmp.path());
514 let agents = root.list().expect("list");
515 let nf = agents
516 .iter()
517 .find(|a| a.file_stem == "no-frontmatter")
518 .expect("no-frontmatter");
519 assert_eq!(nf.name, "no-frontmatter");
520 assert_eq!(nf.description, None);
521 assert!(nf.tools.is_empty());
522 assert!(nf.model.is_none());
523 }
524
525 #[test]
526 fn get_returns_full_agent_with_body() {
527 let tmp = fixture_root();
528 let root = AgentsRoot::at(tmp.path());
529 let agent = root.get("rust-qa").expect("get rust-qa");
530 assert_eq!(agent.name, "rust-qa");
531 assert_eq!(agent.body, "You are a Rust quality gate.");
532 }
533
534 #[test]
535 fn get_no_frontmatter_returns_full_body() {
536 let tmp = fixture_root();
537 let root = AgentsRoot::at(tmp.path());
538 let agent = root.get("no-frontmatter").expect("get");
539 assert_eq!(agent.body, "Just a body, no frontmatter at all.");
540 assert_eq!(agent.name, "no-frontmatter");
541 assert!(agent.tools.is_empty());
542 }
543
544 #[test]
545 fn get_unknown_id_errors() {
546 let tmp = fixture_root();
547 let root = AgentsRoot::at(tmp.path());
548 let err = root.get("nope").unwrap_err();
549 assert!(err.to_string().to_lowercase().contains("no agent"));
550 }
551
552 #[test]
553 fn extra_keys_round_trip_as_strings() {
554 let tmp = fixture_root();
555 let root = AgentsRoot::at(tmp.path());
556 let agent = root.get("weird").expect("get weird");
557 assert_eq!(
558 agent.extra.get("custom_key").map(String::as_str),
559 Some("custom_value")
560 );
561 }
562
563 #[test]
564 fn split_frontmatter_with_block() {
565 let raw = "---\nname: x\n---\nbody text\n";
566 let (fm, body) = split_frontmatter(raw);
567 assert_eq!(fm, Some("name: x\n"));
568 assert_eq!(body, "body text\n");
569 }
570
571 #[test]
572 fn split_frontmatter_no_block() {
573 let raw = "no frontmatter here\nsecond line\n";
574 let (fm, body) = split_frontmatter(raw);
575 assert_eq!(fm, None);
576 assert_eq!(body, raw);
577 }
578
579 #[test]
580 fn split_frontmatter_open_no_close_returns_full() {
581 let raw = "---\nname: x\nstill no close here\n";
584 let (fm, body) = split_frontmatter(raw);
585 assert_eq!(fm, None);
586 assert_eq!(body, raw);
587 }
588
589 #[test]
590 fn empty_value_keys_dont_overwrite_defaults() {
591 let tmp = tempfile::tempdir().expect("tempdir");
592 write_agent(
593 tmp.path(),
594 "empty-name",
595 "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
596 );
597 let root = AgentsRoot::at(tmp.path());
598 let agent = root.get("empty-name").expect("get");
599 assert_eq!(agent.name, "empty-name");
600 }
601
602 fn input_with_body(body: &str) -> AgentWriteInput {
605 AgentWriteInput {
606 body: body.into(),
607 ..Default::default()
608 }
609 }
610
611 #[test]
612 fn write_creates_new_agent_round_trips_via_get() {
613 let tmp = tempfile::tempdir().expect("tempdir");
614 let root = AgentsRoot::at(tmp.path());
615 let input = AgentWriteInput {
616 name: Some("my-agent".into()),
617 description: Some("does the thing".into()),
618 tools: vec!["Read".into(), "Bash".into()],
619 model: Some("sonnet".into()),
620 body: "You are an agent.".into(),
621 extra: BTreeMap::new(),
622 };
623 root.write("my-agent", input).expect("write");
624
625 let agent = root.get("my-agent").expect("get");
626 assert_eq!(agent.name, "my-agent");
627 assert_eq!(agent.description.as_deref(), Some("does the thing"));
628 assert_eq!(agent.tools, vec!["Read", "Bash"]);
629 assert_eq!(agent.model.as_deref(), Some("sonnet"));
630 assert_eq!(agent.body, "You are an agent.");
631 }
632
633 #[test]
634 fn write_overwrites_existing_agent() {
635 let tmp = fixture_root();
636 let root = AgentsRoot::at(tmp.path());
637 let input = AgentWriteInput {
639 description: Some("rewritten".into()),
640 body: "new body".into(),
641 ..Default::default()
642 };
643 root.write("rust-qa", input).expect("overwrite");
644 let agent = root.get("rust-qa").expect("get");
645 assert_eq!(agent.description.as_deref(), Some("rewritten"));
646 assert_eq!(agent.body, "new body");
647 assert!(agent.tools.is_empty(), "tools: {:?}", agent.tools);
650 assert!(agent.model.is_none());
651 }
652
653 #[test]
654 fn write_new_errors_when_already_exists() {
655 let tmp = fixture_root();
656 let root = AgentsRoot::at(tmp.path());
657 let err = root
658 .write_new("rust-qa", input_with_body("body"))
659 .unwrap_err();
660 assert!(err.to_string().contains("already exists"), "err: {err}");
661 }
662
663 #[test]
664 fn write_new_succeeds_for_fresh_stem() {
665 let tmp = fixture_root();
666 let root = AgentsRoot::at(tmp.path());
667 root.write_new("brand-new", input_with_body("hello"))
668 .expect("write_new");
669 let agent = root.get("brand-new").expect("get");
670 assert_eq!(agent.body, "hello");
671 }
672
673 #[test]
674 fn write_creates_root_directory_if_missing() {
675 let tmp = tempfile::tempdir().expect("tempdir");
676 let root = AgentsRoot::at(tmp.path().join("does-not-exist-yet"));
677 root.write("foo", input_with_body("body")).expect("write");
678 let agent = root.get("foo").expect("get");
679 assert_eq!(agent.body, "body");
680 }
681
682 #[test]
683 fn write_defaults_name_to_file_stem_when_absent() {
684 let tmp = tempfile::tempdir().expect("tempdir");
685 let root = AgentsRoot::at(tmp.path());
686 root.write("my-stem", input_with_body("b")).expect("write");
687 let agent = root.get("my-stem").expect("get");
688 assert_eq!(agent.name, "my-stem");
689 }
690
691 #[test]
692 fn write_preserves_extra_keys() {
693 let tmp = tempfile::tempdir().expect("tempdir");
694 let root = AgentsRoot::at(tmp.path());
695 let mut extra = BTreeMap::new();
696 extra.insert("custom_key".into(), "custom_value".into());
697 let input = AgentWriteInput {
698 body: "b".into(),
699 extra,
700 ..Default::default()
701 };
702 root.write("ex", input).expect("write");
703 let agent = root.get("ex").expect("get");
704 assert_eq!(
705 agent.extra.get("custom_key").map(String::as_str),
706 Some("custom_value")
707 );
708 }
709
710 #[test]
711 fn write_omits_optional_keys_when_unset() {
712 let tmp = tempfile::tempdir().expect("tempdir");
713 let root = AgentsRoot::at(tmp.path());
714 root.write("min", input_with_body("body only"))
715 .expect("write");
716 let raw = std::fs::read_to_string(tmp.path().join("min.md")).unwrap();
717 assert!(!raw.contains("description:"), "raw: {raw}");
718 assert!(!raw.contains("tools:"), "raw: {raw}");
719 assert!(!raw.contains("model:"), "raw: {raw}");
720 }
721
722 #[test]
723 fn write_rejects_path_traversal() {
724 let tmp = tempfile::tempdir().expect("tempdir");
725 let root = AgentsRoot::at(tmp.path());
726 for bad in ["", ".", "..", "a/b", "a\\b", "a\0b"] {
727 let err = root.write(bad, input_with_body("b")).unwrap_err();
728 assert!(
729 err.to_string().to_lowercase().contains("file_stem"),
730 "bad stem {bad:?} not rejected: {err}"
731 );
732 }
733 }
734
735 #[test]
736 fn delete_removes_file() {
737 let tmp = fixture_root();
738 let root = AgentsRoot::at(tmp.path());
739 assert!(root.get("rust-qa").is_ok());
740 root.delete("rust-qa").expect("delete");
741 let err = root.get("rust-qa").unwrap_err();
742 assert!(err.to_string().contains("no agent"), "err: {err}");
743 }
744
745 #[test]
746 fn delete_unknown_stem_errors() {
747 let tmp = fixture_root();
748 let root = AgentsRoot::at(tmp.path());
749 let err = root.delete("nope").unwrap_err();
750 assert!(err.to_string().contains("no agent"), "err: {err}");
751 }
752
753 #[test]
754 fn delete_rejects_path_traversal() {
755 let tmp = fixture_root();
756 let root = AgentsRoot::at(tmp.path());
757 for bad in ["", ".", "..", "a/b", "a\\b"] {
758 let err = root.delete(bad).unwrap_err();
759 assert!(
760 err.to_string().to_lowercase().contains("file_stem"),
761 "bad stem {bad:?} not rejected: {err}"
762 );
763 }
764 }
765
766 #[test]
767 fn render_orders_canonical_keys_before_extras() {
768 let mut extra = BTreeMap::new();
769 extra.insert("zzz_last".into(), "v".into());
770 extra.insert("aaa_first".into(), "v".into());
771 let input = AgentWriteInput {
772 name: Some("n".into()),
773 description: Some("d".into()),
774 tools: vec!["t1".into(), "t2".into()],
775 model: Some("haiku".into()),
776 body: "body".into(),
777 extra,
778 };
779 let md = render_agent_markdown("stem", &input);
780 let lines: Vec<&str> = md.lines().collect();
781 assert_eq!(lines[0], "---");
783 assert_eq!(lines[1], "name: n");
785 assert_eq!(lines[2], "description: d");
786 assert_eq!(lines[3], "tools: t1, t2");
787 assert_eq!(lines[4], "model: haiku");
788 assert_eq!(lines[5], "aaa_first: v");
789 assert_eq!(lines[6], "zzz_last: v");
790 assert_eq!(lines[7], "---");
791 }
792}