Skip to main content

zenith_cli/commands/plugin/
install.rs

1//! Write the skill to an agent's target location, idempotently.
2
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use super::agent::{Agent, Scope, SkillFormat};
8use super::assets::{COMMAND_FILES, SKILL_FILES};
9use super::paths::{SkillTarget, command_dir, skill_target};
10use super::render::render_rule;
11
12/// Result of writing (or planning to write) a single file.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WriteOutcome {
15    /// File was (or would be) created or overwritten.
16    Installed,
17    /// File already exists with identical content — nothing to do.
18    AlreadyCurrent,
19    /// File exists with different content; needs `--force` to overwrite.
20    WouldOverwrite,
21}
22
23/// Per-file outcome with its absolute path.
24#[derive(Debug, Clone)]
25pub struct FileResult {
26    pub path: PathBuf,
27    pub outcome: Result<WriteOutcome, String>,
28}
29
30/// Per-agent install report.
31#[derive(Debug, Clone)]
32pub struct AgentInstall {
33    pub agent: Agent,
34    /// The root path (skill dir or rule file). `None` when unsupported.
35    pub root: Option<PathBuf>,
36    pub files: Vec<FileResult>,
37    /// Set when the (agent, scope) pair has no automatic target.
38    pub unsupported: Option<String>,
39}
40
41/// Install (or, when `dry_run`, plan) the skill for one agent.
42pub fn install_agent(
43    agent: Agent,
44    scope: Scope,
45    project_root: &Path,
46    force: bool,
47    dry_run: bool,
48) -> AgentInstall {
49    let Some(target) = skill_target(agent, scope, project_root) else {
50        return AgentInstall {
51            agent,
52            root: None,
53            files: Vec::new(),
54            unsupported: Some(format!(
55                "{} {} scope has no automatic target",
56                agent.display(),
57                scope_label(scope)
58            )),
59        };
60    };
61
62    let mut files = Vec::new();
63    match (agent.format(), &target) {
64        (SkillFormat::Folder, SkillTarget::Folder(dir)) => {
65            for (rel, body) in SKILL_FILES {
66                let path = dir.join(rel);
67                files.push(write(&path, body, force, dry_run));
68            }
69            if let Some(cmd_dir) = command_dir(agent, scope, project_root) {
70                for (name, body) in COMMAND_FILES {
71                    let path = cmd_dir.join(name);
72                    files.push(write(&path, body, force, dry_run));
73                }
74            }
75        }
76        (SkillFormat::Rule, SkillTarget::Rule(path)) => {
77            let body = render_rule(agent);
78            files.push(write(path, &body, force, dry_run));
79        }
80        // Format/target mismatch is impossible by construction.
81        (SkillFormat::Folder, SkillTarget::Rule(_))
82        | (SkillFormat::Rule, SkillTarget::Folder(_)) => {}
83    }
84
85    AgentInstall {
86        agent,
87        root: Some(target.root().to_path_buf()),
88        files,
89        unsupported: None,
90    }
91}
92
93/// Plan, then (unless `dry_run`) perform a single idempotent write.
94fn write(path: &Path, content: &str, force: bool, dry_run: bool) -> FileResult {
95    let outcome = (|| -> io::Result<WriteOutcome> {
96        let planned = plan(path, content, force)?;
97        if !dry_run && planned == WriteOutcome::Installed {
98            if let Some(parent) = path.parent() {
99                fs::create_dir_all(parent)?;
100            }
101            fs::write(path, content)?;
102        }
103        Ok(planned)
104    })();
105    FileResult {
106        path: path.to_path_buf(),
107        outcome: outcome.map_err(|e| e.to_string()),
108    }
109}
110
111/// Decide what writing `content` to `path` would do, without writing.
112fn plan(path: &Path, content: &str, force: bool) -> io::Result<WriteOutcome> {
113    if path.exists() {
114        let existing = fs::read(path)?;
115        if existing == content.as_bytes() {
116            return Ok(WriteOutcome::AlreadyCurrent);
117        }
118        if !force {
119            return Ok(WriteOutcome::WouldOverwrite);
120        }
121    }
122    Ok(WriteOutcome::Installed)
123}
124
125fn scope_label(scope: Scope) -> &'static str {
126    match scope {
127        Scope::Project => "project",
128        Scope::User => "user",
129    }
130}