Skip to main content

leviath_cli/commands/
list.rs

1//! `lev list` - List available agents and blueprints
2
3use clap::Args;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use super::resolve_cwd;
8use crate::config::Config;
9use leviath_core::manifest::parse_manifest;
10
11#[derive(Args)]
12pub struct ListArgs {
13    /// Filter by type (agents, blueprints, all)
14    #[arg(short, long, default_value = "all")]
15    pub filter: String,
16}
17
18/// Info parsed from an agent manifest for display.
19struct AgentInfo {
20    name: String,
21    version: String,
22    description: String,
23    /// The agent's `[read_paths]` grant status under the active config, when it
24    /// declares any. Shown because a declaration nothing grants is inert, and
25    /// the listing is where someone looks before running an agent they just
26    /// installed or copied over from another machine.
27    read_paths: Option<String>,
28}
29
30fn read_agent_info(manifest_path: &Path, config: &Config, cwd: &Path) -> Option<AgentInfo> {
31    let content = fs::read_to_string(manifest_path).ok()?;
32    let blueprint = parse_manifest(&content).ok()?;
33    let read_paths = read_path_summary(&blueprint, config, cwd);
34    Some(AgentInfo {
35        name: blueprint.name,
36        version: blueprint.version,
37        description: blueprint.description,
38        read_paths,
39    })
40}
41
42/// The one-line `[read_paths]` verdict for an agent, or `None` when it declares
43/// none. A config whose own grant list is broken says so here rather than
44/// staying silent; `lev validate` and the spawn error carry the detail.
45fn read_path_summary(
46    blueprint: &leviath_core::Blueprint,
47    config: &Config,
48    cwd: &Path,
49) -> Option<String> {
50    match crate::read_path_report::build(blueprint, config, cwd)? {
51        Ok(report) if report.has_ungranted() => Some(format!(
52            "read_paths: {} - `lev validate` shows which",
53            report.summary()
54        )),
55        Ok(report) => Some(format!("read_paths: {}", report.summary())),
56        Err(e) => Some(format!("read_paths: {e}")),
57    }
58}
59
60fn scan_directory_for_agents(dir: &Path, config: &Config, cwd: &Path) -> Vec<(PathBuf, AgentInfo)> {
61    let mut agents = Vec::new();
62    if !dir.exists() {
63        return agents;
64    }
65
66    // Check if this directory itself has an agent.leviath
67    let direct_manifest = dir.join("agent.leviath");
68    if direct_manifest.exists()
69        && let Some(info) = read_agent_info(&direct_manifest, config, cwd)
70    {
71        agents.push((dir.to_path_buf(), info));
72    }
73
74    // Check subdirectories
75    if let Ok(entries) = fs::read_dir(dir) {
76        for entry in entries.flatten() {
77            let path = entry.path();
78            if path.is_dir() {
79                let manifest_path = path.join("agent.leviath");
80                if manifest_path.exists()
81                    && let Some(info) = read_agent_info(&manifest_path, config, cwd)
82                {
83                    agents.push((path, info));
84                }
85            }
86        }
87    }
88
89    agents
90}
91
92/// One agent's listing: the name line every section shares, plus the
93/// `[read_paths]` line when there is one.
94fn print_agent(info: &AgentInfo) {
95    let desc = if info.description.is_empty() {
96        String::new()
97    } else {
98        format!(" - {}", info.description)
99    };
100    println!("  {} (v{}){}", info.name, info.version, desc);
101    if let Some(read_paths) = &info.read_paths {
102        println!("      {read_paths}");
103    }
104}
105
106pub async fn execute(_args: ListArgs) -> anyhow::Result<()> {
107    // Propagate, don't default: a config that exists but doesn't parse would
108    // silently list from the default `agent_paths`, hiding the user's own
109    // agent directories with no hint why (a missing file loads as defaults).
110    let config = Config::load()?;
111    let agents_dir = get_agents_dir()?;
112    let cwd = resolve_cwd().unwrap_or_default();
113    let exe_dir = std::env::current_exe()
114        .ok()
115        .and_then(|p| p.parent().map(|p| p.to_path_buf()));
116
117    print_agent_listing(&agents_dir, &cwd, exe_dir.as_deref(), &config)
118}
119
120/// Core `lev list` logic, parameterized by every real-environment source it
121/// reads from so it can be tested against tempdirs instead of the real
122/// home directory / CWD / executable location / config.
123fn print_agent_listing(
124    agents_dir: &Path,
125    cwd: &Path,
126    exe_dir: Option<&Path>,
127    config: &Config,
128) -> anyhow::Result<()> {
129    // Tracks whether the user has any agent they can actually *run*. The
130    // bundled catalog deliberately does not count: it is always non-empty, and
131    // treating it as "you have agents" would suppress the get-started guidance
132    // for exactly the person who needs it - someone with a fresh install and
133    // nothing installed yet.
134    let mut found_runnable = false;
135
136    // 1. Installed agents (~/.leviath/agents/)
137    let installed = scan_directory_for_agents(agents_dir, config, cwd);
138    if !installed.is_empty() {
139        found_runnable = true;
140        println!("Installed agents (~/.leviath/agents/):");
141        for (_path, info) in &installed {
142            print_agent(info);
143        }
144        println!();
145    }
146
147    // 2. Local (current directory)
148    let local_manifest = cwd.join("agent.leviath");
149    if local_manifest.exists()
150        && let Some(info) = read_agent_info(&local_manifest, config, cwd)
151    {
152        found_runnable = true;
153        println!("Local (current directory):");
154        print_agent(&info);
155        println!();
156    }
157
158    // 3. Config's agent_paths directories
159    let mut config_agents = Vec::new();
160    for agent_path in &config.agent_paths {
161        let found = scan_directory_for_agents(agent_path, config, cwd);
162        config_agents.extend(found);
163    }
164    if !config_agents.is_empty() {
165        found_runnable = true;
166        println!("From configured paths:");
167        for (_path, info) in &config_agents {
168            print_agent(info);
169        }
170        println!();
171    }
172
173    // 4. Bundled agents - the blueprints embedded in this binary.
174    //
175    // Reports the embedded catalog, which is what `lev setup` installs from.
176    // Scanning only `<exe_dir>/agents` would leave this section blank outside a
177    // git checkout - a directory no real install has. The on-disk scan stays as
178    // a second source so a checkout or a packaging layout that *does* ship an
179    // `agents/` dir next to the binary still shows up.
180    let mut builtin_names: Vec<String> = crate::bundled::BUNDLED_AGENTS
181        .iter()
182        .map(|a| format!("{} (v{})", a.name, a.version))
183        .collect();
184    if let Some(exe_dir) = exe_dir {
185        for (_path, info) in scan_directory_for_agents(&exe_dir.join("agents"), config, cwd) {
186            let entry = format!("{} (v{})", info.name, info.version);
187            if !builtin_names.contains(&entry) {
188                builtin_names.push(entry);
189            }
190        }
191    }
192    // No emptiness guard: the embedded catalog is always populated (a build
193    // that found no blueprints fails `bundled`'s own invariant test), so an
194    // `if !builtin_names.is_empty()` here would be a branch that can never be
195    // false - unreachable code dressed up as a handled case.
196    println!("Bundled agents (install with `lev setup`):");
197    println!("  {}", builtin_names.join(", "));
198    println!();
199
200    if !found_runnable {
201        println!("No agents installed yet.");
202        println!();
203        println!("To install the bundled agents:");
204        println!("  lev setup");
205        println!();
206        println!("To create your own:");
207        println!("  lev create my-agent");
208    }
209
210    Ok(())
211}
212
213/// Core `get_agents_dir` logic, parameterized by the home directory so the
214/// "could not determine home directory" error path can be unit tested
215/// without depending on the real environment.
216fn get_agents_dir_or_error(dir: Option<PathBuf>) -> anyhow::Result<PathBuf> {
217    dir.ok_or(anyhow::anyhow!("Could not determine home directory"))
218}
219
220/// Resolve `~/.leviath/agents`, the directory `lev list` scans for installed
221/// agents.
222///
223/// A thin wrapper over [`get_agents_dir_or_error`] supplying the real
224/// resolved directory. The `#[cfg(test)]` guard below only lets tests force the
225/// "no home directory" error arm of `execute()` deterministically - the real
226/// the shared resolver can't be made to return `None` in any environment a
227/// test may safely create (on macOS `dirs::home_dir()` falls back to a
228/// passwd-database lookup independent of `$HOME`). It does NOT hide the real
229/// body from coverage: with the toggle off, `get_agents_dir_or_error(
230/// leviath_core::paths::agents_dir())` runs (and is measured) in every ordinary test, and
231/// only computes a `PathBuf` (no filesystem writes). The `None` arm of
232/// `get_agents_dir_or_error` is covered directly by
233/// `get_agents_dir_or_error_none_returns_error`.
234fn get_agents_dir() -> anyhow::Result<PathBuf> {
235    #[cfg(test)]
236    if FORCE_AGENTS_DIR_ERROR.with(|f| f.get()) {
237        anyhow::bail!("Could not determine home directory");
238    }
239    get_agents_dir_or_error(leviath_core::paths::agents_dir())
240}
241
242#[cfg(test)]
243thread_local! {
244    /// Test-only toggle letting `execute_returns_err_when_agents_dir_unresolvable`
245    /// force `get_agents_dir`'s `Err` arm deterministically.
246    static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::test_support::write_test_agent;
253
254    fn write_manifest(dir: &Path, name: &str) {
255        write_manifest_with_description(dir, name, "Test agent");
256    }
257
258    /// The scanners under a config that grants nothing, which is what every
259    /// test predating `[read_paths]` reporting assumed. Tests that care about
260    /// grants call the real functions with a config of their own.
261    fn read_agent_info(manifest_path: &Path) -> Option<AgentInfo> {
262        super::read_agent_info(manifest_path, &Config::default(), Path::new("/work"))
263    }
264
265    fn scan_directory_for_agents(dir: &Path) -> Vec<(PathBuf, AgentInfo)> {
266        super::scan_directory_for_agents(dir, &Config::default(), Path::new("/work"))
267    }
268
269    fn write_manifest_with_description(dir: &Path, name: &str, description: &str) {
270        let content = format!(
271            r#"[agent]
272name = "{}"
273version = "1.0.0"
274description = "{}"
275
276[stages.main]
277mode = "autonomous"
278model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
279description = "Main"
280max_iterations = 5
281
282[context.regions]
283system = {{ kind = "pinned", max_tokens = 1000 }}
284"#,
285            name, description
286        );
287        write_test_agent(dir, content);
288    }
289
290    /// An agent that asks to read outside its workdir, for the grant-status
291    /// line. Written as an absolute entry so it compiles the same on every OS.
292    fn write_read_paths_manifest(dir: &Path, name: &str) {
293        let content = format!(
294            r#"[agent]
295name = "{name}"
296version = "1.0.0"
297description = "Test agent"
298
299[stages.main]
300mode = "autonomous"
301model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
302description = "Main"
303max_iterations = 5
304
305[context.regions]
306system = {{ kind = "pinned", max_tokens = 1000 }}
307
308[read_paths]
309allow = ["/data/runs"]
310"#
311        );
312        write_test_agent(dir, content);
313    }
314
315    fn info_with_config(dir: &Path, config: &Config) -> AgentInfo {
316        super::read_agent_info(&dir.join("agent.leviath"), config, Path::new("/work"))
317            .expect("manifest parses")
318    }
319
320    /// The reported bug, in the listing: an agent whose declarations nothing
321    /// grants must say so, and point at where the detail is.
322    #[test]
323    fn an_ungranted_read_paths_declaration_is_listed_as_such() {
324        let dir = tempfile::tempdir().unwrap();
325        write_read_paths_manifest(dir.path(), "cto");
326        let summary = info_with_config(dir.path(), &Config::default())
327            .read_paths
328            .expect("declares read paths");
329        assert!(summary.contains("1 declared, 0 granted"), "{summary}");
330        assert!(summary.contains("lev validate"), "{summary}");
331    }
332
333    #[test]
334    fn a_granted_read_paths_declaration_needs_no_pointer() {
335        let dir = tempfile::tempdir().unwrap();
336        write_read_paths_manifest(dir.path(), "cto");
337        let mut config = Config::default();
338        config.security.read_paths = vec!["/data/runs".to_string()];
339        let summary = info_with_config(dir.path(), &config)
340            .read_paths
341            .expect("declares read paths");
342        assert_eq!(summary, "read_paths: 1 declared, 1 granted");
343    }
344
345    /// A grant list that cannot compile is a hard spawn error later; saying so
346    /// here beats printing a count derived from nothing.
347    #[test]
348    fn a_broken_grant_list_is_reported_on_the_agent() {
349        let dir = tempfile::tempdir().unwrap();
350        write_read_paths_manifest(dir.path(), "cto");
351        let mut config = Config::default();
352        config.security.read_paths = vec!["regex:relative/.*".to_string()];
353        let summary = info_with_config(dir.path(), &config)
354            .read_paths
355            .expect("declares read paths");
356        assert!(summary.contains("config.toml"), "{summary}");
357    }
358
359    #[test]
360    fn an_agent_declaring_no_read_paths_gets_no_line() {
361        let dir = tempfile::tempdir().unwrap();
362        write_manifest(dir.path(), "plain");
363        assert!(
364            info_with_config(dir.path(), &Config::default())
365                .read_paths
366                .is_none()
367        );
368    }
369
370    #[test]
371    fn read_agent_info_valid_manifest() {
372        let dir = tempfile::tempdir().unwrap();
373        write_manifest(dir.path(), "my-agent");
374        let info = read_agent_info(&dir.path().join("agent.leviath")).unwrap();
375        assert_eq!(info.name, "my-agent");
376        assert_eq!(info.version, "1.0.0");
377        assert_eq!(info.description, "Test agent");
378    }
379
380    #[test]
381    fn read_agent_info_missing_file_returns_none() {
382        let result = read_agent_info(Path::new("/nonexistent/agent.leviath"));
383        assert!(result.is_none());
384    }
385
386    #[test]
387    fn read_agent_info_invalid_toml_returns_none() {
388        let dir = tempfile::tempdir().unwrap();
389        fs::write(dir.path().join("agent.leviath"), "not valid toml {{{{").unwrap();
390        let result = read_agent_info(&dir.path().join("agent.leviath"));
391        assert!(result.is_none());
392    }
393
394    #[test]
395    fn scan_directory_nonexistent_returns_empty() {
396        let agents = scan_directory_for_agents(Path::new("/nonexistent/path"));
397        assert!(agents.is_empty());
398    }
399
400    #[test]
401    fn scan_directory_path_is_a_file_returns_empty() {
402        // `dir.exists()` is true for a plain file too, so this reaches
403        // `fs::read_dir(dir)` - which fails with "not a directory",
404        // exercising the `if let Ok(entries) = ...` construct's implicit
405        // (no-`else`) false arm that no other test hits.
406        let tmp = tempfile::tempdir().unwrap();
407        let file_path = tmp.path().join("not-a-directory.txt");
408        fs::write(&file_path, "hello").unwrap();
409        let agents = scan_directory_for_agents(&file_path);
410        assert!(agents.is_empty());
411    }
412
413    #[test]
414    fn scan_directory_direct_manifest_invalid_is_skipped() {
415        // The direct-manifest branch (as opposed to the subdirectory-scan
416        // branch, covered separately by `scan_directory_subdir_with_invalid_manifest`)
417        // has its own `if let Some(info) = read_agent_info(...)` - this
418        // exercises that branch's `None` arm when the manifest at the
419        // directory's own root is present but unparseable.
420        let dir = tempfile::tempdir().unwrap();
421        fs::write(dir.path().join("agent.leviath"), "not valid toml {{{{").unwrap();
422        let agents = scan_directory_for_agents(dir.path());
423        assert!(agents.is_empty());
424    }
425
426    #[test]
427    fn scan_directory_with_direct_manifest() {
428        let dir = tempfile::tempdir().unwrap();
429        write_manifest(dir.path(), "direct-agent");
430        let agents = scan_directory_for_agents(dir.path());
431        assert_eq!(agents.len(), 1);
432        assert_eq!(agents[0].1.name, "direct-agent");
433    }
434
435    #[test]
436    fn scan_directory_with_subdirectories() {
437        let dir = tempfile::tempdir().unwrap();
438        let sub1 = dir.path().join("agent-a");
439        let sub2 = dir.path().join("agent-b");
440        fs::create_dir_all(&sub1).unwrap();
441        fs::create_dir_all(&sub2).unwrap();
442        write_manifest(&sub1, "agent-a");
443        write_manifest(&sub2, "agent-b");
444
445        let agents = scan_directory_for_agents(dir.path());
446        assert_eq!(agents.len(), 2);
447        let names: Vec<&str> = agents.iter().map(|a| a.1.name.as_str()).collect();
448        assert!(names.contains(&"agent-a"));
449        assert!(names.contains(&"agent-b"));
450    }
451
452    #[test]
453    fn scan_directory_ignores_subdirs_without_manifest() {
454        let dir = tempfile::tempdir().unwrap();
455        let sub = dir.path().join("no-manifest");
456        fs::create_dir_all(&sub).unwrap();
457        fs::write(sub.join("readme.txt"), "not a manifest").unwrap();
458
459        let agents = scan_directory_for_agents(dir.path());
460        assert!(agents.is_empty());
461    }
462
463    #[test]
464    fn list_args_default_filter() {
465        let args = ListArgs {
466            filter: "all".to_string(),
467        };
468        assert_eq!(args.filter, "all");
469    }
470
471    // ─── read_agent_info: description and version ───────────────────────
472
473    #[test]
474    fn read_agent_info_extracts_description() {
475        let dir = tempfile::tempdir().unwrap();
476        write_manifest(dir.path(), "my-agent");
477        let info = read_agent_info(&dir.path().join("agent.leviath")).unwrap();
478        assert_eq!(info.description, "Test agent");
479        assert_eq!(info.version, "1.0.0");
480    }
481
482    // ─── scan_directory: nested but not deep ────────────────────────────
483
484    #[test]
485    fn scan_directory_with_both_direct_and_subdirs() {
486        let dir = tempfile::tempdir().unwrap();
487        // Direct manifest
488        write_manifest(dir.path(), "root-agent");
489        // Subdirectory with manifest
490        let sub = dir.path().join("child");
491        fs::create_dir_all(&sub).unwrap();
492        write_manifest(&sub, "child-agent");
493
494        let agents = scan_directory_for_agents(dir.path());
495        assert_eq!(agents.len(), 2);
496        let names: Vec<&str> = agents.iter().map(|a| a.1.name.as_str()).collect();
497        assert!(names.contains(&"root-agent"));
498        assert!(names.contains(&"child-agent"));
499    }
500
501    // ─── scan_directory: empty directory ────────────────────────────────
502
503    #[test]
504    fn scan_directory_empty_dir() {
505        let dir = tempfile::tempdir().unwrap();
506        let agents = scan_directory_for_agents(dir.path());
507        assert!(agents.is_empty());
508    }
509
510    // ─── scan_directory: subdirectory with invalid manifest ─────────────
511
512    #[test]
513    fn scan_directory_subdir_with_invalid_manifest() {
514        let dir = tempfile::tempdir().unwrap();
515        let sub = dir.path().join("bad-agent");
516        fs::create_dir_all(&sub).unwrap();
517        fs::write(sub.join("agent.leviath"), "invalid toml {{{{").unwrap();
518
519        let agents = scan_directory_for_agents(dir.path());
520        assert!(agents.is_empty());
521    }
522
523    // ─── get_agents_dir ────────────────────────────────────────────────
524
525    #[test]
526    fn get_agents_dir_returns_path_with_agents() {
527        let dir = get_agents_dir().unwrap();
528        assert!(dir.to_str().unwrap().contains(".leviath"));
529        assert!(dir.to_str().unwrap().ends_with("agents"));
530    }
531
532    #[test]
533    fn get_agents_dir_or_error_some_returns_path() {
534        let dir = PathBuf::from("/home/testuser/.leviath/agents");
535        assert_eq!(get_agents_dir_or_error(Some(dir.clone())).unwrap(), dir);
536    }
537
538    #[test]
539    fn get_agents_dir_or_error_none_returns_error() {
540        let err = get_agents_dir_or_error(None).unwrap_err();
541        assert!(
542            err.to_string()
543                .contains("Could not determine home directory")
544        );
545    }
546
547    // ─── read_agent_info: minimal manifest ──────────────────────────────
548
549    #[test]
550    fn read_agent_info_minimal_manifest() {
551        let dir = tempfile::tempdir().unwrap();
552        let content = r#"[agent]
553name = "minimal"
554version = "0.0.1"
555description = ""
556
557[stages.main]
558mode = "autonomous"
559model = { provider = "anthropic", model = "claude-sonnet-4-6" }
560description = "Main"
561max_iterations = 5
562
563[context.regions]
564system = { kind = "pinned", max_tokens = 1000 }
565"#;
566        write_test_agent(dir.path(), content);
567        let info = read_agent_info(&dir.path().join("agent.leviath")).unwrap();
568        assert_eq!(info.name, "minimal");
569        assert_eq!(info.description, "");
570    }
571
572    // ─── execute() smoke test (real environment) ────────────────────────
573
574    #[tokio::test]
575    async fn execute_runs_without_error() {
576        // Isolated: this reaches `Config::load()`, which reads process-wide
577        // environment. Unisolated it races every `temp_env` test in the binary.
578        crate::config::with_isolated_config_path_async("list-runs-ok", |_fake_dir| async move {
579            // Touches the real environment (home dir / CWD / exe location /
580            // config) but must always succeed regardless of what it finds.
581            let args = ListArgs {
582                filter: "all".to_string(),
583            };
584            let result = execute(args).await;
585            assert!(result.is_ok());
586        })
587        .await;
588    }
589
590    #[tokio::test]
591    async fn execute_returns_err_when_agents_dir_unresolvable() {
592        // Isolated: this reaches `Config::load()`, which reads process-wide
593        // environment. Unisolated it races every `temp_env` test in the binary.
594        crate::config::with_isolated_config_path_async("list-dir-err", |_fake_dir| async move {
595            // Drives `execute`'s `get_agents_dir()?` error-propagation branch
596            // for real via the test-only `FORCE_AGENTS_DIR_ERROR` toggle on
597            // `get_agents_dir`'s twin (see its doc comment for why the real
598            // implementation's failure can't be forced directly).
599            FORCE_AGENTS_DIR_ERROR.with(|f| f.set(true));
600            let args = ListArgs {
601                filter: "all".to_string(),
602            };
603            let result = execute(args).await;
604            FORCE_AGENTS_DIR_ERROR.with(|f| f.set(false));
605
606            let err = result.unwrap_err();
607            assert!(
608                err.to_string()
609                    .contains("Could not determine home directory")
610            );
611        })
612        .await;
613    }
614
615    // `execute`'s `std::env::current_dir().unwrap_or_default()` can only take
616    // its `Err` arm in a real (if rare) TOCTOU scenario: the process's CWD is
617    // removed out from under it. That's genuinely reproducible on Unix (not a
618    // fake): create a directory, `chdir` into it, then delete it --
619    // `current_dir()` then reliably returns an error. On Windows this same
620    // sequence isn't reproducible: NTFS/Win32 refuse to remove a directory
621    // that's a live process's current working directory (a sharing
622    // violation), so `remove_dir_all` itself fails there instead of
623    // succeeding - confirmed via real Windows CI. Unix-only.
624    #[cfg(unix)]
625    #[tokio::test]
626    async fn execute_falls_back_to_default_cwd_when_current_dir_is_gone() {
627        // Isolated: this reaches `Config::load()`, which reads process-wide
628        // environment. Unisolated it races every `temp_env` test in the binary.
629        crate::config::with_isolated_config_path_async("list-cwd-gone", |_fake_dir| async move {
630            // `isolate_cwd_for_test` serializes against every other CWD-mutating
631            // test in the crate and restores CWD automatically on drop, so it's
632            // safe to hold across the `.await` below.
633            let _guard = crate::config::isolate_cwd_for_test();
634            let dir = std::env::temp_dir().join("lev-test-list-cwd-gone");
635            let _ = std::fs::remove_dir_all(&dir);
636            std::fs::create_dir_all(&dir).unwrap();
637            std::env::set_current_dir(&dir).unwrap();
638            std::fs::remove_dir_all(&dir).unwrap();
639
640            let args = ListArgs {
641                filter: "all".to_string(),
642            };
643            let result = execute(args).await;
644
645            assert!(result.is_ok());
646        })
647        .await;
648    }
649
650    /// Cross-platform companion to the Unix-only real-filesystem test above:
651    /// forces [`resolve_cwd`]'s `Err` arm deterministically via
652    /// [`super::super::force_cwd_error`] so `execute`'s `unwrap_or_default()` fallback is
653    /// also exercised on Windows, where the real filesystem race isn't
654    /// reproducible.
655    #[tokio::test]
656    async fn execute_falls_back_to_default_cwd_via_forced_error() {
657        // Isolated: this reaches `Config::load()`, which reads process-wide
658        // environment. Unisolated it races every `temp_env` test in the binary.
659        crate::config::with_isolated_config_path_async("list-cwd-forced", |_fake_dir| async move {
660            crate::commands::force_cwd_error(true);
661            let args = ListArgs {
662                filter: "all".to_string(),
663            };
664            let result = execute(args).await;
665            crate::commands::force_cwd_error(false);
666
667            assert!(result.is_ok());
668        })
669        .await;
670    }
671
672    /// A config that exists but doesn't parse must fail the command, not
673    /// silently list from the default `agent_paths` (regression: this used to
674    /// be `unwrap_or_default()`, which hid the user's agent directories with
675    /// no hint why).
676    #[tokio::test]
677    async fn execute_fails_loudly_on_a_broken_config() {
678        crate::config::with_isolated_config_path_async(
679            "list-broken-config",
680            |fake_dir| async move {
681                std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
682                let args = ListArgs {
683                    filter: "all".to_string(),
684                };
685                let err = execute(args).await.expect_err("broken config must error");
686                assert!(err.to_string().contains("parse"), "{err}");
687            },
688        )
689        .await;
690    }
691
692    // ─── print_agent_listing (fully injectable) ─────────────────────────
693
694    #[test]
695    fn print_agent_listing_nothing_installed() {
696        // The bundled catalog is always non-empty, so it must not count as
697        // "you have agents" - otherwise the get-started guidance would be
698        // suppressed for exactly the fresh install that needs it.
699        let agents_dir = tempfile::tempdir().unwrap();
700        let cwd = tempfile::tempdir().unwrap();
701        let config = Config::default();
702
703        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
704        assert!(result.is_ok());
705    }
706
707    #[test]
708    fn print_agent_listing_finds_installed_agent() {
709        let agents_dir = tempfile::tempdir().unwrap();
710        let sub = agents_dir.path().join("installed-agent");
711        fs::create_dir_all(&sub).unwrap();
712        write_manifest(&sub, "installed-agent");
713
714        let cwd = tempfile::tempdir().unwrap();
715        let config = Config::default();
716
717        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
718        assert!(result.is_ok());
719    }
720
721    #[test]
722    fn print_agent_listing_finds_local_manifest() {
723        let agents_dir = tempfile::tempdir().unwrap();
724        let cwd = tempfile::tempdir().unwrap();
725        write_manifest(cwd.path(), "local-agent");
726        let config = Config::default();
727
728        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
729        assert!(result.is_ok());
730    }
731
732    #[test]
733    fn print_agent_listing_local_manifest_invalid_is_skipped() {
734        // The local-manifest section has its own `if let Some(info) = ...`
735        // construct with no `else`; this exercises its false arm (an
736        // existing but unparseable `agent.leviath` in the cwd), which
737        // `print_agent_listing_finds_local_manifest` (valid manifest) never
738        // reaches.
739        let agents_dir = tempfile::tempdir().unwrap();
740        let cwd = tempfile::tempdir().unwrap();
741        fs::write(cwd.path().join("agent.leviath"), "not valid toml {{{{").unwrap();
742        let config = Config::default();
743
744        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
745        assert!(result.is_ok());
746    }
747
748    #[test]
749    fn print_agent_listing_finds_configured_path_agent() {
750        let agents_dir = tempfile::tempdir().unwrap();
751        let cwd = tempfile::tempdir().unwrap();
752        let configured = tempfile::tempdir().unwrap();
753        let sub = configured.path().join("configured-agent");
754        fs::create_dir_all(&sub).unwrap();
755        write_manifest(&sub, "configured-agent");
756
757        let config = Config {
758            agent_paths: vec![configured.path().to_path_buf()],
759            ..Config::default()
760        };
761
762        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
763        assert!(result.is_ok());
764    }
765
766    #[test]
767    fn print_agent_listing_finds_builtin_agents() {
768        // An `agents/` directory beside the executable contributes a blueprint
769        // the embedded catalog doesn't have, so it is appended to the list.
770        let agents_dir = tempfile::tempdir().unwrap();
771        let cwd = tempfile::tempdir().unwrap();
772        let exe_dir = tempfile::tempdir().unwrap();
773        let builtin_dir = exe_dir.path().join("agents");
774        let sub = builtin_dir.join("builtin-agent");
775        fs::create_dir_all(&sub).unwrap();
776        write_manifest(&sub, "builtin-agent");
777        let config = Config::default();
778
779        let result =
780            print_agent_listing(agents_dir.path(), cwd.path(), Some(exe_dir.path()), &config);
781        assert!(result.is_ok());
782    }
783
784    /// The listing prints the `[read_paths]` line for an agent that has one,
785    /// which is the second line `print_agent` can emit.
786    #[test]
787    fn print_agent_listing_carries_the_read_paths_line() {
788        let agents_dir = tempfile::tempdir().unwrap();
789        let agent = agents_dir.path().join("cto");
790        fs::create_dir_all(&agent).unwrap();
791        write_read_paths_manifest(&agent, "cto");
792        let cwd = tempfile::tempdir().unwrap();
793
794        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &Config::default());
795
796        assert!(result.is_ok());
797        // The line itself is asserted where it is built, without capturing
798        // stdout; this is the path that reaches the printer with one to print.
799        assert!(
800            info_with_config(&agent, &Config::default())
801                .read_paths
802                .is_some()
803        );
804    }
805
806    #[test]
807    fn print_agent_listing_does_not_list_a_bundled_agent_twice() {
808        // Running from a git checkout puts the *same* blueprints both in the
809        // embedded catalog and in `<exe_dir>/agents`. Listing each one twice
810        // would be pure noise, so the on-disk scan only appends what the
811        // catalog doesn't already carry.
812        let bundled = &crate::bundled::BUNDLED_AGENTS[0];
813        let agents_dir = tempfile::tempdir().unwrap();
814        let cwd = tempfile::tempdir().unwrap();
815        let exe_dir = tempfile::tempdir().unwrap();
816        let sub = exe_dir.path().join("agents").join(bundled.name);
817        fs::create_dir_all(&sub).unwrap();
818        crate::bundled::install_bundled(bundled, &exe_dir.path().join("agents")).unwrap();
819        let config = Config::default();
820
821        let result =
822            print_agent_listing(agents_dir.path(), cwd.path(), Some(exe_dir.path()), &config);
823
824        assert!(result.is_ok());
825        // The same name+version pair the catalog already holds resolves to one
826        // entry, not two.
827        let entry = format!("{} (v{})", bundled.name, bundled.version);
828        let names: Vec<String> = crate::bundled::BUNDLED_AGENTS
829            .iter()
830            .map(|a| format!("{} (v{})", a.name, a.version))
831            .collect();
832        assert_eq!(names.iter().filter(|n| **n == entry).count(), 1);
833    }
834
835    #[test]
836    fn print_agent_listing_all_sources_populated() {
837        let agents_dir = tempfile::tempdir().unwrap();
838        fs::create_dir_all(agents_dir.path().join("installed")).unwrap();
839        write_manifest(&agents_dir.path().join("installed"), "installed");
840
841        let cwd = tempfile::tempdir().unwrap();
842        write_manifest(cwd.path(), "local");
843
844        let configured = tempfile::tempdir().unwrap();
845        fs::create_dir_all(configured.path().join("configured")).unwrap();
846        write_manifest(&configured.path().join("configured"), "configured");
847
848        let exe_dir = tempfile::tempdir().unwrap();
849        let builtin_sub = exe_dir.path().join("agents").join("builtin");
850        fs::create_dir_all(&builtin_sub).unwrap();
851        write_manifest(&builtin_sub, "builtin");
852
853        let config = Config {
854            agent_paths: vec![configured.path().to_path_buf()],
855            ..Config::default()
856        };
857
858        let result =
859            print_agent_listing(agents_dir.path(), cwd.path(), Some(exe_dir.path()), &config);
860        assert!(result.is_ok());
861    }
862
863    #[test]
864    fn print_agent_listing_empty_descriptions_across_all_sources() {
865        // Every section (installed / local / configured-path) has its own
866        // "empty description -> no dash suffix" branch; the tests above only
867        // ever exercise the non-empty path for all three, since
868        // `write_manifest` hardcodes a non-empty description.
869        let agents_dir = tempfile::tempdir().unwrap();
870        fs::create_dir_all(agents_dir.path().join("installed")).unwrap();
871        write_manifest_with_description(&agents_dir.path().join("installed"), "installed", "");
872
873        let cwd = tempfile::tempdir().unwrap();
874        write_manifest_with_description(cwd.path(), "local", "");
875
876        let configured = tempfile::tempdir().unwrap();
877        fs::create_dir_all(configured.path().join("configured")).unwrap();
878        write_manifest_with_description(&configured.path().join("configured"), "configured", "");
879
880        let config = Config {
881            agent_paths: vec![configured.path().to_path_buf()],
882            ..Config::default()
883        };
884
885        let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
886        assert!(result.is_ok());
887    }
888
889    // ─── scan_directory: agent with empty description ────────────────────
890
891    #[test]
892    fn scan_directory_agent_with_empty_description() {
893        let dir = tempfile::tempdir().unwrap();
894        let sub = dir.path().join("my-agent");
895        fs::create_dir_all(&sub).unwrap();
896        let content = r#"[agent]
897name = "my-agent"
898version = "2.0.0"
899description = ""
900
901[stages.main]
902mode = "autonomous"
903model = { provider = "anthropic", model = "claude-sonnet-4-6" }
904description = "Main"
905max_iterations = 5
906
907[context.regions]
908system = { kind = "pinned", max_tokens = 1000 }
909"#;
910        write_test_agent(sub, content);
911
912        let agents = scan_directory_for_agents(dir.path());
913        assert_eq!(agents.len(), 1);
914        assert_eq!(agents[0].1.description, "");
915    }
916
917    // ─── scan_directory: multiple subdirs with mixed manifests ──────────
918
919    #[test]
920    fn scan_directory_mixed_valid_and_invalid() {
921        let dir = tempfile::tempdir().unwrap();
922        let good = dir.path().join("good");
923        let bad = dir.path().join("bad");
924        let empty = dir.path().join("empty");
925        fs::create_dir_all(&good).unwrap();
926        fs::create_dir_all(&bad).unwrap();
927        fs::create_dir_all(&empty).unwrap();
928
929        write_manifest(&good, "good-agent");
930        fs::write(bad.join("agent.leviath"), "bad {{ toml").unwrap();
931
932        let agents = scan_directory_for_agents(dir.path());
933        assert_eq!(agents.len(), 1);
934        assert_eq!(agents[0].1.name, "good-agent");
935    }
936}