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