Skip to main content

mars_agents/cli/
mod.rs

1//! CLI layer — clap definitions + command dispatch.
2//!
3//! Each subcommand is a separate module. The CLI layer:
4//! - Parses args into typed commands
5//! - Locates project root (walk up from cwd, or `--root` flag)
6//! - Calls library functions
7//! - Formats output (human-readable by default, `--json` for machine)
8//! - Maps `MarsError` to exit codes and stderr messages
9
10pub mod add;
11pub mod adopt;
12pub mod agents;
13pub mod build;
14pub mod cache;
15pub mod check;
16pub mod doctor;
17pub mod export;
18pub mod init;
19pub mod link;
20pub mod list;
21pub mod models;
22mod models_common;
23mod models_prompting;
24pub mod outdated;
25pub mod output;
26pub mod override_cmd;
27pub mod remove;
28pub mod rename;
29pub mod repair;
30pub mod resolve_cmd;
31pub mod skills;
32pub mod sync;
33pub mod target;
34pub mod unlink;
35pub mod upgrade;
36pub mod validate;
37pub mod version;
38pub mod why;
39
40use std::path::{Path, PathBuf};
41
42use clap::{Parser, Subcommand};
43
44use crate::error::{ConfigError, LockError, MarsError};
45pub use crate::types::MarsContext;
46use crate::types::managed_cmd;
47
48/// Deprecated generic output directories still recognized for migration hints.
49pub const WELL_KNOWN: &[&str] = &[".agents"];
50
51/// Tool-specific directories that commonly need linking.
52/// `mars link` warns if the target isn't in TOOL_DIRS or WELL_KNOWN.
53pub const TOOL_DIRS: &[&str] = &[".claude", ".codex", ".opencode", ".cursor", ".pi"];
54
55impl MarsContext {
56    /// Build context from project root (directory containing mars.toml).
57    pub fn new(project_root: PathBuf) -> Result<Self, MarsError> {
58        let project_canon = if project_root.exists() {
59            dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
60        } else {
61            project_root.clone()
62        };
63
64        let managed_root = detect_managed_root(&project_canon)?;
65        Self::from_roots(project_canon, managed_root)
66    }
67
68    /// Build context from explicit project and managed roots.
69    pub fn from_roots(project_root: PathBuf, managed_root: PathBuf) -> Result<Self, MarsError> {
70        let project_canon = if project_root.exists() {
71            dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
72        } else {
73            project_root.clone()
74        };
75        let managed_canon = if managed_root.exists() {
76            dunce::canonicalize(&managed_root).unwrap_or(managed_root.clone())
77        } else if let Ok(relative_managed) = managed_root.strip_prefix(&project_root) {
78            project_canon.join(relative_managed)
79        } else {
80            managed_root.clone()
81        };
82
83        if !managed_canon.starts_with(&project_canon) {
84            return Err(MarsError::Config(ConfigError::Invalid {
85                message: format!(
86                    "{} resolves to {} which is outside {}. \
87                     The managed root may be a symlink. Use --root to override.",
88                    managed_root.display(),
89                    managed_canon.display(),
90                    project_canon.display(),
91                ),
92            }));
93        }
94
95        Ok(MarsContext {
96            managed_root: managed_canon,
97            project_root: project_canon,
98            meridian_managed: crate::types::meridian_managed_from_env(),
99        })
100    }
101}
102
103/// mars — agent package manager for agent and skill packages.
104#[derive(Debug, Parser)]
105#[command(name = "mars", version, about = "Agent package manager")]
106pub struct Cli {
107    #[command(subcommand)]
108    pub command: Command,
109
110    /// Path to project root containing mars.toml (default: auto-detect).
111    #[arg(long, global = true)]
112    pub root: Option<PathBuf>,
113
114    /// Output in JSON format.
115    #[arg(long, global = true)]
116    pub json: bool,
117}
118
119#[derive(Debug, Subcommand)]
120pub enum Command {
121    /// Initialize project-level mars.toml and .mars/ compiled store.
122    Init(init::InitArgs),
123
124    /// Add a dependency (git URL, GitHub shorthand, or local path).
125    Add(add::AddArgs),
126
127    /// Adopt an unmanaged target item into `.mars-src/`, then sync.
128    Adopt(adopt::AdoptArgs),
129
130    /// Remove a dependency.
131    Remove(remove::RemoveArgs),
132
133    /// Sync: resolve + install (make reality match config).
134    Sync(sync::SyncArgs),
135
136    /// Upgrade dependencies to newest compatible versions.
137    Upgrade(upgrade::UpgradeArgs),
138
139    /// Show available updates without applying.
140    Outdated(outdated::OutdatedArgs),
141
142    /// Bump package version in mars.toml, commit, and tag.
143    Version(version::VersionArgs),
144
145    /// List managed items with status.
146    List(list::ListArgs),
147
148    /// Explain why an item is installed.
149    Why(why::WhyArgs),
150
151    /// Rename a managed item.
152    Rename(rename::RenameArgs),
153
154    /// Mark conflicts as resolved.
155    Resolve(resolve_cmd::ResolveArgs),
156
157    /// Set a local dev override for a source.
158    Override(override_cmd::OverrideArgs),
159
160    /// Add a managed target directory (e.g. .claude).
161    Link(link::LinkArgs),
162
163    /// Remove a managed target directory.
164    Unlink(unlink::UnlinkArgs),
165
166    /// Dry-run the compiler pipeline and report diagnostics without writing.
167    Validate(validate::ValidateArgs),
168
169    /// Export the compile plan as JSON (dry-run, no writes).
170    Export(export::ExportArgs),
171
172    /// Validate a source package before publishing (structure, frontmatter, deps).
173    Check(check::CheckArgs),
174
175    /// Diagnose problems in an installed mars project (config, lock, files, targets).
176    Doctor(doctor::DoctorArgs),
177
178    /// Rebuild state from lock + sources.
179    Repair(repair::RepairArgs),
180
181    /// Manage the global source cache.
182    Cache(cache::CacheArgs),
183
184    /// Manage model aliases and the models cache.
185    Models(models::ModelsArgs),
186
187    /// Build derived artifacts from static project state.
188    Build(build::BuildArgs),
189
190    /// List and inspect agents.
191    Agents(agents::AgentsArgs),
192
193    /// List and inspect skills.
194    Skills(skills::SkillsArgs),
195}
196
197/// Dispatch a parsed CLI command to the appropriate handler and map errors to
198/// the final exit code.
199pub fn dispatch(cli: Cli) -> i32 {
200    match dispatch_result(cli) {
201        Ok(code) => code,
202        Err(err) => {
203            eprintln!("error: {err}");
204            if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
205                eprintln!(
206                    "hint: run `{}` to rebuild from mars.toml + dependencies",
207                    managed_cmd("mars repair")
208                );
209            }
210            err.exit_code()
211        }
212    }
213}
214
215fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
216    match &cli.command {
217        // Root-free commands
218        Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
219        Command::Check(args) => check::run(args, cli.json),
220        Command::Cache(args) => cache::run(args, cli.json),
221        // All other commands require context
222        cmd => {
223            let ctx = match find_agents_root(cli.root.as_deref()) {
224                Ok(ctx) => ctx,
225                Err(err) if should_auto_init_project(cmd, &err) => {
226                    let initialized = init::initialize_project(cli.root.as_deref(), None)?;
227                    if !cli.json {
228                        output::print_info(&format!(
229                            "auto-initialized {} with mars.toml",
230                            initialized.project_root.display()
231                        ));
232                    }
233                    MarsContext::from_roots(
234                        initialized.project_root.clone(),
235                        initialized
236                            .managed_root
237                            .clone()
238                            .unwrap_or_else(|| initialized.project_root.join(".mars")),
239                    )?
240                }
241                Err(err) if can_run_without_project(cmd, &err) => {
242                    let project_root = cli.root.clone().unwrap_or(std::env::current_dir()?);
243                    MarsContext::from_roots(project_root.clone(), project_root.join(".mars"))?
244                }
245                Err(err) => return Err(err),
246            };
247            dispatch_with_root(cmd, &ctx, cli.json)
248        }
249    }
250}
251
252fn should_auto_init_project(cmd: &Command, err: &MarsError) -> bool {
253    matches!(cmd, Command::Add(_) | Command::Link(_))
254        && matches!(
255            err,
256            MarsError::Config(ConfigError::ProjectRootNotFound { .. })
257        )
258}
259
260fn can_run_without_project(cmd: &Command, err: &MarsError) -> bool {
261    matches!(
262        (cmd, err),
263        (
264            Command::Build(build::BuildArgs {
265                command: build::BuildCommand::LaunchBundle(build::LaunchBundleArgs {
266                    agent: None,
267                    ..
268                })
269            }),
270            MarsError::Config(ConfigError::ProjectRootNotFound { .. })
271        )
272    )
273}
274
275fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
276    match cmd {
277        Command::Validate(args) => validate::run(args, ctx, json),
278        Command::Export(args) => export::run(args, ctx, json),
279        Command::Add(args) => add::run(args, ctx, json),
280        Command::Adopt(args) => adopt::run(args, ctx, json),
281        Command::Remove(args) => remove::run(args, ctx, json),
282        Command::Sync(args) => sync::run(args, ctx, json),
283        Command::Upgrade(args) => upgrade::run(args, ctx, json),
284        Command::Outdated(args) => outdated::run(args, ctx, json),
285        Command::Version(args) => version::run(args, ctx, json),
286        Command::List(args) => list::run(args, ctx, json),
287        Command::Why(args) => why::run(args, ctx, json),
288        Command::Rename(args) => rename::run(args, ctx, json),
289        Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
290        Command::Override(args) => override_cmd::run(args, ctx, json),
291        Command::Link(args) => link::run(args, ctx, json),
292        Command::Unlink(args) => unlink::run(args, ctx, json),
293        Command::Doctor(args) => doctor::run(args, ctx, json),
294        Command::Repair(args) => repair::run(args, ctx, json),
295        Command::Models(args) => models::run(args, ctx, json),
296        Command::Build(args) => build::run(args, ctx, json),
297        Command::Agents(args) => agents::run(args, ctx, json),
298        Command::Skills(args) => skills::run(args, ctx, json),
299        // Root-free commands handled in dispatch_result — unreachable here
300        Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
301    }
302}
303
304/// Check if a path is a symlink (uses symlink_metadata, doesn't follow).
305pub fn is_symlink(path: &Path) -> bool {
306    path.symlink_metadata()
307        .map(|m| m.file_type().is_symlink())
308        .unwrap_or(false)
309}
310
311fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
312    // 1. Check explicit settings in mars.toml.
313    match crate::config::load(project_root) {
314        Ok(config) => {
315            if config.settings.managed_root.is_some()
316                && let Some(name) = config.settings.managed_targets().first()
317            {
318                return Ok(project_root.join(name));
319            }
320            if config
321                .settings
322                .managed_targets()
323                .iter()
324                .any(|target| target == WELL_KNOWN[0])
325            {
326                return Ok(project_root.join(WELL_KNOWN[0]));
327            }
328        }
329        // Config doesn't exist yet (before mars init) — expected, fall through
330        Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
331        // Config exists but has parse errors — surface the real error
332        Err(e) => return Err(e),
333    }
334
335    // 2. Canonical store default. Do not infer legacy `.agents/` ownership from
336    // disk presence; doctor reports leftover target migration hints separately.
337    Ok(project_root.join(".mars"))
338}
339
340/// Find mars project root by walking up from start path to filesystem root.
341///
342/// For context commands (`add`, `sync`, etc.), this walks from the start path
343/// (cwd or `--root`) until it finds a `mars.toml`. Walk-up continues to filesystem
344/// root — git boundaries do not stop the walk.
345///
346/// If `--root` is provided, it sets the walk-up start path, not a direct target.
347pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
348    let start = if let Some(root) = explicit {
349        // Reject --root values that look like managed output directories
350        if let Some(basename) = root.file_name().and_then(|f| f.to_str())
351            && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
352        {
353            return Err(MarsError::Config(ConfigError::Invalid {
354                message: format!(
355                    "`--root {basename}` looks like a managed output directory.\n  \
356                     --root takes the project root (containing mars.toml), not the output directory.\n  \
357                     Try: mars init  (auto-detects project root)\n  \
358                     Or:  mars init {basename}  (specify output directory name)"
359                ),
360            }));
361        }
362
363        root.to_path_buf()
364    } else {
365        std::env::current_dir()?
366    };
367
368    find_agents_root_from(&start)
369}
370
371/// Walk up from `start` to filesystem root searching for `mars.toml`.
372///
373/// Uses `Path::parent()` which returns `None` at filesystem root on all platforms:
374/// - Unix: `/` has no parent
375/// - Windows: `C:\` or UNC roots like `\\server\share` have no parent
376fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
377    let start_canon = dunce::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
378    let mut dir = start_canon.as_path();
379
380    // Walk up to filesystem root (Path::parent() returns None at root)
381    loop {
382        let config_path = dir.join("mars.toml");
383        if config_path.exists() {
384            return MarsContext::new(dir.to_path_buf());
385        }
386
387        match dir.parent() {
388            Some(parent) => dir = parent,
389            None => break,
390        }
391    }
392
393    Err(MarsError::Config(ConfigError::ProjectRootNotFound {
394        start: start.to_path_buf(),
395    }))
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use tempfile::TempDir;
402
403    #[test]
404    fn find_root_with_explicit_path() {
405        let dir = TempDir::new().unwrap();
406        // Canonicalize once and use everywhere to avoid Windows 8.3 short-name mismatches
407        let canonical_dir = dunce::canonicalize(dir.path()).unwrap();
408        std::fs::write(canonical_dir.join("mars.toml"), "[dependencies]\n").unwrap();
409
410        // --root points to a dir with mars.toml — should find it via walk-up
411        let ctx = find_agents_root(Some(&canonical_dir)).unwrap();
412        assert_eq!(ctx.project_root, canonical_dir);
413        assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
414    }
415
416    #[test]
417    fn package_manifest_without_dependencies_is_valid_project_root() {
418        let dir = TempDir::new().unwrap();
419        std::fs::write(
420            dir.path().join("mars.toml"),
421            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
422        )
423        .unwrap();
424
425        let ctx = find_agents_root(Some(dir.path())).unwrap();
426        assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
427    }
428
429    #[test]
430    fn find_root_ignores_leftover_agents_dir_without_explicit_config() {
431        let dir = TempDir::new().unwrap();
432        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
433        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
434
435        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
436        assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
437        assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
438    }
439
440    #[test]
441    fn find_root_with_custom_managed_dir_from_settings() {
442        let dir = TempDir::new().unwrap();
443        std::fs::write(
444            dir.path().join("mars.toml"),
445            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
446        )
447        .unwrap();
448        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
449
450        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
451        assert_eq!(
452            ctx.managed_root,
453            dunce::canonicalize(dir.path().join(".claude")).unwrap()
454        );
455    }
456
457    #[test]
458    fn find_root_with_agents_target_from_settings_targets() {
459        let dir = TempDir::new().unwrap();
460        std::fs::write(
461            dir.path().join("mars.toml"),
462            "[dependencies]\n\n[settings]\ntargets = [\".agents\"]\n",
463        )
464        .unwrap();
465        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
466
467        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
468        assert_eq!(
469            ctx.managed_root,
470            dunce::canonicalize(dir.path().join(".agents")).unwrap()
471        );
472    }
473
474    #[cfg(unix)]
475    #[test]
476    fn context_rejects_symlinked_managed_root_outside_project() {
477        let project_dir = TempDir::new().unwrap();
478        let external_dir = TempDir::new().unwrap();
479        std::fs::write(
480            project_dir.path().join("mars.toml"),
481            "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
482        )
483        .unwrap();
484
485        let external_agents = external_dir.path().join(".agents");
486        std::fs::create_dir_all(&external_agents).unwrap();
487
488        let project_agents = project_dir.path().join(".agents");
489        std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
490
491        let result = MarsContext::new(project_dir.path().to_path_buf());
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn detect_managed_root_reads_settings() {
497        let dir = TempDir::new().unwrap();
498        std::fs::write(
499            dir.path().join("mars.toml"),
500            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
501        )
502        .unwrap();
503        let result = detect_managed_root(dir.path()).unwrap();
504        assert_eq!(result, dir.path().join(".claude"));
505    }
506
507    #[test]
508    fn detect_managed_root_falls_through_on_missing_config() {
509        let dir = TempDir::new().unwrap();
510        let result = detect_managed_root(dir.path()).unwrap();
511        assert_eq!(result, dir.path().join(".mars"));
512    }
513
514    #[test]
515    fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
516        let dir = TempDir::new().unwrap();
517        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
518        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
519
520        let result = detect_managed_root(dir.path()).unwrap();
521        assert_eq!(result, dir.path().join(".mars"));
522    }
523
524    #[test]
525    fn detect_managed_root_surfaces_parse_errors() {
526        let dir = TempDir::new().unwrap();
527        std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
528        let result = detect_managed_root(dir.path());
529        assert!(result.is_err());
530    }
531
532    #[test]
533    fn init_rejects_root_that_looks_like_managed_dir() {
534        let result = find_agents_root(Some(Path::new(".agents")));
535        assert!(result.is_err());
536        let err = result.unwrap_err().to_string();
537        assert!(
538            err.contains("managed output directory"),
539            "should reject .agents as --root: {err}"
540        );
541    }
542
543    // ── Walk-up discovery tests (filesystem root boundary) ──────────────────────────
544
545    #[test]
546    fn walk_up_crosses_git_boundary_to_find_config() {
547        // Outer has mars.toml, inner has .git but no mars.toml
548        // Starting from inner SHOULD find outer's config (git is irrelevant)
549        let dir = TempDir::new().unwrap();
550        let outer = dir.path().join("outer");
551        std::fs::create_dir_all(outer.join(".agents")).unwrap();
552        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
553
554        let inner = outer.join("inner");
555        std::fs::create_dir_all(inner.join(".git")).unwrap();
556
557        let ctx = find_agents_root_from(&inner).unwrap();
558        assert_eq!(
559            ctx.project_root,
560            dunce::canonicalize(&outer).unwrap(),
561            "should find outer config even when inner has .git"
562        );
563    }
564
565    #[test]
566    fn walk_up_finds_config_in_ancestor() {
567        let dir = TempDir::new().unwrap();
568        let root = dir.path().join("project");
569        std::fs::create_dir_all(root.join(".agents")).unwrap();
570        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
571
572        let subdir = root.join("src").join("lib");
573        std::fs::create_dir_all(&subdir).unwrap();
574
575        let ctx = find_agents_root_from(&subdir).unwrap();
576        assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
577    }
578
579    #[test]
580    fn walk_up_prefers_nearest_mars_toml() {
581        // child has package-only mars.toml, parent also has mars.toml
582        let dir = TempDir::new().unwrap();
583        let parent = dir.path().join("parent");
584        std::fs::create_dir_all(parent.join(".agents")).unwrap();
585        std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
586
587        let child = parent.join("child");
588        std::fs::create_dir_all(&child).unwrap();
589        std::fs::write(
590            child.join("mars.toml"),
591            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
592        )
593        .unwrap();
594
595        let ctx = find_agents_root_from(&child).unwrap();
596        assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
597    }
598
599    #[test]
600    fn walk_up_from_deep_subdirectory() {
601        let dir = TempDir::new().unwrap();
602        let root = dir.path().join("repo");
603        std::fs::create_dir_all(root.join(".agents")).unwrap();
604        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
605
606        let deep = root.join("src").join("foo").join("bar");
607        std::fs::create_dir_all(&deep).unwrap();
608
609        let ctx = find_agents_root_from(&deep).unwrap();
610        assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
611    }
612
613    #[test]
614    fn walk_up_crosses_submodule_boundary() {
615        // Outer repo has mars.toml
616        // Inner dir has .git FILE (submodule marker) — walk-up should still find outer config
617        let dir = TempDir::new().unwrap();
618        let outer = dir.path().join("outer");
619        std::fs::create_dir_all(outer.join(".agents")).unwrap();
620        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
621
622        let submodule = outer.join("submodule");
623        std::fs::create_dir_all(&submodule).unwrap();
624        // .git FILE (not dir) marks a submodule
625        std::fs::write(
626            submodule.join(".git"),
627            "gitdir: ../../.git/modules/submodule\n",
628        )
629        .unwrap();
630
631        let ctx = find_agents_root_from(&submodule).unwrap();
632        assert_eq!(
633            ctx.project_root,
634            dunce::canonicalize(&outer).unwrap(),
635            "should find outer config through submodule .git file boundary"
636        );
637    }
638
639    #[test]
640    fn walk_up_errors_when_no_config_found() {
641        let dir = TempDir::new().unwrap();
642        let deep = dir.path().join("a").join("b").join("c");
643        std::fs::create_dir_all(&deep).unwrap();
644
645        let result = find_agents_root_from(&deep);
646        assert!(result.is_err());
647        let err = result.unwrap_err().to_string();
648        assert!(
649            err.contains("no mars.toml found"),
650            "should report no config found: {err}"
651        );
652        assert!(
653            err.contains("filesystem root"),
654            "should mention filesystem root: {err}"
655        );
656    }
657
658    #[test]
659    fn walk_up_with_root_flag_starts_from_specified_path() {
660        let dir = TempDir::new().unwrap();
661        let project = dir.path().join("project");
662        std::fs::create_dir_all(project.join(".agents")).unwrap();
663        std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
664
665        // --root points to subdirectory — walk up should find mars.toml in parent
666        let subdir = project.join("src");
667        std::fs::create_dir_all(&subdir).unwrap();
668
669        let ctx = find_agents_root(Some(&subdir)).unwrap();
670        assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
671    }
672}