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