Skip to main content

bvr/
agents.rs

1//! AGENTS.md blurb management for AI coding agents.
2//!
3//! Detects, injects, updates, and removes standardised beads workflow
4//! instructions in agent configuration files (AGENTS.md, CLAUDE.md, etc.).
5
6use std::path::{Path, PathBuf};
7
8use crate::{BvrError, Result};
9
10// ---------------------------------------------------------------------------
11// Constants
12// ---------------------------------------------------------------------------
13
14/// Current blurb format version. Increment on breaking changes.
15///
16/// COUPLING NOTE: When bumping this value, also update the `v<N>` marker
17/// inside `AGENT_BLURB` below (the raw string literal cannot interpolate).
18/// The `agent_blurb_version_matches_constant` test will catch any mismatch.
19const BLURB_VERSION: u32 = 1;
20
21#[cfg(test)]
22const BLURB_START_MARKER: &str = "<!-- bv-agent-instructions-v1 -->";
23const BLURB_END_MARKER: &str = "<!-- end-bv-agent-instructions -->";
24
25/// Ordered preference for agent file names.
26const SUPPORTED_FILES: &[&str] = &["AGENTS.md", "CLAUDE.md", "agents.md", "claude.md"];
27
28/// Beads workflow instructions blurb.
29///
30/// The `<!-- bv-agent-instructions-vN -->` marker must match [`BLURB_VERSION`].
31const AGENT_BLURB: &str = r#"<!-- bv-agent-instructions-v1 -->
32
33---
34
35## Beads Workflow Integration
36
37This project uses [beads_rust](https://github.com/Dicklesworthstone/beads_rust) (`br`) for issue tracking. Issues are stored in `.beads/` and tracked in git.
38
39**Important:** `br` is non-invasive—it NEVER runs git commands automatically. After `br sync --flush-only`, you must manually commit changes.
40
41### Essential Commands
42
43```bash
44# View issues (launches TUI - avoid in automated sessions)
45bvr
46
47# CLI commands for agents (use these instead)
48br ready              # Show issues ready to work (no blockers)
49br list --status=open # All open issues
50br show <id>          # Full issue details with dependencies
51br create --title="..." --type=task --priority=2
52br update <id> --status=in_progress
53br close <id> --reason "Completed"
54br close <id1> <id2>  # Close multiple issues at once
55br sync --flush-only  # Export to JSONL (NO git operations)
56```
57
58### Workflow Pattern
59
601. **Start**: Run `br ready` to find actionable work
612. **Claim**: Use `br update <id> --status=in_progress`
623. **Work**: Implement the task
634. **Complete**: Use `br close <id>`
645. **Sync**: Run `br sync --flush-only` then manually commit
65
66### Key Concepts
67
68- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work.
69- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words)
70- **Types**: task, bug, feature, epic, question, docs
71- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies
72
73### Session Protocol
74
75**Before ending any session, run this checklist:**
76
77```bash
78git status              # Check what changed
79git add <files>         # Stage code changes
80br sync --flush-only    # Export beads to JSONL
81git add .beads/         # Stage beads changes
82git commit -m "..."     # Commit everything together
83git push                # Push to remote
84```
85
86### Best Practices
87
88- Check `br ready` at session start to find available work
89- Update status as you work (in_progress -> closed)
90- Create new issues with `br create` when you discover tasks
91- Use descriptive titles and set appropriate priority/type
92- Always `br sync --flush-only && git add .beads/` before ending session
93
94<!-- end-bv-agent-instructions -->"#;
95
96// ---------------------------------------------------------------------------
97// Detection
98// ---------------------------------------------------------------------------
99
100/// Result of detecting an agent config file.
101#[derive(Debug, Clone, Default)]
102pub struct AgentFileDetection {
103    pub file_path: Option<PathBuf>,
104    pub file_type: String,
105    pub has_blurb: bool,
106    pub has_legacy_blurb: bool,
107    pub blurb_version: u32,
108    pub content: String,
109    /// True when the file exists but could not be read (permissions, encoding).
110    pub read_error: bool,
111}
112
113impl AgentFileDetection {
114    pub fn found(&self) -> bool {
115        self.file_path.is_some()
116    }
117
118    pub fn needs_blurb(&self) -> bool {
119        self.found() && !self.has_blurb
120    }
121
122    pub fn needs_upgrade(&self) -> bool {
123        if self.has_legacy_blurb {
124            return true;
125        }
126        self.has_blurb && self.blurb_version < BLURB_VERSION
127    }
128}
129
130/// Check if content contains the current blurb format.
131fn contains_blurb(content: &str) -> bool {
132    content.contains("<!-- bv-agent-instructions-v")
133}
134
135/// Check if content contains the legacy blurb (pre-v1, no HTML markers).
136fn contains_legacy_blurb(content: &str) -> bool {
137    let patterns = [
138        "### Using bv as an AI sidecar",
139        "--robot-insights",
140        "--robot-plan",
141        "bv already computes the hard parts",
142    ];
143
144    // Must have the section header
145    if !content.contains("Using bv as an AI sidecar") {
146        return false;
147    }
148
149    // Require all patterns
150    patterns.iter().all(|p| content.contains(p))
151}
152
153/// Extract blurb version from content.
154fn get_blurb_version(content: &str) -> u32 {
155    // Look for <!-- bv-agent-instructions-v<N> -->
156    let marker = "<!-- bv-agent-instructions-v";
157    if let Some(pos) = content.find(marker) {
158        let after = &content[pos + marker.len()..];
159        if let Some(end) = after.find(" -->") {
160            if let Ok(v) = after[..end].parse::<u32>() {
161                return v;
162            }
163        }
164    }
165    0
166}
167
168/// Detect an agent file in a single directory.
169fn detect_agent_file(work_dir: &Path) -> AgentFileDetection {
170    // Try uppercase variants first
171    for &filename in SUPPORTED_FILES
172        .iter()
173        .filter(|f| f.starts_with(|c: char| c.is_uppercase()))
174    {
175        let path = work_dir.join(filename);
176        if let Some(det) = check_agent_file(&path, filename) {
177            return det;
178        }
179    }
180
181    // Try lowercase variants
182    for &filename in SUPPORTED_FILES
183        .iter()
184        .filter(|f| f.starts_with(|c: char| c.is_lowercase()))
185    {
186        let path = work_dir.join(filename);
187        if let Some(det) = check_agent_file(&path, filename) {
188            return det;
189        }
190    }
191
192    AgentFileDetection::default()
193}
194
195fn check_agent_file(path: &Path, file_type: &str) -> Option<AgentFileDetection> {
196    if !path.is_file() {
197        return None;
198    }
199
200    let content = match std::fs::read_to_string(path) {
201        Ok(text) => text,
202        Err(error) => {
203            tracing::warn!(
204                "cannot read {}: {error} — treating as unreadable",
205                path.display()
206            );
207            return Some(AgentFileDetection {
208                file_path: Some(path.to_path_buf()),
209                file_type: file_type.to_string(),
210                read_error: true,
211                ..Default::default()
212            });
213        }
214    };
215
216    let has_legacy = contains_legacy_blurb(&content);
217    let has_blurb = contains_blurb(&content) || has_legacy;
218
219    Some(AgentFileDetection {
220        file_path: Some(path.to_path_buf()),
221        file_type: file_type.to_string(),
222        has_blurb,
223        has_legacy_blurb: has_legacy,
224        blurb_version: get_blurb_version(&content),
225        content,
226        read_error: false,
227    })
228}
229
230/// Search for agent files starting from `work_dir` and walking up.
231pub fn detect_agent_file_in_parents(work_dir: &Path, max_levels: usize) -> AgentFileDetection {
232    let mut current = work_dir.to_path_buf();
233    for _ in 0..=max_levels {
234        let det = detect_agent_file(&current);
235        if det.found() {
236            return det;
237        }
238        match current.parent() {
239            Some(parent) if parent != current => current = parent.to_path_buf(),
240            _ => break,
241        }
242    }
243    AgentFileDetection::default()
244}
245
246// ---------------------------------------------------------------------------
247// Blurb manipulation
248// ---------------------------------------------------------------------------
249
250/// Append the blurb to content.
251fn append_blurb(content: &str) -> String {
252    let mut out = content.to_string();
253    if !out.ends_with('\n') {
254        out.push('\n');
255    }
256    out.push('\n');
257    out.push_str(AGENT_BLURB);
258    out.push('\n');
259    out
260}
261
262/// Remove the current-format blurb from content.
263fn remove_blurb(content: &str) -> String {
264    let Some(start_byte) = content.find("<!-- bv-agent-instructions-v") else {
265        return content.to_string();
266    };
267    let Some(end_byte) = content.find(BLURB_END_MARKER) else {
268        return content.to_string();
269    };
270
271    let mut end = end_byte + BLURB_END_MARKER.len();
272    // Consume trailing newlines
273    while end < content.len() && matches!(content.as_bytes()[end], b'\n' | b'\r') {
274        end += 1;
275    }
276    // Consume leading newlines
277    let mut start = start_byte;
278    while start > 0 && matches!(content.as_bytes()[start - 1], b'\n' | b'\r') {
279        start -= 1;
280    }
281
282    let mut result = content[..start].to_string();
283    result.push_str(&content[end..]);
284    result
285}
286
287/// Remove the legacy blurb (pre-v1) from content.
288fn remove_legacy_blurb(content: &str) -> String {
289    if !contains_legacy_blurb(content) {
290        return content.to_string();
291    }
292
293    // Find start: "## Using bv as an AI sidecar" or "### Using bv as an AI sidecar"
294    let Some(start_byte) = content.find("Using bv as an AI sidecar") else {
295        return content.to_string();
296    };
297    // Back up to the heading marker
298    let start = content[..start_byte].rfind('#').unwrap_or(start_byte);
299
300    // Find end: "bv already computes the hard parts"
301    let end = content[start..]
302        .find("bv already computes the hard parts")
303        .map_or(content.len(), |pos| {
304            let mut e = start + pos;
305            // Skip to end of line
306            if let Some(nl) = content[e..].find('\n') {
307                e += nl + 1;
308            } else {
309                e = content.len();
310            }
311            // Skip trailing code fence if present
312            let remaining = &content[e..];
313            if remaining.starts_with("```") {
314                if let Some(nl) = remaining.find('\n') {
315                    e += nl + 1;
316                }
317            }
318            // Consume trailing newlines
319            while e < content.len() && matches!(content.as_bytes()[e], b'\n' | b'\r') {
320                e += 1;
321            }
322            e
323        });
324
325    // Consume leading newlines before start
326    let mut adj_start = start;
327    while adj_start > 0 && matches!(content.as_bytes()[adj_start - 1], b'\n' | b'\r') {
328        adj_start -= 1;
329    }
330    if adj_start > 0 {
331        adj_start += 1; // Keep one newline separator
332    }
333
334    let mut result = content[..adj_start].to_string();
335    result.push_str(&content[end..]);
336    result
337}
338
339/// Replace existing blurb (current or legacy) with the current version.
340fn update_blurb(content: &str) -> String {
341    let content = remove_legacy_blurb(content);
342    let content = remove_blurb(&content);
343    append_blurb(&content)
344}
345
346// ---------------------------------------------------------------------------
347// File operations
348// ---------------------------------------------------------------------------
349
350/// Write file contents, using temp-file + rename when possible for atomicity.
351fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
352    use std::io::Write;
353
354    let dir = path.parent().unwrap_or_else(|| Path::new("."));
355
356    // Try atomic temp-file + rename first
357    match tempfile::NamedTempFile::new_in(dir) {
358        Ok(mut tmp) => {
359            tmp.write_all(content).map_err(BvrError::Io)?;
360            tmp.as_file().sync_all().map_err(BvrError::Io)?;
361            tmp.persist(path).map_err(|e| BvrError::Io(e.error))?;
362        }
363        Err(_) => {
364            // Fall back to direct write (e.g. when dir is not writable for temp files)
365            std::fs::write(path, content).map_err(BvrError::Io)?;
366        }
367    }
368
369    Ok(())
370}
371
372// ---------------------------------------------------------------------------
373// Public CLI actions
374// ---------------------------------------------------------------------------
375
376/// Result returned to the CLI dispatcher with a user-facing message.
377pub struct AgentsResult {
378    pub message: String,
379    pub changed: bool,
380}
381
382fn agents_check_from_detection(work_dir: &Path, det: &AgentFileDetection) -> AgentsResult {
383    if !det.found() {
384        return AgentsResult {
385            message: format!(
386                "No agent file found (searched up to 3 parent directories from {}).\n\
387                 Run 'bvr --agents-add' to create AGENTS.md with beads workflow instructions.",
388                work_dir.display()
389            ),
390            changed: false,
391        };
392    }
393
394    let Some(path_buf) = det.file_path.as_ref() else {
395        return AgentsResult {
396            message: format!(
397                "Found {} but could not resolve its path; run 'bvr --agents-check' again.",
398                det.file_type
399            ),
400            changed: false,
401        };
402    };
403    let path = path_buf.display();
404
405    if det.read_error {
406        return AgentsResult {
407            message: format!(
408                "Found {file_type} at {path}, but it could not be read.\n\
409                 Check file permissions before running any agents command.",
410                file_type = det.file_type,
411            ),
412            changed: false,
413        };
414    }
415
416    if det.needs_upgrade() {
417        let current_ver = if det.has_legacy_blurb {
418            "legacy".to_string()
419        } else {
420            format!("v{}", det.blurb_version)
421        };
422        return AgentsResult {
423            message: format!(
424                "Found {file_type} at {path} (blurb {current_ver}, current v{BLURB_VERSION} — needs update)\n\
425                 Run 'bvr --agents-update' to update to the latest version.",
426                file_type = det.file_type,
427            ),
428            changed: false,
429        };
430    }
431
432    if det.needs_blurb() {
433        return AgentsResult {
434            message: format!(
435                "Found {file_type} at {path} (no blurb)\n\
436                 Run 'bvr --agents-add' to add beads workflow instructions.",
437                file_type = det.file_type,
438            ),
439            changed: false,
440        };
441    }
442
443    AgentsResult {
444        message: format!(
445            "Found {file_type} at {path} (blurb v{BLURB_VERSION} — up to date)",
446            file_type = det.file_type,
447        ),
448        changed: false,
449    }
450}
451
452/// `--agents-check`: report blurb status.
453pub fn agents_check(work_dir: &Path) -> AgentsResult {
454    let det = detect_agent_file_in_parents(work_dir, 3);
455    agents_check_from_detection(work_dir, &det)
456}
457
458/// `--agents-add`: add blurb to agent file (create if needed).
459pub fn agents_add(work_dir: &Path, dry_run: bool) -> Result<AgentsResult> {
460    let det = detect_agent_file_in_parents(work_dir, 3);
461
462    if det.found() {
463        let Some(path) = det.file_path.as_ref() else {
464            return Err(BvrError::InvalidArgument(
465                "Agent file detected but no file path was recorded.".to_string(),
466            ));
467        };
468
469        if det.read_error {
470            return Err(BvrError::InvalidArgument(format!(
471                "Cannot read {} — check file permissions.",
472                path.display()
473            )));
474        }
475
476        if det.has_blurb && !det.needs_upgrade() {
477            return Ok(AgentsResult {
478                message: format!(
479                    "{} already has blurb v{BLURB_VERSION} — nothing to do.",
480                    det.file_type
481                ),
482                changed: false,
483            });
484        }
485
486        if det.needs_upgrade() {
487            return Err(BvrError::InvalidArgument(format!(
488                "{} has outdated blurb. Run 'bvr --agents-update' instead.",
489                det.file_type
490            )));
491        }
492
493        // File exists but no blurb — append
494        if dry_run {
495            return Ok(AgentsResult {
496                message: format!("[dry-run] Would append blurb to {}.", path.display()),
497                changed: false,
498            });
499        }
500
501        let new_content = append_blurb(&det.content);
502        atomic_write(path, new_content.as_bytes())?;
503
504        return Ok(AgentsResult {
505            message: format!("Added blurb to {}.", path.display()),
506            changed: true,
507        });
508    }
509
510    // No agent file — create AGENTS.md
511    let path = work_dir.join("AGENTS.md");
512    if dry_run {
513        return Ok(AgentsResult {
514            message: format!("[dry-run] Would create {}.", path.display()),
515            changed: false,
516        });
517    }
518
519    let content = format!("# AI Agent Instructions\n\n{AGENT_BLURB}\n");
520    atomic_write(&path, content.as_bytes())?;
521
522    Ok(AgentsResult {
523        message: format!(
524            "Created {} with beads workflow instructions.",
525            path.display()
526        ),
527        changed: true,
528    })
529}
530
531/// `--agents-update`: upgrade blurb to current version.
532pub fn agents_update(work_dir: &Path, dry_run: bool) -> Result<AgentsResult> {
533    let det = detect_agent_file_in_parents(work_dir, 3);
534
535    if !det.found() {
536        return Err(BvrError::InvalidArgument(
537            "No agent file found. Run 'bvr --agents-add' first.".to_string(),
538        ));
539    }
540
541    let Some(path) = det.file_path.as_ref() else {
542        return Err(BvrError::InvalidArgument(
543            "Agent file detected but no file path was recorded.".to_string(),
544        ));
545    };
546
547    if det.read_error {
548        return Err(BvrError::InvalidArgument(format!(
549            "Cannot read {} — check file permissions.",
550            path.display()
551        )));
552    }
553
554    if !det.has_blurb {
555        return Err(BvrError::InvalidArgument(format!(
556            "{} has no blurb to update. Run 'bvr --agents-add' instead.",
557            det.file_type,
558        )));
559    }
560
561    if !det.needs_upgrade() {
562        return Ok(AgentsResult {
563            message: format!(
564                "{} blurb is already v{BLURB_VERSION} — nothing to do.",
565                det.file_type,
566            ),
567            changed: false,
568        });
569    }
570
571    let label = if det.has_legacy_blurb {
572        "legacy blurb"
573    } else {
574        "outdated blurb"
575    };
576
577    if dry_run {
578        return Ok(AgentsResult {
579            message: format!(
580                "[dry-run] Would upgrade {label} to v{BLURB_VERSION} in {}.",
581                path.display()
582            ),
583            changed: false,
584        });
585    }
586
587    let new_content = update_blurb(&det.content);
588    atomic_write(path, new_content.as_bytes())?;
589
590    Ok(AgentsResult {
591        message: format!("Updated blurb to v{BLURB_VERSION} in {}.", path.display()),
592        changed: true,
593    })
594}
595
596/// `--agents-remove`: remove blurb from agent file.
597pub fn agents_remove(work_dir: &Path, dry_run: bool) -> Result<AgentsResult> {
598    let det = detect_agent_file_in_parents(work_dir, 3);
599
600    if !det.found() {
601        return Ok(AgentsResult {
602            message: "No agent file found — nothing to remove.".to_string(),
603            changed: false,
604        });
605    }
606
607    let Some(path) = det.file_path.as_ref() else {
608        return Err(BvrError::InvalidArgument(
609            "Agent file detected but no file path was recorded.".to_string(),
610        ));
611    };
612
613    if det.read_error {
614        return Err(BvrError::InvalidArgument(format!(
615            "Cannot read {} — check file permissions.",
616            path.display()
617        )));
618    }
619
620    if !det.has_blurb {
621        return Ok(AgentsResult {
622            message: format!("{} has no blurb — nothing to remove.", det.file_type),
623            changed: false,
624        });
625    }
626
627    if dry_run {
628        return Ok(AgentsResult {
629            message: format!("[dry-run] Would remove blurb from {}.", path.display()),
630            changed: false,
631        });
632    }
633
634    let new_content = if det.has_legacy_blurb {
635        remove_legacy_blurb(&det.content)
636    } else {
637        remove_blurb(&det.content)
638    };
639    atomic_write(path, new_content.as_bytes())?;
640
641    Ok(AgentsResult {
642        message: format!("Removed blurb from {}.", path.display()),
643        changed: true,
644    })
645}
646
647// ---------------------------------------------------------------------------
648// Tests
649// ---------------------------------------------------------------------------
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn contains_blurb_detects_current_marker() {
657        assert!(contains_blurb(
658            "before\n<!-- bv-agent-instructions-v1 -->\nstuff\n<!-- end-bv-agent-instructions -->\nafter"
659        ));
660        assert!(!contains_blurb("no marker here"));
661    }
662
663    #[test]
664    fn get_blurb_version_extracts_version() {
665        assert_eq!(get_blurb_version("<!-- bv-agent-instructions-v1 -->"), 1);
666        assert_eq!(get_blurb_version("<!-- bv-agent-instructions-v42 -->"), 42);
667        assert_eq!(get_blurb_version("no marker"), 0);
668    }
669
670    #[test]
671    fn agent_blurb_version_matches_constant() {
672        // Guard against BLURB_VERSION being bumped without updating the
673        // version marker inside AGENT_BLURB. If this fails, update the
674        // `<!-- bv-agent-instructions-vN -->` marker in AGENT_BLURB to
675        // match BLURB_VERSION.
676        assert_eq!(
677            get_blurb_version(AGENT_BLURB),
678            BLURB_VERSION,
679            "AGENT_BLURB marker version must match BLURB_VERSION (currently {BLURB_VERSION})"
680        );
681    }
682
683    #[test]
684    fn append_blurb_adds_to_content() {
685        let result = append_blurb("# Existing\n");
686        assert!(result.starts_with("# Existing\n"));
687        assert!(result.contains(BLURB_START_MARKER));
688        assert!(result.contains(BLURB_END_MARKER));
689    }
690
691    #[test]
692    fn agent_blurb_uses_bvr_command_name() {
693        assert!(AGENT_BLURB.contains("\nbvr\n"));
694        assert!(!AGENT_BLURB.contains("\nbv\n"));
695    }
696
697    #[test]
698    fn remove_blurb_strips_current() {
699        let content = format!("before\n\n{AGENT_BLURB}\n\nafter");
700        let result = remove_blurb(&content);
701        assert!(result.contains("before"));
702        assert!(result.contains("after"));
703        assert!(!result.contains(BLURB_START_MARKER));
704    }
705
706    #[test]
707    fn update_blurb_replaces_existing() {
708        let old = "# File\n\n<!-- bv-agent-instructions-v0 -->\nold stuff\n<!-- end-bv-agent-instructions -->\n";
709        let result = update_blurb(old);
710        assert!(result.contains(BLURB_START_MARKER));
711        assert!(!result.contains("old stuff"));
712        assert!(result.contains("br ready"));
713    }
714
715    #[test]
716    fn detection_defaults() {
717        let det = AgentFileDetection::default();
718        assert!(!det.found());
719        assert!(!det.needs_blurb());
720        assert!(!det.needs_upgrade());
721    }
722
723    #[test]
724    fn detection_needs_upgrade_for_legacy() {
725        let det = AgentFileDetection {
726            file_path: Some(PathBuf::from("/test/AGENTS.md")),
727            has_blurb: true,
728            has_legacy_blurb: true,
729            ..Default::default()
730        };
731        assert!(det.needs_upgrade());
732    }
733
734    #[test]
735    fn detection_up_to_date() {
736        let det = AgentFileDetection {
737            file_path: Some(PathBuf::from("/test/AGENTS.md")),
738            has_blurb: true,
739            blurb_version: BLURB_VERSION,
740            ..Default::default()
741        };
742        assert!(!det.needs_upgrade());
743        assert!(!det.needs_blurb());
744    }
745
746    #[test]
747    fn agents_check_no_file() {
748        let tmp = tempfile::tempdir().unwrap();
749        // Nest 4 levels deep so the 3-level parent walk stays inside the temp dir
750        let nested = tmp.path().join("a/b/c/d");
751        std::fs::create_dir_all(&nested).unwrap();
752        let result = agents_check(&nested);
753        assert!(
754            result.message.contains("No agent file found"),
755            "unexpected message: {}",
756            result.message
757        );
758        assert!(!result.changed);
759    }
760
761    #[test]
762    fn agents_check_reports_unreadable_agent_file() {
763        let work_dir = Path::new("/tmp/project");
764        let detection = AgentFileDetection {
765            file_path: Some(PathBuf::from("/tmp/project/AGENTS.md")),
766            file_type: "AGENTS.md".to_string(),
767            read_error: true,
768            ..Default::default()
769        };
770
771        let result = agents_check_from_detection(work_dir, &detection);
772        assert!(result.message.contains("could not be read"));
773        assert!(result.message.contains("Check file permissions"));
774        assert!(!result.changed);
775    }
776
777    #[test]
778    fn agents_add_creates_file() {
779        let tmp = tempfile::tempdir().unwrap();
780        let nested = tmp.path().join("a/b/c/d");
781        std::fs::create_dir_all(&nested).unwrap();
782        let result = agents_add(&nested, false).unwrap();
783        assert!(result.changed);
784        assert!(result.message.contains("Created"));
785
786        let content = std::fs::read_to_string(nested.join("AGENTS.md")).unwrap();
787        assert!(content.contains(BLURB_START_MARKER));
788    }
789
790    #[test]
791    fn agents_add_dry_run_no_write() {
792        let tmp = tempfile::tempdir().unwrap();
793        let nested = tmp.path().join("a/b/c/d");
794        std::fs::create_dir_all(&nested).unwrap();
795        let result = agents_add(&nested, true).unwrap();
796        assert!(!result.changed);
797        assert!(result.message.contains("[dry-run]"));
798        assert!(!nested.join("AGENTS.md").exists());
799    }
800
801    #[test]
802    fn agents_remove_strips_blurb() {
803        let tmp = tempfile::tempdir().unwrap();
804        let nested = tmp.path().join("a/b/c/d");
805        std::fs::create_dir_all(&nested).unwrap();
806        let path = nested.join("AGENTS.md");
807        let content = format!("# Header\n\n{AGENT_BLURB}\n\n## Other\n");
808        std::fs::write(&path, &content).unwrap();
809
810        let result = agents_remove(&nested, false).unwrap();
811        assert!(result.changed);
812
813        let updated = std::fs::read_to_string(&path).unwrap();
814        assert!(!updated.contains(BLURB_START_MARKER));
815        assert!(updated.contains("# Header"));
816        assert!(updated.contains("## Other"));
817    }
818
819    #[test]
820    fn agents_update_upgrades_old_version() {
821        let tmp = tempfile::tempdir().unwrap();
822        let nested = tmp.path().join("a/b/c/d");
823        std::fs::create_dir_all(&nested).unwrap();
824        let path = nested.join("AGENTS.md");
825        let content = "# Header\n\n<!-- bv-agent-instructions-v0 -->\nold\n<!-- end-bv-agent-instructions -->\n";
826        std::fs::write(&path, content).unwrap();
827
828        let result = agents_update(&nested, false).unwrap();
829        assert!(result.changed);
830
831        let updated = std::fs::read_to_string(&path).unwrap();
832        assert!(updated.contains("bv-agent-instructions-v1"));
833        assert!(updated.contains("br ready"));
834    }
835
836    #[test]
837    fn roundtrip_add_check_remove() {
838        let tmp = tempfile::tempdir().unwrap();
839        let nested = tmp.path().join("a/b/c/d");
840        std::fs::create_dir_all(&nested).unwrap();
841
842        // Add
843        let r = agents_add(&nested, false).unwrap();
844        assert!(r.changed);
845
846        // Check
847        let r = agents_check(&nested);
848        assert!(r.message.contains("up to date"));
849
850        // Remove
851        let r = agents_remove(&nested, false).unwrap();
852        assert!(r.changed);
853
854        // Check again
855        let r = agents_check(&nested);
856        assert!(r.message.contains("no blurb"));
857    }
858}