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)]
54pub(crate) struct AgentInfo {
55 pub(crate) name: String,
56 version: String,
57 pub(crate) description: String,
58 read_paths: Option<String>,
63}
64
65#[derive(serde::Serialize)]
68pub(crate) struct ListedAgent {
69 #[serde(flatten)]
70 pub(crate) info: AgentInfo,
71 pub(crate) source: &'static str,
73 pub(crate) path: String,
74}
75
76#[derive(serde::Serialize)]
78pub(crate) struct ListReport {
79 pub(crate) agents: Vec<ListedAgent>,
81 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
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
207pub(crate) fn build_list_report(
211 agents_dir: &Path,
212 cwd: &Path,
213 config: &Config,
214 filter: ListFilter,
215) -> ListReport {
216 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
259fn 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
270fn 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 let mut found_runnable = false;
286
287 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 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 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 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 if filter.shows_blueprints() {
359 println!("Bundled agents (install with `lev setup`):");
360 println!(" {}", builtin_names.join(", "));
361 println!();
362 }
363
364 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
381fn get_agents_dir_or_error(dir: Option<PathBuf>) -> anyhow::Result<PathBuf> {
385 dir.ok_or(anyhow::anyhow!("Could not determine home directory"))
386}
387
388fn 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 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 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 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[test]
722 fn scan_directory_with_both_direct_and_subdirs() {
723 let dir = tempfile::tempdir().unwrap();
724 write_manifest(dir.path(), "root-agent");
726 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 #[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 #[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 #[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 #[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 #[tokio::test]
812 async fn execute_runs_without_error() {
813 crate::config::with_isolated_config_path_async("list-runs-ok", |_fake_dir| async move {
816 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 crate::config::with_isolated_config_path_async("list-dir-err", |_fake_dir| async move {
833 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 #[cfg(unix)]
864 #[tokio::test]
865 async fn execute_falls_back_to_default_cwd_when_current_dir_is_gone() {
866 crate::config::with_isolated_config_path_async("list-cwd-gone", |_fake_dir| async move {
869 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 #[tokio::test]
896 async fn execute_falls_back_to_default_cwd_via_forced_error() {
897 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 #[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 #[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 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 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 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 #[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 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 #[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 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 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 #[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 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 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 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 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 #[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 #[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}