leviath-cli 0.3.8

Command-line interface for Leviath agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
//! `lev update` - bring this copy of Leviath up to date, then everything that
//! shipped with it.
//!
//! Three steps, in the order that matters: the binary, the bundled blueprints,
//! and the config file. The binary first because the other two are decided by
//! what the *new* binary ships, and a user who updates the agents against the
//! old one has done half a job.
//!
//! All three run every time. The binary step is never a reason to skip the
//! other two, and this is the whole point: `brew upgrade` and `scoop update`
//! hand over a new binary and say nothing about the blueprints in the user's
//! agents directory or the config beside them, so anyone who has ever updated
//! that way is carrying blueprints from whenever they last ran `lev setup`. A
//! binary that needs no update is not evidence that anything else is current,
//! which is why "already up to date" is a sentence this command never says on
//! its own.
//!
//! # Why the install method is detected rather than guessed
//!
//! Every channel installs the same `CARGO_PKG_VERSION`: the `-alpha` and
//! `-beta` suffixes live in the tap manifests the release workflow bumps, not
//! in `Cargo.toml`. So the version string says nothing about which channel this
//! binary came from, and nothing about which installer put it there. What does
//! say something is *where the file is*: a Homebrew Cellar path carries the
//! formula name, and the formula name carries the channel; a Scoop `apps` path
//! carries the package the same way; `~/.cargo/bin` means somebody compiled it.
//!
//! The hosted install script is the one method that records nothing. It writes
//! a plain binary into an ordinary directory and keeps no receipt, so a copy
//! that came from it is indistinguishable from any other loose binary. That is
//! what [`UpdateArgs::channel`] is for, and why the script arm defaults to
//! `stable` and says so rather than inferring a channel it cannot know.
//!
//! # Seams
//!
//! Running the upgrade and asking the user a question are both injected (see
//! [`UpdateEnv`]), so the tests assert the command that *would* run without a
//! single process being spawned - the same shape `lev mcp` uses for the browser
//! and the daemon uses for seed commands.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use clap::Args;

use crate::bundled::{AgentAction, BundledAgent, install_bundled, plan_agent_actions};
use crate::config::Config;

/// Where the hosted installer lives. One constant, because the invocation below
/// is easy to get subtly wrong and there must be exactly one copy of it.
const INSTALL_URL: &str = "https://leviath.dev/install.sh";

/// `lev update --help`.
pub const UPDATE_LONG_ABOUT: &str = "\
Update Leviath, then offer to bring everything else up to date with it.

The binary is updated with the installer that put it there, which is worked out
from where the file is rather than guessed from the version string (every
channel ships the same version number, so the string cannot tell you):

  Homebrew   a Cellar path names the formula, and the formula names the
             channel: `brew upgrade leviath-beta`
  Scoop      the same, from the package under scoop/apps
  cargo      says to run `cargo install leviath-cli` and stops. Updating it
             means a long compile, which is not something to start unasked
  script     re-runs the hosted installer for a channel. The install script
             keeps no record of the channel it used, so this defaults to
             stable - pass --channel to say otherwise

The blueprints and the config are checked every time, whatever the binary step
did. `brew upgrade` on its own leaves both behind, and a binary that was
already current is not a reason to stop looking: an install can be months
behind on its blueprints with a `lev` that needs no update at all.

Nothing is written to your agents directory without a yes. The whole list is
printed first, then one confirmation covers it; --install-agents is how a
script says yes. --yes alone is not enough, because updating a binary and
replacing the blueprints in your agents directory are different requests. A
copy you edited is named as edited and asked about on its own, and no flag
covers it: installing removes the directory and takes your edits with it.

Config migrations are described line by line before anything is written, and
then asked about.

--check and --json report the plan and change nothing. --dry-run walks the
whole flow, prompts and all, and prints what each step would do instead of
doing it.";

// ─── Channels ─────────────────────────────────────────────────────────────────

/// A release channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum Channel {
    /// The weekly stable release. What crates.io and `brew install leviath` track.
    Stable,
    /// The promoted build a week ahead of stable.
    Beta,
    /// The nightly build.
    Alpha,
}

impl Channel {
    /// The name the install script and the docs use.
    pub fn id(self) -> &'static str {
        match self {
            Self::Stable => "stable",
            Self::Beta => "beta",
            Self::Alpha => "alpha",
        }
    }

    /// The Homebrew formula and Scoop package for this channel. They share a
    /// naming scheme on purpose, so one function answers for both.
    pub fn package(self) -> &'static str {
        match self {
            Self::Stable => "leviath",
            Self::Beta => "leviath-beta",
            Self::Alpha => "leviath-alpha",
        }
    }

    /// The channel a package name carries, or `None` for a name this build does
    /// not ship (someone's own formula, or one from a future channel).
    pub fn from_package(name: &str) -> Option<Self> {
        [Self::Stable, Self::Beta, Self::Alpha]
            .into_iter()
            .find(|c| c.package() == name)
    }
}

// ─── Install-method detection ─────────────────────────────────────────────────

/// How this copy of `lev` got onto the machine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallMethod {
    /// Homebrew, under the named formula.
    Homebrew {
        /// The formula, which is also what carries the channel.
        formula: String,
    },
    /// Scoop, under the named package.
    Scoop {
        /// The package, which carries the channel the same way a formula does.
        package: String,
    },
    /// `cargo install`, so the binary was compiled locally.
    Cargo,
    /// The hosted install script, or something else that dropped a plain binary
    /// where the install script puts one.
    Script {
        /// The channel to re-install. Never detected: see the module docs.
        channel: Channel,
    },
    /// Somewhere no supported installer writes.
    Unknown {
        /// Where the binary actually is, so the report can say it.
        path: PathBuf,
    },
}

impl InstallMethod {
    /// The short name used in `--json`.
    pub fn id(&self) -> &'static str {
        match self {
            Self::Homebrew { .. } => "homebrew",
            Self::Scoop { .. } => "scoop",
            Self::Cargo => "cargo",
            Self::Script { .. } => "script",
            Self::Unknown { .. } => "unknown",
        }
    }

    /// The channel this install tracks, where that is knowable.
    ///
    /// `cargo install leviath-cli` resolves crates.io, and each stable deploy
    /// publishes there from the same commit the binaries were built at, so a
    /// cargo install is a stable install by construction.
    pub fn channel(&self) -> Option<Channel> {
        match self {
            Self::Homebrew { formula } => Channel::from_package(formula),
            Self::Scoop { package } => Channel::from_package(package),
            Self::Cargo => Some(Channel::Stable),
            Self::Script { channel } => Some(*channel),
            Self::Unknown { .. } => None,
        }
    }

    /// The one-line description the report opens with.
    pub fn describe(&self) -> String {
        let channel = match self.channel() {
            Some(c) => format!(", {} channel", c.id()),
            // A formula this build does not ship, or a path nothing claims.
            None => String::new(),
        };
        match self {
            Self::Homebrew { formula } => format!("Homebrew (formula {formula}{channel})"),
            Self::Scoop { package } => format!("Scoop (package {package}{channel})"),
            Self::Cargo => format!("cargo install (crates.io{channel})"),
            Self::Script { .. } => format!("the install script ({INSTALL_URL}{channel})"),
            Self::Unknown { path } => {
                format!("something else - the binary is at {}", path.display())
            }
        }
    }
}

/// The component of `path` immediately after the first one equal to `marker`.
///
/// This is how both package managers record what they installed: Homebrew lays
/// a binary out as `<prefix>/Cellar/<formula>/<version>/bin/lev`, and Scoop as
/// `<root>/apps/<package>/current/lev.exe`. The name in that slot is the real
/// answer to "which channel is this", and it is on disk rather than inferred.
fn component_after(path: &Path, marker: &str) -> Option<String> {
    let mut components = path
        .components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned());
    components.by_ref().find(|c| c == marker)?;
    components.next()
}

/// Whether `path` has a component equal to `marker`, ignoring case.
///
/// Scoop's root is a user-chosen directory that is conventionally but not
/// reliably lowercase, and Windows paths are case-insensitive anyway.
fn has_component(path: &Path, marker: &str) -> bool {
    path.components()
        .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(marker))
}

/// Homebrew prefixes that mean Homebrew and nothing else, for the case where
/// the binary is the `bin/lev` symlink rather than the Cellar path behind it.
///
/// `/usr/local` is deliberately absent even though it is Homebrew's own prefix
/// on Intel macOS: it is also where the install script and a hand-unpacked
/// tarball put a binary, so treating everything under it as Homebrew would send
/// a script install to `brew upgrade`. Under that prefix the Cellar component is
/// the only evidence that counts.
const UNAMBIGUOUS_BREW_PREFIXES: &[&str] = &["/opt/homebrew", "/home/linuxbrew/.linuxbrew"];

/// Absolute directories the installers write to. The Linux installer hard-codes
/// `/usr/local/bin`; `/usr/bin` is where the manual tarball instructions end up
/// for anyone who moved it there instead.
const SCRIPT_DESTINATIONS: &[&str] = &["/usr/local/bin", "/usr/bin"];

/// Every directory a plain-binary install lands in, including the two that are
/// home-relative: `~/.local/bin`, and the `%LOCALAPPDATA%\Leviath\bin` that
/// `install.ps1` writes on Windows.
///
/// A loose binary in one of these is a script install. A loose binary anywhere
/// else is not something to guess about, because re-running an installer aims
/// at a fixed destination and would leave the copy actually on `PATH` untouched.
fn script_destinations(home: Option<&Path>) -> Vec<PathBuf> {
    let mut dirs: Vec<PathBuf> = SCRIPT_DESTINATIONS.iter().map(PathBuf::from).collect();
    if let Some(home) = home {
        dirs.push(home.join(".local").join("bin"));
        dirs.push(
            home.join("AppData")
                .join("Local")
                .join("Leviath")
                .join("bin"),
        );
    }
    dirs
}

/// Work out how `exe` was installed.
///
/// Pure over its inputs - the resolved executable path, the home directory, the
/// answer `brew --prefix` gave (if it was asked and answered), and the channel
/// the user named - so every arm is testable without a Homebrew, a Scoop or a
/// second machine.
pub fn detect(
    exe: &Path,
    home: Option<&Path>,
    brew_prefix: Option<&Path>,
    requested: Option<Channel>,
) -> InstallMethod {
    // The channel to fall back on where the path does not name one.
    let channel = requested.unwrap_or(Channel::Stable);

    // Homebrew, from the strongest evidence down. A Cellar path names the
    // formula outright; a prefix only says "Homebrew put this here".
    if let Some(formula) = component_after(exe, "Cellar") {
        return InstallMethod::Homebrew { formula };
    }
    let under_brew = UNAMBIGUOUS_BREW_PREFIXES.iter().any(|p| exe.starts_with(p))
        || brew_prefix.is_some_and(|p| exe.starts_with(p) && !is_ambiguous_prefix(p));
    if under_brew {
        return InstallMethod::Homebrew {
            formula: channel.package().to_string(),
        };
    }

    // Scoop, the same two ways round.
    if has_component(exe, "scoop") {
        let package = component_after(exe, "apps").unwrap_or_else(|| channel.package().to_string());
        return InstallMethod::Scoop { package };
    }

    // A cargo install, which is the one method that cannot be updated in place.
    let cargo_bin = home.map(|h| h.join(".cargo").join("bin"));
    if cargo_bin.is_some_and(|dir| exe.starts_with(dir)) {
        return InstallMethod::Cargo;
    }

    let parent = exe.parent();
    let script_dir = script_destinations(home)
        .iter()
        .any(|d| parent == Some(d.as_path()));
    match script_dir {
        true => InstallMethod::Script { channel },
        false => InstallMethod::Unknown {
            path: exe.to_path_buf(),
        },
    }
}

/// Whether a prefix is too general to be evidence of anything on its own. See
/// [`UNAMBIGUOUS_BREW_PREFIXES`] for why `/usr/local` is the case that matters.
fn is_ambiguous_prefix(prefix: &Path) -> bool {
    matches!(
        prefix.to_string_lossy().trim_end_matches('/'),
        "/usr/local" | "/usr" | "" | "/"
    )
}

// ─── Config migrations ────────────────────────────────────────────────────────

/// One config change `lev update` knows how to make on the user's behalf.
///
/// The mechanism exists so that a future incompatibility - a key that moved, a
/// value whose meaning changed - is either fixed automatically or at least
/// explained at the moment the user updates into it, rather than surfacing as a
/// broken run days later. [`MIGRATIONS`] is empty today because no shipped
/// version has changed a key's name or meaning; the tests drive the machinery
/// with a sample so the wiring is proven rather than assumed.
pub struct Migration {
    /// A short stable name, shown in the report and in `--json`.
    pub name: &'static str,
    /// What it changes and why, in one line.
    pub description: &'static str,
    /// Whether this config needs it.
    ///
    /// Gets the parsed [`Config`] *and* the raw document, because the two see
    /// different things: a key serde no longer reads vanishes from the parsed
    /// value entirely, and a key that is still read but now means something
    /// else is only visible there.
    pub applies: fn(&Config, &toml::Table) -> bool,
    /// Make the change, returning one line per thing it did.
    pub apply: fn(&mut Config) -> Vec<String>,
}

/// The migrations this build knows about, oldest first.
///
/// Empty, and honestly so: nothing in a released `config.toml` has to change to
/// work with this version. Adding one is adding an entry here.
pub const MIGRATIONS: &[Migration] = &[];

// ─── The plan ─────────────────────────────────────────────────────────────────

/// What to do about the binary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BinaryStep {
    /// Run this, argv-style.
    Run(Vec<String>),
    /// There is nothing to run here. Tell the user this instead.
    Advise(String),
}

/// Everything `lev update` intends to do, as plain data.
///
/// Built before anything happens and rendered before anything happens, so the
/// report, the JSON and the actions can never disagree about what was planned.
pub struct UpdatePlan {
    /// How this copy was installed.
    pub method: InstallMethod,
    /// The binary step.
    pub binary: BinaryStep,
    /// Every bundled blueprint and what would happen to it.
    pub agents: Vec<(&'static BundledAgent, AgentAction)>,
    /// The migrations that apply to the config as it stands.
    pub migrations: Vec<&'static Migration>,
    /// What reading the config found.
    pub config: ConfigState,
}

/// What the plan found when it read the config file.
///
/// One value rather than a `Config` and an error beside it, because those two
/// only ever come in two of the four combinations and the other two would be
/// arms nothing could reach.
///
/// The config is carried rather than re-read at write time so there is exactly
/// one read: re-opening the file would add an error arm only a race could take,
/// and applying a migration to a document nobody has looked at since the report
/// was printed is exactly the surprise this command exists to avoid.
pub enum ConfigState {
    /// The config as it stands, for the migrations to be applied to. Boxed
    /// because a `Config` is far larger than the message beside it.
    Loaded(Box<Config>),
    /// It could not be read, and this is why.
    Unreadable(String),
}

/// The upgrade step for an install method: a command, or the reason there
/// isn't one.
pub fn binary_step(method: &InstallMethod) -> BinaryStep {
    match method {
        InstallMethod::Homebrew { formula } => BinaryStep::Run(vec![
            "brew".to_string(),
            "upgrade".to_string(),
            formula.clone(),
        ]),
        InstallMethod::Scoop { package } => BinaryStep::Run(vec![
            "scoop".to_string(),
            "update".to_string(),
            package.clone(),
        ]),
        // Deliberately not run. `cargo install` rebuilds the whole workspace
        // from source, which is minutes of CPU nobody asked for by typing
        // `lev update`.
        InstallMethod::Cargo => BinaryStep::Advise(
            "this copy was built by `cargo install`. Update it with \
             `cargo install leviath-cli` - that is a full compile, so it is not \
             something to start for you."
                .to_string(),
        ),
        // One shell, one pipeline. `LEVIATH_CHANNEL=beta curl ... | sh` is the
        // form to never generate: the assignment belongs to `curl`, the piped
        // shell never sees it, and the installer silently takes stable.
        InstallMethod::Script { channel } => BinaryStep::Run(vec![
            "sh".to_string(),
            "-c".to_string(),
            format!(
                "curl -fsSL {INSTALL_URL} | sh -s -- --channel {}",
                channel.id()
            ),
        ]),
        InstallMethod::Unknown { path } => BinaryStep::Advise(format!(
            "`lev` is at {}, which is not where any installer Leviath ships puts it. \
             Update it the way you installed it, or re-install with \
             `curl -fsSL {INSTALL_URL} | sh`.",
            path.display()
        )),
    }
}

/// The config as `lev update` needs to see it: parsed, and the document behind
/// it.
struct LoadedConfig {
    config: Config,
    raw: toml::Table,
}

/// Read the config file both ways.
fn load_config(path: &Path) -> anyhow::Result<LoadedConfig> {
    let config = Config::load_from_path_public(path)?;
    let raw = match std::fs::read_to_string(path) {
        // `expect`: `load_from_path_public` above parsed this same text as
        // TOML, so a document that reaches here is a document that parses.
        Ok(text) => toml::from_str::<toml::Table>(&text).expect("the config parsed a moment ago"),
        // No file at all, which loads as the defaults and an empty document.
        Err(_) => toml::Table::new(),
    };
    Ok(LoadedConfig { config, raw })
}

/// Work out everything the command would do, without doing any of it.
pub fn plan(args: &UpdateArgs, env: &UpdateEnv) -> UpdatePlan {
    let method = detect(
        &env.exe,
        env.home.as_deref(),
        env.brew_prefix.as_deref(),
        args.channel,
    );
    let binary = binary_step(&method);
    let agents = plan_agent_actions(&env.agents_dir);

    // A config that will not parse is reported, not fatal: the binary step is
    // the part of this command that matters most and it does not need one.
    let (migrations, config) = match load_config(&env.config_path) {
        Ok(loaded) => (
            env.migrations
                .iter()
                .filter(|m| (m.applies)(&loaded.config, &loaded.raw))
                .collect(),
            ConfigState::Loaded(Box::new(loaded.config)),
        ),
        Err(e) => (Vec::new(), ConfigState::Unreadable(e.to_string())),
    };

    UpdatePlan {
        method,
        binary,
        agents,
        migrations,
        config,
    }
}

// ─── Rendering ────────────────────────────────────────────────────────────────

/// The blueprints this plan would change.
fn changing(plan: &UpdatePlan) -> Vec<&(&'static BundledAgent, AgentAction)> {
    plan.agents.iter().filter(|(_, a)| a.is_change()).collect()
}

/// Render the plan the way `lev update` prints it.
///
/// Pure, so the tests assert the text exactly - everything that varies between
/// machines is already in the [`UpdatePlan`].
pub fn format_plan(plan: &UpdatePlan, version: &str) -> String {
    let mut out = format!(
        "\nlev {version}, installed with {}\n\n",
        plan.method.describe()
    );

    match &plan.binary {
        BinaryStep::Run(argv) => out.push_str(&format!("  binary   {}\n", argv.join(" "))),
        BinaryStep::Advise(text) => out.push_str(&format!("  binary   {text}\n")),
    }

    let changes = changing(plan);
    match changes.is_empty() {
        true => out.push_str(&format!(
            "  agents   all {} bundled blueprints are up to date\n",
            plan.agents.len()
        )),
        false => {
            out.push_str(&format!(
                "  agents   {} of {} would change\n",
                changes.len(),
                plan.agents.len()
            ));
            for (agent, action) in &changes {
                out.push_str(&format!(
                    "             {} - {}\n",
                    agent.name,
                    action.label(agent.version)
                ));
            }
        }
    }

    match (&plan.config, plan.migrations.is_empty()) {
        (ConfigState::Unreadable(e), _) => {
            out.push_str(&format!("  config   could not be read: {e}\n"))
        }
        (ConfigState::Loaded(_), true) => out.push_str("  config   nothing to migrate\n"),
        (ConfigState::Loaded(_), false) => {
            out.push_str(&format!(
                "  config   {} migration(s)\n",
                plan.migrations.len()
            ));
            for migration in &plan.migrations {
                out.push_str(&format!(
                    "             {} - {}\n",
                    migration.name, migration.description
                ));
            }
        }
    }
    out
}

/// The plan as JSON. Built by hand, like `lev tools` and `lev doctor`, so the
/// shape is explicit and does not move when a type gains a field.
pub fn plan_json(plan: &UpdatePlan, version: &str) -> serde_json::Value {
    let binary = match &plan.binary {
        BinaryStep::Run(argv) => serde_json::json!({ "action": "run", "command": argv }),
        BinaryStep::Advise(text) => serde_json::json!({ "action": "advise", "message": text }),
    };
    let agents: Vec<serde_json::Value> = plan
        .agents
        .iter()
        .map(|(agent, action)| {
            serde_json::json!({
                "name": agent.name,
                "version": agent.version,
                "change": action.label(agent.version),
                "changes": action.is_change(),
                "preselected": action.preselect(),
            })
        })
        .collect();
    let migrations: Vec<serde_json::Value> = plan
        .migrations
        .iter()
        .map(|m| serde_json::json!({ "name": m.name, "description": m.description }))
        .collect();
    serde_json::json!({
        "version": version,
        "install_method": plan.method.id(),
        "channel": plan.method.channel().map(Channel::id),
        "binary": binary,
        "agents": agents,
        "migrations": migrations,
        "config_error": match &plan.config {
            ConfigState::Unreadable(e) => serde_json::Value::String(e.clone()),
            ConfigState::Loaded(_) => serde_json::Value::Null,
        },
    })
}

// ─── Arguments and seams ──────────────────────────────────────────────────────

/// Arguments for `lev update`.
#[derive(Args, Debug, Clone, Default)]
pub struct UpdateArgs {
    /// Report what would happen and change nothing.
    #[arg(long)]
    pub check: bool,

    /// Answer yes to the binary upgrade and the config write without asking.
    /// It deliberately does not install blueprints: see `--install-agents`.
    #[arg(long)]
    pub yes: bool,

    /// Install the bundled blueprints without asking. A copy you edited
    /// locally is still asked about on its own, since installing destroys it.
    #[arg(long)]
    pub install_agents: bool,

    /// The channel to install, for the install-script method, which records
    /// none. Ignored by Homebrew and Scoop, whose package name already says.
    #[arg(long, value_name = "CHANNEL")]
    pub channel: Option<Channel>,

    /// Walk the whole flow, but print each action instead of performing it.
    #[arg(long)]
    pub dry_run: bool,

    /// Print the plan as JSON and change nothing.
    #[arg(long)]
    pub json: bool,
}

/// Runs the upgrade command: argv in, success or the reason it failed out.
///
/// Injected rather than called directly so the tests assert *which* command
/// would run without spawning anything. Mirrors the `BrowserOpener` and
/// `SeedCommandRunner` seams.
pub type CommandRunner = Arc<dyn Fn(&[String]) -> anyhow::Result<()> + Send + Sync>;

/// Asks the user a yes/no question. Injected for the same reason: a unit test
/// has no terminal, and which questions a flag answers on the user's behalf -
/// and, for a blueprint they edited, which ones no flag answers - is something
/// the tests have to be able to prove rather than describe.
pub type Confirm = Arc<dyn Fn(&str) -> bool + Send + Sync>;

/// The real I/O `lev update` depends on, injected so the command logic is
/// testable without a package manager, a terminal, or the real home directory.
pub struct UpdateEnv {
    /// The running binary, symlinks already resolved. Resolving matters: a
    /// Homebrew `bin/lev` is a symlink into the Cellar, and the Cellar path is
    /// the one that names the formula.
    pub exe: PathBuf,
    /// The user's home directory, when one resolves.
    pub home: Option<PathBuf>,
    /// What `brew --prefix` answered, when it was asked and answered.
    pub brew_prefix: Option<PathBuf>,
    /// Where the bundled blueprints are installed.
    pub agents_dir: PathBuf,
    /// The config file to read and, with permission, rewrite.
    pub config_path: PathBuf,
    /// How to run the upgrade command.
    pub runner: CommandRunner,
    /// How to ask a yes/no question.
    pub confirm: Confirm,
    /// The migrations to consider, in order. Production passes [`MIGRATIONS`].
    pub migrations: &'static [Migration],
}

// ─── Execution ────────────────────────────────────────────────────────────────

/// Ask, unless `--yes` has already answered.
fn agreed(args: &UpdateArgs, env: &UpdateEnv, question: &str) -> bool {
    args.yes || (env.confirm)(question)
}

/// Step one: the binary.
fn update_binary(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
    let argv = match &plan.binary {
        BinaryStep::Advise(text) => {
            println!("  {text}");
            return Ok(());
        }
        BinaryStep::Run(argv) => argv,
    };
    let shown = argv.join(" ");
    if !agreed(args, env, &format!("Run `{shown}`?")) {
        println!("  left the binary alone");
        return Ok(());
    }
    if args.dry_run {
        println!("  would run: {shown}");
        return Ok(());
    }
    (env.runner)(argv)
}

/// Step two: the bundled blueprints.
///
/// Nothing here is a default. The whole list is printed first, and the agents
/// directory is not written to until someone has said yes to it: `--yes` is
/// deliberately not enough, because "update my binary without stopping to ask"
/// and "replace the blueprints in my agents directory" are different requests
/// and `lev setup` already treats them that way. `--install-agents` is how a
/// script says the second one.
///
/// A copy the user edited is named as edited and asked about on its own, and no
/// flag covers it: [`install_bundled`] removes the destination directory first,
/// so a bulk yes would take their edits and any file they added with it.
///
/// A failed install is a warning, not an abort, for the same reason `lev setup`
/// treats it that way: an updated binary plus most of the blueprints is a far
/// better place to leave someone than a command that gave up in the middle.
fn update_agents(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) {
    let changes = changing(plan);
    if changes.is_empty() {
        println!("  every bundled blueprint is already up to date");
        return;
    }

    // Seen before agreed to, always.
    for (agent, action) in &changes {
        println!("    {} - {}", agent.name, action.label(agent.version));
    }
    let clean = changes.iter().filter(|(_, a)| a.preselect()).count();
    let edited = changes.len() - clean;
    if edited > 0 {
        println!(
            "  {edited} of these you have edited locally. Installing removes the directory \
             first, so your edits and any file you added go with it - each is asked about \
             on its own."
        );
    }

    // One question for the whole clean set, rather than one per blueprint: the
    // list above is already the detail, and seven prompts in a row is how a
    // person stops reading them.
    let install_clean = match clean {
        0 => false,
        n => args.install_agents || (env.confirm)(&format!("Install these {n} blueprint(s)?")),
    };

    for (agent, action) in changes {
        let ok = match action.preselect() {
            true => install_clean,
            false => (env.confirm)(&format!(
                "{} - {}. Overwrite your edited copy?",
                agent.name,
                action.label(agent.version)
            )),
        };
        if !ok {
            println!("  skipped {}", agent.name);
            continue;
        }
        if args.dry_run {
            println!("  would install {} {}", agent.name, agent.version);
            continue;
        }
        match install_bundled(agent, &env.agents_dir) {
            Ok(()) => println!("  installed {} {}", agent.name, agent.version),
            Err(e) => println!("  could not install {}: {e}", agent.name),
        }
    }
}

/// Step three: the config.
///
/// Nothing is written before the user has seen, line by line, what changed.
fn migrate_config(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
    let config = match &plan.config {
        ConfigState::Unreadable(e) => {
            println!("  the config could not be read, so it was left alone: {e}");
            return Ok(());
        }
        ConfigState::Loaded(config) => config,
    };
    if plan.migrations.is_empty() {
        println!("  the config needs no changes");
        return Ok(());
    }

    let mut config = config.as_ref().clone();
    let mut changed = Vec::new();
    for migration in &plan.migrations {
        for line in (migration.apply)(&mut config) {
            changed.push(format!("{}: {line}", migration.name));
        }
    }
    for line in &changed {
        println!("    - {line}");
    }

    let path = env.config_path.display();
    if !agreed(args, env, &format!("Write these changes to {path}?")) {
        println!("  config left as it is");
        return Ok(());
    }
    if args.dry_run {
        println!("  would write {path}");
        return Ok(());
    }
    config.save_to_path_public(&env.config_path)?;
    println!("  wrote {path}");
    Ok(())
}

/// Run `lev update` against an injected environment.
pub fn execute_with(args: &UpdateArgs, env: &UpdateEnv, version: &str) -> anyhow::Result<()> {
    let plan = plan(args, env);

    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&plan_json(&plan, version))
                .expect("a plan is plain data and always serializes")
        );
        return Ok(());
    }

    print!("{}", format_plan(&plan, version));
    if args.check {
        return Ok(());
    }

    println!("\nbinary");
    update_binary(args, env, &plan)?;
    println!("\nblueprints");
    update_agents(args, env, &plan);
    println!("\nconfig");
    migrate_config(args, env, &plan)?;
    Ok(())
}

#[cfg(test)]
mod tests;