1use clap::Args;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use super::resolve_cwd;
8use crate::config::Config;
9use leviath_core::manifest::parse_manifest;
10
11#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum)]
18pub enum ListFilter {
19 All,
21 Agents,
23 Blueprints,
25}
26
27impl ListFilter {
28 fn shows_agents(self) -> bool {
30 matches!(self, Self::All | Self::Agents)
31 }
32
33 fn shows_blueprints(self) -> bool {
35 matches!(self, Self::All | Self::Blueprints)
36 }
37}
38
39#[derive(Args)]
41pub struct ListArgs {
42 #[arg(short, long, value_enum, default_value_t = ListFilter::All)]
44 pub filter: ListFilter,
45
46 #[arg(long)]
49 pub json: bool,
50}
51
52#[derive(serde::Serialize)]
54struct AgentInfo {
55 name: String,
56 version: String,
57 description: String,
58 read_paths: Option<String>,
63}
64
65#[derive(serde::Serialize)]
68struct ListedAgent {
69 #[serde(flatten)]
70 info: AgentInfo,
71 source: &'static str,
73 path: String,
74}
75
76#[derive(serde::Serialize)]
78struct ListReport {
79 agents: Vec<ListedAgent>,
81 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
104fn 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 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 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
154fn 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
168pub async fn execute(args: ListArgs) -> anyhow::Result<()> {
170 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
186fn 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 println!(
201 "{}",
202 serde_json::to_string_pretty(&report).expect("an agent listing serializes")
203 );
204 Ok(())
205}
206
207fn build_list_report(
210 agents_dir: &Path,
211 cwd: &Path,
212 config: &Config,
213 filter: ListFilter,
214) -> ListReport {
215 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
258fn 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
269fn 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 let mut found_runnable = false;
285
286 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 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 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 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 if filter.shows_blueprints() {
358 println!("Bundled agents (install with `lev setup`):");
359 println!(" {}", builtin_names.join(", "));
360 println!();
361 }
362
363 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
380fn get_agents_dir_or_error(dir: Option<PathBuf>) -> anyhow::Result<PathBuf> {
384 dir.ok_or(anyhow::anyhow!("Could not determine home directory"))
385}
386
387fn 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 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 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 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[test]
721 fn scan_directory_with_both_direct_and_subdirs() {
722 let dir = tempfile::tempdir().unwrap();
723 write_manifest(dir.path(), "root-agent");
725 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 #[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 #[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 #[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 #[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 #[tokio::test]
811 async fn execute_runs_without_error() {
812 crate::config::with_isolated_config_path_async("list-runs-ok", |_fake_dir| async move {
815 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 crate::config::with_isolated_config_path_async("list-dir-err", |_fake_dir| async move {
832 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 #[cfg(unix)]
863 #[tokio::test]
864 async fn execute_falls_back_to_default_cwd_when_current_dir_is_gone() {
865 crate::config::with_isolated_config_path_async("list-cwd-gone", |_fake_dir| async move {
868 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 #[tokio::test]
895 async fn execute_falls_back_to_default_cwd_via_forced_error() {
896 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 #[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 #[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 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 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 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 #[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 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 #[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 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 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 #[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 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 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 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 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 #[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 #[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}