1use clap::Args;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::config::Config;
8use leviath_core::manifest::parse_manifest;
9
10#[derive(Args)]
11pub struct ListArgs {
12 #[arg(short, long, default_value = "all")]
14 pub filter: String,
15}
16
17struct AgentInfo {
19 name: String,
20 version: String,
21 description: String,
22}
23
24fn read_agent_info(manifest_path: &Path) -> Option<AgentInfo> {
25 let content = fs::read_to_string(manifest_path).ok()?;
26 let blueprint = parse_manifest(&content).ok()?;
27 Some(AgentInfo {
28 name: blueprint.name,
29 version: blueprint.version,
30 description: blueprint.description,
31 })
32}
33
34fn scan_directory_for_agents(dir: &Path) -> Vec<(PathBuf, AgentInfo)> {
35 let mut agents = Vec::new();
36 if !dir.exists() {
37 return agents;
38 }
39
40 let direct_manifest = dir.join("agent.leviath");
42 if direct_manifest.exists()
43 && let Some(info) = read_agent_info(&direct_manifest)
44 {
45 agents.push((dir.to_path_buf(), info));
46 }
47
48 if let Ok(entries) = fs::read_dir(dir) {
50 for entry in entries.flatten() {
51 let path = entry.path();
52 if path.is_dir() {
53 let manifest_path = path.join("agent.leviath");
54 if manifest_path.exists()
55 && let Some(info) = read_agent_info(&manifest_path)
56 {
57 agents.push((path, info));
58 }
59 }
60 }
61 }
62
63 agents
64}
65
66#[cfg(test)]
67thread_local! {
68 static FORCE_CWD_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
76}
77
78fn resolve_cwd() -> std::io::Result<PathBuf> {
82 #[cfg(test)]
83 if FORCE_CWD_ERROR.with(|f| f.get()) {
84 return Err(std::io::Error::other("forced CWD error for testing"));
85 }
86 std::env::current_dir()
87}
88
89pub async fn execute(_args: ListArgs) -> anyhow::Result<()> {
90 let config = Config::load()?;
94 let agents_dir = get_agents_dir()?;
95 let cwd = resolve_cwd().unwrap_or_default();
96 let exe_dir = std::env::current_exe()
97 .ok()
98 .and_then(|p| p.parent().map(|p| p.to_path_buf()));
99
100 print_agent_listing(&agents_dir, &cwd, exe_dir.as_deref(), &config)
101}
102
103fn print_agent_listing(
107 agents_dir: &Path,
108 cwd: &Path,
109 exe_dir: Option<&Path>,
110 config: &Config,
111) -> anyhow::Result<()> {
112 let mut found_runnable = false;
118
119 let installed = scan_directory_for_agents(agents_dir);
121 if !installed.is_empty() {
122 found_runnable = true;
123 println!("Installed agents (~/.leviath/agents/):");
124 for (_path, info) in &installed {
125 let desc = if info.description.is_empty() {
126 String::new()
127 } else {
128 format!(" - {}", info.description)
129 };
130 println!(" {} (v{}){}", info.name, info.version, desc);
131 }
132 println!();
133 }
134
135 let local_manifest = cwd.join("agent.leviath");
137 if local_manifest.exists()
138 && let Some(info) = read_agent_info(&local_manifest)
139 {
140 found_runnable = true;
141 let desc = if info.description.is_empty() {
142 String::new()
143 } else {
144 format!(" - {}", info.description)
145 };
146 println!("Local (current directory):");
147 println!(" {} (v{}){}", info.name, info.version, desc);
148 println!();
149 }
150
151 let mut config_agents = Vec::new();
153 for agent_path in &config.agent_paths {
154 let found = scan_directory_for_agents(agent_path);
155 config_agents.extend(found);
156 }
157 if !config_agents.is_empty() {
158 found_runnable = true;
159 println!("From configured paths:");
160 for (_path, info) in &config_agents {
161 let desc = if info.description.is_empty() {
162 String::new()
163 } else {
164 format!(" - {}", info.description)
165 };
166 println!(" {} (v{}){}", info.name, info.version, desc);
167 }
168 println!();
169 }
170
171 let mut builtin_names: Vec<String> = crate::bundled::BUNDLED_AGENTS
179 .iter()
180 .map(|a| format!("{} (v{})", a.name, a.version))
181 .collect();
182 if let Some(exe_dir) = exe_dir {
183 for (_path, info) in scan_directory_for_agents(&exe_dir.join("agents")) {
184 let entry = format!("{} (v{})", info.name, info.version);
185 if !builtin_names.contains(&entry) {
186 builtin_names.push(entry);
187 }
188 }
189 }
190 println!("Bundled agents (install with `lev setup`):");
195 println!(" {}", builtin_names.join(", "));
196 println!();
197
198 if !found_runnable {
199 println!("No agents installed yet.");
200 println!();
201 println!("To install the bundled agents:");
202 println!(" lev setup");
203 println!();
204 println!("To create your own:");
205 println!(" lev create my-agent");
206 }
207
208 Ok(())
209}
210
211fn get_agents_dir_or_error(dir: Option<PathBuf>) -> anyhow::Result<PathBuf> {
215 dir.ok_or(anyhow::anyhow!("Could not determine home directory"))
216}
217
218fn get_agents_dir() -> anyhow::Result<PathBuf> {
233 #[cfg(test)]
234 if FORCE_AGENTS_DIR_ERROR.with(|f| f.get()) {
235 anyhow::bail!("Could not determine home directory");
236 }
237 get_agents_dir_or_error(leviath_core::paths::agents_dir())
238}
239
240#[cfg(test)]
241thread_local! {
242 static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::test_support::write_test_agent;
251
252 fn write_manifest(dir: &Path, name: &str) {
253 write_manifest_with_description(dir, name, "Test agent");
254 }
255
256 fn write_manifest_with_description(dir: &Path, name: &str, description: &str) {
257 let content = format!(
258 r#"[agent]
259name = "{}"
260version = "1.0.0"
261description = "{}"
262
263[stages.main]
264mode = "autonomous"
265model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
266description = "Main"
267max_iterations = 5
268
269[context.regions]
270system = {{ kind = "pinned", max_tokens = 1000 }}
271"#,
272 name, description
273 );
274 write_test_agent(dir, content);
275 }
276
277 #[test]
278 fn read_agent_info_valid_manifest() {
279 let dir = tempfile::tempdir().unwrap();
280 write_manifest(dir.path(), "my-agent");
281 let info = read_agent_info(&dir.path().join("agent.leviath")).unwrap();
282 assert_eq!(info.name, "my-agent");
283 assert_eq!(info.version, "1.0.0");
284 assert_eq!(info.description, "Test agent");
285 }
286
287 #[test]
288 fn read_agent_info_missing_file_returns_none() {
289 let result = read_agent_info(Path::new("/nonexistent/agent.leviath"));
290 assert!(result.is_none());
291 }
292
293 #[test]
294 fn read_agent_info_invalid_toml_returns_none() {
295 let dir = tempfile::tempdir().unwrap();
296 fs::write(dir.path().join("agent.leviath"), "not valid toml {{{{").unwrap();
297 let result = read_agent_info(&dir.path().join("agent.leviath"));
298 assert!(result.is_none());
299 }
300
301 #[test]
302 fn scan_directory_nonexistent_returns_empty() {
303 let agents = scan_directory_for_agents(Path::new("/nonexistent/path"));
304 assert!(agents.is_empty());
305 }
306
307 #[test]
308 fn scan_directory_path_is_a_file_returns_empty() {
309 let tmp = tempfile::tempdir().unwrap();
314 let file_path = tmp.path().join("not-a-directory.txt");
315 fs::write(&file_path, "hello").unwrap();
316 let agents = scan_directory_for_agents(&file_path);
317 assert!(agents.is_empty());
318 }
319
320 #[test]
321 fn scan_directory_direct_manifest_invalid_is_skipped() {
322 let dir = tempfile::tempdir().unwrap();
328 fs::write(dir.path().join("agent.leviath"), "not valid toml {{{{").unwrap();
329 let agents = scan_directory_for_agents(dir.path());
330 assert!(agents.is_empty());
331 }
332
333 #[test]
334 fn scan_directory_with_direct_manifest() {
335 let dir = tempfile::tempdir().unwrap();
336 write_manifest(dir.path(), "direct-agent");
337 let agents = scan_directory_for_agents(dir.path());
338 assert_eq!(agents.len(), 1);
339 assert_eq!(agents[0].1.name, "direct-agent");
340 }
341
342 #[test]
343 fn scan_directory_with_subdirectories() {
344 let dir = tempfile::tempdir().unwrap();
345 let sub1 = dir.path().join("agent-a");
346 let sub2 = dir.path().join("agent-b");
347 fs::create_dir_all(&sub1).unwrap();
348 fs::create_dir_all(&sub2).unwrap();
349 write_manifest(&sub1, "agent-a");
350 write_manifest(&sub2, "agent-b");
351
352 let agents = scan_directory_for_agents(dir.path());
353 assert_eq!(agents.len(), 2);
354 let names: Vec<&str> = agents.iter().map(|a| a.1.name.as_str()).collect();
355 assert!(names.contains(&"agent-a"));
356 assert!(names.contains(&"agent-b"));
357 }
358
359 #[test]
360 fn scan_directory_ignores_subdirs_without_manifest() {
361 let dir = tempfile::tempdir().unwrap();
362 let sub = dir.path().join("no-manifest");
363 fs::create_dir_all(&sub).unwrap();
364 fs::write(sub.join("readme.txt"), "not a manifest").unwrap();
365
366 let agents = scan_directory_for_agents(dir.path());
367 assert!(agents.is_empty());
368 }
369
370 #[test]
371 fn list_args_default_filter() {
372 let args = ListArgs {
373 filter: "all".to_string(),
374 };
375 assert_eq!(args.filter, "all");
376 }
377
378 #[test]
381 fn read_agent_info_extracts_description() {
382 let dir = tempfile::tempdir().unwrap();
383 write_manifest(dir.path(), "my-agent");
384 let info = read_agent_info(&dir.path().join("agent.leviath")).unwrap();
385 assert_eq!(info.description, "Test agent");
386 assert_eq!(info.version, "1.0.0");
387 }
388
389 #[test]
392 fn scan_directory_with_both_direct_and_subdirs() {
393 let dir = tempfile::tempdir().unwrap();
394 write_manifest(dir.path(), "root-agent");
396 let sub = dir.path().join("child");
398 fs::create_dir_all(&sub).unwrap();
399 write_manifest(&sub, "child-agent");
400
401 let agents = scan_directory_for_agents(dir.path());
402 assert_eq!(agents.len(), 2);
403 let names: Vec<&str> = agents.iter().map(|a| a.1.name.as_str()).collect();
404 assert!(names.contains(&"root-agent"));
405 assert!(names.contains(&"child-agent"));
406 }
407
408 #[test]
411 fn scan_directory_empty_dir() {
412 let dir = tempfile::tempdir().unwrap();
413 let agents = scan_directory_for_agents(dir.path());
414 assert!(agents.is_empty());
415 }
416
417 #[test]
420 fn scan_directory_subdir_with_invalid_manifest() {
421 let dir = tempfile::tempdir().unwrap();
422 let sub = dir.path().join("bad-agent");
423 fs::create_dir_all(&sub).unwrap();
424 fs::write(sub.join("agent.leviath"), "invalid toml {{{{").unwrap();
425
426 let agents = scan_directory_for_agents(dir.path());
427 assert!(agents.is_empty());
428 }
429
430 #[test]
433 fn get_agents_dir_returns_path_with_agents() {
434 let dir = get_agents_dir().unwrap();
435 assert!(dir.to_str().unwrap().contains(".leviath"));
436 assert!(dir.to_str().unwrap().ends_with("agents"));
437 }
438
439 #[test]
440 fn get_agents_dir_or_error_some_returns_path() {
441 let dir = PathBuf::from("/home/testuser/.leviath/agents");
442 assert_eq!(get_agents_dir_or_error(Some(dir.clone())).unwrap(), dir);
443 }
444
445 #[test]
446 fn get_agents_dir_or_error_none_returns_error() {
447 let err = get_agents_dir_or_error(None).unwrap_err();
448 assert!(
449 err.to_string()
450 .contains("Could not determine home directory")
451 );
452 }
453
454 #[test]
457 fn read_agent_info_minimal_manifest() {
458 let dir = tempfile::tempdir().unwrap();
459 let content = r#"[agent]
460name = "minimal"
461version = "0.0.1"
462description = ""
463
464[stages.main]
465mode = "autonomous"
466model = { provider = "anthropic", model = "claude-sonnet-4-6" }
467description = "Main"
468max_iterations = 5
469
470[context.regions]
471system = { kind = "pinned", max_tokens = 1000 }
472"#;
473 write_test_agent(dir.path(), content);
474 let info = read_agent_info(&dir.path().join("agent.leviath")).unwrap();
475 assert_eq!(info.name, "minimal");
476 assert_eq!(info.description, "");
477 }
478
479 #[tokio::test]
482 async fn execute_runs_without_error() {
483 crate::config::with_isolated_config_path_async("list-runs-ok", |_fake_dir| async move {
486 let args = ListArgs {
489 filter: "all".to_string(),
490 };
491 let result = execute(args).await;
492 assert!(result.is_ok());
493 })
494 .await;
495 }
496
497 #[tokio::test]
498 async fn execute_returns_err_when_agents_dir_unresolvable() {
499 crate::config::with_isolated_config_path_async("list-dir-err", |_fake_dir| async move {
502 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(true));
507 let args = ListArgs {
508 filter: "all".to_string(),
509 };
510 let result = execute(args).await;
511 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(false));
512
513 let err = result.unwrap_err();
514 assert!(
515 err.to_string()
516 .contains("Could not determine home directory")
517 );
518 })
519 .await;
520 }
521
522 #[cfg(unix)]
532 #[tokio::test]
533 async fn execute_falls_back_to_default_cwd_when_current_dir_is_gone() {
534 crate::config::with_isolated_config_path_async("list-cwd-gone", |_fake_dir| async move {
537 let _guard = crate::config::isolate_cwd_for_test();
541 let dir = std::env::temp_dir().join("lev-test-list-cwd-gone");
542 let _ = std::fs::remove_dir_all(&dir);
543 std::fs::create_dir_all(&dir).unwrap();
544 std::env::set_current_dir(&dir).unwrap();
545 std::fs::remove_dir_all(&dir).unwrap();
546
547 let args = ListArgs {
548 filter: "all".to_string(),
549 };
550 let result = execute(args).await;
551
552 assert!(result.is_ok());
553 })
554 .await;
555 }
556
557 #[tokio::test]
563 async fn execute_falls_back_to_default_cwd_via_forced_error() {
564 crate::config::with_isolated_config_path_async("list-cwd-forced", |_fake_dir| async move {
567 FORCE_CWD_ERROR.with(|f| f.set(true));
568 let args = ListArgs {
569 filter: "all".to_string(),
570 };
571 let result = execute(args).await;
572 FORCE_CWD_ERROR.with(|f| f.set(false));
573
574 assert!(result.is_ok());
575 })
576 .await;
577 }
578
579 #[tokio::test]
584 async fn execute_fails_loudly_on_a_broken_config() {
585 crate::config::with_isolated_config_path_async(
586 "list-broken-config",
587 |fake_dir| async move {
588 std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
589 let args = ListArgs {
590 filter: "all".to_string(),
591 };
592 let err = execute(args).await.expect_err("broken config must error");
593 assert!(err.to_string().contains("parse"), "{err}");
594 },
595 )
596 .await;
597 }
598
599 #[test]
602 fn print_agent_listing_nothing_installed() {
603 let agents_dir = tempfile::tempdir().unwrap();
607 let cwd = tempfile::tempdir().unwrap();
608 let config = Config::default();
609
610 let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
611 assert!(result.is_ok());
612 }
613
614 #[test]
615 fn print_agent_listing_finds_installed_agent() {
616 let agents_dir = tempfile::tempdir().unwrap();
617 let sub = agents_dir.path().join("installed-agent");
618 fs::create_dir_all(&sub).unwrap();
619 write_manifest(&sub, "installed-agent");
620
621 let cwd = tempfile::tempdir().unwrap();
622 let config = Config::default();
623
624 let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
625 assert!(result.is_ok());
626 }
627
628 #[test]
629 fn print_agent_listing_finds_local_manifest() {
630 let agents_dir = tempfile::tempdir().unwrap();
631 let cwd = tempfile::tempdir().unwrap();
632 write_manifest(cwd.path(), "local-agent");
633 let config = Config::default();
634
635 let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
636 assert!(result.is_ok());
637 }
638
639 #[test]
640 fn print_agent_listing_local_manifest_invalid_is_skipped() {
641 let agents_dir = tempfile::tempdir().unwrap();
647 let cwd = tempfile::tempdir().unwrap();
648 fs::write(cwd.path().join("agent.leviath"), "not valid toml {{{{").unwrap();
649 let config = Config::default();
650
651 let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
652 assert!(result.is_ok());
653 }
654
655 #[test]
656 fn print_agent_listing_finds_configured_path_agent() {
657 let agents_dir = tempfile::tempdir().unwrap();
658 let cwd = tempfile::tempdir().unwrap();
659 let configured = tempfile::tempdir().unwrap();
660 let sub = configured.path().join("configured-agent");
661 fs::create_dir_all(&sub).unwrap();
662 write_manifest(&sub, "configured-agent");
663
664 let config = Config {
665 agent_paths: vec![configured.path().to_path_buf()],
666 ..Config::default()
667 };
668
669 let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
670 assert!(result.is_ok());
671 }
672
673 #[test]
674 fn print_agent_listing_finds_builtin_agents() {
675 let agents_dir = tempfile::tempdir().unwrap();
678 let cwd = tempfile::tempdir().unwrap();
679 let exe_dir = tempfile::tempdir().unwrap();
680 let builtin_dir = exe_dir.path().join("agents");
681 let sub = builtin_dir.join("builtin-agent");
682 fs::create_dir_all(&sub).unwrap();
683 write_manifest(&sub, "builtin-agent");
684 let config = Config::default();
685
686 let result =
687 print_agent_listing(agents_dir.path(), cwd.path(), Some(exe_dir.path()), &config);
688 assert!(result.is_ok());
689 }
690
691 #[test]
692 fn print_agent_listing_does_not_list_a_bundled_agent_twice() {
693 let bundled = &crate::bundled::BUNDLED_AGENTS[0];
698 let agents_dir = tempfile::tempdir().unwrap();
699 let cwd = tempfile::tempdir().unwrap();
700 let exe_dir = tempfile::tempdir().unwrap();
701 let sub = exe_dir.path().join("agents").join(bundled.name);
702 fs::create_dir_all(&sub).unwrap();
703 crate::bundled::install_bundled(bundled, &exe_dir.path().join("agents")).unwrap();
704 let config = Config::default();
705
706 let result =
707 print_agent_listing(agents_dir.path(), cwd.path(), Some(exe_dir.path()), &config);
708
709 assert!(result.is_ok());
710 let entry = format!("{} (v{})", bundled.name, bundled.version);
713 let names: Vec<String> = crate::bundled::BUNDLED_AGENTS
714 .iter()
715 .map(|a| format!("{} (v{})", a.name, a.version))
716 .collect();
717 assert_eq!(names.iter().filter(|n| **n == entry).count(), 1);
718 }
719
720 #[test]
721 fn print_agent_listing_all_sources_populated() {
722 let agents_dir = tempfile::tempdir().unwrap();
723 fs::create_dir_all(agents_dir.path().join("installed")).unwrap();
724 write_manifest(&agents_dir.path().join("installed"), "installed");
725
726 let cwd = tempfile::tempdir().unwrap();
727 write_manifest(cwd.path(), "local");
728
729 let configured = tempfile::tempdir().unwrap();
730 fs::create_dir_all(configured.path().join("configured")).unwrap();
731 write_manifest(&configured.path().join("configured"), "configured");
732
733 let exe_dir = tempfile::tempdir().unwrap();
734 let builtin_sub = exe_dir.path().join("agents").join("builtin");
735 fs::create_dir_all(&builtin_sub).unwrap();
736 write_manifest(&builtin_sub, "builtin");
737
738 let config = Config {
739 agent_paths: vec![configured.path().to_path_buf()],
740 ..Config::default()
741 };
742
743 let result =
744 print_agent_listing(agents_dir.path(), cwd.path(), Some(exe_dir.path()), &config);
745 assert!(result.is_ok());
746 }
747
748 #[test]
749 fn print_agent_listing_empty_descriptions_across_all_sources() {
750 let agents_dir = tempfile::tempdir().unwrap();
755 fs::create_dir_all(agents_dir.path().join("installed")).unwrap();
756 write_manifest_with_description(&agents_dir.path().join("installed"), "installed", "");
757
758 let cwd = tempfile::tempdir().unwrap();
759 write_manifest_with_description(cwd.path(), "local", "");
760
761 let configured = tempfile::tempdir().unwrap();
762 fs::create_dir_all(configured.path().join("configured")).unwrap();
763 write_manifest_with_description(&configured.path().join("configured"), "configured", "");
764
765 let config = Config {
766 agent_paths: vec![configured.path().to_path_buf()],
767 ..Config::default()
768 };
769
770 let result = print_agent_listing(agents_dir.path(), cwd.path(), None, &config);
771 assert!(result.is_ok());
772 }
773
774 #[test]
777 fn scan_directory_agent_with_empty_description() {
778 let dir = tempfile::tempdir().unwrap();
779 let sub = dir.path().join("my-agent");
780 fs::create_dir_all(&sub).unwrap();
781 let content = r#"[agent]
782name = "my-agent"
783version = "2.0.0"
784description = ""
785
786[stages.main]
787mode = "autonomous"
788model = { provider = "anthropic", model = "claude-sonnet-4-6" }
789description = "Main"
790max_iterations = 5
791
792[context.regions]
793system = { kind = "pinned", max_tokens = 1000 }
794"#;
795 write_test_agent(sub, content);
796
797 let agents = scan_directory_for_agents(dir.path());
798 assert_eq!(agents.len(), 1);
799 assert_eq!(agents[0].1.description, "");
800 }
801
802 #[test]
805 fn scan_directory_mixed_valid_and_invalid() {
806 let dir = tempfile::tempdir().unwrap();
807 let good = dir.path().join("good");
808 let bad = dir.path().join("bad");
809 let empty = dir.path().join("empty");
810 fs::create_dir_all(&good).unwrap();
811 fs::create_dir_all(&bad).unwrap();
812 fs::create_dir_all(&empty).unwrap();
813
814 write_manifest(&good, "good-agent");
815 fs::write(bad.join("agent.leviath"), "bad {{ toml").unwrap();
816
817 let agents = scan_directory_for_agents(dir.path());
818 assert_eq!(agents.len(), 1);
819 assert_eq!(agents[0].1.name, "good-agent");
820 }
821}