Skip to main content

claude_wrapper/
artifacts.rs

1//! Read-side access to Claude Code's on-disk **agent** definitions.
2//!
3//! Claude Code resolves user-level agents from
4//! `~/.claude/agents/<name>.md`. Each file is plain markdown with a
5//! YAML-style frontmatter block delimited by `---` lines. The
6//! frontmatter carries the agent's metadata (name, description,
7//! optional tool allow-list, optional model); the body is the agent's
8//! system prompt.
9//!
10//! This module is read-only on purpose -- mutations (create / update
11//! / delete) are tracked separately so consumers that only want to
12//! introspect the agent set don't need to opt into write semantics.
13//!
14//! Two levels of granularity:
15//!
16//! - [`AgentsRoot::list`] -- enumerate every agent at the root with
17//!   summary metadata (name, description, tools, model, file path).
18//! - [`AgentsRoot::get`] -- read one agent's full record including
19//!   the prompt body.
20//!
21//! # Frontmatter format
22//!
23//! Real-world agents look like:
24//!
25//! ```text
26//! ---
27//! name: rust-qa
28//! description: Use PROACTIVELY before declaring Rust work done...
29//! tools: Read, Grep, Glob, Bash
30//! model: sonnet
31//! ---
32//!
33//! You are a Rust quality gate. ...
34//! ```
35//!
36//! The parser is permissive: only `name`, `description`, `tools`,
37//! `model`, and `skills` are typed. `tools` is a comma-separated
38//! list; `skills` is usually a YAML block sequence:
39//!
40//! ```text
41//! skills:
42//!   - sandbox-preflight
43//!   - durable-context
44//! ```
45//!
46//! Any other `key: value` pairs land in [`Agent::extra`] so unknown
47//! future keys survive a round trip. Frontmatter is optional -- a
48//! body-only file parses fine, with `name` defaulting to the file
49//! stem.
50//!
51//! Sequences under keys other than `skills` reach [`Agent::extra`]
52//! joined by `", "`, since `extra` holds raw strings. Writing such an
53//! agent back out renders them comma-joined on one line rather than
54//! as a block sequence.
55//!
56//! YAML block scalars are supported for any key, which is how
57//! multi-line descriptions are usually written:
58//!
59//! ```text
60//! ---
61//! name: auditor
62//! description: >-
63//!   Use when surveying a codebase against a rubric. Read-only:
64//!   never edits files, opens PRs, or commits.
65//! ---
66//! ```
67//!
68//! `>` folds the block into one line (blank lines become newlines),
69//! `|` preserves the line breaks, and the chomping indicator
70//! (`-` / none / `+`) controls the trailing newline. Continuation
71//! lines are part of the value even when they contain a colon.
72//!
73//! # Example
74//!
75//! ```no_run
76//! use claude_wrapper::artifacts::AgentsRoot;
77//!
78//! # fn example() -> claude_wrapper::Result<()> {
79//! let root = AgentsRoot::home()?;
80//! for summary in root.list()? {
81//!     println!("{}: {}", summary.name, summary.description.as_deref().unwrap_or(""));
82//! }
83//! let agent = root.get("rust-qa")?;
84//! println!("{}", agent.body);
85//! # Ok(()) }
86//! ```
87//!
88//! # Slug, name, file stem
89//!
90//! By convention an agent's `name` matches its filename stem:
91//! `rust-qa.md` carries `name: rust-qa`. The two can diverge -- the
92//! parser keeps both. [`AgentsRoot::get`] looks up by file stem
93//! (because that's what the filesystem indexes), not by the
94//! frontmatter `name`.
95
96use std::collections::BTreeMap;
97use std::fs;
98use std::path::{Path, PathBuf};
99
100use serde::Serialize;
101
102use crate::error::{Error, Result};
103
104/// Root directory of Claude Code's user-level agent definitions.
105/// Defaults to `~/.claude/agents`; override with [`AgentsRoot::at`]
106/// for tests or non-default installs.
107#[derive(Debug, Clone)]
108pub struct AgentsRoot {
109    path: PathBuf,
110}
111
112impl AgentsRoot {
113    /// Resolve the default `~/.claude/agents`. Errors if `$HOME`
114    /// (or the platform-specific user home) cannot be determined.
115    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    /// Use a specific path as the agents root. Useful for tests
125    /// (point at a tempdir) and for non-default installs.
126    pub fn at(path: impl Into<PathBuf>) -> Self {
127        Self { path: path.into() }
128    }
129
130    /// The configured root directory.
131    pub fn path(&self) -> &Path {
132        &self.path
133    }
134
135    /// List every `*.md` agent at the root, sorted by file stem.
136    ///
137    /// Returns an empty vec if the root directory doesn't exist (a
138    /// fresh Claude Code install with no user agents). Files that
139    /// fail to parse contribute a tracing warning and are skipped
140    /// rather than failing the whole listing.
141    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    /// Read one agent by file stem (i.e. the basename of `<stem>.md`
168    /// under the root). Errors if no such file exists or it fails
169    /// to parse.
170    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    /// Write (create or overwrite) an agent at `<file_stem>.md`.
181    ///
182    /// Atomic: writes to a temp file in the same directory and
183    /// renames into place, so a crash mid-write can't leave a
184    /// partially-written file. Creates the agents root directory
185    /// if it doesn't exist.
186    ///
187    /// `file_stem` is validated for path traversal and reserved
188    /// names (empty, `.`, `..`, embedded slashes / NUL bytes).
189    /// To fail when the agent already exists instead of overwriting,
190    /// use [`Self::write_new`].
191    pub fn write(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
192        self.write_inner(file_stem, input, true)
193    }
194
195    /// Like [`Self::write`] but errors if the agent already exists.
196    /// Useful for "create only" flows where overwriting an existing
197    /// agent would be a bug.
198    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        // Atomic write: tempfile in same dir, then rename. Same-dir
220        // tempfile keeps the rename a single inode operation on most
221        // filesystems.
222        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            // Best-effort cleanup; the rename failure is the real error.
226            let _ = fs::remove_file(&tmp);
227            return Err(e.into());
228        }
229        Ok(())
230    }
231
232    /// Remove the `<file_stem>.md` agent. Errors if no such file
233    /// exists.
234    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/// Input to [`AgentsRoot::write`] / [`AgentsRoot::write_new`].
248///
249/// Mirrors the parsed [`Agent`] minus the derived bits
250/// (`file_stem` and `file_path` are determined by where the agent
251/// is being written). `body` is required; everything else is
252/// optional and omitted from the rendered frontmatter when empty.
253#[derive(Debug, Clone, Default)]
254pub struct AgentWriteInput {
255    /// Frontmatter `name`. Defaults to the `file_stem` argument
256    /// when absent.
257    pub name: Option<String>,
258    /// Frontmatter `description`. Omitted when None.
259    pub description: Option<String>,
260    /// Frontmatter `tools` as a list; rendered comma-joined.
261    /// Empty list omits the key entirely.
262    pub tools: Vec<String>,
263    /// Frontmatter `model`. Omitted when None.
264    pub model: Option<String>,
265    /// Frontmatter `skills` as a list; rendered as a YAML block
266    /// sequence. Empty list omits the key entirely.
267    pub skills: Vec<String>,
268    /// Body of the agent prompt. Trimmed of surrounding whitespace
269    /// before write.
270    pub body: String,
271    /// Additional frontmatter key/value pairs preserved verbatim.
272    /// Iterated in sorted order for deterministic output.
273    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    // Rendered as a block sequence rather than comma-joined: YAML
290    // reads `skills: a, b` as a plain scalar, not a list.
291    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
306/// Render one frontmatter field.
307///
308/// Single-line values are written as plain `key: value`. Multi-line
309/// values go out as a literal block scalar, with the chomping
310/// indicator chosen to preserve the exact trailing newlines: a bare
311/// `key: value` would leave the continuation lines at the top level,
312/// where the reader takes them for new keys.
313fn 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/// Lightweight metadata for one agent, returned by
356/// [`AgentsRoot::list`]. Strips the body to keep listings cheap.
357#[derive(Debug, Clone, Serialize)]
358pub struct AgentSummary {
359    /// Filename stem (`<stem>.md`). The canonical handle for lookup.
360    pub file_stem: String,
361    /// Frontmatter `name` if present; falls back to `file_stem`.
362    pub name: String,
363    /// Frontmatter `description` if present.
364    pub description: Option<String>,
365    /// Frontmatter `tools` parsed as a comma-separated list.
366    pub tools: Vec<String>,
367    /// Frontmatter `model` if present.
368    pub model: Option<String>,
369    /// Frontmatter `skills` parsed as a list.
370    pub skills: Vec<String>,
371    /// Absolute path to the source file.
372    pub file_path: PathBuf,
373    /// File size in bytes; useful for cheap UI hints.
374    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/// Full agent record returned by [`AgentsRoot::get`].
396#[derive(Debug, Clone, Serialize)]
397pub struct Agent {
398    /// Filename stem (`<stem>.md`). The canonical handle for lookup.
399    pub file_stem: String,
400    /// Frontmatter `name` if present; falls back to `file_stem`.
401    pub name: String,
402    /// Frontmatter `description` if present.
403    pub description: Option<String>,
404    /// Frontmatter `tools` parsed as a comma-separated list.
405    pub tools: Vec<String>,
406    /// Frontmatter `model` if present.
407    pub model: Option<String>,
408    /// Frontmatter `skills` parsed as a list. Accepts a YAML block
409    /// sequence, a flow sequence, or a comma-separated scalar.
410    pub skills: Vec<String>,
411    /// Absolute path to the source file.
412    pub file_path: PathBuf,
413    /// Markdown body after the frontmatter block (trimmed of
414    /// leading/trailing blank lines).
415    pub body: String,
416    /// Frontmatter keys other than the typed ones. Preserves
417    /// unknown future fields verbatim as raw strings.
418    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
460/// Parse a frontmatter block into ordered `(key, value)` pairs.
461///
462/// Shared by the agent, skill, and command readers so all three
463/// artifact types accept the same frontmatter shapes.
464///
465/// The parser stays permissive and flat: every `key: value` line at
466/// any indentation becomes an entry, and lines without a colon are
467/// skipped. Duplicate keys are returned in file order, so callers
468/// that fold into a map get last-wins.
469///
470/// It understands two structural features.
471///
472/// **Block scalars.** A value of `>`, `>-`, `>+`, `|`, `|-`, or `|+`
473/// (with an optional explicit indentation digit and an optional
474/// trailing comment) consumes the indented block that follows:
475///
476/// - `>` folds line breaks into spaces; a blank line becomes a
477///   newline, and more-indented lines keep their breaks.
478/// - `|` preserves line breaks verbatim.
479/// - The chomping indicator sets the trailing newline: `-` strips
480///   it, the default clips to one, `+` keeps every one.
481///
482/// Without this, a folded `description` yields the indicator itself
483/// as the value and its continuation lines leak out as bogus keys
484/// (any line containing a colon) or vanish (any line without one).
485///
486/// **Block sequences.** An empty value followed by more-indented
487/// `- item` lines yields those items joined by `", "`, matching the
488/// comma-separated form `tools:` already uses. So
489///
490/// ```text
491/// skills:
492///   - sandbox-preflight
493///   - durable-context
494/// ```
495///
496/// becomes `("skills", "sandbox-preflight, durable-context")`.
497/// Without this the key yields an empty value and the items vanish
498/// (no colon to split on) or, if an item contains a colon, leak out
499/// as a bogus `- item` key. Use [`split_list`] to get the items back
500/// as a `Vec`.
501///
502/// Nested mappings are deliberately *not* structural: they keep
503/// flattening into bare keys, which [`crate::memory`] depends on to
504/// read `type:` out of a `metadata:` block.
505pub(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            // An empty value may be the head of a block sequence.
531            // `read_block_sequence` returns None for anything else
532            // (a nested mapping, a genuinely empty value), leaving
533            // those lines to the flat path exactly as before.
534            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
547/// Read the block sequence that follows a key with an empty value.
548///
549/// `lines` starts at the line after the key. Returns the item texts
550/// and how many lines they span, or `None` when the block isn't a
551/// plain sequence.
552///
553/// Every non-blank line in the block must be a `- item` entry. That
554/// rules out nested mappings (`metadata:` followed by `type: x`),
555/// which [`crate::memory`] relies on the flat path flattening, and
556/// item bodies that continue onto their own lines (`- matcher: Bash`
557/// followed by an indented `command:`). Both keep their existing
558/// behavior rather than being half-parsed here.
559fn 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    // Trailing blank lines belong to whatever follows, so the
565    // sequence ends at its last non-blank line. No non-blank line
566    // means no sequence.
567    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        // `-` must be followed by whitespace (or end the line) to be
579        // an item marker; `-foo` is a scalar that happens to start
580        // with a dash.
581        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
590/// Split a comma-separated frontmatter list value.
591///
592/// Accepts both spellings Claude Code frontmatter uses for these
593/// keys: the bare form (`Read, Grep`) and the YAML flow sequence
594/// (`[Read, Grep]`). Block sequences arrive here already joined by
595/// [`frontmatter_entries`]. Empty items are dropped.
596pub(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/// How a block scalar treats trailing line breaks.
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610enum Chomp {
611    /// `-`: drop the trailing line break entirely.
612    Strip,
613    /// Default: keep exactly one trailing line break.
614    Clip,
615    /// `+`: keep every trailing line break.
616    Keep,
617}
618
619/// A parsed block-scalar header (`>` / `|` plus modifiers).
620#[derive(Debug, Clone, Copy)]
621struct BlockHeader {
622    /// `|` preserves line breaks; `>` folds them into spaces.
623    literal: bool,
624    chomp: Chomp,
625    /// Explicit indentation indicator, relative to the key's indent.
626    indent: Option<usize>,
627}
628
629/// Recognize a block-scalar header. Returns `None` for anything that
630/// isn't one, so plain values (including a value that merely starts
631/// with `>`) fall through to the flat path unchanged.
632fn parse_block_header(rest: &str) -> Option<BlockHeader> {
633    // A header is the indicator plus modifiers, optionally followed
634    // by whitespace and a `#` comment. Anything else is a plain value.
635    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
667/// Read the block that follows a block-scalar header.
668///
669/// `lines` starts at the line after the header. Returns the scalar
670/// value and how many lines it consumed. The block is every
671/// following line that is blank or indented deeper than
672/// `parent_indent` (the indentation of the key line itself).
673fn 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    // Content indentation: the explicit indicator if given, else the
681    // indentation of the first non-blank line.
682    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    // Trailing blank lines are the chomping tail, not content.
696    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
732/// Fold a `>` block: line breaks between plain lines become spaces,
733/// blank lines become newlines, and breaks adjacent to a
734/// more-indented line stay newlines.
735fn 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
763/// Width of a line's leading whitespace. Spaces and tabs are one
764/// column each; YAML forbids tabs in indentation anyway.
765fn indent_width(line: &str) -> usize {
766    line.len() - line.trim_start_matches([' ', '\t']).len()
767}
768
769/// Split a markdown file into (optional frontmatter body, content
770/// after the frontmatter). Frontmatter is delimited by a leading
771/// `---` line and a closing `---` line. Anything else returns
772/// `(None, full_text)`.
773pub(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        // A file with an unknown extra key should round-trip.
840        write_agent(
841            tmp.path(),
842            "weird",
843            "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
844        );
845        // Non-md file should be ignored by list().
846        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        // An opening --- with no matching close shouldn't swallow
956        // the file. Conservative behavior: treat as no frontmatter.
957        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    // -- block scalars -------------------------------------------------
964
965    /// The exact shape that motivated block-scalar support: a folded
966    /// description whose continuation lines contain colons. Before,
967    /// the value was the `>-` indicator itself, `Read-only` leaked
968    /// into `extra`, and the colon-free line was dropped.
969    #[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        // Continuation lines must not leak out as keys.
998        assert!(agent.extra.is_empty(), "extra: {:?}", agent.extra);
999        // Keys after the block still parse.
1000        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                // Only the first colon splits; the rest is the value.
1030                ("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        // `> not an indicator` is a plain value, not a block header.
1039        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        // `|4` sets the content indent explicitly, so the two extra
1090        // spaces on the second line are part of the value.
1091        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    // -- block sequences -----------------------------------------------
1145
1146    #[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        // A key with nothing indented under it is YAML null, not a
1171        // sequence. It keeps the pre-existing empty-value behavior.
1172        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        // Without sequence support this line splits on the colon and
1197        // leaks out as a bogus `- Use when` key.
1198        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        // `crate::memory` reads `type:` out of a `metadata:` block by
1226        // relying on this flattening, so a nested mapping must not be
1227        // mistaken for a sequence.
1228        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        // The second line isn't a `- item`, so the block isn't a plain
1242        // sequence and keeps its previous (flat) parse.
1243        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        // `-5` is a value, not an item marker.
1257        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        // Brackets must be balanced to be stripped.
1268        assert_eq!(split_list("[a"), vec!["[a"]);
1269    }
1270
1271    /// The exact shape that motivated block-sequence support: the real
1272    /// `~/.claude/agents/auditor.md`. Before, `skills` landed in
1273    /// `extra` as an empty string and the three items were dropped.
1274    #[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        // The key must not also land in extra.
1300        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        // list() carries skills too.
1306        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        // Rendered as a real YAML sequence, not `skills: a, b` (which
1340        // YAML would read as a plain scalar).
1341        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    // -- write / write_new / delete -----------------------------------
1353
1354    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        // rust-qa exists in the fixture.
1389        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        // tools/model from the original should be gone -- write
1399        // replaces the whole file.
1400        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        // A description with embedded newlines and a colon: written
1522        // as a plain `key: value` it would corrupt the frontmatter.
1523        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        // Header
1574        assert_eq!(lines[0], "---");
1575        // Canonical order: name, description, tools, model, skills,
1576        // then sorted extras.
1577        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}