1use std::collections::BTreeMap;
97use std::fs;
98use std::path::{Path, PathBuf};
99
100use serde::Serialize;
101
102use crate::error::{Error, Result};
103
104#[derive(Debug, Clone)]
108pub struct AgentsRoot {
109 path: PathBuf,
110}
111
112impl AgentsRoot {
113 pub fn home() -> Result<Self> {
116 let home = home_dir().ok_or_else(|| Error::Artifacts {
117 message: "could not determine user home directory".to_string(),
118 })?;
119 Ok(Self {
120 path: home.join(".claude").join("agents"),
121 })
122 }
123
124 pub fn at(path: impl Into<PathBuf>) -> Self {
127 Self { path: path.into() }
128 }
129
130 pub fn path(&self) -> &Path {
132 &self.path
133 }
134
135 pub fn list(&self) -> Result<Vec<AgentSummary>> {
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_agent_file(&path, &stem) {
159 Ok(agent) => out.push(AgentSummary::from_agent(&agent)),
160 Err(e) => tracing::warn!(?path, "skipping agent: {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<Agent> {
171 let path = self.path.join(format!("{file_stem}.md"));
172 if !path.exists() {
173 return Err(Error::Artifacts {
174 message: format!("no agent at {}", path.display()),
175 });
176 }
177 parse_agent_file(&path, file_stem)
178 }
179
180 pub fn write(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
192 self.write_inner(file_stem, input, true)
193 }
194
195 pub fn write_new(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
199 self.write_inner(file_stem, input, false)
200 }
201
202 fn write_inner(
203 &self,
204 file_stem: &str,
205 input: AgentWriteInput,
206 allow_overwrite: bool,
207 ) -> Result<()> {
208 validate_stem(file_stem)?;
209 fs::create_dir_all(&self.path)?;
210 let path = self.path.join(format!("{file_stem}.md"));
211 if !allow_overwrite && path.exists() {
212 return Err(Error::Artifacts {
213 message: format!("agent already exists at {}", path.display()),
214 });
215 }
216
217 let markdown = render_agent_markdown(file_stem, &input);
218
219 let tmp = self.path.join(format!(".{file_stem}.md.tmp"));
223 fs::write(&tmp, markdown)?;
224 if let Err(e) = fs::rename(&tmp, &path) {
225 let _ = fs::remove_file(&tmp);
227 return Err(e.into());
228 }
229 Ok(())
230 }
231
232 pub fn delete(&self, file_stem: &str) -> Result<()> {
235 validate_stem(file_stem)?;
236 let path = self.path.join(format!("{file_stem}.md"));
237 if !path.exists() {
238 return Err(Error::Artifacts {
239 message: format!("no agent at {}", path.display()),
240 });
241 }
242 fs::remove_file(&path)?;
243 Ok(())
244 }
245}
246
247#[derive(Debug, Clone, Default)]
254pub struct AgentWriteInput {
255 pub name: Option<String>,
258 pub description: Option<String>,
260 pub tools: Vec<String>,
263 pub model: Option<String>,
265 pub skills: Vec<String>,
268 pub body: String,
271 pub extra: BTreeMap<String, String>,
274}
275
276fn render_agent_markdown(file_stem: &str, input: &AgentWriteInput) -> String {
277 let name = input.name.as_deref().unwrap_or(file_stem);
278 let mut out = String::from("---\n");
279 push_frontmatter_field(&mut out, "name", name);
280 if let Some(desc) = &input.description {
281 push_frontmatter_field(&mut out, "description", desc);
282 }
283 if !input.tools.is_empty() {
284 push_frontmatter_field(&mut out, "tools", &input.tools.join(", "));
285 }
286 if let Some(model) = &input.model {
287 push_frontmatter_field(&mut out, "model", model);
288 }
289 if !input.skills.is_empty() {
292 out.push_str("skills:\n");
293 for skill in &input.skills {
294 out.push_str(&format!(" - {skill}\n"));
295 }
296 }
297 for (k, v) in &input.extra {
298 push_frontmatter_field(&mut out, k, v);
299 }
300 out.push_str("---\n\n");
301 out.push_str(input.body.trim());
302 out.push('\n');
303 out
304}
305
306fn push_frontmatter_field(out: &mut String, key: &str, value: &str) {
314 let core = value.trim_end_matches('\n');
315 if !value.contains('\n') || core.is_empty() {
316 out.push_str(&format!("{key}: {core}\n"));
317 return;
318 }
319 let trailing = value.len() - core.len();
320 let indicator = match trailing {
321 0 => "|-",
322 1 => "|",
323 _ => "|+",
324 };
325 out.push_str(&format!("{key}: {indicator}\n"));
326 for line in core.lines() {
327 if line.is_empty() {
328 out.push('\n');
329 } else {
330 out.push_str(&format!(" {line}\n"));
331 }
332 }
333 out.push_str(&"\n".repeat(trailing.saturating_sub(1)));
334}
335
336fn validate_stem(stem: &str) -> Result<()> {
337 if stem.is_empty() {
338 return Err(Error::Artifacts {
339 message: "file_stem cannot be empty".into(),
340 });
341 }
342 if stem == "." || stem == ".." {
343 return Err(Error::Artifacts {
344 message: format!("file_stem cannot be {stem:?}"),
345 });
346 }
347 if stem.contains('/') || stem.contains('\\') || stem.contains('\0') {
348 return Err(Error::Artifacts {
349 message: format!("file_stem contains invalid characters: {stem:?}"),
350 });
351 }
352 Ok(())
353}
354
355#[derive(Debug, Clone, Serialize)]
358pub struct AgentSummary {
359 pub file_stem: String,
361 pub name: String,
363 pub description: Option<String>,
365 pub tools: Vec<String>,
367 pub model: Option<String>,
369 pub skills: Vec<String>,
371 pub file_path: PathBuf,
373 pub size_bytes: u64,
375}
376
377impl AgentSummary {
378 fn from_agent(a: &Agent) -> Self {
379 let size_bytes = fs::metadata(&a.file_path)
380 .map(|m| m.len())
381 .unwrap_or_default();
382 Self {
383 file_stem: a.file_stem.clone(),
384 name: a.name.clone(),
385 description: a.description.clone(),
386 tools: a.tools.clone(),
387 model: a.model.clone(),
388 skills: a.skills.clone(),
389 file_path: a.file_path.clone(),
390 size_bytes,
391 }
392 }
393}
394
395#[derive(Debug, Clone, Serialize)]
397pub struct Agent {
398 pub file_stem: String,
400 pub name: String,
402 pub description: Option<String>,
404 pub tools: Vec<String>,
406 pub model: Option<String>,
408 pub skills: Vec<String>,
411 pub file_path: PathBuf,
413 pub body: String,
416 pub extra: BTreeMap<String, String>,
419}
420
421fn parse_agent_file(path: &Path, file_stem: &str) -> Result<Agent> {
422 let raw = fs::read_to_string(path)?;
423 let (frontmatter, body) = split_frontmatter(&raw);
424
425 let mut name = file_stem.to_string();
426 let mut description = None;
427 let mut tools = Vec::new();
428 let mut model = None;
429 let mut skills = Vec::new();
430 let mut extra = BTreeMap::new();
431
432 if let Some(fm) = frontmatter {
433 for (key, value) in frontmatter_entries(fm) {
434 match key.as_str() {
435 "name" if !value.is_empty() => name = value,
436 "description" if !value.is_empty() => description = Some(value),
437 "tools" if !value.is_empty() => tools = split_list(&value),
438 "model" if !value.is_empty() => model = Some(value),
439 "skills" if !value.is_empty() => skills = split_list(&value),
440 _ => {
441 extra.insert(key, value);
442 }
443 }
444 }
445 }
446
447 Ok(Agent {
448 file_stem: file_stem.to_string(),
449 name,
450 description,
451 tools,
452 model,
453 skills,
454 file_path: path.to_path_buf(),
455 body: body.trim().to_string(),
456 extra,
457 })
458}
459
460pub(crate) fn frontmatter_entries(fm: &str) -> Vec<(String, String)> {
506 let lines: Vec<&str> = fm.lines().collect();
507 let mut out = Vec::new();
508 let mut i = 0;
509 while i < lines.len() {
510 let line = lines[i];
511 i += 1;
512 let trimmed = line.trim();
513 if trimmed.is_empty() {
514 continue;
515 }
516 let Some((k, v)) = trimmed.split_once(':') else {
517 continue;
518 };
519 let key = k.trim();
520 if key.is_empty() {
521 continue;
522 }
523 let rest = v.trim();
524 match parse_block_header(rest) {
525 Some(header) => {
526 let (value, consumed) = read_block_scalar(&lines[i..], indent_width(line), header);
527 i += consumed;
528 out.push((key.to_string(), value));
529 }
530 None if rest.is_empty() => match read_block_sequence(&lines[i..], indent_width(line)) {
535 Some((items, consumed)) => {
536 i += consumed;
537 out.push((key.to_string(), items.join(", ")));
538 }
539 None => out.push((key.to_string(), String::new())),
540 },
541 None => out.push((key.to_string(), rest.to_string())),
542 }
543 }
544 out
545}
546
547fn read_block_sequence(lines: &[&str], parent_indent: usize) -> Option<(Vec<String>, usize)> {
560 let block = lines
561 .iter()
562 .take_while(|l| l.trim().is_empty() || indent_width(l) > parent_indent)
563 .count();
564 let end = lines[..block]
568 .iter()
569 .rposition(|l| !l.trim().is_empty())
570 .map(|i| i + 1)?;
571
572 let mut items = Vec::new();
573 for line in &lines[..end] {
574 let trimmed = line.trim();
575 if trimmed.is_empty() {
576 continue;
577 }
578 let item = trimmed.strip_prefix('-')?;
582 if !item.is_empty() && !item.starts_with([' ', '\t']) {
583 return None;
584 }
585 items.push(item.trim().to_string());
586 }
587 Some((items, end))
588}
589
590pub(crate) fn split_list(value: &str) -> Vec<String> {
597 let inner = value
598 .strip_prefix('[')
599 .and_then(|v| v.strip_suffix(']'))
600 .unwrap_or(value);
601 inner
602 .split(',')
603 .map(|t| t.trim().to_string())
604 .filter(|t| !t.is_empty())
605 .collect()
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610enum Chomp {
611 Strip,
613 Clip,
615 Keep,
617}
618
619#[derive(Debug, Clone, Copy)]
621struct BlockHeader {
622 literal: bool,
624 chomp: Chomp,
625 indent: Option<usize>,
627}
628
629fn parse_block_header(rest: &str) -> Option<BlockHeader> {
633 let (head, tail) = match rest.split_once(char::is_whitespace) {
636 Some((h, t)) => (h, t.trim_start()),
637 None => (rest, ""),
638 };
639 if !tail.is_empty() && !tail.starts_with('#') {
640 return None;
641 }
642
643 let mut chars = head.chars();
644 let literal = match chars.next()? {
645 '|' => true,
646 '>' => false,
647 _ => return None,
648 };
649 let mut chomp = Chomp::Clip;
650 let mut indent = None;
651 for c in chars {
652 match c {
653 '-' | '+' if chomp == Chomp::Clip => {
654 chomp = if c == '-' { Chomp::Strip } else { Chomp::Keep };
655 }
656 '1'..='9' if indent.is_none() => indent = Some(c as usize - '0' as usize),
657 _ => return None,
658 }
659 }
660 Some(BlockHeader {
661 literal,
662 chomp,
663 indent,
664 })
665}
666
667fn read_block_scalar(lines: &[&str], parent_indent: usize, header: BlockHeader) -> (String, usize) {
674 let consumed = lines
675 .iter()
676 .take_while(|l| l.trim().is_empty() || indent_width(l) > parent_indent)
677 .count();
678 let block = &lines[..consumed];
679
680 let content_indent = match header.indent {
683 Some(n) => parent_indent + n,
684 None => block
685 .iter()
686 .find(|l| !l.trim().is_empty())
687 .map(|l| indent_width(l))
688 .unwrap_or(parent_indent + 1),
689 };
690 let stripped: Vec<&str> = block
691 .iter()
692 .map(|l| &l[indent_width(l).min(content_indent)..])
693 .collect();
694
695 let end = stripped
697 .iter()
698 .rposition(|l| !l.trim().is_empty())
699 .map(|i| i + 1)
700 .unwrap_or(0);
701 let trailing_blanks = stripped.len() - end;
702 let content = &stripped[..end];
703
704 let mut value = if header.literal {
705 content
706 .iter()
707 .map(|l| l.trim_end())
708 .collect::<Vec<_>>()
709 .join("\n")
710 } else {
711 fold_block(content)
712 };
713 match header.chomp {
714 Chomp::Strip => {}
715 Chomp::Clip => {
716 if !value.is_empty() {
717 value.push('\n');
718 }
719 }
720 Chomp::Keep => {
721 let n = if value.is_empty() {
722 trailing_blanks
723 } else {
724 trailing_blanks + 1
725 };
726 value.push_str(&"\n".repeat(n));
727 }
728 }
729 (value, consumed)
730}
731
732fn fold_block(lines: &[&str]) -> String {
736 let mut out = String::new();
737 let mut blank_run = 0usize;
738 let mut have_content = false;
739 let mut prev_more_indented = false;
740 for line in lines {
741 if line.trim().is_empty() {
742 blank_run += 1;
743 continue;
744 }
745 let more_indented = line.starts_with([' ', '\t']);
746 if blank_run > 0 {
747 out.push_str(&"\n".repeat(blank_run));
748 } else if have_content {
749 if more_indented || prev_more_indented {
750 out.push('\n');
751 } else {
752 out.push(' ');
753 }
754 }
755 out.push_str(line.trim_end());
756 blank_run = 0;
757 have_content = true;
758 prev_more_indented = more_indented;
759 }
760 out
761}
762
763fn indent_width(line: &str) -> usize {
766 line.len() - line.trim_start_matches([' ', '\t']).len()
767}
768
769pub(crate) fn split_frontmatter(raw: &str) -> (Option<&str>, &str) {
774 let mut lines = raw.split_inclusive('\n');
775 let Some(first) = lines.next() else {
776 return (None, raw);
777 };
778 if first.trim_end_matches(['\n', '\r']) != "---" {
779 return (None, raw);
780 }
781 let after_first = first.len();
782 let mut cursor = after_first;
783 for line in lines {
784 let len = line.len();
785 if line.trim_end_matches(['\n', '\r']) == "---" {
786 let fm = &raw[after_first..cursor];
787 let body_start = cursor + len;
788 let body = &raw[body_start..];
789 return (Some(fm), body);
790 }
791 cursor += len;
792 }
793 (None, raw)
794}
795
796fn home_dir() -> Option<PathBuf> {
797 if let Ok(h) = std::env::var("HOME")
798 && !h.is_empty()
799 {
800 return Some(PathBuf::from(h));
801 }
802 if let Ok(h) = std::env::var("USERPROFILE")
803 && !h.is_empty()
804 {
805 return Some(PathBuf::from(h));
806 }
807 None
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use std::io::Write;
814
815 fn write_agent(dir: &Path, file_stem: &str, contents: &str) -> PathBuf {
816 let path = dir.join(format!("{file_stem}.md"));
817 let mut f = fs::File::create(&path).expect("create md");
818 f.write_all(contents.as_bytes()).expect("write md");
819 path
820 }
821
822 fn fixture_root() -> tempfile::TempDir {
823 let tmp = tempfile::tempdir().expect("tempdir");
824 write_agent(
825 tmp.path(),
826 "rust-qa",
827 "---\nname: rust-qa\ndescription: Rust quality gate\ntools: Read, Grep, Bash\nmodel: sonnet\n---\n\nYou are a Rust quality gate.\n",
828 );
829 write_agent(
830 tmp.path(),
831 "no-frontmatter",
832 "Just a body, no frontmatter at all.\n",
833 );
834 write_agent(
835 tmp.path(),
836 "minimal",
837 "---\nname: minimal\ndescription: Minimal agent\n---\nBody here.\n",
838 );
839 write_agent(
841 tmp.path(),
842 "weird",
843 "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
844 );
845 let other = tmp.path().join("README.txt");
847 fs::write(&other, "ignore me").expect("write txt");
848 tmp
849 }
850
851 #[test]
852 fn list_returns_only_md_files_sorted() {
853 let tmp = fixture_root();
854 let root = AgentsRoot::at(tmp.path());
855 let agents = root.list().expect("list");
856 let stems: Vec<&str> = agents.iter().map(|a| a.file_stem.as_str()).collect();
857 assert_eq!(stems, ["minimal", "no-frontmatter", "rust-qa", "weird"]);
858 }
859
860 #[test]
861 fn list_missing_root_returns_empty() {
862 let tmp = tempfile::tempdir().expect("tempdir");
863 let root = AgentsRoot::at(tmp.path().join("does-not-exist"));
864 let agents = root.list().expect("list");
865 assert!(agents.is_empty());
866 }
867
868 #[test]
869 fn list_typed_metadata() {
870 let tmp = fixture_root();
871 let root = AgentsRoot::at(tmp.path());
872 let agents = root.list().expect("list");
873 let rust_qa = agents
874 .iter()
875 .find(|a| a.file_stem == "rust-qa")
876 .expect("rust-qa");
877 assert_eq!(rust_qa.name, "rust-qa");
878 assert_eq!(rust_qa.description.as_deref(), Some("Rust quality gate"));
879 assert_eq!(rust_qa.tools, vec!["Read", "Grep", "Bash"]);
880 assert_eq!(rust_qa.model.as_deref(), Some("sonnet"));
881 assert!(rust_qa.size_bytes > 0);
882 }
883
884 #[test]
885 fn list_no_frontmatter_falls_back_to_stem() {
886 let tmp = fixture_root();
887 let root = AgentsRoot::at(tmp.path());
888 let agents = root.list().expect("list");
889 let nf = agents
890 .iter()
891 .find(|a| a.file_stem == "no-frontmatter")
892 .expect("no-frontmatter");
893 assert_eq!(nf.name, "no-frontmatter");
894 assert_eq!(nf.description, None);
895 assert!(nf.tools.is_empty());
896 assert!(nf.model.is_none());
897 }
898
899 #[test]
900 fn get_returns_full_agent_with_body() {
901 let tmp = fixture_root();
902 let root = AgentsRoot::at(tmp.path());
903 let agent = root.get("rust-qa").expect("get rust-qa");
904 assert_eq!(agent.name, "rust-qa");
905 assert_eq!(agent.body, "You are a Rust quality gate.");
906 }
907
908 #[test]
909 fn get_no_frontmatter_returns_full_body() {
910 let tmp = fixture_root();
911 let root = AgentsRoot::at(tmp.path());
912 let agent = root.get("no-frontmatter").expect("get");
913 assert_eq!(agent.body, "Just a body, no frontmatter at all.");
914 assert_eq!(agent.name, "no-frontmatter");
915 assert!(agent.tools.is_empty());
916 }
917
918 #[test]
919 fn get_unknown_id_errors() {
920 let tmp = fixture_root();
921 let root = AgentsRoot::at(tmp.path());
922 let err = root.get("nope").unwrap_err();
923 assert!(err.to_string().to_lowercase().contains("no agent"));
924 }
925
926 #[test]
927 fn extra_keys_round_trip_as_strings() {
928 let tmp = fixture_root();
929 let root = AgentsRoot::at(tmp.path());
930 let agent = root.get("weird").expect("get weird");
931 assert_eq!(
932 agent.extra.get("custom_key").map(String::as_str),
933 Some("custom_value")
934 );
935 }
936
937 #[test]
938 fn split_frontmatter_with_block() {
939 let raw = "---\nname: x\n---\nbody text\n";
940 let (fm, body) = split_frontmatter(raw);
941 assert_eq!(fm, Some("name: x\n"));
942 assert_eq!(body, "body text\n");
943 }
944
945 #[test]
946 fn split_frontmatter_no_block() {
947 let raw = "no frontmatter here\nsecond line\n";
948 let (fm, body) = split_frontmatter(raw);
949 assert_eq!(fm, None);
950 assert_eq!(body, raw);
951 }
952
953 #[test]
954 fn split_frontmatter_open_no_close_returns_full() {
955 let raw = "---\nname: x\nstill no close here\n";
958 let (fm, body) = split_frontmatter(raw);
959 assert_eq!(fm, None);
960 assert_eq!(body, raw);
961 }
962
963 #[test]
970 fn folded_description_with_colons_is_one_value() {
971 let tmp = tempfile::tempdir().expect("tempdir");
972 write_agent(
973 tmp.path(),
974 "auditor",
975 concat!(
976 "---\n",
977 "name: auditor\n",
978 "description: >-\n",
979 " Use when surveying a codebase against a rubric and generating a backlog of\n",
980 " GitHub issues. Read-only: never edits files, opens PRs, or commits. Accepts:\n",
981 " \"audit <domain> in <repo>\", dispatched by dispatcher for audit+remediate shape.\n",
982 "tools: Read, Glob, Grep, Bash\n",
983 "model: sonnet\n",
984 "---\n\nBody.\n",
985 ),
986 );
987 let root = AgentsRoot::at(tmp.path());
988 let agent = root.get("auditor").expect("get");
989 assert_eq!(
990 agent.description.as_deref(),
991 Some(
992 "Use when surveying a codebase against a rubric and generating a backlog of \
993 GitHub issues. Read-only: never edits files, opens PRs, or commits. Accepts: \
994 \"audit <domain> in <repo>\", dispatched by dispatcher for audit+remediate shape."
995 )
996 );
997 assert!(agent.extra.is_empty(), "extra: {:?}", agent.extra);
999 assert_eq!(agent.tools, vec!["Read", "Glob", "Grep", "Bash"]);
1001 assert_eq!(agent.model.as_deref(), Some("sonnet"));
1002 assert_eq!(agent.body, "Body.");
1003 }
1004
1005 #[test]
1006 fn literal_block_preserves_newlines() {
1007 let tmp = tempfile::tempdir().expect("tempdir");
1008 write_agent(
1009 tmp.path(),
1010 "lit",
1011 "---\nname: lit\ndescription: |-\n first line\n second: line\n\n after blank\nmodel: sonnet\n---\nbody\n",
1012 );
1013 let root = AgentsRoot::at(tmp.path());
1014 let agent = root.get("lit").expect("get");
1015 assert_eq!(
1016 agent.description.as_deref(),
1017 Some("first line\nsecond: line\n\nafter blank")
1018 );
1019 assert_eq!(agent.model.as_deref(), Some("sonnet"));
1020 }
1021
1022 #[test]
1023 fn plain_single_line_values_are_unchanged() {
1024 let entries = frontmatter_entries("name: x\ndescription: a: b\nmodel: sonnet\n");
1025 assert_eq!(
1026 entries,
1027 vec![
1028 ("name".to_string(), "x".to_string()),
1029 ("description".to_string(), "a: b".to_string()),
1031 ("model".to_string(), "sonnet".to_string()),
1032 ]
1033 );
1034 }
1035
1036 #[test]
1037 fn values_starting_with_indicator_char_are_not_blocks() {
1038 let entries = frontmatter_entries("description: > plain text\nmodel: sonnet\n");
1040 assert_eq!(
1041 entries,
1042 vec![
1043 ("description".to_string(), "> plain text".to_string()),
1044 ("model".to_string(), "sonnet".to_string()),
1045 ]
1046 );
1047 }
1048
1049 #[test]
1050 fn chomping_controls_trailing_newline() {
1051 let cases = [
1052 (">-", "one two"),
1053 (">", "one two\n"),
1054 (">+", "one two\n\n\n"),
1055 ("|-", "one\ntwo"),
1056 ("|", "one\ntwo\n"),
1057 ("|+", "one\ntwo\n\n\n"),
1058 ];
1059 for (indicator, expected) in cases {
1060 let fm = format!("description: {indicator}\n one\n two\n\n\nmodel: sonnet\n");
1061 let entries = frontmatter_entries(&fm);
1062 assert_eq!(
1063 entries,
1064 vec![
1065 ("description".to_string(), expected.to_string()),
1066 ("model".to_string(), "sonnet".to_string()),
1067 ],
1068 "indicator {indicator:?}"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn folded_block_keeps_more_indented_lines_on_their_own_lines() {
1075 let entries = frontmatter_entries(
1076 "description: >-\n intro line\n indented literal\n tail line\n",
1077 );
1078 assert_eq!(
1079 entries,
1080 vec![(
1081 "description".to_string(),
1082 "intro line\n indented literal\ntail line".to_string()
1083 )]
1084 );
1085 }
1086
1087 #[test]
1088 fn explicit_indentation_indicator_is_honored() {
1089 let entries = frontmatter_entries("description: |4-\n one\n two\n");
1092 assert_eq!(
1093 entries,
1094 vec![("description".to_string(), "one\n two".to_string())]
1095 );
1096 }
1097
1098 #[test]
1099 fn block_scalar_at_end_of_frontmatter() {
1100 let entries = frontmatter_entries("name: x\ndescription: >-\n only value\n");
1101 assert_eq!(
1102 entries,
1103 vec![
1104 ("name".to_string(), "x".to_string()),
1105 ("description".to_string(), "only value".to_string()),
1106 ]
1107 );
1108 }
1109
1110 #[test]
1111 fn empty_block_scalar_yields_empty_value() {
1112 let entries = frontmatter_entries("description: >-\nmodel: sonnet\n");
1113 assert_eq!(
1114 entries,
1115 vec![
1116 ("description".to_string(), String::new()),
1117 ("model".to_string(), "sonnet".to_string()),
1118 ]
1119 );
1120 }
1121
1122 #[test]
1123 fn block_header_trailing_comment_is_ignored() {
1124 let entries = frontmatter_entries("description: >- # why\n folded text\n");
1125 assert_eq!(
1126 entries,
1127 vec![("description".to_string(), "folded text".to_string())]
1128 );
1129 }
1130
1131 #[test]
1132 fn empty_value_keys_dont_overwrite_defaults() {
1133 let tmp = tempfile::tempdir().expect("tempdir");
1134 write_agent(
1135 tmp.path(),
1136 "empty-name",
1137 "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
1138 );
1139 let root = AgentsRoot::at(tmp.path());
1140 let agent = root.get("empty-name").expect("get");
1141 assert_eq!(agent.name, "empty-name");
1142 }
1143
1144 #[test]
1147 fn block_sequence_becomes_comma_joined_value() {
1148 let entries = frontmatter_entries("skills:\n - alpha\n - beta\n - gamma\n");
1149 assert_eq!(
1150 entries,
1151 vec![("skills".to_string(), "alpha, beta, gamma".to_string())]
1152 );
1153 }
1154
1155 #[test]
1156 fn block_sequence_followed_by_another_key() {
1157 let entries = frontmatter_entries("skills:\n - alpha\n - beta\nmodel: sonnet\nname: x\n");
1158 assert_eq!(
1159 entries,
1160 vec![
1161 ("skills".to_string(), "alpha, beta".to_string()),
1162 ("model".to_string(), "sonnet".to_string()),
1163 ("name".to_string(), "x".to_string()),
1164 ]
1165 );
1166 }
1167
1168 #[test]
1169 fn empty_sequence_yields_empty_value() {
1170 let entries = frontmatter_entries("skills:\nmodel: sonnet\n");
1173 assert_eq!(
1174 entries,
1175 vec![
1176 ("skills".to_string(), String::new()),
1177 ("model".to_string(), "sonnet".to_string()),
1178 ]
1179 );
1180 }
1181
1182 #[test]
1183 fn empty_sequence_at_end_of_frontmatter() {
1184 let entries = frontmatter_entries("name: x\nskills:\n");
1185 assert_eq!(
1186 entries,
1187 vec![
1188 ("name".to_string(), "x".to_string()),
1189 ("skills".to_string(), String::new()),
1190 ]
1191 );
1192 }
1193
1194 #[test]
1195 fn sequence_item_containing_colon_stays_one_item() {
1196 let entries = frontmatter_entries("tags:\n - Use when: needed\n - simple\n");
1199 assert_eq!(
1200 entries,
1201 vec![("tags".to_string(), "Use when: needed, simple".to_string())]
1202 );
1203 }
1204
1205 #[test]
1206 fn blank_lines_around_sequence_are_not_swallowed() {
1207 let entries = frontmatter_entries("skills:\n - alpha\n\n - beta\n\nmodel: sonnet\n");
1208 assert_eq!(
1209 entries,
1210 vec![
1211 ("skills".to_string(), "alpha, beta".to_string()),
1212 ("model".to_string(), "sonnet".to_string()),
1213 ]
1214 );
1215 }
1216
1217 #[test]
1218 fn bare_dash_item_is_an_empty_string() {
1219 let entries = frontmatter_entries("skills:\n -\n - beta\n");
1220 assert_eq!(entries, vec![("skills".to_string(), ", beta".to_string())]);
1221 }
1222
1223 #[test]
1224 fn nested_mapping_still_flattens() {
1225 let entries = frontmatter_entries("metadata:\n type: reference\n origin: abc\n");
1229 assert_eq!(
1230 entries,
1231 vec![
1232 ("metadata".to_string(), String::new()),
1233 ("type".to_string(), "reference".to_string()),
1234 ("origin".to_string(), "abc".to_string()),
1235 ]
1236 );
1237 }
1238
1239 #[test]
1240 fn sequence_of_mappings_is_left_to_the_flat_path() {
1241 let entries = frontmatter_entries("hooks:\n - matcher: Bash\n command: fmt\n");
1244 assert_eq!(
1245 entries,
1246 vec![
1247 ("hooks".to_string(), String::new()),
1248 ("- matcher".to_string(), "Bash".to_string()),
1249 ("command".to_string(), "fmt".to_string()),
1250 ]
1251 );
1252 }
1253
1254 #[test]
1255 fn dash_prefixed_scalar_is_not_a_sequence() {
1256 let entries = frontmatter_entries("weird:\n -5\n");
1258 assert_eq!(entries, vec![("weird".to_string(), String::new())]);
1259 }
1260
1261 #[test]
1262 fn split_list_accepts_bare_and_flow_forms() {
1263 assert_eq!(split_list("a, b, c"), vec!["a", "b", "c"]);
1264 assert_eq!(split_list("[a, b, c]"), vec!["a", "b", "c"]);
1265 assert_eq!(split_list("[]"), Vec::<String>::new());
1266 assert_eq!(split_list("solo"), vec!["solo"]);
1267 assert_eq!(split_list("[a"), vec!["[a"]);
1269 }
1270
1271 #[test]
1275 fn agent_skills_block_sequence_parses() {
1276 let tmp = tempfile::tempdir().expect("tempdir");
1277 write_agent(
1278 tmp.path(),
1279 "auditor",
1280 concat!(
1281 "---\n",
1282 "name: auditor\n",
1283 "description: Surveys a codebase against a rubric.\n",
1284 "tools: Read, Glob, Grep, Bash\n",
1285 "model: sonnet\n",
1286 "skills:\n",
1287 " - sandbox-preflight\n",
1288 " - durable-context\n",
1289 " - audit-protocol\n",
1290 "---\n\nYou are the auditor.\n",
1291 ),
1292 );
1293 let root = AgentsRoot::at(tmp.path());
1294 let agent = root.get("auditor").expect("get");
1295 assert_eq!(
1296 agent.skills,
1297 vec!["sandbox-preflight", "durable-context", "audit-protocol"]
1298 );
1299 assert!(agent.extra.is_empty(), "extra: {:?}", agent.extra);
1301 assert_eq!(agent.tools, vec!["Read", "Glob", "Grep", "Bash"]);
1302 assert_eq!(agent.model.as_deref(), Some("sonnet"));
1303 assert_eq!(agent.body, "You are the auditor.");
1304
1305 let summary = root.list().expect("list").into_iter().next().expect("one");
1307 assert_eq!(summary.skills, agent.skills);
1308 }
1309
1310 #[test]
1311 fn agent_skills_accepts_flow_and_scalar_forms() {
1312 let tmp = tempfile::tempdir().expect("tempdir");
1313 write_agent(tmp.path(), "flow", "---\nskills: [a, b]\n---\nbody\n");
1314 write_agent(tmp.path(), "scalar", "---\nskills: a, b\n---\nbody\n");
1315 let root = AgentsRoot::at(tmp.path());
1316 assert_eq!(root.get("flow").expect("get").skills, vec!["a", "b"]);
1317 assert_eq!(root.get("scalar").expect("get").skills, vec!["a", "b"]);
1318 }
1319
1320 #[test]
1321 fn agent_without_skills_has_empty_list() {
1322 let tmp = fixture_root();
1323 let root = AgentsRoot::at(tmp.path());
1324 assert!(root.get("rust-qa").expect("get").skills.is_empty());
1325 }
1326
1327 #[test]
1328 fn skills_round_trip_through_write_as_a_block_sequence() {
1329 let tmp = tempfile::tempdir().expect("tempdir");
1330 let root = AgentsRoot::at(tmp.path());
1331 let input = AgentWriteInput {
1332 name: Some("auditor".into()),
1333 skills: vec!["sandbox-preflight".into(), "durable-context".into()],
1334 body: "b".into(),
1335 ..Default::default()
1336 };
1337 root.write("auditor", input).expect("write");
1338
1339 let raw = fs::read_to_string(tmp.path().join("auditor.md")).expect("read");
1342 assert!(
1343 raw.contains("skills:\n - sandbox-preflight\n - durable-context\n"),
1344 "raw: {raw}"
1345 );
1346 assert_eq!(
1347 root.get("auditor").expect("get").skills,
1348 vec!["sandbox-preflight", "durable-context"]
1349 );
1350 }
1351
1352 fn input_with_body(body: &str) -> AgentWriteInput {
1355 AgentWriteInput {
1356 body: body.into(),
1357 ..Default::default()
1358 }
1359 }
1360
1361 #[test]
1362 fn write_creates_new_agent_round_trips_via_get() {
1363 let tmp = tempfile::tempdir().expect("tempdir");
1364 let root = AgentsRoot::at(tmp.path());
1365 let input = AgentWriteInput {
1366 name: Some("my-agent".into()),
1367 description: Some("does the thing".into()),
1368 tools: vec!["Read".into(), "Bash".into()],
1369 model: Some("sonnet".into()),
1370 skills: vec!["durable-context".into()],
1371 body: "You are an agent.".into(),
1372 extra: BTreeMap::new(),
1373 };
1374 root.write("my-agent", input).expect("write");
1375
1376 let agent = root.get("my-agent").expect("get");
1377 assert_eq!(agent.name, "my-agent");
1378 assert_eq!(agent.description.as_deref(), Some("does the thing"));
1379 assert_eq!(agent.tools, vec!["Read", "Bash"]);
1380 assert_eq!(agent.model.as_deref(), Some("sonnet"));
1381 assert_eq!(agent.body, "You are an agent.");
1382 }
1383
1384 #[test]
1385 fn write_overwrites_existing_agent() {
1386 let tmp = fixture_root();
1387 let root = AgentsRoot::at(tmp.path());
1388 let input = AgentWriteInput {
1390 description: Some("rewritten".into()),
1391 body: "new body".into(),
1392 ..Default::default()
1393 };
1394 root.write("rust-qa", input).expect("overwrite");
1395 let agent = root.get("rust-qa").expect("get");
1396 assert_eq!(agent.description.as_deref(), Some("rewritten"));
1397 assert_eq!(agent.body, "new body");
1398 assert!(agent.tools.is_empty(), "tools: {:?}", agent.tools);
1401 assert!(agent.model.is_none());
1402 }
1403
1404 #[test]
1405 fn write_new_errors_when_already_exists() {
1406 let tmp = fixture_root();
1407 let root = AgentsRoot::at(tmp.path());
1408 let err = root
1409 .write_new("rust-qa", input_with_body("body"))
1410 .unwrap_err();
1411 assert!(err.to_string().contains("already exists"), "err: {err}");
1412 }
1413
1414 #[test]
1415 fn write_new_succeeds_for_fresh_stem() {
1416 let tmp = fixture_root();
1417 let root = AgentsRoot::at(tmp.path());
1418 root.write_new("brand-new", input_with_body("hello"))
1419 .expect("write_new");
1420 let agent = root.get("brand-new").expect("get");
1421 assert_eq!(agent.body, "hello");
1422 }
1423
1424 #[test]
1425 fn write_creates_root_directory_if_missing() {
1426 let tmp = tempfile::tempdir().expect("tempdir");
1427 let root = AgentsRoot::at(tmp.path().join("does-not-exist-yet"));
1428 root.write("foo", input_with_body("body")).expect("write");
1429 let agent = root.get("foo").expect("get");
1430 assert_eq!(agent.body, "body");
1431 }
1432
1433 #[test]
1434 fn write_defaults_name_to_file_stem_when_absent() {
1435 let tmp = tempfile::tempdir().expect("tempdir");
1436 let root = AgentsRoot::at(tmp.path());
1437 root.write("my-stem", input_with_body("b")).expect("write");
1438 let agent = root.get("my-stem").expect("get");
1439 assert_eq!(agent.name, "my-stem");
1440 }
1441
1442 #[test]
1443 fn write_preserves_extra_keys() {
1444 let tmp = tempfile::tempdir().expect("tempdir");
1445 let root = AgentsRoot::at(tmp.path());
1446 let mut extra = BTreeMap::new();
1447 extra.insert("custom_key".into(), "custom_value".into());
1448 let input = AgentWriteInput {
1449 body: "b".into(),
1450 extra,
1451 ..Default::default()
1452 };
1453 root.write("ex", input).expect("write");
1454 let agent = root.get("ex").expect("get");
1455 assert_eq!(
1456 agent.extra.get("custom_key").map(String::as_str),
1457 Some("custom_value")
1458 );
1459 }
1460
1461 #[test]
1462 fn write_omits_optional_keys_when_unset() {
1463 let tmp = tempfile::tempdir().expect("tempdir");
1464 let root = AgentsRoot::at(tmp.path());
1465 root.write("min", input_with_body("body only"))
1466 .expect("write");
1467 let raw = std::fs::read_to_string(tmp.path().join("min.md")).unwrap();
1468 assert!(!raw.contains("description:"), "raw: {raw}");
1469 assert!(!raw.contains("tools:"), "raw: {raw}");
1470 assert!(!raw.contains("model:"), "raw: {raw}");
1471 }
1472
1473 #[test]
1474 fn write_rejects_path_traversal() {
1475 let tmp = tempfile::tempdir().expect("tempdir");
1476 let root = AgentsRoot::at(tmp.path());
1477 for bad in ["", ".", "..", "a/b", "a\\b", "a\0b"] {
1478 let err = root.write(bad, input_with_body("b")).unwrap_err();
1479 assert!(
1480 err.to_string().to_lowercase().contains("file_stem"),
1481 "bad stem {bad:?} not rejected: {err}"
1482 );
1483 }
1484 }
1485
1486 #[test]
1487 fn delete_removes_file() {
1488 let tmp = fixture_root();
1489 let root = AgentsRoot::at(tmp.path());
1490 assert!(root.get("rust-qa").is_ok());
1491 root.delete("rust-qa").expect("delete");
1492 let err = root.get("rust-qa").unwrap_err();
1493 assert!(err.to_string().contains("no agent"), "err: {err}");
1494 }
1495
1496 #[test]
1497 fn delete_unknown_stem_errors() {
1498 let tmp = fixture_root();
1499 let root = AgentsRoot::at(tmp.path());
1500 let err = root.delete("nope").unwrap_err();
1501 assert!(err.to_string().contains("no agent"), "err: {err}");
1502 }
1503
1504 #[test]
1505 fn delete_rejects_path_traversal() {
1506 let tmp = fixture_root();
1507 let root = AgentsRoot::at(tmp.path());
1508 for bad in ["", ".", "..", "a/b", "a\\b"] {
1509 let err = root.delete(bad).unwrap_err();
1510 assert!(
1511 err.to_string().to_lowercase().contains("file_stem"),
1512 "bad stem {bad:?} not rejected: {err}"
1513 );
1514 }
1515 }
1516
1517 #[test]
1518 fn write_round_trips_multi_line_description() {
1519 let tmp = tempfile::tempdir().expect("tempdir");
1520 let root = AgentsRoot::at(tmp.path());
1521 let desc = "first line\nsecond: line\n\nafter blank";
1524 let input = AgentWriteInput {
1525 description: Some(desc.into()),
1526 model: Some("sonnet".into()),
1527 body: "b".into(),
1528 ..Default::default()
1529 };
1530 root.write("multi", input).expect("write");
1531
1532 let raw = std::fs::read_to_string(tmp.path().join("multi.md")).expect("read");
1533 assert!(raw.contains("description: |-\n"), "raw: {raw}");
1534
1535 let agent = root.get("multi").expect("get");
1536 assert_eq!(agent.description.as_deref(), Some(desc));
1537 assert_eq!(agent.model.as_deref(), Some("sonnet"));
1538 assert!(agent.extra.is_empty(), "extra: {:?}", agent.extra);
1539 }
1540
1541 #[test]
1542 fn write_round_trips_trailing_newlines_in_description() {
1543 let tmp = tempfile::tempdir().expect("tempdir");
1544 let root = AgentsRoot::at(tmp.path());
1545 for desc in ["a\nb", "a\nb\n", "a\nb\n\n\n"] {
1546 let input = AgentWriteInput {
1547 description: Some(desc.into()),
1548 body: "b".into(),
1549 ..Default::default()
1550 };
1551 root.write("chomp", input).expect("write");
1552 let agent = root.get("chomp").expect("get");
1553 assert_eq!(agent.description.as_deref(), Some(desc), "desc {desc:?}");
1554 }
1555 }
1556
1557 #[test]
1558 fn render_orders_canonical_keys_before_extras() {
1559 let mut extra = BTreeMap::new();
1560 extra.insert("zzz_last".into(), "v".into());
1561 extra.insert("aaa_first".into(), "v".into());
1562 let input = AgentWriteInput {
1563 name: Some("n".into()),
1564 description: Some("d".into()),
1565 tools: vec!["t1".into(), "t2".into()],
1566 model: Some("haiku".into()),
1567 skills: vec!["s1".into(), "s2".into()],
1568 body: "body".into(),
1569 extra,
1570 };
1571 let md = render_agent_markdown("stem", &input);
1572 let lines: Vec<&str> = md.lines().collect();
1573 assert_eq!(lines[0], "---");
1575 assert_eq!(lines[1], "name: n");
1578 assert_eq!(lines[2], "description: d");
1579 assert_eq!(lines[3], "tools: t1, t2");
1580 assert_eq!(lines[4], "model: haiku");
1581 assert_eq!(lines[5], "skills:");
1582 assert_eq!(lines[6], " - s1");
1583 assert_eq!(lines[7], " - s2");
1584 assert_eq!(lines[8], "aaa_first: v");
1585 assert_eq!(lines[9], "zzz_last: v");
1586 assert_eq!(lines[10], "---");
1587 }
1588}