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