Skip to main content

zenith_cli/commands/plugin/
run.rs

1//! Orchestration for `zenith plugin {install,uninstall,list}`: resolve targets,
2//! perform the work, print a concise human report, and return an exit code.
3
4use std::path::Path;
5
6use super::agent::{ALL_AGENTS, Agent, Scope};
7use super::detect::{detect_present, is_installed};
8use super::install::{WriteOutcome, install_agent};
9use super::path_check::zenith_on_path;
10use super::uninstall::{RemoveOutcome, uninstall_agent};
11
12/// Which agents a command should act on.
13#[derive(Debug, Clone)]
14pub enum Targets {
15    /// An explicit set chosen via `--claude`, `--codex`, … flags.
16    Agents(Vec<Agent>),
17    /// Every supported agent (`--all`).
18    All,
19    /// Auto-detect agents present on the machine (no flags given).
20    Auto,
21}
22
23/// `zenith plugin install`.
24pub fn run_install(
25    project_root: &Path,
26    targets: Targets,
27    scope: Scope,
28    force: bool,
29    dry_run: bool,
30) -> u8 {
31    let agents = match resolve(targets, project_root) {
32        Ok(a) => a,
33        Err(code) => return code,
34    };
35
36    let mut any_overwrite = false;
37    let mut any_error = false;
38    let verb = if dry_run {
39        "would install"
40    } else {
41        "installed"
42    };
43
44    for agent in agents {
45        let report = install_agent(agent, scope, project_root, force, dry_run);
46        if let Some(reason) = &report.unsupported {
47            println!("- {}: skipped ({reason})", agent.display());
48            continue;
49        }
50        let root = report
51            .root
52            .as_ref()
53            .map(|p| p.display().to_string())
54            .unwrap_or_default();
55
56        let mut installed = 0usize;
57        let mut current = 0usize;
58        for f in &report.files {
59            match &f.outcome {
60                Ok(WriteOutcome::Installed) => installed += 1,
61                Ok(WriteOutcome::AlreadyCurrent) => current += 1,
62                Ok(WriteOutcome::WouldOverwrite) => {
63                    any_overwrite = true;
64                    println!("    differs (needs --force): {}", f.path.display());
65                }
66                Err(e) => {
67                    any_error = true;
68                    println!("    error: {}: {e}", f.path.display());
69                }
70            }
71        }
72        println!(
73            "- {} ({}): {verb} {installed}, current {current} → {root}",
74            agent.display(),
75            scope_label(scope),
76        );
77    }
78
79    if zenith_on_path().is_none() {
80        println!("warning: the installed skill calls `zenith`, but `zenith` is not on your PATH.");
81        println!(
82            "  Install it: `cargo install --path zenith-cli`, or `./scripts/install.sh`, then ensure its dir is on PATH."
83        );
84    }
85
86    finish(any_error, any_overwrite, dry_run)
87}
88
89/// `zenith plugin uninstall`.
90pub fn run_uninstall(project_root: &Path, targets: Targets, scope: Scope, dry_run: bool) -> u8 {
91    let agents = match resolve(targets, project_root) {
92        Ok(a) => a,
93        Err(code) => return code,
94    };
95
96    let mut any_error = false;
97    let verb = if dry_run { "would remove" } else { "removed" };
98
99    for agent in agents {
100        let report = uninstall_agent(agent, scope, project_root, dry_run);
101        if let Some(reason) = &report.unsupported {
102            println!("- {}: skipped ({reason})", agent.display());
103            continue;
104        }
105        let mut removed = 0usize;
106        let mut absent = 0usize;
107        for item in &report.items {
108            match &item.outcome {
109                Ok(RemoveOutcome::Removed) => removed += 1,
110                Ok(RemoveOutcome::Absent) => absent += 1,
111                Err(e) => {
112                    any_error = true;
113                    println!("    error: {}: {e}", item.path.display());
114                }
115            }
116        }
117        println!(
118            "- {} ({}): {verb} {removed}, absent {absent}",
119            agent.display(),
120            scope_label(scope),
121        );
122    }
123
124    if any_error { 2 } else { 0 }
125}
126
127/// `zenith plugin list` — show install state per agent in both scopes.
128pub fn run_list(project_root: &Path) -> u8 {
129    println!("Zenith skill install state (agent / project / user):");
130    for agent in ALL_AGENTS {
131        let proj = mark(is_installed(*agent, Scope::Project, project_root));
132        let user = mark(is_installed(*agent, Scope::User, project_root));
133        let present = if detect_present(project_root).contains(agent) {
134            " (detected)"
135        } else {
136            ""
137        };
138        println!(
139            "- {:<14} project:{proj}  user:{user}{present}",
140            agent.display()
141        );
142    }
143    match zenith_on_path() {
144        Some(path) => println!("zenith binary: {}", path.display()),
145        None => println!(
146            "zenith binary: NOT on PATH — the skill calls `zenith` by name; \
147             install it (`cargo install --path zenith-cli` or `./scripts/install.sh`) and put its dir on PATH"
148        ),
149    }
150    0
151}
152
153// ── Internal ────────────────────────────────────────────────────────────────
154
155/// Resolve `Targets` to a concrete agent list, printing guidance on empty auto.
156fn resolve(targets: Targets, project_root: &Path) -> Result<Vec<Agent>, u8> {
157    match targets {
158        Targets::Agents(a) if !a.is_empty() => Ok(a),
159        Targets::Agents(_) => {
160            eprintln!(
161                "error: no agent selected — pass --all, an agent flag, or none to auto-detect"
162            );
163            Err(2)
164        }
165        Targets::All => Ok(ALL_AGENTS.to_vec()),
166        Targets::Auto => {
167            let found = detect_present(project_root);
168            if found.is_empty() {
169                eprintln!(
170                    "no agents detected in {} or your home directory.",
171                    project_root.display()
172                );
173                eprintln!("pass an explicit flag (e.g. --claude) or --all to choose targets.");
174                return Err(1);
175            }
176            let names: Vec<&str> = found.iter().map(|a| a.display()).collect();
177            eprintln!("detected: {}", names.join(", "));
178            Ok(found)
179        }
180    }
181}
182
183fn finish(any_error: bool, any_overwrite: bool, dry_run: bool) -> u8 {
184    if any_error {
185        return 2;
186    }
187    if any_overwrite {
188        if dry_run {
189            eprintln!("some files differ; re-run without --dry-run and with --force to overwrite.");
190        } else {
191            eprintln!("some files were left unchanged; re-run with --force to overwrite them.");
192        }
193        return 2;
194    }
195    0
196}
197
198fn scope_label(scope: Scope) -> &'static str {
199    match scope {
200        Scope::Project => "project",
201        Scope::User => "user",
202    }
203}
204
205fn mark(installed: bool) -> &'static str {
206    if installed { "yes" } else { "—" }
207}