1use clap::Args;
4use std::path::Path;
5
6#[derive(Args)]
7pub struct AddArgs {
8 #[arg(value_name = "PACKAGE")]
10 pub package: String,
11}
12
13fn agents_dir_or_error(dir: Option<std::path::PathBuf>) -> anyhow::Result<std::path::PathBuf> {
14 dir.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
15}
16
17pub async fn execute(args: AddArgs) -> anyhow::Result<()> {
18 let installer = leviath_package::AgentInstaller::new();
19 let agents_dir = resolve_agents_dir()?;
20 execute_with(&args, &installer, &agents_dir).await
21}
22
23fn resolve_agents_dir() -> anyhow::Result<std::path::PathBuf> {
36 #[cfg(test)]
37 if FORCE_AGENTS_DIR_ERROR.with(|f| f.get()) {
38 anyhow::bail!("Could not determine home directory");
39 }
40 agents_dir_or_error(leviath_core::paths::agents_dir())
41}
42
43#[cfg(test)]
44thread_local! {
45 static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
48}
49
50async fn execute_with(
54 args: &AddArgs,
55 installer: &leviath_package::AgentInstaller,
56 agents_dir: &Path,
57) -> anyhow::Result<()> {
58 tracing::info!("Installing agent package");
59
60 let package_path = Path::new(&args.package);
61
62 if package_path.is_dir() {
63 install_from_dir(package_path, agents_dir)?;
65 } else if package_path.exists() || args.package.ends_with(".leviath-bundle") {
66 if !package_path.exists() {
68 anyhow::bail!("Package file not found: {}", args.package);
69 }
70 println!("Installing from bundle: {}", args.package);
71 let installed = installer.install(package_path)?;
72 println!(
73 "Installed agent '{}' v{} to {}",
74 installed.name,
75 installed.version,
76 installed.path.display()
77 );
78 print_capabilities(&installed.name, &installed.path);
79 } else {
80 anyhow::bail!(
83 "'{}' is not a local agent directory or a .leviath-bundle file - \
84 pass a path to one of those instead.",
85 args.package
86 );
87 }
88
89 Ok(())
90}
91
92pub(crate) fn describe_capabilities(manifest_toml: &str, script_tools: &[String]) -> Vec<String> {
107 let mut findings = Vec::new();
108 let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
114 return findings;
117 };
118
119 if !script_tools.is_empty() {
120 findings.push(format!(
121 "ships {} executable script tool(s): {}",
122 script_tools.len(),
123 script_tools.join(", ")
124 ));
125 }
126
127 let mut granted: Vec<String> = Vec::new();
129 let mut collect_grants = |table: Option<&toml::Value>| {
130 if let Some(t) = table.and_then(|v| v.as_table()) {
131 for (tool, policy) in t {
132 if policy.as_str() == Some("allow") && !granted.contains(tool) {
133 granted.push(tool.clone());
134 }
135 }
136 }
137 };
138 collect_grants(value.get("tool_permissions"));
139 if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
140 for stage in stages.values() {
141 collect_grants(stage.get("tool_permissions"));
142 }
143 }
144 if !granted.is_empty() {
145 granted.sort();
146 findings.push(format!(
147 "pre-approves these tools (no prompt at run time): {}",
148 granted.join(", ")
149 ));
150 }
151
152 if let Some(t) = value
154 .get("tool_script_permissions")
155 .and_then(|v| v.as_table())
156 {
157 let mut allowed: Vec<&String> = t
158 .iter()
159 .filter(|(_, v)| v.as_str() == Some("allow"))
160 .map(|(k, _)| k)
161 .collect();
162 if !allowed.is_empty() {
163 allowed.sort();
164 findings.push(format!(
165 "requests script host access: {}",
166 allowed
167 .iter()
168 .map(|s| s.as_str())
169 .collect::<Vec<_>>()
170 .join(", ")
171 ));
172 }
173 }
174
175 if let Some(kind) = value
177 .get("sandbox")
178 .and_then(|v| v.get("kind"))
179 .and_then(|v| v.as_str())
180 && kind == "none"
181 {
182 findings.push("asks to run tools directly on the host (sandbox = none)".to_string());
183 }
184
185 let seed_commands = collect_seed_commands(&value);
189 for command in seed_commands {
190 findings.push(format!(
191 "runs this command at startup, before any prompt: `{command}`"
192 ));
193 }
194
195 findings
196}
197
198fn collect_seed_commands(value: &toml::Value) -> Vec<String> {
201 let mut out = Vec::new();
202 let mut scan = |regions: Option<&toml::Value>| {
203 if let Some(t) = regions.and_then(|v| v.as_table()) {
204 for region in t.values() {
205 if let Some(cmd) = region
206 .get("seed")
207 .and_then(|s| s.get("command"))
208 .and_then(|c| c.as_str())
209 {
210 out.push(cmd.to_string());
211 }
212 }
213 }
214 };
215 scan(value.get("context").and_then(|c| c.get("regions")));
216 if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
217 for stage in stages.values() {
218 scan(stage.get("context").and_then(|c| c.get("regions")));
219 }
220 }
221 out
222}
223
224fn print_capabilities(name: &str, install_dir: &Path) {
226 let manifest = std::fs::read_to_string(install_dir.join("agent.leviath")).unwrap_or_default();
227 let scripts = script_tool_names(install_dir);
228 let findings = describe_capabilities(&manifest, &scripts);
229 if findings.is_empty() {
230 return;
231 }
232 println!("\n '{name}' asks for the following. Review before running it:");
233 for finding in &findings {
234 println!(" - {finding}");
235 }
236 println!(" Inspect it with: lev validate {name}");
237}
238
239fn script_tool_names(install_dir: &Path) -> Vec<String> {
241 let mut names: Vec<String> = std::fs::read_dir(install_dir.join("tools"))
242 .into_iter()
243 .flatten()
244 .flatten()
245 .filter_map(|e| {
249 let name = e.file_name().to_string_lossy().into_owned();
250 name.ends_with(".rhai").then_some(name)
251 })
252 .collect();
253 names.sort();
254 names
255}
256
257fn install_from_dir(src: &Path, agents_dir: &Path) -> anyhow::Result<()> {
262 let manifest_path = src.join("agent.leviath");
263 if !manifest_path.exists() {
264 anyhow::bail!(
265 "No agent.leviath found in '{}'. Is this an agent directory?",
266 src.display()
267 );
268 }
269
270 let content = std::fs::read_to_string(&manifest_path)?;
272 let name = parse_agent_name(&content).unwrap_or_else(|| {
273 src.file_name()
274 .and_then(|n| n.to_str())
275 .unwrap_or("unknown")
276 .to_string()
277 });
278
279 let install_dir = agents_dir.join(&name);
280
281 if install_dir.exists() {
282 println!("Reinstalling agent '{}' (replacing existing)", name);
283 std::fs::remove_dir_all(&install_dir)?;
284 }
285
286 copy_dir_recursive(src, &install_dir)?;
287 println!("Installed agent '{}' to {}", name, install_dir.display());
288 print_capabilities(&name, &install_dir);
289 println!("Run with: lev run {} --task \"...\"", name);
290 Ok(())
291}
292
293#[cfg(test)]
294thread_local! {
295 static FORCE_DIR_ENTRY_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
302}
303
304fn unwrap_dir_entry(
310 entry: std::io::Result<std::fs::DirEntry>,
311) -> anyhow::Result<std::fs::DirEntry> {
312 #[cfg(test)]
313 if FORCE_DIR_ENTRY_ERROR.with(|f| f.get()) {
314 anyhow::bail!("forced dir-entry error for testing");
315 }
316 Ok(entry?)
317}
318
319fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
321 std::fs::create_dir_all(dst)?;
322 for entry in std::fs::read_dir(src)? {
323 let entry = unwrap_dir_entry(entry)?;
324 let src_path = entry.path();
325 let dst_path = dst.join(entry.file_name());
326 if src_path.is_dir() {
327 copy_dir_recursive(&src_path, &dst_path)?;
328 } else {
329 std::fs::copy(&src_path, &dst_path)?;
330 }
331 }
332 Ok(())
333}
334
335fn parse_agent_name(content: &str) -> Option<String> {
337 for line in content.lines() {
338 let trimmed = line.trim();
339 if let Some(rest) = trimmed.strip_prefix("name") {
340 let rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '=');
341 let name = rest.trim().trim_matches('"');
342 if !name.is_empty() {
343 return Some(name.to_string());
344 }
345 }
346 }
347 None
348}
349
350#[cfg(test)]
351mod capability_tests {
352 use super::describe_capabilities;
353
354 #[test]
357 fn an_ordinary_agent_has_nothing_to_report() {
358 let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
359 [stages.main]\nprompt = \"p\"\n";
360 assert!(describe_capabilities(manifest, &[]).is_empty());
361 }
362
363 #[test]
364 fn script_tools_are_listed_by_name() {
365 let findings = describe_capabilities(
366 "[agent]\nname = \"x\"\n",
367 &["web_fetch.rhai".to_string(), "post.rhai".to_string()],
368 );
369 assert_eq!(findings.len(), 1);
370 assert!(findings[0].contains("2 executable script tool"));
371 assert!(findings[0].contains("web_fetch.rhai"));
372 }
373
374 #[test]
378 fn self_granted_tool_permissions_are_reported() {
379 let manifest = "[agent]\nname = \"x\"\n\n\
380 [tool_permissions]\nshell = \"allow\"\nread_file = \"ask\"\n";
381 let findings = describe_capabilities(manifest, &[]);
382 assert_eq!(findings.len(), 1);
383 assert!(findings[0].contains("pre-approves"));
384 assert!(findings[0].contains("shell"));
385 assert!(!findings[0].contains("read_file"));
387 }
388
389 #[test]
393 fn a_script_permission_table_that_grants_nothing_is_not_reported() {
394 let manifest = "[agent]\nname = \"x\"\n\n\
395 [tool_script_permissions]\nenv_var = \"deny\"\nhttp_get = \"ask\"\n";
396 assert!(
397 describe_capabilities(manifest, &[]).is_empty(),
398 "denying host access is not a capability to warn about"
399 );
400 }
401
402 #[test]
403 fn stage_level_grants_are_reported_too() {
404 let manifest = "[agent]\nname = \"x\"\n\n\
405 [stages.build.tool_permissions]\nwrite_file = \"allow\"\n";
406 let findings = describe_capabilities(manifest, &[]);
407 assert!(findings[0].contains("write_file"), "{findings:?}");
408 }
409
410 #[test]
411 fn script_host_grants_and_sandbox_opt_out_are_reported() {
412 let manifest = "[agent]\nname = \"x\"\n\n\
413 [tool_script_permissions]\nshell = \"allow\"\nhttp_post = \"allow\"\n\n\
414 [sandbox]\nkind = \"none\"\n";
415 let findings = describe_capabilities(manifest, &[]);
416 let joined = findings.join(" | ");
417 assert!(joined.contains("script host access"), "{joined}");
418 assert!(joined.contains("http_post"), "{joined}");
419 assert!(joined.contains("sandbox = none"), "{joined}");
420 }
421
422 #[test]
426 fn command_seeds_are_reported_verbatim() {
427 let manifest = "[agent]\nname = \"x\"\n\n\
428 [context.regions]\n\
429 repo = { kind = \"pinned\", seed = { command = \"git ls-files\" } }\n";
430 let findings = describe_capabilities(manifest, &[]);
431 assert_eq!(findings.len(), 1);
432 assert!(findings[0].contains("before any prompt"), "{findings:?}");
433 assert!(findings[0].contains("git ls-files"), "{findings:?}");
434 }
435
436 #[test]
437 fn stage_level_command_seeds_are_reported() {
438 let manifest = "[agent]\nname = \"x\"\n\n\
439 [stages.discover.context.regions]\n\
440 env = { kind = \"pinned\", seed = { command = \"curl https://evil\" } }\n";
441 let findings = describe_capabilities(manifest, &[]);
442 assert!(findings[0].contains("curl https://evil"), "{findings:?}");
443 }
444
445 #[test]
447 fn opting_into_a_sandbox_is_not_reported() {
448 let manifest = "[agent]\nname = \"x\"\n\n[sandbox]\nkind = \"container\"\n";
449 assert!(describe_capabilities(manifest, &[]).is_empty());
450 }
451
452 #[test]
455 fn script_tool_names_lists_only_rhai_files_sorted() {
456 let dir = tempfile::tempdir().unwrap();
457 let tools = dir.path().join("tools");
458 std::fs::create_dir(&tools).unwrap();
459 for name in ["zeta.rhai", "alpha.rhai", "README.md", "notes.txt"] {
460 std::fs::write(tools.join(name), "x").unwrap();
461 }
462 assert_eq!(
463 super::script_tool_names(dir.path()),
464 vec!["alpha.rhai".to_string(), "zeta.rhai".to_string()]
465 );
466 }
467
468 #[test]
470 fn script_tool_names_is_empty_without_a_tools_directory() {
471 let dir = tempfile::tempdir().unwrap();
472 assert!(super::script_tool_names(dir.path()).is_empty());
473 }
474
475 #[test]
478 fn print_capabilities_reads_the_installed_directory() {
479 crate::test_support::with_tracing(|| {
480 let dir = tempfile::tempdir().unwrap();
481 std::fs::write(
482 dir.path().join("agent.leviath"),
483 "[agent]\nname = \"q\"\n\n[tool_permissions]\nshell = \"allow\"\n",
484 )
485 .unwrap();
486 let tools = dir.path().join("tools");
487 std::fs::create_dir(&tools).unwrap();
488 std::fs::write(tools.join("t.rhai"), "// @tool t\n").unwrap();
489 super::print_capabilities("q", dir.path());
490
491 let plain = tempfile::tempdir().unwrap();
493 std::fs::write(
494 plain.path().join("agent.leviath"),
495 "[agent]\nname = \"p\"\n\n[stages.main]\nprompt = \"p\"\n",
496 )
497 .unwrap();
498 super::print_capabilities("p", plain.path());
499 });
500 }
501
502 #[test]
503 fn an_unparseable_manifest_reports_nothing() {
504 assert!(describe_capabilities("{ not toml", &[]).is_empty());
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::test_support::{with_tracing, write_test_agent};
512
513 #[test]
516 fn agents_dir_or_error_some_returns_path() {
517 let dir = std::path::PathBuf::from("/home/testuser/.leviath/agents");
518 assert_eq!(agents_dir_or_error(Some(dir.clone())).unwrap(), dir);
519 }
520
521 #[test]
522 fn agents_dir_or_error_none_returns_error() {
523 let err = agents_dir_or_error(None).unwrap_err();
524 assert!(
525 err.to_string()
526 .contains("Could not determine home directory")
527 );
528 }
529
530 #[test]
533 fn parse_agent_name_standard() {
534 let content = r#"
535name = "my-agent"
536version = "1.0"
537"#;
538 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
539 }
540
541 #[test]
542 fn parse_agent_name_no_quotes() {
543 let content = r#"name = my-agent"#;
544 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
545 }
546
547 #[test]
548 fn parse_agent_name_extra_whitespace() {
549 let content = r#" name = "spacy-agent" "#;
550 assert_eq!(parse_agent_name(content), Some("spacy-agent".to_string()));
551 }
552
553 #[test]
554 fn parse_agent_name_missing() {
555 let content = r#"
556version = "1.0"
557description = "test"
558"#;
559 assert_eq!(parse_agent_name(content), None);
560 }
561
562 #[test]
563 fn parse_agent_name_empty_value() {
564 let content = r#"name = """#;
565 assert_eq!(parse_agent_name(content), None);
566 }
567
568 #[test]
571 fn copy_dir_recursive_copies_files() {
572 let src_dir = tempfile::tempdir().unwrap();
573 let dst_dir = tempfile::tempdir().unwrap();
574 let dst_path = dst_dir.path().join("copy");
575
576 std::fs::write(src_dir.path().join("file1.txt"), "hello").unwrap();
577 std::fs::create_dir_all(src_dir.path().join("sub")).unwrap();
578 std::fs::write(src_dir.path().join("sub/file2.txt"), "world").unwrap();
579
580 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
581
582 assert!(dst_path.join("file1.txt").exists());
583 assert!(dst_path.join("sub/file2.txt").exists());
584 assert_eq!(
585 std::fs::read_to_string(dst_path.join("file1.txt")).unwrap(),
586 "hello"
587 );
588 assert_eq!(
589 std::fs::read_to_string(dst_path.join("sub/file2.txt")).unwrap(),
590 "world"
591 );
592 }
593
594 #[test]
595 fn copy_dir_recursive_empty_dir() {
596 let src_dir = tempfile::tempdir().unwrap();
597 let dst_dir = tempfile::tempdir().unwrap();
598 let dst_path = dst_dir.path().join("empty-copy");
599
600 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
601 assert!(dst_path.exists());
602 assert!(dst_path.is_dir());
603 }
604
605 #[test]
606 fn copy_dir_recursive_nonexistent_src_errors() {
607 let dst_dir = tempfile::tempdir().unwrap();
608 let dst_path = dst_dir.path().join("dst");
609 let missing_src = dst_dir.path().join("does-not-exist");
610
611 let result = copy_dir_recursive(&missing_src, &dst_path);
612 assert!(result.is_err());
613 }
614
615 #[test]
616 fn copy_dir_recursive_dst_parent_is_file_errors() {
617 let tmp = tempfile::tempdir().unwrap();
618 let file_path = tmp.path().join("not-a-dir");
619 std::fs::write(&file_path, "x").unwrap();
620 let src = tempfile::tempdir().unwrap();
621 let dst = file_path.join("child");
622
623 let result = copy_dir_recursive(src.path(), &dst);
624 assert!(result.is_err());
625 }
626
627 #[test]
628 fn copy_dir_recursive_file_over_existing_dir_errors() {
629 let src_dir = tempfile::tempdir().unwrap();
633 std::fs::write(src_dir.path().join("clash"), "top secret").unwrap();
634
635 let dst_dir = tempfile::tempdir().unwrap();
636 let dst_path = dst_dir.path().join("copy");
637 std::fs::create_dir_all(dst_path.join("clash")).unwrap();
639
640 let result = copy_dir_recursive(src_dir.path(), &dst_path);
641 assert!(result.is_err());
642 }
643
644 #[test]
645 fn copy_dir_recursive_recursion_error_propagates() {
646 let src_dir = tempfile::tempdir().unwrap();
652 let sub = src_dir.path().join("sub");
653 std::fs::create_dir_all(&sub).unwrap();
654 std::fs::write(sub.join("file.txt"), "data").unwrap();
655
656 let dst_dir = tempfile::tempdir().unwrap();
657 let dst_path = dst_dir.path().join("copy");
658 std::fs::create_dir_all(&dst_path).unwrap();
659 std::fs::write(dst_path.join("sub"), "i am a file").unwrap();
661
662 let result = copy_dir_recursive(src_dir.path(), &dst_path);
663 assert!(result.is_err());
664 }
665
666 #[test]
667 fn copy_dir_recursive_forced_mid_iteration_entry_error() {
668 let src_dir = tempfile::tempdir().unwrap();
672 std::fs::write(src_dir.path().join("file.txt"), "data").unwrap();
673
674 let dst_dir = tempfile::tempdir().unwrap();
675 let dst_path = dst_dir.path().join("copy");
676
677 FORCE_DIR_ENTRY_ERROR.with(|f| f.set(true));
678 let result = copy_dir_recursive(src_dir.path(), &dst_path);
679 FORCE_DIR_ENTRY_ERROR.with(|f| f.set(false));
680
681 assert!(result.is_err());
682 }
683
684 #[test]
685 fn unwrap_dir_entry_propagates_a_real_err_argument() {
686 let result = unwrap_dir_entry(Err(std::io::Error::other("synthetic entry error")));
693 assert!(result.is_err());
694 }
695
696 #[test]
699 fn install_from_dir_no_manifest_errors() {
700 let dir = tempfile::tempdir().unwrap();
701 let agents_dir = tempfile::tempdir().unwrap();
702 let result = install_from_dir(dir.path(), agents_dir.path());
703 assert!(result.is_err());
704 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
705 }
706
707 #[test]
708 fn install_from_dir_copies_and_names_from_manifest() {
709 let src = tempfile::tempdir().unwrap();
710 let agents_dir = tempfile::tempdir().unwrap();
711 std::fs::write(
712 src.path().join("agent.leviath"),
713 "[agent]\nname = \"my-agent\"\n",
714 )
715 .unwrap();
716 std::fs::write(src.path().join("extra.txt"), "data").unwrap();
717
718 install_from_dir(src.path(), agents_dir.path()).unwrap();
719
720 let installed_dir = agents_dir.path().join("my-agent");
721 assert!(installed_dir.join("agent.leviath").exists());
722 assert!(installed_dir.join("extra.txt").exists());
723 }
724
725 #[test]
726 fn install_from_dir_falls_back_to_dirname_when_name_missing() {
727 let src = tempfile::tempdir().unwrap();
728 let agent_dir = src.path().join("my-dir-name");
729 std::fs::create_dir_all(&agent_dir).unwrap();
730 std::fs::write(agent_dir.join("agent.leviath"), "version = \"1.0\"\n").unwrap();
731 let agents_dir = tempfile::tempdir().unwrap();
732
733 install_from_dir(&agent_dir, agents_dir.path()).unwrap();
734
735 assert!(agents_dir.path().join("my-dir-name").exists());
736 }
737
738 #[test]
739 fn install_from_dir_reinstalls_existing() {
740 let src = tempfile::tempdir().unwrap();
741 std::fs::write(
742 src.path().join("agent.leviath"),
743 "[agent]\nname = \"dup-agent\"\n",
744 )
745 .unwrap();
746 let agents_dir = tempfile::tempdir().unwrap();
747
748 let existing = agents_dir.path().join("dup-agent");
750 std::fs::create_dir_all(&existing).unwrap();
751 std::fs::write(existing.join("stale.txt"), "old").unwrap();
752
753 install_from_dir(src.path(), agents_dir.path()).unwrap();
754
755 assert!(!existing.join("stale.txt").exists());
756 assert!(existing.join("agent.leviath").exists());
757 }
758
759 #[test]
760 fn install_from_dir_invalid_utf8_manifest_errors() {
761 let dir = tempfile::tempdir().unwrap();
762 std::fs::write(dir.path().join("agent.leviath"), [0xFF, 0xFE, 0xFA]).unwrap();
763 let agents_dir = tempfile::tempdir().unwrap();
764
765 let result = install_from_dir(dir.path(), agents_dir.path());
766 assert!(result.is_err());
767 }
768
769 #[test]
770 fn install_from_dir_remove_dir_all_failure_errors() {
771 let src = tempfile::tempdir().unwrap();
775 std::fs::write(
776 src.path().join("agent.leviath"),
777 "[agent]\nname = \"file-agent\"\n",
778 )
779 .unwrap();
780
781 let agents_dir = tempfile::tempdir().unwrap();
782 std::fs::write(agents_dir.path().join("file-agent"), "not a dir").unwrap();
783
784 let result = install_from_dir(src.path(), agents_dir.path());
785 assert!(result.is_err());
786 }
787
788 #[test]
789 fn install_from_dir_copy_failure_propagates() {
790 let src = tempfile::tempdir().unwrap();
795 std::fs::write(
796 src.path().join("agent.leviath"),
797 "[agent]\nname = \"broken-copy-agent\"\n",
798 )
799 .unwrap();
800 std::fs::write(src.path().join("extra.txt"), "data").unwrap();
801
802 let tmp = tempfile::tempdir().unwrap();
803 let agents_file = tmp.path().join("agents-is-a-file");
804 std::fs::write(&agents_file, "not a dir").unwrap();
805
806 let result = install_from_dir(src.path(), &agents_file);
807 assert!(result.is_err());
808 }
809
810 #[test]
813 fn execute_with_directory_package_installs() {
814 let rt = tokio::runtime::Runtime::new().unwrap();
815 with_tracing(|| {
816 rt.block_on(async {
817 let src = tempfile::tempdir().unwrap();
818 std::fs::write(
819 src.path().join("agent.leviath"),
820 "[agent]\nname = \"dir-pkg\"\n",
821 )
822 .unwrap();
823 let agents_dir = tempfile::tempdir().unwrap();
824 let installer = leviath_package::AgentInstaller::with_install_dir(
825 agents_dir.path().to_path_buf(),
826 );
827 let args = AddArgs {
828 package: src.path().to_str().unwrap().to_string(),
829 };
830
831 execute_with(&args, &installer, agents_dir.path())
832 .await
833 .unwrap();
834
835 assert!(agents_dir.path().join("dir-pkg").exists());
836 })
837 });
838 }
839
840 #[test]
841 fn execute_with_directory_without_manifest_errors() {
842 let rt = tokio::runtime::Runtime::new().unwrap();
843 with_tracing(|| {
844 rt.block_on(async {
845 let src = tempfile::tempdir().unwrap(); let agents_dir = tempfile::tempdir().unwrap();
847 let installer = leviath_package::AgentInstaller::with_install_dir(
848 agents_dir.path().to_path_buf(),
849 );
850 let args = AddArgs {
851 package: src.path().to_str().unwrap().to_string(),
852 };
853
854 let err = execute_with(&args, &installer, agents_dir.path())
855 .await
856 .unwrap_err();
857 assert!(err.to_string().contains("agent.leviath"));
858 })
859 });
860 }
861
862 #[test]
863 fn execute_with_missing_bundle_file_errors() {
864 let rt = tokio::runtime::Runtime::new().unwrap();
865 with_tracing(|| {
866 rt.block_on(async {
867 let agents_dir = tempfile::tempdir().unwrap();
868 let installer = leviath_package::AgentInstaller::with_install_dir(
869 agents_dir.path().to_path_buf(),
870 );
871 let args = AddArgs {
872 package: "nonexistent.leviath-bundle".to_string(),
873 };
874
875 let err = execute_with(&args, &installer, agents_dir.path())
876 .await
877 .unwrap_err();
878 assert!(err.to_string().contains("Package file not found"));
879 })
880 });
881 }
882
883 #[test]
884 fn execute_with_bundle_file_installs() {
885 let rt = tokio::runtime::Runtime::new().unwrap();
886 with_tracing(|| {
887 rt.block_on(async {
888 let project_dir = tempfile::tempdir().unwrap();
889 std::fs::write(
890 project_dir.path().join("agent.leviath"),
891 "[agent]\nname = \"bundled-pkg\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
892 )
893 .unwrap();
894 let bundle_bytes = leviath_package::AgentBundler::new()
895 .bundle(project_dir.path())
896 .unwrap();
897 let bundle_dir = tempfile::tempdir().unwrap();
898 let bundle_path = bundle_dir.path().join("bundled-pkg.leviath-bundle");
902 std::fs::write(&bundle_path, bundle_bytes).unwrap();
903
904 let agents_dir = tempfile::tempdir().unwrap();
905 let installer = leviath_package::AgentInstaller::with_install_dir(
906 agents_dir.path().to_path_buf(),
907 );
908 let args = AddArgs {
909 package: bundle_path.to_str().unwrap().to_string(),
910 };
911
912 execute_with(&args, &installer, agents_dir.path())
913 .await
914 .unwrap();
915
916 assert!(agents_dir.path().join("bundled-pkg").exists());
917 })
918 });
919 }
920
921 #[test]
922 fn execute_with_corrupt_bundle_file_errors() {
923 let rt = tokio::runtime::Runtime::new().unwrap();
924 with_tracing(|| {
925 rt.block_on(async {
926 let bundle_dir = tempfile::tempdir().unwrap();
927 let bundle_path = bundle_dir.path().join("broken.leviath-bundle");
928 std::fs::write(&bundle_path, b"not a valid gzip archive").unwrap();
929
930 let agents_dir = tempfile::tempdir().unwrap();
931 let installer = leviath_package::AgentInstaller::with_install_dir(
932 agents_dir.path().to_path_buf(),
933 );
934 let args = AddArgs {
935 package: bundle_path.to_str().unwrap().to_string(),
936 };
937
938 let err = execute_with(&args, &installer, agents_dir.path())
939 .await
940 .unwrap_err();
941 assert!(err.to_string().contains("Failed to extract package"));
942 })
943 });
944 }
945
946 #[test]
947 fn execute_with_unrecognized_package_reports_local_only() {
948 let rt = tokio::runtime::Runtime::new().unwrap();
951 with_tracing(|| {
952 rt.block_on(async {
953 let agents_dir = tempfile::tempdir().unwrap();
954 let installer = leviath_package::AgentInstaller::with_install_dir(
955 agents_dir.path().to_path_buf(),
956 );
957 let args = AddArgs {
958 package: "some-registry-agent".to_string(),
959 };
960 let err = execute_with(&args, &installer, agents_dir.path())
961 .await
962 .unwrap_err();
963 assert!(
964 err.to_string()
965 .contains("not a local agent directory or a .leviath-bundle file"),
966 "expected the v1-cut message, got: {err}"
967 );
968 })
969 });
970 }
971
972 #[test]
975 fn bundle_extension_detected() {
976 let package = "my-agent-1.0.leviath-bundle";
977 assert!(package.ends_with(".leviath-bundle"));
978 }
979
980 #[test]
981 fn directory_path_detected() {
982 let dir = tempfile::tempdir().unwrap();
983 let package_path = Path::new(dir.path().to_str().unwrap());
984 assert!(package_path.is_dir());
985 }
986
987 #[test]
988 fn registry_name_not_dir_not_bundle() {
989 let package = "my-cool-agent";
990 let package_path = Path::new(package);
991 assert!(!package_path.is_dir());
992 assert!(!package.ends_with(".leviath-bundle"));
993 }
994
995 #[test]
998 fn parse_agent_name_in_section() {
999 let content = r#"
1000[agent]
1001name = "my-agent"
1002version = "1.0"
1003"#;
1004 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
1005 }
1006
1007 #[test]
1008 fn parse_agent_name_with_single_quotes() {
1009 let content = r#"name = my-agent-no-quotes"#;
1011 assert_eq!(
1012 parse_agent_name(content),
1013 Some("my-agent-no-quotes".to_string())
1014 );
1015 }
1016
1017 #[test]
1018 fn parse_agent_name_multiple_name_fields_returns_first() {
1019 let content = r#"
1020name = "first"
1021name = "second"
1022"#;
1023 assert_eq!(parse_agent_name(content), Some("first".to_string()));
1024 }
1025
1026 #[test]
1029 fn copy_dir_recursive_deeply_nested() {
1030 let src_dir = tempfile::tempdir().unwrap();
1031 let dst_dir = tempfile::tempdir().unwrap();
1032 let dst_path = dst_dir.path().join("deep-copy");
1033
1034 std::fs::create_dir_all(src_dir.path().join("a/b/c")).unwrap();
1035 std::fs::write(src_dir.path().join("a/b/c/deep.txt"), "deep").unwrap();
1036
1037 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
1038
1039 assert!(dst_path.join("a/b/c/deep.txt").exists());
1040 assert_eq!(
1041 std::fs::read_to_string(dst_path.join("a/b/c/deep.txt")).unwrap(),
1042 "deep"
1043 );
1044 }
1045
1046 #[test]
1049 fn execute_real_wrapper_fails_fast_without_touching_real_agents_dir() {
1050 let rt = tokio::runtime::Runtime::new().unwrap();
1056 with_tracing(|| {
1057 rt.block_on(async {
1058 let args = AddArgs {
1059 package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
1060 };
1061 let err = execute(args).await.unwrap_err();
1062 assert!(err.to_string().contains("Package file not found"));
1063 })
1064 });
1065 }
1066
1067 #[test]
1068 fn execute_returns_err_when_agents_dir_unresolvable() {
1069 let rt = tokio::runtime::Runtime::new().unwrap();
1074 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(true));
1075 let result = rt.block_on(async {
1076 let args = AddArgs {
1077 package: "whatever.leviath-bundle".to_string(),
1078 };
1079 execute(args).await
1080 });
1081 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(false));
1082
1083 let err = result.unwrap_err();
1084 assert!(
1085 err.to_string()
1086 .contains("Could not determine home directory")
1087 );
1088 }
1089
1090 #[test]
1093 fn install_from_dir_with_manifest_runs() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let manifest = r#"
1096[agent]
1097name = "test-install-agent-xyz"
1098version = "0.1.0"
1099description = "test"
1100"#;
1101 write_test_agent(dir.path(), manifest);
1102 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1103
1104 let agents_dir = tempfile::tempdir().unwrap();
1105 install_from_dir(dir.path(), agents_dir.path()).unwrap();
1106
1107 let install_dir = agents_dir.path().join("test-install-agent-xyz");
1108 assert!(install_dir.join("agent.leviath").exists());
1109 assert!(install_dir.join("readme.txt").exists());
1110 }
1111}