Skip to main content

leviath_cli/commands/
add.rs

1//! `lev add` - Install an agent package
2
3use clap::Args;
4use std::path::Path;
5
6#[derive(Args)]
7pub struct AddArgs {
8    /// Path to an agent directory or a .leviath-bundle file
9    #[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
23/// Resolve `~/.leviath/agents`, the install root for `lev add`.
24///
25/// A thin wrapper over [`agents_dir_or_error`] supplying the real resolved
26/// directory. The `#[cfg(test)]` guard below only lets tests force the
27/// "no home directory" error arm of `execute()` deterministically - the real
28/// the shared resolver can't be made to return `None` in any environment a
29/// test may safely create (on macOS `dirs::home_dir()` falls back to a
30/// passwd-database lookup independent of `$HOME`). It does NOT hide the real
31/// body from coverage: with the toggle off, `agents_dir_or_error(
32/// leviath_core::paths::agents_dir())` runs (and is measured) in every ordinary test. This
33/// only computes a `PathBuf`; the `None` arm of `agents_dir_or_error` is
34/// covered directly by `agents_dir_or_error_none_returns_error`.
35fn 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    /// Test-only toggle letting `execute_returns_err_when_agents_dir_unresolvable`
46    /// force `resolve_agents_dir`'s `Err` arm deterministically.
47    static FORCE_AGENTS_DIR_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
48}
49
50/// Core `lev add` logic, parameterized by installer + agents base directory
51/// so it can be tested against tempdirs instead of the real
52/// `~/.leviath/agents`.
53async 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        // Directory install: copy directory into <agents_dir>/<name>/
64        install_from_dir(package_path, agents_dir)?;
65    } else if package_path.exists() || args.package.ends_with(".leviath-bundle") {
66        // Bundle file installation
67        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        // Only local installs exist: agent directories and .leviath-bundle
81        // files. Fail with a clear message rather than guessing at intent.
82        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
92/// The security-relevant things an agent package carries, as human-readable
93/// lines.
94///
95/// A bare "Installed agent 'x' to …" would never tell the user that the
96/// package ships executable `.rhai` tool scripts, pre-approves its own `shell`,
97/// turns the sandbox off, or runs a command at spawn before any prompt. Every
98/// one of those is a decision the user is making by installing, so `lev add`
99/// must surface them.
100///
101/// Empty means the package declares nothing unusual - a plain prompt-and-stages
102/// agent - in which case there is nothing to warn about and we stay quiet.
103///
104/// Pure over `(manifest_toml, dir_entries)` so the whole table is testable
105/// without a filesystem or an installed agent.
106pub(crate) fn describe_capabilities(manifest_toml: &str, script_tools: &[String]) -> Vec<String> {
107    let mut findings = Vec::new();
108    // `toml::from_str`, not `manifest_toml.parse::<toml::Value>()`. In toml 1.x
109    // `FromStr for Value` parses a single *value*, not a document - so a real
110    // manifest starting with `[agent]` reads as an array literal followed by
111    // junk and fails. It still compiles, so the change is silent; the tests are
112    // what caught it.
113    let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
114        // An unparseable manifest is reported by the installer itself; there is
115        // nothing to inventory.
116        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    // Tool permissions the package grants itself, at agent or stage level.
128    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    // Script host functions it grants itself.
153    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    // A sandbox opt-out.
176    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    // Command seeds run at spawn, before the first inference and therefore
186    // before any approval prompt - the one place a manifest executes something
187    // without being asked.
188    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
198/// Every `seed = { command = "..." }` in a manifest, from agent-level and
199/// stage-level `[context.regions]` blocks alike.
200fn 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
224/// Print the capability inventory for a freshly installed agent, if it has one.
225fn 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
239/// Names of the `.rhai` tool scripts an installed agent ships.
240fn 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        // `DirEntry::file_name` rather than `path().file_name()`: the latter
246        // returns an `Option` that a directory entry can never actually be
247        // missing, leaving an arm no test can reach.
248        .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
257/// Copy a plain agent directory into `<agents_dir>/<name>/`.
258///
259/// The agent name is read from `agent.leviath` in the directory (falling back
260/// to the directory's own name).
261fn 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    // Read the manifest to extract the agent name
271    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    /// Test-only toggle letting a test force the `Err` arm of a
296    /// mid-iteration `ReadDir` entry deterministically (see
297    /// [`unwrap_dir_entry`]) - the real failure mode (the directory handle
298    /// becoming invalid mid-iteration: deleted out from under the process,
299    /// an NFS ESTALE, or similar) is a genuine OS-level race that can't be
300    /// reproduced deterministically across Linux/macOS/Windows CI.
301    static FORCE_DIR_ENTRY_ERROR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
302}
303
304/// Unwrap one `ReadDir` iteration result, with a test-only failure-injection
305/// toggle (see [`FORCE_DIR_ENTRY_ERROR`]) so the `Err` arm - `ReadDir::next()`
306/// failing after `read_dir` already succeeded in opening the directory --
307/// can be exercised deterministically without needing to actually race the
308/// filesystem.
309fn 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
319/// Recursively copy a directory tree.
320fn 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
335/// Parse the agent name from an `agent.leviath` manifest (first `name = "..."` line).
336fn 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    /// A plain agent declares nothing unusual, so the inventory stays quiet -
355    /// a warning that fires on everything teaches people to skip it.
356    #[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    /// The case that matters most: a package that pre-approves its own shell.
375    /// Under the permission floor a user's explicit config still wins, but where
376    /// the user has said nothing this is a real grant they should see.
377    #[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        // `ask` is the default posture, not a grant.
386        assert!(!findings[0].contains("read_file"));
387    }
388
389    /// A `[tool_script_permissions]` table that only *tightens* is not a grant,
390    /// so it must not appear in the inventory - the same "quiet unless there is
391    /// something to say" rule the ordinary-agent case establishes.
392    #[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    /// A command seed runs at spawn - before the first inference and therefore
423    /// before any approval prompt. It is the one thing a manifest executes
424    /// without being asked, so the exact command is shown.
425    #[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    /// A sandbox the manifest *opts into* is not a warning - only opting out is.
446    #[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    /// The `tools/` scan that feeds the inventory: only `.rhai` files count, and
453    /// they come back sorted so the message is stable between runs.
454    #[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    /// An agent with no `tools/` directory at all - the common case.
469    #[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    /// The end-to-end printer, over a directory rather than a string: it must
476    /// stay silent for an ordinary agent and speak for a demanding one.
477    #[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            // And the quiet path: a plain agent prints nothing.
492            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    // ─── agents_dir_or_error ─────────────────────────────────────────────
514
515    #[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    // ─── parse_agent_name ──────────────────────────────────────────────────
531
532    #[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    // ─── copy_dir_recursive ────────────────────────────────────────────────
569
570    #[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        // Copying a file onto a destination path that already exists as a
630        // directory fails on every platform (EISDIR / ERROR_ACCESS_DENIED),
631        // exercising the `std::fs::copy(...)?` error arm.
632        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        // Pre-create dst/clash as a directory so the file copy collides.
638        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        // Exercises the recursive-call error-propagation branch OS-agnostically:
647        // the destination already has a *file* where the recursion needs to
648        // create a subdirectory, so the nested `create_dir_all` fails on every
649        // platform and that `Err` bubbles up through the parent's
650        // `copy_dir_recursive(...)?`.
651        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        // Block the recursion's create_dir_all(dst/sub) with a file at that path.
660        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        // Deterministically exercises `unwrap_dir_entry`'s `Err` arm (a real
669        // `ReadDir::next()` failure mid-iteration) without racing the
670        // filesystem, via the FORCE_DIR_ENTRY_ERROR test toggle.
671        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        // `unwrap_dir_entry`'s own `Ok(entry?)` `?` still has a real error
687        // arm distinct from the `FORCE_DIR_ENTRY_ERROR`-triggered early
688        // `bail!` above it (that toggle short-circuits *before* this line
689        // is ever reached) - `DirEntry` isn't constructible directly, but
690        // its `Result` wrapper doesn't need a real one to test the `Err`
691        // case: pass a synthetic `io::Error` straight in.
692        let result = unwrap_dir_entry(Err(std::io::Error::other("synthetic entry error")));
693        assert!(result.is_err());
694    }
695
696    // ─── install_from_dir ──────────────────────────────────────────────────
697
698    #[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        // Pre-create an existing install with a stale file that should be wiped.
749        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        // The existing install target is a *file*, so `exists()` passes the
772        // reinstall guard but `remove_dir_all` (which requires a directory)
773        // fails on every platform, exercising that `?` arm.
774        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        // `agents_dir` is itself a *file*, so `copy_dir_recursive`'s
791        // `create_dir_all` for the install target (a child path of a file)
792        // fails on every platform, and that `Err` propagates through
793        // `install_from_dir`'s `copy_dir_recursive(...)?`.
794        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    // ─── execute_with: directory + bundle-file paths ───────────────────────
811
812    #[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(); // no agent.leviath inside
846                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                // AgentInstaller::install() derives the agent name from the
899                // bundle *filename* (not the manifest content), so name it
900                // to match what we assert on below.
901                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        // A package that is neither a local directory nor a .leviath-bundle
949        // file must fail with a clear message, never a network attempt.
950        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    // ─── path detection ────────────────────────────────────────────────────
973
974    #[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    // ─── parse_agent_name additional ──────────────────────────────────────
996
997    #[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        // toml uses double quotes, but our parser uses trim_matches('"')
1010        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    // ─── copy_dir_recursive with nested dirs ──────────────────────────────
1027
1028    #[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    // ─── execute(): real entry point wrapper ───────────────────────────────
1047
1048    #[test]
1049    fn execute_real_wrapper_fails_fast_without_touching_real_agents_dir() {
1050        // Drives the real `execute()` (dirs::home_dir() + AgentInstaller::new()
1051        // + delegation to execute_with) - safe because a nonexistent
1052        // ".leviath-bundle" path bails out in execute_with's "Package file
1053        // not found" check before any real file under ~/.leviath/agents is
1054        // ever touched.
1055        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        // Drives `execute`'s `resolve_agents_dir()?` error-propagation
1070        // branch for real via the test-only `FORCE_AGENTS_DIR_ERROR` toggle
1071        // on `resolve_agents_dir`'s twin (see its doc comment for why the
1072        // real implementation's failure can't be forced directly).
1073        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    // ─── install_from_dir with valid manifest ─────────────────────────────
1091
1092    #[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}