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