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`, and
37//! `model` are typed. `tools` is a comma-separated list. Any other
38//! `key: value` pairs land in [`Agent::extra`] so unknown future keys
39//! survive a round trip. Frontmatter is optional -- a body-only file
40//! parses fine, with `name` defaulting to the file stem.
41//!
42//! # Example
43//!
44//! ```no_run
45//! use claude_wrapper::artifacts::AgentsRoot;
46//!
47//! # fn example() -> claude_wrapper::Result<()> {
48//! let root = AgentsRoot::home()?;
49//! for summary in root.list()? {
50//!     println!("{}: {}", summary.name, summary.description.as_deref().unwrap_or(""));
51//! }
52//! let agent = root.get("rust-qa")?;
53//! println!("{}", agent.body);
54//! # Ok(()) }
55//! ```
56//!
57//! # Slug, name, file stem
58//!
59//! By convention an agent's `name` matches its filename stem:
60//! `rust-qa.md` carries `name: rust-qa`. The two can diverge -- the
61//! parser keeps both. [`AgentsRoot::get`] looks up by file stem
62//! (because that's what the filesystem indexes), not by the
63//! frontmatter `name`.
64
65use std::collections::BTreeMap;
66use std::fs;
67use std::path::{Path, PathBuf};
68
69use serde::Serialize;
70
71use crate::error::{Error, Result};
72
73/// Root directory of Claude Code's user-level agent definitions.
74/// Defaults to `~/.claude/agents`; override with [`AgentsRoot::at`]
75/// for tests or non-default installs.
76#[derive(Debug, Clone)]
77pub struct AgentsRoot {
78    path: PathBuf,
79}
80
81impl AgentsRoot {
82    /// Resolve the default `~/.claude/agents`. Errors if `$HOME`
83    /// (or the platform-specific user home) cannot be determined.
84    pub fn home() -> Result<Self> {
85        let home = home_dir().ok_or_else(|| Error::Artifacts {
86            message: "could not determine user home directory".to_string(),
87        })?;
88        Ok(Self {
89            path: home.join(".claude").join("agents"),
90        })
91    }
92
93    /// Use a specific path as the agents root. Useful for tests
94    /// (point at a tempdir) and for non-default installs.
95    pub fn at(path: impl Into<PathBuf>) -> Self {
96        Self { path: path.into() }
97    }
98
99    /// The configured root directory.
100    pub fn path(&self) -> &Path {
101        &self.path
102    }
103
104    /// List every `*.md` agent at the root, sorted by file stem.
105    ///
106    /// Returns an empty vec if the root directory doesn't exist (a
107    /// fresh Claude Code install with no user agents). Files that
108    /// fail to parse contribute a tracing warning and are skipped
109    /// rather than failing the whole listing.
110    pub fn list(&self) -> Result<Vec<AgentSummary>> {
111        let entries = match fs::read_dir(&self.path) {
112            Ok(it) => it,
113            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
114            Err(e) => return Err(e.into()),
115        };
116
117        let mut out = Vec::new();
118        for entry in entries.flatten() {
119            let path = entry.path();
120            if path.extension().and_then(|s| s.to_str()) != Some("md") {
121                continue;
122            }
123            let stem = match path.file_stem().and_then(|s| s.to_str()) {
124                Some(s) => s.to_string(),
125                None => continue,
126            };
127            match parse_agent_file(&path, &stem) {
128                Ok(agent) => out.push(AgentSummary::from_agent(&agent)),
129                Err(e) => tracing::warn!(?path, "skipping agent: {e}"),
130            }
131        }
132        out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
133        Ok(out)
134    }
135
136    /// Read one agent by file stem (i.e. the basename of `<stem>.md`
137    /// under the root). Errors if no such file exists or it fails
138    /// to parse.
139    pub fn get(&self, file_stem: &str) -> Result<Agent> {
140        let path = self.path.join(format!("{file_stem}.md"));
141        if !path.exists() {
142            return Err(Error::Artifacts {
143                message: format!("no agent at {}", path.display()),
144            });
145        }
146        parse_agent_file(&path, file_stem)
147    }
148
149    /// Write (create or overwrite) an agent at `<file_stem>.md`.
150    ///
151    /// Atomic: writes to a temp file in the same directory and
152    /// renames into place, so a crash mid-write can't leave a
153    /// partially-written file. Creates the agents root directory
154    /// if it doesn't exist.
155    ///
156    /// `file_stem` is validated for path traversal and reserved
157    /// names (empty, `.`, `..`, embedded slashes / NUL bytes).
158    /// To fail when the agent already exists instead of overwriting,
159    /// use [`Self::write_new`].
160    pub fn write(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
161        self.write_inner(file_stem, input, true)
162    }
163
164    /// Like [`Self::write`] but errors if the agent already exists.
165    /// Useful for "create only" flows where overwriting an existing
166    /// agent would be a bug.
167    pub fn write_new(&self, file_stem: &str, input: AgentWriteInput) -> Result<()> {
168        self.write_inner(file_stem, input, false)
169    }
170
171    fn write_inner(
172        &self,
173        file_stem: &str,
174        input: AgentWriteInput,
175        allow_overwrite: bool,
176    ) -> Result<()> {
177        validate_stem(file_stem)?;
178        fs::create_dir_all(&self.path)?;
179        let path = self.path.join(format!("{file_stem}.md"));
180        if !allow_overwrite && path.exists() {
181            return Err(Error::Artifacts {
182                message: format!("agent already exists at {}", path.display()),
183            });
184        }
185
186        let markdown = render_agent_markdown(file_stem, &input);
187
188        // Atomic write: tempfile in same dir, then rename. Same-dir
189        // tempfile keeps the rename a single inode operation on most
190        // filesystems.
191        let tmp = self.path.join(format!(".{file_stem}.md.tmp"));
192        fs::write(&tmp, markdown)?;
193        if let Err(e) = fs::rename(&tmp, &path) {
194            // Best-effort cleanup; the rename failure is the real error.
195            let _ = fs::remove_file(&tmp);
196            return Err(e.into());
197        }
198        Ok(())
199    }
200
201    /// Remove the `<file_stem>.md` agent. Errors if no such file
202    /// exists.
203    pub fn delete(&self, file_stem: &str) -> Result<()> {
204        validate_stem(file_stem)?;
205        let path = self.path.join(format!("{file_stem}.md"));
206        if !path.exists() {
207            return Err(Error::Artifacts {
208                message: format!("no agent at {}", path.display()),
209            });
210        }
211        fs::remove_file(&path)?;
212        Ok(())
213    }
214}
215
216/// Input to [`AgentsRoot::write`] / [`AgentsRoot::write_new`].
217///
218/// Mirrors the parsed [`Agent`] minus the derived bits
219/// (`file_stem` and `file_path` are determined by where the agent
220/// is being written). `body` is required; everything else is
221/// optional and omitted from the rendered frontmatter when empty.
222#[derive(Debug, Clone, Default)]
223pub struct AgentWriteInput {
224    /// Frontmatter `name`. Defaults to the `file_stem` argument
225    /// when absent.
226    pub name: Option<String>,
227    /// Frontmatter `description`. Omitted when None.
228    pub description: Option<String>,
229    /// Frontmatter `tools` as a list; rendered comma-joined.
230    /// Empty list omits the key entirely.
231    pub tools: Vec<String>,
232    /// Frontmatter `model`. Omitted when None.
233    pub model: Option<String>,
234    /// Body of the agent prompt. Trimmed of surrounding whitespace
235    /// before write.
236    pub body: String,
237    /// Additional frontmatter key/value pairs preserved verbatim.
238    /// Iterated in sorted order for deterministic output.
239    pub extra: BTreeMap<String, String>,
240}
241
242fn render_agent_markdown(file_stem: &str, input: &AgentWriteInput) -> String {
243    let name = input.name.as_deref().unwrap_or(file_stem);
244    let mut out = String::from("---\n");
245    out.push_str(&format!("name: {name}\n"));
246    if let Some(desc) = &input.description {
247        out.push_str(&format!("description: {desc}\n"));
248    }
249    if !input.tools.is_empty() {
250        out.push_str(&format!("tools: {}\n", input.tools.join(", ")));
251    }
252    if let Some(model) = &input.model {
253        out.push_str(&format!("model: {model}\n"));
254    }
255    for (k, v) in &input.extra {
256        out.push_str(&format!("{k}: {v}\n"));
257    }
258    out.push_str("---\n\n");
259    out.push_str(input.body.trim());
260    out.push('\n');
261    out
262}
263
264fn validate_stem(stem: &str) -> Result<()> {
265    if stem.is_empty() {
266        return Err(Error::Artifacts {
267            message: "file_stem cannot be empty".into(),
268        });
269    }
270    if stem == "." || stem == ".." {
271        return Err(Error::Artifacts {
272            message: format!("file_stem cannot be {stem:?}"),
273        });
274    }
275    if stem.contains('/') || stem.contains('\\') || stem.contains('\0') {
276        return Err(Error::Artifacts {
277            message: format!("file_stem contains invalid characters: {stem:?}"),
278        });
279    }
280    Ok(())
281}
282
283/// Lightweight metadata for one agent, returned by
284/// [`AgentsRoot::list`]. Strips the body to keep listings cheap.
285#[derive(Debug, Clone, Serialize)]
286pub struct AgentSummary {
287    /// Filename stem (`<stem>.md`). The canonical handle for lookup.
288    pub file_stem: String,
289    /// Frontmatter `name` if present; falls back to `file_stem`.
290    pub name: String,
291    /// Frontmatter `description` if present.
292    pub description: Option<String>,
293    /// Frontmatter `tools` parsed as a comma-separated list.
294    pub tools: Vec<String>,
295    /// Frontmatter `model` if present.
296    pub model: Option<String>,
297    /// Absolute path to the source file.
298    pub file_path: PathBuf,
299    /// File size in bytes; useful for cheap UI hints.
300    pub size_bytes: u64,
301}
302
303impl AgentSummary {
304    fn from_agent(a: &Agent) -> Self {
305        let size_bytes = fs::metadata(&a.file_path)
306            .map(|m| m.len())
307            .unwrap_or_default();
308        Self {
309            file_stem: a.file_stem.clone(),
310            name: a.name.clone(),
311            description: a.description.clone(),
312            tools: a.tools.clone(),
313            model: a.model.clone(),
314            file_path: a.file_path.clone(),
315            size_bytes,
316        }
317    }
318}
319
320/// Full agent record returned by [`AgentsRoot::get`].
321#[derive(Debug, Clone, Serialize)]
322pub struct Agent {
323    /// Filename stem (`<stem>.md`). The canonical handle for lookup.
324    pub file_stem: String,
325    /// Frontmatter `name` if present; falls back to `file_stem`.
326    pub name: String,
327    /// Frontmatter `description` if present.
328    pub description: Option<String>,
329    /// Frontmatter `tools` parsed as a comma-separated list.
330    pub tools: Vec<String>,
331    /// Frontmatter `model` if present.
332    pub model: Option<String>,
333    /// Absolute path to the source file.
334    pub file_path: PathBuf,
335    /// Markdown body after the frontmatter block (trimmed of
336    /// leading/trailing blank lines).
337    pub body: String,
338    /// Frontmatter keys other than the typed ones. Preserves
339    /// unknown future fields verbatim as raw strings.
340    pub extra: BTreeMap<String, String>,
341}
342
343fn parse_agent_file(path: &Path, file_stem: &str) -> Result<Agent> {
344    let raw = fs::read_to_string(path)?;
345    let (frontmatter, body) = split_frontmatter(&raw);
346
347    let mut name = file_stem.to_string();
348    let mut description = None;
349    let mut tools = Vec::new();
350    let mut model = None;
351    let mut extra = BTreeMap::new();
352
353    if let Some(fm) = frontmatter {
354        for line in fm.lines() {
355            let trimmed = line.trim();
356            if trimmed.is_empty() {
357                continue;
358            }
359            let Some((k, v)) = trimmed.split_once(':') else {
360                continue;
361            };
362            let key = k.trim();
363            let value = v.trim().to_string();
364            match key {
365                "name" if !value.is_empty() => name = value,
366                "description" if !value.is_empty() => description = Some(value),
367                "tools" if !value.is_empty() => {
368                    tools = value
369                        .split(',')
370                        .map(|t| t.trim().to_string())
371                        .filter(|t| !t.is_empty())
372                        .collect();
373                }
374                "model" if !value.is_empty() => model = Some(value),
375                _ if !key.is_empty() => {
376                    extra.insert(key.to_string(), value);
377                }
378                _ => {}
379            }
380        }
381    }
382
383    Ok(Agent {
384        file_stem: file_stem.to_string(),
385        name,
386        description,
387        tools,
388        model,
389        file_path: path.to_path_buf(),
390        body: body.trim().to_string(),
391        extra,
392    })
393}
394
395/// Split a markdown file into (optional frontmatter body, content
396/// after the frontmatter). Frontmatter is delimited by a leading
397/// `---` line and a closing `---` line. Anything else returns
398/// `(None, full_text)`.
399pub(crate) fn split_frontmatter(raw: &str) -> (Option<&str>, &str) {
400    let mut lines = raw.split_inclusive('\n');
401    let Some(first) = lines.next() else {
402        return (None, raw);
403    };
404    if first.trim_end_matches(['\n', '\r']) != "---" {
405        return (None, raw);
406    }
407    let after_first = first.len();
408    let mut cursor = after_first;
409    for line in lines {
410        let len = line.len();
411        if line.trim_end_matches(['\n', '\r']) == "---" {
412            let fm = &raw[after_first..cursor];
413            let body_start = cursor + len;
414            let body = &raw[body_start..];
415            return (Some(fm), body);
416        }
417        cursor += len;
418    }
419    (None, raw)
420}
421
422fn home_dir() -> Option<PathBuf> {
423    if let Ok(h) = std::env::var("HOME")
424        && !h.is_empty()
425    {
426        return Some(PathBuf::from(h));
427    }
428    if let Ok(h) = std::env::var("USERPROFILE")
429        && !h.is_empty()
430    {
431        return Some(PathBuf::from(h));
432    }
433    None
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use std::io::Write;
440
441    fn write_agent(dir: &Path, file_stem: &str, contents: &str) -> PathBuf {
442        let path = dir.join(format!("{file_stem}.md"));
443        let mut f = fs::File::create(&path).expect("create md");
444        f.write_all(contents.as_bytes()).expect("write md");
445        path
446    }
447
448    fn fixture_root() -> tempfile::TempDir {
449        let tmp = tempfile::tempdir().expect("tempdir");
450        write_agent(
451            tmp.path(),
452            "rust-qa",
453            "---\nname: rust-qa\ndescription: Rust quality gate\ntools: Read, Grep, Bash\nmodel: sonnet\n---\n\nYou are a Rust quality gate.\n",
454        );
455        write_agent(
456            tmp.path(),
457            "no-frontmatter",
458            "Just a body, no frontmatter at all.\n",
459        );
460        write_agent(
461            tmp.path(),
462            "minimal",
463            "---\nname: minimal\ndescription: Minimal agent\n---\nBody here.\n",
464        );
465        // A file with an unknown extra key should round-trip.
466        write_agent(
467            tmp.path(),
468            "weird",
469            "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
470        );
471        // Non-md file should be ignored by list().
472        let other = tmp.path().join("README.txt");
473        fs::write(&other, "ignore me").expect("write txt");
474        tmp
475    }
476
477    #[test]
478    fn list_returns_only_md_files_sorted() {
479        let tmp = fixture_root();
480        let root = AgentsRoot::at(tmp.path());
481        let agents = root.list().expect("list");
482        let stems: Vec<&str> = agents.iter().map(|a| a.file_stem.as_str()).collect();
483        assert_eq!(stems, ["minimal", "no-frontmatter", "rust-qa", "weird"]);
484    }
485
486    #[test]
487    fn list_missing_root_returns_empty() {
488        let tmp = tempfile::tempdir().expect("tempdir");
489        let root = AgentsRoot::at(tmp.path().join("does-not-exist"));
490        let agents = root.list().expect("list");
491        assert!(agents.is_empty());
492    }
493
494    #[test]
495    fn list_typed_metadata() {
496        let tmp = fixture_root();
497        let root = AgentsRoot::at(tmp.path());
498        let agents = root.list().expect("list");
499        let rust_qa = agents
500            .iter()
501            .find(|a| a.file_stem == "rust-qa")
502            .expect("rust-qa");
503        assert_eq!(rust_qa.name, "rust-qa");
504        assert_eq!(rust_qa.description.as_deref(), Some("Rust quality gate"));
505        assert_eq!(rust_qa.tools, vec!["Read", "Grep", "Bash"]);
506        assert_eq!(rust_qa.model.as_deref(), Some("sonnet"));
507        assert!(rust_qa.size_bytes > 0);
508    }
509
510    #[test]
511    fn list_no_frontmatter_falls_back_to_stem() {
512        let tmp = fixture_root();
513        let root = AgentsRoot::at(tmp.path());
514        let agents = root.list().expect("list");
515        let nf = agents
516            .iter()
517            .find(|a| a.file_stem == "no-frontmatter")
518            .expect("no-frontmatter");
519        assert_eq!(nf.name, "no-frontmatter");
520        assert_eq!(nf.description, None);
521        assert!(nf.tools.is_empty());
522        assert!(nf.model.is_none());
523    }
524
525    #[test]
526    fn get_returns_full_agent_with_body() {
527        let tmp = fixture_root();
528        let root = AgentsRoot::at(tmp.path());
529        let agent = root.get("rust-qa").expect("get rust-qa");
530        assert_eq!(agent.name, "rust-qa");
531        assert_eq!(agent.body, "You are a Rust quality gate.");
532    }
533
534    #[test]
535    fn get_no_frontmatter_returns_full_body() {
536        let tmp = fixture_root();
537        let root = AgentsRoot::at(tmp.path());
538        let agent = root.get("no-frontmatter").expect("get");
539        assert_eq!(agent.body, "Just a body, no frontmatter at all.");
540        assert_eq!(agent.name, "no-frontmatter");
541        assert!(agent.tools.is_empty());
542    }
543
544    #[test]
545    fn get_unknown_id_errors() {
546        let tmp = fixture_root();
547        let root = AgentsRoot::at(tmp.path());
548        let err = root.get("nope").unwrap_err();
549        assert!(err.to_string().to_lowercase().contains("no agent"));
550    }
551
552    #[test]
553    fn extra_keys_round_trip_as_strings() {
554        let tmp = fixture_root();
555        let root = AgentsRoot::at(tmp.path());
556        let agent = root.get("weird").expect("get weird");
557        assert_eq!(
558            agent.extra.get("custom_key").map(String::as_str),
559            Some("custom_value")
560        );
561    }
562
563    #[test]
564    fn split_frontmatter_with_block() {
565        let raw = "---\nname: x\n---\nbody text\n";
566        let (fm, body) = split_frontmatter(raw);
567        assert_eq!(fm, Some("name: x\n"));
568        assert_eq!(body, "body text\n");
569    }
570
571    #[test]
572    fn split_frontmatter_no_block() {
573        let raw = "no frontmatter here\nsecond line\n";
574        let (fm, body) = split_frontmatter(raw);
575        assert_eq!(fm, None);
576        assert_eq!(body, raw);
577    }
578
579    #[test]
580    fn split_frontmatter_open_no_close_returns_full() {
581        // An opening --- with no matching close shouldn't swallow
582        // the file. Conservative behavior: treat as no frontmatter.
583        let raw = "---\nname: x\nstill no close here\n";
584        let (fm, body) = split_frontmatter(raw);
585        assert_eq!(fm, None);
586        assert_eq!(body, raw);
587    }
588
589    #[test]
590    fn empty_value_keys_dont_overwrite_defaults() {
591        let tmp = tempfile::tempdir().expect("tempdir");
592        write_agent(
593            tmp.path(),
594            "empty-name",
595            "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
596        );
597        let root = AgentsRoot::at(tmp.path());
598        let agent = root.get("empty-name").expect("get");
599        assert_eq!(agent.name, "empty-name");
600    }
601
602    // -- write / write_new / delete -----------------------------------
603
604    fn input_with_body(body: &str) -> AgentWriteInput {
605        AgentWriteInput {
606            body: body.into(),
607            ..Default::default()
608        }
609    }
610
611    #[test]
612    fn write_creates_new_agent_round_trips_via_get() {
613        let tmp = tempfile::tempdir().expect("tempdir");
614        let root = AgentsRoot::at(tmp.path());
615        let input = AgentWriteInput {
616            name: Some("my-agent".into()),
617            description: Some("does the thing".into()),
618            tools: vec!["Read".into(), "Bash".into()],
619            model: Some("sonnet".into()),
620            body: "You are an agent.".into(),
621            extra: BTreeMap::new(),
622        };
623        root.write("my-agent", input).expect("write");
624
625        let agent = root.get("my-agent").expect("get");
626        assert_eq!(agent.name, "my-agent");
627        assert_eq!(agent.description.as_deref(), Some("does the thing"));
628        assert_eq!(agent.tools, vec!["Read", "Bash"]);
629        assert_eq!(agent.model.as_deref(), Some("sonnet"));
630        assert_eq!(agent.body, "You are an agent.");
631    }
632
633    #[test]
634    fn write_overwrites_existing_agent() {
635        let tmp = fixture_root();
636        let root = AgentsRoot::at(tmp.path());
637        // rust-qa exists in the fixture.
638        let input = AgentWriteInput {
639            description: Some("rewritten".into()),
640            body: "new body".into(),
641            ..Default::default()
642        };
643        root.write("rust-qa", input).expect("overwrite");
644        let agent = root.get("rust-qa").expect("get");
645        assert_eq!(agent.description.as_deref(), Some("rewritten"));
646        assert_eq!(agent.body, "new body");
647        // tools/model from the original should be gone -- write
648        // replaces the whole file.
649        assert!(agent.tools.is_empty(), "tools: {:?}", agent.tools);
650        assert!(agent.model.is_none());
651    }
652
653    #[test]
654    fn write_new_errors_when_already_exists() {
655        let tmp = fixture_root();
656        let root = AgentsRoot::at(tmp.path());
657        let err = root
658            .write_new("rust-qa", input_with_body("body"))
659            .unwrap_err();
660        assert!(err.to_string().contains("already exists"), "err: {err}");
661    }
662
663    #[test]
664    fn write_new_succeeds_for_fresh_stem() {
665        let tmp = fixture_root();
666        let root = AgentsRoot::at(tmp.path());
667        root.write_new("brand-new", input_with_body("hello"))
668            .expect("write_new");
669        let agent = root.get("brand-new").expect("get");
670        assert_eq!(agent.body, "hello");
671    }
672
673    #[test]
674    fn write_creates_root_directory_if_missing() {
675        let tmp = tempfile::tempdir().expect("tempdir");
676        let root = AgentsRoot::at(tmp.path().join("does-not-exist-yet"));
677        root.write("foo", input_with_body("body")).expect("write");
678        let agent = root.get("foo").expect("get");
679        assert_eq!(agent.body, "body");
680    }
681
682    #[test]
683    fn write_defaults_name_to_file_stem_when_absent() {
684        let tmp = tempfile::tempdir().expect("tempdir");
685        let root = AgentsRoot::at(tmp.path());
686        root.write("my-stem", input_with_body("b")).expect("write");
687        let agent = root.get("my-stem").expect("get");
688        assert_eq!(agent.name, "my-stem");
689    }
690
691    #[test]
692    fn write_preserves_extra_keys() {
693        let tmp = tempfile::tempdir().expect("tempdir");
694        let root = AgentsRoot::at(tmp.path());
695        let mut extra = BTreeMap::new();
696        extra.insert("custom_key".into(), "custom_value".into());
697        let input = AgentWriteInput {
698            body: "b".into(),
699            extra,
700            ..Default::default()
701        };
702        root.write("ex", input).expect("write");
703        let agent = root.get("ex").expect("get");
704        assert_eq!(
705            agent.extra.get("custom_key").map(String::as_str),
706            Some("custom_value")
707        );
708    }
709
710    #[test]
711    fn write_omits_optional_keys_when_unset() {
712        let tmp = tempfile::tempdir().expect("tempdir");
713        let root = AgentsRoot::at(tmp.path());
714        root.write("min", input_with_body("body only"))
715            .expect("write");
716        let raw = std::fs::read_to_string(tmp.path().join("min.md")).unwrap();
717        assert!(!raw.contains("description:"), "raw: {raw}");
718        assert!(!raw.contains("tools:"), "raw: {raw}");
719        assert!(!raw.contains("model:"), "raw: {raw}");
720    }
721
722    #[test]
723    fn write_rejects_path_traversal() {
724        let tmp = tempfile::tempdir().expect("tempdir");
725        let root = AgentsRoot::at(tmp.path());
726        for bad in ["", ".", "..", "a/b", "a\\b", "a\0b"] {
727            let err = root.write(bad, input_with_body("b")).unwrap_err();
728            assert!(
729                err.to_string().to_lowercase().contains("file_stem"),
730                "bad stem {bad:?} not rejected: {err}"
731            );
732        }
733    }
734
735    #[test]
736    fn delete_removes_file() {
737        let tmp = fixture_root();
738        let root = AgentsRoot::at(tmp.path());
739        assert!(root.get("rust-qa").is_ok());
740        root.delete("rust-qa").expect("delete");
741        let err = root.get("rust-qa").unwrap_err();
742        assert!(err.to_string().contains("no agent"), "err: {err}");
743    }
744
745    #[test]
746    fn delete_unknown_stem_errors() {
747        let tmp = fixture_root();
748        let root = AgentsRoot::at(tmp.path());
749        let err = root.delete("nope").unwrap_err();
750        assert!(err.to_string().contains("no agent"), "err: {err}");
751    }
752
753    #[test]
754    fn delete_rejects_path_traversal() {
755        let tmp = fixture_root();
756        let root = AgentsRoot::at(tmp.path());
757        for bad in ["", ".", "..", "a/b", "a\\b"] {
758            let err = root.delete(bad).unwrap_err();
759            assert!(
760                err.to_string().to_lowercase().contains("file_stem"),
761                "bad stem {bad:?} not rejected: {err}"
762            );
763        }
764    }
765
766    #[test]
767    fn render_orders_canonical_keys_before_extras() {
768        let mut extra = BTreeMap::new();
769        extra.insert("zzz_last".into(), "v".into());
770        extra.insert("aaa_first".into(), "v".into());
771        let input = AgentWriteInput {
772            name: Some("n".into()),
773            description: Some("d".into()),
774            tools: vec!["t1".into(), "t2".into()],
775            model: Some("haiku".into()),
776            body: "body".into(),
777            extra,
778        };
779        let md = render_agent_markdown("stem", &input);
780        let lines: Vec<&str> = md.lines().collect();
781        // Header
782        assert_eq!(lines[0], "---");
783        // Canonical order: name, description, tools, model, then sorted extras.
784        assert_eq!(lines[1], "name: n");
785        assert_eq!(lines[2], "description: d");
786        assert_eq!(lines[3], "tools: t1, t2");
787        assert_eq!(lines[4], "model: haiku");
788        assert_eq!(lines[5], "aaa_first: v");
789        assert_eq!(lines[6], "zzz_last: v");
790        assert_eq!(lines[7], "---");
791    }
792}