1use clap::Args;
4use std::path::Path;
5
6#[derive(Args)]
8pub struct AddArgs {
9 #[arg(value_name = "PACKAGE")]
11 pub package: String,
12}
13
14fn agents_dir_or_error(dir: Option<std::path::PathBuf>) -> anyhow::Result<std::path::PathBuf> {
15 dir.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
16}
17
18pub async fn execute(args: AddArgs) -> anyhow::Result<()> {
20 let installer = leviath_package::AgentInstaller::new();
21 let agents_dir = resolve_agents_dir()?;
22 let config = crate::config::Config::load().ok();
26 execute_with(&args, &installer, &agents_dir, config.as_ref()).await
27}
28
29fn resolve_agents_dir() -> anyhow::Result<std::path::PathBuf> {
42 #[cfg(test)]
43 if FORCE_AGENTS_DIR_ERROR.with(|f| f.get()) {
44 anyhow::bail!("Could not determine home directory");
45 }
46 agents_dir_or_error(leviath_core::paths::agents_dir())
47}
48
49#[cfg(test)]
50thread_local! {
51 static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
54}
55
56async fn execute_with(
60 args: &AddArgs,
61 installer: &leviath_package::AgentInstaller,
62 agents_dir: &Path,
63 config: Option<&crate::config::Config>,
64) -> anyhow::Result<()> {
65 tracing::info!("Installing agent package");
66
67 let package_path = Path::new(&args.package);
68
69 if package_path.is_dir() {
70 install_from_dir(package_path, agents_dir, config)?;
72 } else if package_path.exists() || args.package.ends_with(".leviath-bundle") {
73 if !package_path.exists() {
75 anyhow::bail!("Package file not found: {}", args.package);
76 }
77 println!("Installing from bundle: {}", args.package);
78 let installed = installer.install(package_path)?;
79 println!(
80 "Installed agent '{}' v{} to {}",
81 installed.name,
82 installed.version,
83 installed.path.display()
84 );
85 print_capabilities(&installed.name, &installed.path, config);
86 } else {
87 anyhow::bail!(
90 "'{}' is not a local agent directory or a .leviath-bundle file - \
91 pass a path to one of those instead.",
92 args.package
93 );
94 }
95
96 Ok(())
97}
98
99pub(crate) fn describe_capabilities(
116 manifest_toml: &str,
117 script_tools: &[String],
118 read_paths: Option<&crate::read_path_report::GrantReport>,
119) -> Vec<String> {
120 let mut findings = Vec::new();
121 let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
127 return findings;
130 };
131
132 if !script_tools.is_empty() {
133 findings.push(format!(
134 "ships {} executable script tool(s): {}",
135 script_tools.len(),
136 script_tools.join(", ")
137 ));
138 }
139
140 let mut granted: Vec<String> = Vec::new();
142 let mut collect_grants = |table: Option<&toml::Value>| {
143 if let Some(t) = table.and_then(|v| v.as_table()) {
144 for (tool, policy) in t {
145 if policy.as_str() == Some("allow") && !granted.contains(tool) {
146 granted.push(tool.clone());
147 }
148 }
149 }
150 };
151 collect_grants(value.get("tool_permissions"));
152 if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
153 for stage in stages.values() {
154 collect_grants(stage.get("tool_permissions"));
155 }
156 }
157 if !granted.is_empty() {
158 granted.sort();
159 findings.push(format!(
160 "pre-approves these tools (no prompt at run time): {}",
161 granted.join(", ")
162 ));
163 }
164
165 if let Some(t) = value
167 .get("tool_script_permissions")
168 .and_then(|v| v.as_table())
169 {
170 let mut allowed: Vec<&String> = t
171 .iter()
172 .filter(|(_, v)| v.as_str() == Some("allow"))
173 .map(|(k, _)| k)
174 .collect();
175 if !allowed.is_empty() {
176 allowed.sort();
177 findings.push(format!(
178 "requests script host access: {}",
179 allowed
180 .iter()
181 .map(|s| s.as_str())
182 .collect::<Vec<_>>()
183 .join(", ")
184 ));
185 }
186 }
187
188 if let Some(kind) = value
190 .get("sandbox")
191 .and_then(|v| v.get("kind"))
192 .and_then(|v| v.as_str())
193 && kind == "none"
194 {
195 findings.push("asks to run tools directly on the host (sandbox = none)".to_string());
196 }
197
198 if let Some(entries) = value
202 .get("read_paths")
203 .and_then(|v| v.get("allow"))
204 .and_then(|v| v.as_array())
205 && !entries.is_empty()
206 {
207 let listed: Vec<String> = entries
208 .iter()
209 .filter_map(|e| e.as_str().map(str::to_string))
210 .collect();
211 let status = match read_paths {
214 Some(report) if report.has_ungranted() => format!(
215 "; {} - grant the rest with [agent_read_paths.{}] in your config",
216 report.summary(),
217 report.agent
218 ),
219 Some(report) => format!("; {}, all granted by your config", report.summary()),
220 None => "; inert unless you grant it via [security] read_paths / \
221 allow_blueprint_read_paths or [agent_read_paths.<name>] in your config"
222 .to_string(),
223 };
224 findings.push(format!(
225 "asks to read outside its workdir (read-only): {}{status}",
226 listed.join(", ")
227 ));
228 }
229
230 let seed_commands = collect_seed_commands(&value);
234 for command in seed_commands {
235 findings.push(format!(
236 "runs this command at startup, before any prompt: `{command}`"
237 ));
238 }
239
240 findings
241}
242
243fn collect_seed_commands(value: &toml::Value) -> Vec<String> {
246 let mut out = Vec::new();
247 let mut scan = |regions: Option<&toml::Value>| {
248 if let Some(t) = regions.and_then(|v| v.as_table()) {
249 for region in t.values() {
250 if let Some(cmd) = region
251 .get("seed")
252 .and_then(|s| s.get("command"))
253 .and_then(|c| c.as_str())
254 {
255 out.push(cmd.to_string());
256 }
257 }
258 }
259 };
260 scan(value.get("context").and_then(|c| c.get("regions")));
261 if let Some(stages) = value.get("stages").and_then(|v| v.as_table()) {
262 for stage in stages.values() {
263 scan(stage.get("context").and_then(|c| c.get("regions")));
264 }
265 }
266 out
267}
268
269fn print_capabilities(name: &str, install_dir: &Path, config: Option<&crate::config::Config>) {
271 let manifest = std::fs::read_to_string(install_dir.join("agent.leviath")).unwrap_or_default();
272 let scripts = script_tool_names(install_dir);
273 let report = read_path_report(&manifest, config);
274 let findings = describe_capabilities(&manifest, &scripts, report.as_ref());
275 if findings.is_empty() {
276 return;
277 }
278 println!("\n '{name}' asks for the following. Review before running it:");
279 for finding in &findings {
280 println!(" - {finding}");
281 }
282 println!(" Inspect it with: lev validate {name}");
283}
284
285fn read_path_report(
293 manifest_toml: &str,
294 config: Option<&crate::config::Config>,
295) -> Option<crate::read_path_report::GrantReport> {
296 let config = config?;
297 let blueprint = leviath_core::manifest::parse_manifest(manifest_toml).ok()?;
298 let workdir = crate::commands::resolve_cwd().unwrap_or_default();
299 crate::read_path_report::build(&blueprint, config, &workdir)?.ok()
300}
301
302fn script_tool_names(install_dir: &Path) -> Vec<String> {
304 let mut names: Vec<String> = std::fs::read_dir(install_dir.join("tools"))
305 .into_iter()
306 .flatten()
307 .flatten()
308 .filter_map(|e| {
312 let name = e.file_name().to_string_lossy().into_owned();
313 name.ends_with(".rhai").then_some(name)
314 })
315 .collect();
316 names.sort();
317 names
318}
319
320fn install_from_dir(
325 src: &Path,
326 agents_dir: &Path,
327 config: Option<&crate::config::Config>,
328) -> anyhow::Result<()> {
329 let manifest_path = src.join("agent.leviath");
330 if !manifest_path.exists() {
331 anyhow::bail!(
332 "No agent.leviath found in '{}'. Is this an agent directory?",
333 src.display()
334 );
335 }
336
337 let content = std::fs::read_to_string(&manifest_path)?;
339 let name = parse_agent_name(&content).unwrap_or_else(|| {
340 src.file_name()
341 .and_then(|n| n.to_str())
342 .unwrap_or("unknown")
343 .to_string()
344 });
345
346 let install_dir = agents_dir.join(&name);
347
348 if install_dir.exists() {
349 println!("Reinstalling agent '{}' (replacing existing)", name);
350 std::fs::remove_dir_all(&install_dir)?;
351 }
352
353 copy_dir_recursive(src, &install_dir)?;
354 println!("Installed agent '{}' to {}", name, install_dir.display());
355 print_capabilities(&name, &install_dir, config);
356 println!("Run with: lev run {} --task \"...\"", name);
357 Ok(())
358}
359
360#[cfg(test)]
361thread_local! {
362 static FORCE_DIR_ENTRY_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
369}
370
371fn unwrap_dir_entry(
377 entry: std::io::Result<std::fs::DirEntry>,
378) -> anyhow::Result<std::fs::DirEntry> {
379 #[cfg(test)]
380 if FORCE_DIR_ENTRY_ERROR.with(|f| f.get()) {
381 anyhow::bail!("forced dir-entry error for testing");
382 }
383 Ok(entry?)
384}
385
386fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
388 std::fs::create_dir_all(dst)?;
389 for entry in std::fs::read_dir(src)? {
390 let entry = unwrap_dir_entry(entry)?;
391 let src_path = entry.path();
392 let dst_path = dst.join(entry.file_name());
393 if src_path.is_dir() {
394 copy_dir_recursive(&src_path, &dst_path)?;
395 } else {
396 std::fs::copy(&src_path, &dst_path)?;
397 }
398 }
399 Ok(())
400}
401
402fn parse_agent_name(content: &str) -> Option<String> {
404 for line in content.lines() {
405 let trimmed = line.trim();
406 if let Some(rest) = trimmed.strip_prefix("name") {
407 let rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '=');
408 let name = rest.trim().trim_matches('"');
409 if !name.is_empty() {
410 return Some(name.to_string());
411 }
412 }
413 }
414 None
415}
416
417#[cfg(test)]
418mod capability_tests {
419 use std::path::Path;
420
421 fn describe_capabilities(manifest_toml: &str, script_tools: &[String]) -> Vec<String> {
425 super::describe_capabilities(manifest_toml, script_tools, None)
426 }
427
428 #[test]
431 fn an_ordinary_agent_has_nothing_to_report() {
432 let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
433 [stages.main]\nsystem_prompt = \"p\"\n";
434 assert!(describe_capabilities(manifest, &[]).is_empty());
435 }
436
437 #[test]
438 fn script_tools_are_listed_by_name() {
439 let findings = describe_capabilities(
440 "[agent]\nname = \"x\"\n",
441 &["web_fetch.rhai".to_string(), "post.rhai".to_string()],
442 );
443 assert_eq!(findings.len(), 1);
444 assert!(findings[0].contains("2 executable script tool"));
445 assert!(findings[0].contains("web_fetch.rhai"));
446 }
447
448 #[test]
452 fn self_granted_tool_permissions_are_reported() {
453 let manifest = "[agent]\nname = \"x\"\n\n\
454 [tool_permissions]\nshell = \"allow\"\nread_file = \"ask\"\n";
455 let findings = describe_capabilities(manifest, &[]);
456 assert_eq!(findings.len(), 1);
457 assert!(findings[0].contains("pre-approves"));
458 assert!(findings[0].contains("shell"));
459 assert!(!findings[0].contains("read_file"));
461 }
462
463 #[test]
467 fn a_script_permission_table_that_grants_nothing_is_not_reported() {
468 let manifest = "[agent]\nname = \"x\"\n\n\
469 [tool_script_permissions]\nenv_var = \"deny\"\nhttp_get = \"ask\"\n";
470 assert!(
471 describe_capabilities(manifest, &[]).is_empty(),
472 "denying host access is not a capability to warn about"
473 );
474 }
475
476 #[test]
477 fn stage_level_grants_are_reported_too() {
478 let manifest = "[agent]\nname = \"x\"\n\n\
479 [stages.build.tool_permissions]\nwrite_file = \"allow\"\n";
480 let findings = describe_capabilities(manifest, &[]);
481 assert!(findings[0].contains("write_file"), "{findings:?}");
482 }
483
484 #[test]
485 fn script_host_grants_and_sandbox_opt_out_are_reported() {
486 let manifest = "[agent]\nname = \"x\"\n\n\
487 [tool_script_permissions]\nshell = \"allow\"\nhttp_post = \"allow\"\n\n\
488 [sandbox]\nkind = \"none\"\n";
489 let findings = describe_capabilities(manifest, &[]);
490 let joined = findings.join(" | ");
491 assert!(joined.contains("script host access"), "{joined}");
492 assert!(joined.contains("http_post"), "{joined}");
493 assert!(joined.contains("sandbox = none"), "{joined}");
494 }
495
496 #[test]
500 fn command_seeds_are_reported_verbatim() {
501 let manifest = "[agent]\nname = \"x\"\n\n\
502 [context.regions]\n\
503 repo = { kind = \"pinned\", seed = { command = \"git ls-files\" } }\n";
504 let findings = describe_capabilities(manifest, &[]);
505 assert_eq!(findings.len(), 1);
506 assert!(findings[0].contains("before any prompt"), "{findings:?}");
507 assert!(findings[0].contains("git ls-files"), "{findings:?}");
508 }
509
510 #[test]
511 fn stage_level_command_seeds_are_reported() {
512 let manifest = "[agent]\nname = \"x\"\n\n\
513 [stages.discover.context.regions]\n\
514 env = { kind = \"pinned\", seed = { command = \"curl https://evil\" } }\n";
515 let findings = describe_capabilities(manifest, &[]);
516 assert!(findings[0].contains("curl https://evil"), "{findings:?}");
517 }
518
519 #[test]
521 fn opting_into_a_sandbox_is_not_reported() {
522 let manifest = "[agent]\nname = \"x\"\n\n[sandbox]\nkind = \"container\"\n";
523 assert!(describe_capabilities(manifest, &[]).is_empty());
524 }
525
526 #[test]
529 fn read_path_declarations_are_reported() {
530 let manifest = "[agent]\nname = \"x\"\n\n\
531 [read_paths]\n\
532 allow = [\"~/.leviath/runs\", \"glob:~/design-docs/**\"]\n";
533 let findings = describe_capabilities(manifest, &[]);
534 assert_eq!(findings.len(), 1);
535 assert!(
536 findings[0].contains("read outside its workdir"),
537 "{findings:?}"
538 );
539 assert!(findings[0].contains("~/.leviath/runs"), "{findings:?}");
540 assert!(
541 findings[0].contains("glob:~/design-docs/**"),
542 "{findings:?}"
543 );
544 assert!(
545 findings[0].contains("inert unless you grant it"),
546 "{findings:?}"
547 );
548 }
549
550 #[test]
554 fn read_path_declarations_carry_their_grant_status() {
555 let manifest = "[agent]\nname = \"cto\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
556 [stages.main]\nmode = \"autonomous\"\n\n\
557 [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n\n\
558 [read_paths]\nallow = [\"/data/runs\", \"/data/docs\"]\n";
559 let blueprint = leviath_core::manifest::parse_manifest(manifest).expect("parses");
560
561 let mut config = crate::config::Config::default();
562 config.security.read_paths = vec!["/data/runs".to_string()];
563 let partial = crate::read_path_report::build(&blueprint, &config, Path::new("/work"))
564 .expect("declares read paths")
565 .expect("grants compile");
566 let findings = super::describe_capabilities(manifest, &[], Some(&partial));
567 assert!(
568 findings[0].contains("2 declared, 1 granted"),
569 "{findings:?}"
570 );
571 assert!(
572 findings[0].contains("[agent_read_paths.cto]"),
573 "{findings:?}"
574 );
575
576 config.security.read_paths.push("/data/docs".to_string());
577 let full = crate::read_path_report::build(&blueprint, &config, Path::new("/work"))
578 .expect("declares read paths")
579 .expect("grants compile");
580 let findings = super::describe_capabilities(manifest, &[], Some(&full));
581 assert!(findings[0].contains("all granted"), "{findings:?}");
582 }
583
584 #[test]
586 fn an_empty_read_paths_block_is_not_reported() {
587 let manifest = "[agent]\nname = \"x\"\n\n[read_paths]\nallow = []\n";
588 assert!(describe_capabilities(manifest, &[]).is_empty());
589 }
590
591 #[test]
595 fn no_grant_report_is_built_without_a_config_or_a_parseable_manifest() {
596 let manifest = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
597 [stages.main]\nmode = \"autonomous\"\n\n\
598 [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n\n\
599 [read_paths]\nallow = [\"/data/runs\"]\n";
600 assert!(super::read_path_report(manifest, None).is_none());
601 assert!(
602 super::read_path_report(
603 "not valid toml [[[",
604 Some(&crate::config::Config::default())
605 )
606 .is_none()
607 );
608
609 let plain = "[agent]\nname = \"x\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n\
611 [stages.main]\nmode = \"autonomous\"\n\n\
612 [context.regions]\nsystem = { kind = \"pinned\", max_tokens = 1000 }\n";
613 assert!(super::read_path_report(plain, Some(&crate::config::Config::default())).is_none());
614
615 let mut broken = crate::config::Config::default();
617 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
618 assert!(super::read_path_report(manifest, Some(&broken)).is_none());
619
620 let report = super::read_path_report(manifest, Some(&crate::config::Config::default()))
622 .expect("a parseable manifest and a config give a report");
623 assert_eq!(report.declared(), 1);
624 }
625
626 #[test]
629 fn script_tool_names_lists_only_rhai_files_sorted() {
630 let dir = tempfile::tempdir().unwrap();
631 let tools = dir.path().join("tools");
632 std::fs::create_dir(&tools).unwrap();
633 for name in ["zeta.rhai", "alpha.rhai", "README.md", "notes.txt"] {
634 std::fs::write(tools.join(name), "x").unwrap();
635 }
636 assert_eq!(
637 super::script_tool_names(dir.path()),
638 vec!["alpha.rhai".to_string(), "zeta.rhai".to_string()]
639 );
640 }
641
642 #[test]
644 fn script_tool_names_is_empty_without_a_tools_directory() {
645 let dir = tempfile::tempdir().unwrap();
646 assert!(super::script_tool_names(dir.path()).is_empty());
647 }
648
649 #[test]
652 fn print_capabilities_reads_the_installed_directory() {
653 crate::test_support::with_tracing(|| {
654 let dir = tempfile::tempdir().unwrap();
655 std::fs::write(
656 dir.path().join("agent.leviath"),
657 "[agent]\nname = \"q\"\n\n[tool_permissions]\nshell = \"allow\"\n",
658 )
659 .unwrap();
660 let tools = dir.path().join("tools");
661 std::fs::create_dir(&tools).unwrap();
662 std::fs::write(tools.join("t.rhai"), "// @tool t\n").unwrap();
663 super::print_capabilities("q", dir.path(), None);
664
665 let plain = tempfile::tempdir().unwrap();
667 std::fs::write(
668 plain.path().join("agent.leviath"),
669 "[agent]\nname = \"p\"\n\n[stages.main]\nsystem_prompt = \"p\"\n",
670 )
671 .unwrap();
672 super::print_capabilities("p", plain.path(), None);
673 });
674 }
675
676 #[test]
677 fn an_unparseable_manifest_reports_nothing() {
678 assert!(describe_capabilities("{ not toml", &[]).is_empty());
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::test_support::{with_tracing, write_test_agent};
686
687 async fn execute_with(
690 args: &AddArgs,
691 installer: &leviath_package::AgentInstaller,
692 agents_dir: &Path,
693 ) -> anyhow::Result<()> {
694 super::execute_with(args, installer, agents_dir, None).await
695 }
696
697 fn install_from_dir(src: &Path, agents_dir: &Path) -> anyhow::Result<()> {
698 super::install_from_dir(src, agents_dir, None)
699 }
700
701 #[test]
704 fn agents_dir_or_error_some_returns_path() {
705 let dir = std::path::PathBuf::from("/home/testuser/.leviath/agents");
706 assert_eq!(agents_dir_or_error(Some(dir.clone())).unwrap(), dir);
707 }
708
709 #[test]
710 fn agents_dir_or_error_none_returns_error() {
711 let err = agents_dir_or_error(None).unwrap_err();
712 assert!(
713 err.to_string()
714 .contains("Could not determine home directory")
715 );
716 }
717
718 #[test]
721 fn parse_agent_name_standard() {
722 let content = r#"
723name = "my-agent"
724version = "1.0"
725"#;
726 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
727 }
728
729 #[test]
730 fn parse_agent_name_no_quotes() {
731 let content = r#"name = my-agent"#;
732 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
733 }
734
735 #[test]
736 fn parse_agent_name_extra_whitespace() {
737 let content = r#" name = "spacy-agent" "#;
738 assert_eq!(parse_agent_name(content), Some("spacy-agent".to_string()));
739 }
740
741 #[test]
742 fn parse_agent_name_missing() {
743 let content = r#"
744version = "1.0"
745description = "test"
746"#;
747 assert_eq!(parse_agent_name(content), None);
748 }
749
750 #[test]
751 fn parse_agent_name_empty_value() {
752 let content = r#"name = """#;
753 assert_eq!(parse_agent_name(content), None);
754 }
755
756 #[test]
759 fn copy_dir_recursive_copies_files() {
760 let src_dir = tempfile::tempdir().unwrap();
761 let dst_dir = tempfile::tempdir().unwrap();
762 let dst_path = dst_dir.path().join("copy");
763
764 std::fs::write(src_dir.path().join("file1.txt"), "hello").unwrap();
765 std::fs::create_dir_all(src_dir.path().join("sub")).unwrap();
766 std::fs::write(src_dir.path().join("sub/file2.txt"), "world").unwrap();
767
768 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
769
770 assert!(dst_path.join("file1.txt").exists());
771 assert!(dst_path.join("sub/file2.txt").exists());
772 assert_eq!(
773 std::fs::read_to_string(dst_path.join("file1.txt")).unwrap(),
774 "hello"
775 );
776 assert_eq!(
777 std::fs::read_to_string(dst_path.join("sub/file2.txt")).unwrap(),
778 "world"
779 );
780 }
781
782 #[test]
783 fn copy_dir_recursive_empty_dir() {
784 let src_dir = tempfile::tempdir().unwrap();
785 let dst_dir = tempfile::tempdir().unwrap();
786 let dst_path = dst_dir.path().join("empty-copy");
787
788 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
789 assert!(dst_path.exists());
790 assert!(dst_path.is_dir());
791 }
792
793 #[test]
794 fn copy_dir_recursive_nonexistent_src_errors() {
795 let dst_dir = tempfile::tempdir().unwrap();
796 let dst_path = dst_dir.path().join("dst");
797 let missing_src = dst_dir.path().join("does-not-exist");
798
799 let result = copy_dir_recursive(&missing_src, &dst_path);
800 assert!(result.is_err());
801 }
802
803 #[test]
804 fn copy_dir_recursive_dst_parent_is_file_errors() {
805 let tmp = tempfile::tempdir().unwrap();
806 let file_path = tmp.path().join("not-a-dir");
807 std::fs::write(&file_path, "x").unwrap();
808 let src = tempfile::tempdir().unwrap();
809 let dst = file_path.join("child");
810
811 let result = copy_dir_recursive(src.path(), &dst);
812 assert!(result.is_err());
813 }
814
815 #[test]
816 fn copy_dir_recursive_file_over_existing_dir_errors() {
817 let src_dir = tempfile::tempdir().unwrap();
821 std::fs::write(src_dir.path().join("clash"), "top secret").unwrap();
822
823 let dst_dir = tempfile::tempdir().unwrap();
824 let dst_path = dst_dir.path().join("copy");
825 std::fs::create_dir_all(dst_path.join("clash")).unwrap();
827
828 let result = copy_dir_recursive(src_dir.path(), &dst_path);
829 assert!(result.is_err());
830 }
831
832 #[test]
833 fn copy_dir_recursive_recursion_error_propagates() {
834 let src_dir = tempfile::tempdir().unwrap();
840 let sub = src_dir.path().join("sub");
841 std::fs::create_dir_all(&sub).unwrap();
842 std::fs::write(sub.join("file.txt"), "data").unwrap();
843
844 let dst_dir = tempfile::tempdir().unwrap();
845 let dst_path = dst_dir.path().join("copy");
846 std::fs::create_dir_all(&dst_path).unwrap();
847 std::fs::write(dst_path.join("sub"), "i am a file").unwrap();
849
850 let result = copy_dir_recursive(src_dir.path(), &dst_path);
851 assert!(result.is_err());
852 }
853
854 #[test]
855 fn copy_dir_recursive_forced_mid_iteration_entry_error() {
856 let src_dir = tempfile::tempdir().unwrap();
860 std::fs::write(src_dir.path().join("file.txt"), "data").unwrap();
861
862 let dst_dir = tempfile::tempdir().unwrap();
863 let dst_path = dst_dir.path().join("copy");
864
865 FORCE_DIR_ENTRY_ERROR.with(|f| f.set(true));
866 let result = copy_dir_recursive(src_dir.path(), &dst_path);
867 FORCE_DIR_ENTRY_ERROR.with(|f| f.set(false));
868
869 assert!(result.is_err());
870 }
871
872 #[test]
873 fn unwrap_dir_entry_propagates_a_real_err_argument() {
874 let result = unwrap_dir_entry(Err(std::io::Error::other("synthetic entry error")));
881 assert!(result.is_err());
882 }
883
884 #[test]
887 fn install_from_dir_no_manifest_errors() {
888 let dir = tempfile::tempdir().unwrap();
889 let agents_dir = tempfile::tempdir().unwrap();
890 let result = install_from_dir(dir.path(), agents_dir.path());
891 assert!(result.is_err());
892 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
893 }
894
895 #[test]
896 fn install_from_dir_copies_and_names_from_manifest() {
897 let src = tempfile::tempdir().unwrap();
898 let agents_dir = tempfile::tempdir().unwrap();
899 std::fs::write(
900 src.path().join("agent.leviath"),
901 "[agent]\nname = \"my-agent\"\n",
902 )
903 .unwrap();
904 std::fs::write(src.path().join("extra.txt"), "data").unwrap();
905
906 install_from_dir(src.path(), agents_dir.path()).unwrap();
907
908 let installed_dir = agents_dir.path().join("my-agent");
909 assert!(installed_dir.join("agent.leviath").exists());
910 assert!(installed_dir.join("extra.txt").exists());
911 }
912
913 #[test]
914 fn install_from_dir_falls_back_to_dirname_when_name_missing() {
915 let src = tempfile::tempdir().unwrap();
916 let agent_dir = src.path().join("my-dir-name");
917 std::fs::create_dir_all(&agent_dir).unwrap();
918 std::fs::write(agent_dir.join("agent.leviath"), "version = \"1.0\"\n").unwrap();
919 let agents_dir = tempfile::tempdir().unwrap();
920
921 install_from_dir(&agent_dir, agents_dir.path()).unwrap();
922
923 assert!(agents_dir.path().join("my-dir-name").exists());
924 }
925
926 #[test]
927 fn install_from_dir_reinstalls_existing() {
928 let src = tempfile::tempdir().unwrap();
929 std::fs::write(
930 src.path().join("agent.leviath"),
931 "[agent]\nname = \"dup-agent\"\n",
932 )
933 .unwrap();
934 let agents_dir = tempfile::tempdir().unwrap();
935
936 let existing = agents_dir.path().join("dup-agent");
938 std::fs::create_dir_all(&existing).unwrap();
939 std::fs::write(existing.join("stale.txt"), "old").unwrap();
940
941 install_from_dir(src.path(), agents_dir.path()).unwrap();
942
943 assert!(!existing.join("stale.txt").exists());
944 assert!(existing.join("agent.leviath").exists());
945 }
946
947 #[test]
948 fn install_from_dir_invalid_utf8_manifest_errors() {
949 let dir = tempfile::tempdir().unwrap();
950 std::fs::write(dir.path().join("agent.leviath"), [0xFF, 0xFE, 0xFA]).unwrap();
951 let agents_dir = tempfile::tempdir().unwrap();
952
953 let result = install_from_dir(dir.path(), agents_dir.path());
954 assert!(result.is_err());
955 }
956
957 #[test]
958 fn install_from_dir_remove_dir_all_failure_errors() {
959 let src = tempfile::tempdir().unwrap();
963 std::fs::write(
964 src.path().join("agent.leviath"),
965 "[agent]\nname = \"file-agent\"\n",
966 )
967 .unwrap();
968
969 let agents_dir = tempfile::tempdir().unwrap();
970 std::fs::write(agents_dir.path().join("file-agent"), "not a dir").unwrap();
971
972 let result = install_from_dir(src.path(), agents_dir.path());
973 assert!(result.is_err());
974 }
975
976 #[test]
977 fn install_from_dir_copy_failure_propagates() {
978 let src = tempfile::tempdir().unwrap();
983 std::fs::write(
984 src.path().join("agent.leviath"),
985 "[agent]\nname = \"broken-copy-agent\"\n",
986 )
987 .unwrap();
988 std::fs::write(src.path().join("extra.txt"), "data").unwrap();
989
990 let tmp = tempfile::tempdir().unwrap();
991 let agents_file = tmp.path().join("agents-is-a-file");
992 std::fs::write(&agents_file, "not a dir").unwrap();
993
994 let result = install_from_dir(src.path(), &agents_file);
995 assert!(result.is_err());
996 }
997
998 #[test]
1001 fn execute_with_directory_package_installs() {
1002 let rt = tokio::runtime::Runtime::new().unwrap();
1003 with_tracing(|| {
1004 rt.block_on(async {
1005 let src = tempfile::tempdir().unwrap();
1006 std::fs::write(
1007 src.path().join("agent.leviath"),
1008 "[agent]\nname = \"dir-pkg\"\n",
1009 )
1010 .unwrap();
1011 let agents_dir = tempfile::tempdir().unwrap();
1012 let installer = leviath_package::AgentInstaller::with_install_dir(
1013 agents_dir.path().to_path_buf(),
1014 );
1015 let args = AddArgs {
1016 package: src.path().to_str().unwrap().to_string(),
1017 };
1018
1019 execute_with(&args, &installer, agents_dir.path())
1020 .await
1021 .unwrap();
1022
1023 assert!(agents_dir.path().join("dir-pkg").exists());
1024 })
1025 });
1026 }
1027
1028 #[test]
1029 fn execute_with_directory_without_manifest_errors() {
1030 let rt = tokio::runtime::Runtime::new().unwrap();
1031 with_tracing(|| {
1032 rt.block_on(async {
1033 let src = tempfile::tempdir().unwrap(); let agents_dir = tempfile::tempdir().unwrap();
1035 let installer = leviath_package::AgentInstaller::with_install_dir(
1036 agents_dir.path().to_path_buf(),
1037 );
1038 let args = AddArgs {
1039 package: src.path().to_str().unwrap().to_string(),
1040 };
1041
1042 let err = execute_with(&args, &installer, agents_dir.path())
1043 .await
1044 .unwrap_err();
1045 assert!(err.to_string().contains("agent.leviath"));
1046 })
1047 });
1048 }
1049
1050 #[test]
1051 fn execute_with_missing_bundle_file_errors() {
1052 let rt = tokio::runtime::Runtime::new().unwrap();
1053 with_tracing(|| {
1054 rt.block_on(async {
1055 let agents_dir = tempfile::tempdir().unwrap();
1056 let installer = leviath_package::AgentInstaller::with_install_dir(
1057 agents_dir.path().to_path_buf(),
1058 );
1059 let args = AddArgs {
1060 package: "nonexistent.leviath-bundle".to_string(),
1061 };
1062
1063 let err = execute_with(&args, &installer, agents_dir.path())
1064 .await
1065 .unwrap_err();
1066 assert!(err.to_string().contains("Package file not found"));
1067 })
1068 });
1069 }
1070
1071 #[test]
1072 fn execute_with_bundle_file_installs() {
1073 let rt = tokio::runtime::Runtime::new().unwrap();
1074 with_tracing(|| {
1075 rt.block_on(async {
1076 let project_dir = tempfile::tempdir().unwrap();
1077 std::fs::write(
1078 project_dir.path().join("agent.leviath"),
1079 "[agent]\nname = \"bundled-pkg\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
1080 )
1081 .unwrap();
1082 let bundle_bytes = leviath_package::AgentBundler::new()
1083 .bundle(project_dir.path())
1084 .unwrap();
1085 let bundle_dir = tempfile::tempdir().unwrap();
1086 let bundle_path = bundle_dir.path().join("bundled-pkg.leviath-bundle");
1090 std::fs::write(&bundle_path, bundle_bytes).unwrap();
1091
1092 let agents_dir = tempfile::tempdir().unwrap();
1093 let installer = leviath_package::AgentInstaller::with_install_dir(
1094 agents_dir.path().to_path_buf(),
1095 );
1096 let args = AddArgs {
1097 package: bundle_path.to_str().unwrap().to_string(),
1098 };
1099
1100 execute_with(&args, &installer, agents_dir.path())
1101 .await
1102 .unwrap();
1103
1104 assert!(agents_dir.path().join("bundled-pkg").exists());
1105 })
1106 });
1107 }
1108
1109 #[test]
1110 fn execute_with_corrupt_bundle_file_errors() {
1111 let rt = tokio::runtime::Runtime::new().unwrap();
1112 with_tracing(|| {
1113 rt.block_on(async {
1114 let bundle_dir = tempfile::tempdir().unwrap();
1115 let bundle_path = bundle_dir.path().join("broken.leviath-bundle");
1116 std::fs::write(&bundle_path, b"not a valid gzip archive").unwrap();
1117
1118 let agents_dir = tempfile::tempdir().unwrap();
1119 let installer = leviath_package::AgentInstaller::with_install_dir(
1120 agents_dir.path().to_path_buf(),
1121 );
1122 let args = AddArgs {
1123 package: bundle_path.to_str().unwrap().to_string(),
1124 };
1125
1126 let err = execute_with(&args, &installer, agents_dir.path())
1127 .await
1128 .unwrap_err();
1129 assert!(err.to_string().contains("Failed to extract package"));
1130 })
1131 });
1132 }
1133
1134 #[test]
1135 fn execute_with_unrecognized_package_reports_local_only() {
1136 let rt = tokio::runtime::Runtime::new().unwrap();
1139 with_tracing(|| {
1140 rt.block_on(async {
1141 let agents_dir = tempfile::tempdir().unwrap();
1142 let installer = leviath_package::AgentInstaller::with_install_dir(
1143 agents_dir.path().to_path_buf(),
1144 );
1145 let args = AddArgs {
1146 package: "some-registry-agent".to_string(),
1147 };
1148 let err = execute_with(&args, &installer, agents_dir.path())
1149 .await
1150 .unwrap_err();
1151 assert!(
1152 err.to_string()
1153 .contains("not a local agent directory or a .leviath-bundle file"),
1154 "expected the v1-cut message, got: {err}"
1155 );
1156 })
1157 });
1158 }
1159
1160 #[test]
1163 fn bundle_extension_detected() {
1164 let package = "my-agent-1.0.leviath-bundle";
1165 assert!(package.ends_with(".leviath-bundle"));
1166 }
1167
1168 #[test]
1169 fn directory_path_detected() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let package_path = Path::new(dir.path().to_str().unwrap());
1172 assert!(package_path.is_dir());
1173 }
1174
1175 #[test]
1176 fn registry_name_not_dir_not_bundle() {
1177 let package = "my-cool-agent";
1178 let package_path = Path::new(package);
1179 assert!(!package_path.is_dir());
1180 assert!(!package.ends_with(".leviath-bundle"));
1181 }
1182
1183 #[test]
1186 fn parse_agent_name_in_section() {
1187 let content = r#"
1188[agent]
1189name = "my-agent"
1190version = "1.0"
1191"#;
1192 assert_eq!(parse_agent_name(content), Some("my-agent".to_string()));
1193 }
1194
1195 #[test]
1196 fn parse_agent_name_with_single_quotes() {
1197 let content = r#"name = my-agent-no-quotes"#;
1199 assert_eq!(
1200 parse_agent_name(content),
1201 Some("my-agent-no-quotes".to_string())
1202 );
1203 }
1204
1205 #[test]
1206 fn parse_agent_name_multiple_name_fields_returns_first() {
1207 let content = r#"
1208name = "first"
1209name = "second"
1210"#;
1211 assert_eq!(parse_agent_name(content), Some("first".to_string()));
1212 }
1213
1214 #[test]
1217 fn copy_dir_recursive_deeply_nested() {
1218 let src_dir = tempfile::tempdir().unwrap();
1219 let dst_dir = tempfile::tempdir().unwrap();
1220 let dst_path = dst_dir.path().join("deep-copy");
1221
1222 std::fs::create_dir_all(src_dir.path().join("a/b/c")).unwrap();
1223 std::fs::write(src_dir.path().join("a/b/c/deep.txt"), "deep").unwrap();
1224
1225 copy_dir_recursive(src_dir.path(), &dst_path).unwrap();
1226
1227 assert!(dst_path.join("a/b/c/deep.txt").exists());
1228 assert_eq!(
1229 std::fs::read_to_string(dst_path.join("a/b/c/deep.txt")).unwrap(),
1230 "deep"
1231 );
1232 }
1233
1234 #[test]
1237 fn execute_real_wrapper_fails_fast_without_touching_real_agents_dir() {
1238 let rt = tokio::runtime::Runtime::new().unwrap();
1244 with_tracing(|| {
1245 rt.block_on(async {
1246 crate::config::with_isolated_config_path_async("add-real-wrapper", |_fake| async {
1249 let args = AddArgs {
1250 package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
1251 };
1252 let err = execute(args).await.unwrap_err();
1253 assert!(err.to_string().contains("Package file not found"));
1254 })
1255 .await;
1256 })
1257 });
1258 }
1259
1260 #[test]
1261 fn execute_returns_err_when_agents_dir_unresolvable() {
1262 let rt = tokio::runtime::Runtime::new().unwrap();
1267 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(true));
1268 let result = rt.block_on(async {
1269 let args = AddArgs {
1270 package: "whatever.leviath-bundle".to_string(),
1271 };
1272 execute(args).await
1273 });
1274 FORCE_AGENTS_DIR_ERROR.with(|f| f.set(false));
1275
1276 let err = result.unwrap_err();
1277 assert!(
1278 err.to_string()
1279 .contains("Could not determine home directory")
1280 );
1281 }
1282
1283 #[test]
1286 fn install_from_dir_with_manifest_runs() {
1287 let dir = tempfile::tempdir().unwrap();
1288 let manifest = r#"
1289[agent]
1290name = "test-install-agent-xyz"
1291version = "0.1.0"
1292description = "test"
1293"#;
1294 write_test_agent(dir.path(), manifest);
1295 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1296
1297 let agents_dir = tempfile::tempdir().unwrap();
1298 install_from_dir(dir.path(), agents_dir.path()).unwrap();
1299
1300 let install_dir = agents_dir.path().join("test-install-agent-xyz");
1301 assert!(install_dir.join("agent.leviath").exists());
1302 assert!(install_dir.join("readme.txt").exists());
1303 }
1304}