Skip to main content

leviath_cli/
bundled.rs

1//! The agent blueprints shipped inside the `lev` binary, and the planner that
2//! decides what to do with them.
3//!
4//! Embedding is what makes the ten blueprints under the workspace's `agents/`
5//! directory reachable outside a git checkout: `lev add` takes a local path,
6//! and an `agents/` directory next to the executable is a layout no real
7//! install has, so without the bundle a user who downloads a release binary
8//! gets a working runtime and zero agents to run on it.
9//!
10//! `build.rs` embeds every file of every blueprint via `include_str!` (23
11//! files, ~170 KB of text) and generates the [`BUNDLED_AGENTS`] table included
12//! below. `lev setup` offers to install them; `lev list` reports them.
13
14include!(concat!(env!("OUT_DIR"), "/bundled_agents.rs"));
15
16use std::path::Path;
17
18/// What `lev setup` should do with one bundled blueprint, given what is
19/// currently installed.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum AgentAction {
22    /// Not installed.
23    Install,
24    /// Installed at a different version.
25    Update { from: String },
26    /// Installed at the bundled version.
27    UpToDate,
28}
29
30impl AgentAction {
31    /// Whether applying this action would change anything on disk. Drives which
32    /// rows the wizard pre-checks.
33    pub fn is_change(&self) -> bool {
34        !matches!(self, Self::UpToDate)
35    }
36
37    /// Short label for the wizard's blueprint list.
38    pub fn label(&self, to: &str) -> String {
39        match self {
40            Self::Install => format!("install {to}"),
41            Self::Update { from } => format!("update {from} → {to}"),
42            Self::UpToDate => "up to date".to_string(),
43        }
44    }
45}
46
47/// The installed version of `name` under `agents_dir`, if a readable manifest
48/// is there.
49///
50/// Deliberately lenient: a blueprint directory whose manifest is missing or
51/// unparseable reads as *not installed*, so the wizard offers a clean reinstall
52/// instead of refusing to plan. An unreadable manifest is exactly the state a
53/// half-finished copy leaves behind.
54pub fn installed_version(agents_dir: &Path, name: &str) -> Option<String> {
55    let manifest = std::fs::read_to_string(agents_dir.join(name).join("agent.leviath")).ok()?;
56    leviath_core::manifest::parse_manifest(&manifest)
57        .ok()
58        .map(|bp| bp.version)
59}
60
61/// Decide what to do with every bundled blueprint.
62///
63/// Version comparison is plain string inequality, not semver ordering: this
64/// crate has no semver dependency, and both versions are shown to the user
65/// anyway, so a hand-edited blueprint surfaces as an offered update they can
66/// decline rather than being silently overwritten or silently skipped.
67///
68/// Known limitation: a blueprint edited *without* bumping its version reads as
69/// up to date, because nothing hashes the contents.
70pub fn plan_agent_actions(agents_dir: &Path) -> Vec<(&'static BundledAgent, AgentAction)> {
71    BUNDLED_AGENTS
72        .iter()
73        .map(|agent| {
74            let action = match installed_version(agents_dir, agent.name) {
75                None => AgentAction::Install,
76                Some(v) if v == agent.version => AgentAction::UpToDate,
77                Some(from) => AgentAction::Update { from },
78            };
79            (agent, action)
80        })
81        .collect()
82}
83
84/// Write one bundled blueprint into `<agents_dir>/<name>/`, replacing whatever
85/// is there.
86///
87/// The existing tree is removed first rather than merged over: a stale file
88/// from an older version of the blueprint (a tool script that was dropped, say)
89/// would otherwise survive forever and keep being loaded. This mirrors what
90/// `lev add`'s directory install already does.
91pub fn install_bundled(agent: &BundledAgent, agents_dir: &Path) -> anyhow::Result<()> {
92    let dest = agents_dir.join(agent.name);
93    if dest.exists() {
94        std::fs::remove_dir_all(&dest)?;
95    }
96    for (rel, contents) in agent.files {
97        // Derive the parent from the *relative* path rather than calling
98        // `path.parent()`. `dest.join(rel)` always has a parent, so the `None`
99        // arm of `parent()` would be unreachable code pretending to be a
100        // handled case; splitting `rel` gives two arms that both actually
101        // happen - nested (`tools/web_fetch.rhai`) and flat (`agent.leviath`).
102        let parent = match rel.rsplit_once('/') {
103            Some((dir, _)) => dest.join(dir),
104            None => dest.clone(),
105        };
106        std::fs::create_dir_all(&parent)?;
107        std::fs::write(dest.join(rel), contents)?;
108    }
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    /// Every assertion here is an invariant over *all* discovered blueprints.
117    /// Naming individual agents would turn adding or renaming one into a test
118    /// edit, and would stop testing the property the moment the list drifted.
119    #[test]
120    fn every_bundled_agent_has_a_name_version_and_manifest() {
121        assert!(
122            !BUNDLED_AGENTS.is_empty(),
123            "the binary shipped with no blueprints -- build.rs found no agents/ directory"
124        );
125        for agent in BUNDLED_AGENTS {
126            assert!(!agent.name.is_empty(), "a bundled agent has an empty name");
127            assert!(
128                !agent.version.is_empty(),
129                "bundled agent {} has an empty version",
130                agent.name
131            );
132            assert!(
133                agent.files.iter().any(|(rel, _)| *rel == "agent.leviath"),
134                "bundled agent {} has no agent.leviath",
135                agent.name
136            );
137            for (rel, contents) in agent.files {
138                assert!(
139                    !rel.is_empty(),
140                    "bundled agent {} has an empty path",
141                    agent.name
142                );
143                assert!(
144                    !contents.is_empty(),
145                    "bundled agent {} has an empty file {rel}",
146                    agent.name
147                );
148            }
149        }
150    }
151
152    /// A tool script shipped under the same filename by more than one agent
153    /// must be byte-identical everywhere.
154    ///
155    /// Each agent directory is self-contained - that is what lets `lev add
156    /// <dir>` and `lev pack` work - so `web_fetch.rhai` and `web_search.rhai`
157    /// exist as five copies each rather than one shared file. That is fine
158    /// until one copy is fixed and the others are not: these scripts are the
159    /// agents' network surface, so a hardening change applied to one of five is
160    /// four agents still carrying the unfixed behaviour, with nothing to say so.
161    ///
162    /// This turns that silent drift into a test failure. Deliberately keyed on
163    /// filename over *all* discovered agents rather than naming the five, so it
164    /// keeps holding as agents are added or renamed.
165    #[test]
166    fn a_tool_script_shared_by_several_agents_is_identical_in_all_of_them() {
167        use std::collections::HashMap;
168
169        // filename -> (first agent that shipped it, its contents)
170        let mut first_seen: HashMap<&str, (&str, &str)> = HashMap::new();
171        for agent in BUNDLED_AGENTS {
172            for (rel, contents) in agent.files {
173                let Some(filename) = rel.strip_prefix("tools/") else {
174                    continue;
175                };
176                match first_seen.get(filename) {
177                    Some((other, expected)) => assert!(
178                        expected == contents,
179                        "tools/{filename} differs between bundled agents {other} and {} - \
180                         a change to one copy was not applied to the others",
181                        agent.name
182                    ),
183                    None => {
184                        first_seen.insert(filename, (agent.name, contents));
185                    }
186                }
187            }
188        }
189        // Guard against a vacuous pass: if the scan found no tool scripts at
190        // all, the loop above asserts nothing.
191        assert!(
192            !first_seen.is_empty(),
193            "no bundled agent ships a tools/ script - this invariant is not being tested"
194        );
195    }
196
197    #[test]
198    fn every_bundled_manifest_parses_and_agrees_with_its_recorded_version() {
199        // The recorded version drives install/update planning, so a build.rs
200        // scan that disagreed with the manifest would make the wizard lie.
201        for agent in BUNDLED_AGENTS {
202            let manifest = agent
203                .files
204                .iter()
205                .find(|(rel, _)| *rel == "agent.leviath")
206                .map(|(_, c)| *c)
207                .expect("checked above");
208            // `.expect`, not `.unwrap_or_else(|e| panic!(...))`: the closure in
209            // the latter is a function that never runs on a passing test, which
210            // reads to llvm-cov as an uncovered region. For the same reason the
211            // message is a literal - a *call* in an `assert!`'s format args is
212            // also a region that only the failing path reaches.
213            let parsed = leviath_core::manifest::parse_manifest(manifest);
214            assert!(
215                parsed.is_ok(),
216                "bundled agent {} does not parse",
217                agent.name
218            );
219            let blueprint = parsed.expect("asserted Ok just above");
220            assert_eq!(blueprint.version, agent.version);
221            assert_eq!(blueprint.name, agent.name);
222        }
223    }
224
225    /// Every provider `lev setup` can configure. Claude Code is a transport
226    /// rather than a provider a stage names, so it is not in this list.
227    const SETUP_PROVIDERS: &[&str] = &["anthropic", "openai", "google", "openrouter", "ollama"];
228
229    /// The published JSON Schema for `agent.leviath`.
230    ///
231    /// Compiled into the test so it cannot drift from the file that ships:
232    /// this is the same text served at
233    /// `https://leviath.dev/docs/<channel>/blueprint.schema.json`.
234    const BLUEPRINT_SCHEMA: &str = include_str!("../../../docs/schema/blueprint.schema.json");
235
236    /// Every way `value` fails `validator`, as readable lines.
237    ///
238    /// Shared by the positive and negative tests so the formatting closure runs
239    /// against real errors. Called only from the passing path of each, because
240    /// a call inside an `assert!` message is a region only failure reaches.
241    fn schema_problems(
242        validator: &jsonschema::Validator,
243        value: &serde_json::Value,
244    ) -> Vec<String> {
245        validator
246            .iter_errors(value)
247            .map(|e| format!("{}: {e}", e.instance_path()))
248            .collect()
249    }
250
251    /// Convert parsed TOML to JSON so a JSON Schema can be applied to it.
252    fn toml_to_json(value: &toml::Value) -> serde_json::Value {
253        match value {
254            toml::Value::String(s) => serde_json::Value::String(s.clone()),
255            toml::Value::Integer(i) => serde_json::Value::from(*i),
256            toml::Value::Float(f) => serde_json::Value::from(*f),
257            toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
258            // A TOML datetime has no JSON counterpart; the blueprint format has
259            // no datetime-valued key, so rendering it as its own text is enough
260            // for the schema to reject it wherever it appears.
261            toml::Value::Datetime(d) => serde_json::Value::String(d.to_string()),
262            toml::Value::Array(items) => {
263                serde_json::Value::Array(items.iter().map(toml_to_json).collect())
264            }
265            toml::Value::Table(table) => serde_json::Value::Object(
266                table
267                    .iter()
268                    .map(|(k, v)| (k.clone(), toml_to_json(v)))
269                    .collect(),
270            ),
271        }
272    }
273
274    #[test]
275    fn toml_converts_to_json_for_every_value_kind() {
276        // Every arm, because a kind converted wrongly would be validated
277        // against the wrong JSON type and the schema would pass or fail for the
278        // wrong reason. `temperature` is a real float-valued blueprint key, so
279        // that arm is not hypothetical.
280        let source = concat!(
281            "s = \"text\"\n",
282            "i = 7\n",
283            "f = 0.5\n",
284            "b = true\n",
285            "d = 1979-05-27T07:32:00Z\n",
286            "a = [1, \"two\"]\n",
287            "[t]\n",
288            "nested = 1\n"
289        );
290        let parsed: toml::Value = toml::from_str(source).expect("valid TOML");
291        let json = toml_to_json(&parsed);
292        assert_eq!(json["s"], serde_json::json!("text"));
293        assert_eq!(json["i"], serde_json::json!(7));
294        assert_eq!(json["f"], serde_json::json!(0.5));
295        assert_eq!(json["b"], serde_json::json!(true));
296        // No JSON counterpart for a datetime, so it becomes its own text.
297        assert!(json["d"].is_string());
298        assert_eq!(json["a"], serde_json::json!([1, "two"]));
299        assert_eq!(json["t"]["nested"], serde_json::json!(1));
300    }
301
302    #[test]
303    fn every_bundled_blueprint_validates_against_the_published_schema() {
304        // The schema is the only machine-readable description of this format,
305        // and an agent authoring a blueprint will write against it. Nothing but
306        // this test keeps it honest: the parser is a hand-rolled toml::Value
307        // walker, so there is no derive to generate it from.
308        let schema: serde_json::Value =
309            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
310        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
311
312        for agent in BUNDLED_AGENTS {
313            let manifest = agent
314                .files
315                .iter()
316                .find(|(rel, _)| *rel == "agent.leviath")
317                .map(|(_, c)| *c)
318                .expect("every bundled agent has a manifest");
319            let parsed: toml::Value = toml::from_str(manifest).expect("the manifest is valid TOML");
320            let json = toml_to_json(&parsed);
321
322            assert_eq!(
323                schema_problems(&validator, &json),
324                Vec::<String>::new(),
325                "{} does not match blueprint.schema.json",
326                agent.name
327            );
328        }
329    }
330
331    #[test]
332    fn the_blueprint_schema_rejects_what_the_parser_rejects() {
333        // A schema that accepts everything would pass the test above over any
334        // input at all. These are the mistakes it exists to catch before a run
335        // is ever spawned.
336        let schema: serde_json::Value =
337            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
338        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
339        // Goes through `schema_problems` rather than `is_valid`, so the same
340        // error-formatting path the positive test uses is actually exercised
341        // by something that produces errors.
342        let rejects = |manifest: &str| {
343            let parsed: toml::Value = toml::from_str(manifest).expect("valid TOML");
344            !schema_problems(&validator, &toml_to_json(&parsed)).is_empty()
345        };
346
347        assert!(
348            rejects("[stages.main]\nmode = \"autonomous\"\n"),
349            "no [agent]"
350        );
351        assert!(
352            rejects("[agent]\nname = \"a\"\n\n[context.regions]\nx = { kind = \"nonsense\" }\n"),
353            "unknown region kind"
354        );
355        assert!(
356            rejects(
357                "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"whenever\"\n"
358            ),
359            "unknown transition condition"
360        );
361        assert!(
362            rejects("[agent]\nname = \"a\"\n\n[stages.main]\nmax_iteratoins = 5\n"),
363            "a typo'd stage key"
364        );
365        assert!(
366            rejects("[agent]\nname = \"a\"\n\n[tool_permissions]\nshell = \"maybe\"\n"),
367            "an invalid tool policy"
368        );
369        // And the minimum the parser accepts still passes, so the rules above
370        // are not rejecting everything.
371        assert!(!rejects("[agent]\nname = \"a\"\n"), "a minimal manifest");
372    }
373
374    #[test]
375    fn every_bundled_stage_offers_every_provider_setup_can_configure() {
376        // Getting Started promises that one provider is all you need. That is
377        // only true if each stage lists them all: a stage naming a subset fails
378        // at spawn on a machine holding a key for a provider it left out.
379        //
380        // Discovered from BUNDLED_AGENTS rather than enumerated, so a new
381        // blueprint is covered the day it lands.
382        for agent in BUNDLED_AGENTS {
383            let manifest = agent
384                .files
385                .iter()
386                .find(|(rel, _)| *rel == "agent.leviath")
387                .map(|(_, c)| *c)
388                .expect("every bundled agent has a manifest");
389            let blueprint =
390                leviath_core::manifest::parse_manifest(manifest).expect("manifest parses");
391
392            for stage in &blueprint.stages {
393                let stage_name = &stage.name;
394                let listed: Vec<&str> = stage
395                    .model
396                    .models
397                    .iter()
398                    .map(|entry| entry.provider.as_str())
399                    .collect();
400                for provider in SETUP_PROVIDERS {
401                    assert!(
402                        listed.contains(provider),
403                        "{}/{} omits provider {}",
404                        agent.name,
405                        stage_name,
406                        provider
407                    );
408                }
409                // Ollama needs no API key, so it registers on every machine. Any
410                // position but last makes it beat a provider the user actually
411                // configured, and the run then dies on its first inference.
412                assert_eq!(
413                    listed.last().copied(),
414                    Some("ollama"),
415                    "{}/{} must list ollama last",
416                    agent.name,
417                    stage_name
418                );
419            }
420        }
421    }
422
423    /// The lint env for a bundled agent: the built-ins, the sub-agent tools,
424    /// and the agent's own `tools/<name>.rhai`, each of which defines `<name>`.
425    ///
426    /// Built by hand rather than through `LintEnv::offline`, which discovers
427    /// script tools by reading a directory: a bundled agent's files are
428    /// compiled into the binary and there is no directory to read.
429    fn lint_env_for(agent: &BundledAgent) -> crate::lint::LintEnv {
430        let mut known_tools: std::collections::HashSet<String> = leviath_tools::BuiltinTools::new(
431            leviath_tools::ToolContext::new(std::path::PathBuf::from(".")),
432        )
433        .names()
434        .into_iter()
435        .collect();
436        known_tools.extend(leviath_tools::BuiltinTools::subagent_tool_names());
437        known_tools.extend(
438            agent
439                .files
440                .iter()
441                .filter_map(|(rel, _)| rel.strip_prefix("tools/"))
442                .filter_map(|f| f.strip_suffix(".rhai"))
443                .map(str::to_string),
444        );
445        crate::lint::LintEnv {
446            known_tools,
447            known_models: crate::commands::models::closed_catalog_models(),
448            available_providers: None,
449            read_paths: None,
450        }
451    }
452
453    /// No bundled agent ships a blueprint the linter calls broken.
454    ///
455    /// The errors this catches are the ones that are invisible on inspection: a
456    /// tool name matching nothing is silently dropped from what the stage
457    /// advertises, so the model is told the tool does not exist and the stage
458    /// cannot do its job. A permission for a tool the stage never granted is the
459    /// same drift from the other side, reading as a grant and not being one.
460    ///
461    /// Asserted by running the shipped linter rather than by a parallel copy of
462    /// its rules, and over all discovered agents rather than a list of names -
463    /// either would stop testing the property the moment it drifted.
464    #[test]
465    fn no_bundled_agent_has_a_lint_error() {
466        for agent in BUNDLED_AGENTS {
467            let manifest = agent
468                .files
469                .iter()
470                .find(|(rel, _)| *rel == "agent.leviath")
471                .map(|(_, c)| *c)
472                .expect("every bundled agent has a manifest");
473            let parsed = leviath_core::manifest::parse_manifest(manifest);
474            assert!(
475                parsed.is_ok(),
476                "bundled agent {} does not parse",
477                agent.name
478            );
479            let blueprint = parsed.expect("asserted Ok just above");
480            // Every finding is rendered up front, and the errors are then
481            // *counted* rather than collected. Any per-error work - a `.map`
482            // that formats, a `.collect` into a list of messages - sits in a
483            // closure that only runs when the test is about to fail, which
484            // llvm-cov reads as an uncovered region for as long as the
485            // invariant holds. Counting has no such body.
486            let rendered: Vec<(bool, String)> =
487                crate::lint::lint_manifest(manifest, &blueprint, &lint_env_for(agent))
488                    .iter()
489                    .map(|f| (f.is_error(), format!("{} [{}]", f.one_line(), f.code)))
490                    .collect();
491            let error_count = rendered.iter().filter(|(is_error, _)| *is_error).count();
492            assert_eq!(
493                error_count, 0,
494                "bundled agent {} has lint errors, among {rendered:?}",
495                agent.name
496            );
497        }
498    }
499
500    /// The invariant above can actually fail - a check over shipped data that
501    /// happens to pass says nothing about whether it would catch drift.
502    #[test]
503    fn the_lint_invariant_catches_a_typo_and_an_orphan_permission() {
504        let manifest = r#"
505[agent]
506name = "x"
507version = "0.1.0"
508description = "x"
509
510[stages.only]
511mode = "autonomous"
512model = { provider = "anthropic", model = "claude-sonnet-5" }
513max_iterations = 5
514available_tools = ["read_file", "raed_file"]
515
516[stages.only.tool_permissions]
517write_file = "allow"
518"#;
519        let bp = leviath_core::manifest::parse_manifest(manifest)
520            .expect("the fixture parses; it is the lint that should object");
521        // Reuse the same env shape a real bundled agent gets, minus any scripts.
522        let env = lint_env_for(&BundledAgent {
523            name: "x",
524            version: "0.1.0",
525            files: &[],
526        });
527        let codes: Vec<&str> = crate::lint::lint_manifest(manifest, &bp, &env)
528            .iter()
529            .filter(|f| f.is_error())
530            .map(|f| f.code)
531            .collect();
532        assert_eq!(codes, ["unknown-tool", "orphan-stage-permission"]);
533    }
534
535    #[test]
536    fn bundled_agent_names_are_unique() {
537        let mut names: Vec<&str> = BUNDLED_AGENTS.iter().map(|a| a.name).collect();
538        names.sort_unstable();
539        let count = names.len();
540        names.dedup();
541        assert_eq!(count, names.len(), "duplicate bundled agent names");
542    }
543
544    // ─── installed_version ──────────────────────────────────────────────────
545
546    #[test]
547    fn installed_version_reads_a_manifest() {
548        let dir = tempfile::tempdir().unwrap();
549        let agent = &BUNDLED_AGENTS[0];
550        install_bundled(agent, dir.path()).unwrap();
551
552        assert_eq!(
553            installed_version(dir.path(), agent.name).as_deref(),
554            Some(agent.version)
555        );
556    }
557
558    #[test]
559    fn installed_version_is_none_when_nothing_is_installed() {
560        let dir = tempfile::tempdir().unwrap();
561        assert!(installed_version(dir.path(), "not-installed").is_none());
562    }
563
564    #[test]
565    fn installed_version_is_none_for_an_unparseable_manifest() {
566        // A half-written install must read as "not installed" so the wizard
567        // offers a clean reinstall rather than refusing to plan.
568        let dir = tempfile::tempdir().unwrap();
569        std::fs::create_dir_all(dir.path().join("broken")).unwrap();
570        std::fs::write(
571            dir.path().join("broken/agent.leviath"),
572            "not valid toml {{{",
573        )
574        .unwrap();
575
576        assert!(installed_version(dir.path(), "broken").is_none());
577    }
578
579    // ─── plan_agent_actions ─────────────────────────────────────────────────
580
581    #[test]
582    fn plan_offers_to_install_everything_into_an_empty_dir() {
583        let dir = tempfile::tempdir().unwrap();
584
585        let plan = plan_agent_actions(dir.path());
586
587        assert_eq!(plan.len(), BUNDLED_AGENTS.len());
588        for (agent, action) in &plan {
589            assert_eq!(*action, AgentAction::Install);
590            assert!(action.is_change());
591            assert_eq!(
592                action.label(agent.version),
593                format!("install {}", agent.version)
594            );
595        }
596    }
597
598    #[test]
599    fn plan_reports_up_to_date_after_installing() {
600        let dir = tempfile::tempdir().unwrap();
601        for agent in BUNDLED_AGENTS {
602            install_bundled(agent, dir.path()).unwrap();
603        }
604
605        let plan = plan_agent_actions(dir.path());
606
607        for (agent, action) in &plan {
608            assert_eq!(*action, AgentAction::UpToDate, "{}", agent.name);
609            assert!(!action.is_change());
610            assert_eq!(action.label(agent.version), "up to date");
611        }
612    }
613
614    #[test]
615    fn plan_reports_an_update_when_the_installed_version_differs() {
616        let dir = tempfile::tempdir().unwrap();
617        let agent = &BUNDLED_AGENTS[0];
618        install_bundled(agent, dir.path()).unwrap();
619        // Rewrite the installed manifest at a different version.
620        let manifest_path = dir.path().join(agent.name).join("agent.leviath");
621        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
622        let bumped = manifest.replacen(
623            &format!("version = \"{}\"", agent.version),
624            "version = \"9.9.9\"",
625            1,
626        );
627        std::fs::write(&manifest_path, bumped).unwrap();
628
629        let plan = plan_agent_actions(dir.path());
630        let (_, action) = plan
631            .iter()
632            .find(|(a, _)| a.name == agent.name)
633            .expect("the bundled agent is in the plan");
634
635        assert_eq!(
636            *action,
637            AgentAction::Update {
638                from: "9.9.9".to_string()
639            }
640        );
641        assert!(action.is_change());
642        assert_eq!(
643            action.label(agent.version),
644            format!("update 9.9.9 → {}", agent.version)
645        );
646    }
647
648    // ─── install_bundled ────────────────────────────────────────────────────
649
650    #[test]
651    fn install_writes_every_file_including_nested_ones() {
652        let dir = tempfile::tempdir().unwrap();
653        // Pick a blueprint that actually has a nested `tools/` file, so the
654        // create_dir_all arm is exercised by a real shipped layout rather than
655        // a fixture. If none ships nested files any more, the flat arm below
656        // still covers the rest.
657        for agent in BUNDLED_AGENTS {
658            install_bundled(agent, dir.path()).unwrap();
659            for (rel, contents) in agent.files {
660                let written = std::fs::read_to_string(dir.path().join(agent.name).join(rel));
661                assert!(written.is_ok(), "{}/{rel} was not written", agent.name);
662                assert_eq!(written.expect("asserted Ok just above"), *contents);
663            }
664        }
665        assert!(
666            BUNDLED_AGENTS
667                .iter()
668                .any(|a| a.files.iter().any(|(rel, _)| rel.contains('/'))),
669            "no bundled blueprint has a nested file, so install's mkdir path is untested"
670        );
671    }
672
673    #[test]
674    fn install_replaces_an_existing_tree_and_drops_stale_files() {
675        let dir = tempfile::tempdir().unwrap();
676        let agent = &BUNDLED_AGENTS[0];
677        install_bundled(agent, dir.path()).unwrap();
678        let stale = dir
679            .path()
680            .join(agent.name)
681            .join("stale-from-an-older-version");
682        std::fs::write(&stale, "leftover").unwrap();
683
684        install_bundled(agent, dir.path()).unwrap();
685
686        assert!(
687            !stale.exists(),
688            "a reinstall must not leave files from the previous version behind"
689        );
690        assert!(dir.path().join(agent.name).join("agent.leviath").exists());
691    }
692
693    #[test]
694    fn install_surfaces_a_directory_creation_failure() {
695        // `agents_dir` is itself a file, so creating the blueprint directory
696        // under it fails.
697        let dir = tempfile::tempdir().unwrap();
698        let blocked = dir.path().join("not-a-dir");
699        std::fs::write(&blocked, "").unwrap();
700
701        let result = install_bundled(&BUNDLED_AGENTS[0], &blocked);
702
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn install_surfaces_a_file_write_failure() {
708        // Isolating the `write` error from the `create_dir_all` error needs a
709        // layout where the directory step succeeds and only the write fails.
710        // A synthetic blueprint whose second entry names a path the first entry
711        // already created as a *directory* does exactly that: `create_dir_all`
712        // sees an existing dir and returns Ok, then the write hits EISDIR.
713        // No shipped blueprint has that shape, hence the hand-built one.
714        let agent = BundledAgent {
715            name: "collides-with-its-own-directory",
716            version: "0.0.1",
717            files: &[("tools/a.rhai", "nested first"), ("tools", "then the dir")],
718        };
719        let dir = tempfile::tempdir().unwrap();
720
721        let result = install_bundled(&agent, dir.path());
722
723        assert!(result.is_err());
724    }
725
726    #[test]
727    fn install_surfaces_a_remove_failure() {
728        // The destination exists but is a *file*, so `remove_dir_all` fails
729        // rather than the write.
730        let dir = tempfile::tempdir().unwrap();
731        let agent = &BUNDLED_AGENTS[0];
732        std::fs::write(dir.path().join(agent.name), "").unwrap();
733
734        let result = install_bundled(agent, dir.path());
735
736        assert!(result.is_err());
737    }
738}