Skip to main content

agentctl/skill/
lifecycle.rs

1use anyhow::{bail, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4
5use super::vars;
6
7pub type Approver = fn(&str) -> bool;
8pub type Executor = fn(&str) -> Result<()>;
9
10#[derive(Debug, Deserialize)]
11pub struct LifecycleStep {
12    pub command: String,
13    pub description: String,
14    #[serde(default = "default_platform")]
15    pub platform: String,
16    #[serde(default = "default_approval")]
17    pub requires_approval: bool,
18}
19
20fn default_platform() -> String {
21    "all".into()
22}
23fn default_approval() -> bool {
24    true
25}
26
27#[derive(Debug, Deserialize)]
28pub struct LifecycleFile {
29    #[serde(default)]
30    pub variables: HashMap<String, String>,
31    #[serde(default)]
32    pub install: Vec<LifecycleStep>,
33    #[serde(default)]
34    pub update: Vec<LifecycleStep>,
35    #[serde(default)]
36    pub uninstall: Vec<LifecycleStep>,
37}
38
39pub fn parse(yaml: &str) -> Result<LifecycleFile> {
40    Ok(serde_yaml::from_str(yaml)?)
41}
42
43fn current_platform() -> &'static str {
44    if cfg!(target_os = "macos") {
45        "macos"
46    } else if cfg!(target_os = "windows") {
47        "windows"
48    } else {
49        "linux"
50    }
51}
52
53pub fn execute_lifecycle(
54    steps: &[LifecycleStep],
55    vars: &HashMap<String, String>,
56    quiet: bool,
57    approver: Approver,
58    executor: Executor,
59) -> Result<()> {
60    let platform = current_platform();
61    for step in steps {
62        if step.platform != "all" && step.platform != platform {
63            continue;
64        }
65        let cmd = vars::expand(&step.command, vars)?;
66        if !quiet {
67            println!("  \u{2192} {}", step.description);
68            println!("    {cmd}");
69            if step.requires_approval {
70                print!("  Approve? [y/N] ");
71                use std::io::Write;
72                std::io::stdout().flush()?;
73                if !approver(&cmd) {
74                    bail!("aborted by user");
75                }
76            }
77        }
78        executor(&cmd)?;
79    }
80    Ok(())
81}
82
83pub fn execute_update(
84    lf: &LifecycleFile,
85    vars: &HashMap<String, String>,
86    quiet: bool,
87    force: bool,
88    approver: Approver,
89    executor: Executor,
90) -> Result<()> {
91    if lf.update.is_empty() {
92        if !force {
93            bail!("skill has no update lifecycle — use --force to reinstall");
94        }
95        execute_lifecycle(&lf.uninstall, vars, quiet, approver, executor)?;
96        execute_lifecycle(&lf.install, vars, quiet, approver, executor)?;
97    } else {
98        execute_lifecycle(&lf.update, vars, quiet, approver, executor)?;
99    }
100    Ok(())
101}
102
103pub fn sh_executor(cmd: &str) -> Result<()> {
104    let status = std::process::Command::new("sh")
105        .arg("-c")
106        .arg(cmd)
107        .status()?;
108    if !status.success() {
109        bail!("command failed: {cmd}");
110    }
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    const YAML: &str = r#"
119variables:
120  VENV: ${SKILL_PATH}/.venv
121
122install:
123  - command: echo install ${SKILL_NAME}
124    description: Install skill
125    platform: all
126    requires_approval: false
127
128  - command: echo macos-only
129    description: macOS step
130    platform: macos
131    requires_approval: false
132
133  - command: echo needs-approval
134    description: Needs approval
135    platform: all
136    requires_approval: true
137"#;
138
139    fn base_vars() -> HashMap<String, String> {
140        let mut m = HashMap::new();
141        m.insert("SKILL_NAME".into(), "my-skill".into());
142        m.insert("SKILL_PATH".into(), "/skills/my-skill".into());
143        m.insert("HOME".into(), "/home/user".into());
144        m.insert("PLATFORM".into(), "linux".into());
145        m
146    }
147
148    #[test]
149    fn parse_lifecycle_yaml() {
150        let lf = parse(YAML).unwrap();
151        assert_eq!(lf.install.len(), 3);
152        assert_eq!(lf.variables["VENV"], "${SKILL_PATH}/.venv");
153    }
154
155    #[test]
156    fn execute_skips_wrong_platform() {
157        let lf = parse(YAML).unwrap();
158        let vars = base_vars();
159        // macos step skipped on linux — verify no error
160        let result = execute_lifecycle(&lf.install, &vars, true, |_| true, |_cmd| Ok(()));
161        assert!(result.is_ok());
162    }
163
164    #[test]
165    fn execute_approver_abort() {
166        let lf = parse(YAML).unwrap();
167        let vars = base_vars();
168        // always_no approver — should abort on requires_approval step
169        let result = execute_lifecycle(&lf.install, &vars, false, |_| false, |_| Ok(()));
170        assert!(result.is_err());
171    }
172
173    #[test]
174    fn execute_quiet_skips_approval() {
175        let lf = parse(YAML).unwrap();
176        let vars = base_vars();
177        // quiet=true, approver never called — should succeed
178        let result = execute_lifecycle(&lf.install, &vars, true, |_| false, |_| Ok(()));
179        assert!(result.is_ok());
180    }
181
182    const YAML_NO_UPDATE: &str = r#"
183install:
184  - command: echo install
185    description: Install
186    requires_approval: false
187uninstall:
188  - command: echo uninstall
189    description: Uninstall
190    requires_approval: false
191"#;
192
193    #[test]
194    fn execute_update_no_section_errors_without_force() {
195        let lf = parse(YAML_NO_UPDATE).unwrap();
196        assert!(execute_update(&lf, &base_vars(), true, false, |_| true, |_| Ok(())).is_err());
197    }
198
199    #[test]
200    fn execute_update_no_section_force_runs_uninstall_then_install() {
201        let lf = parse(YAML_NO_UPDATE).unwrap();
202        // force=true with no update section should succeed (runs uninstall then install)
203        assert!(execute_update(&lf, &base_vars(), true, true, |_| true, |_| Ok(())).is_ok());
204    }
205
206    #[test]
207    fn execute_update_with_section_runs_update() {
208        let yaml = r#"
209update:
210  - command: echo update
211    description: Update
212    requires_approval: false
213"#;
214        let lf = parse(yaml).unwrap();
215        // succeeds and runs the update section (verified by no error)
216        assert!(execute_update(&lf, &base_vars(), true, false, |_| true, |_| Ok(())).is_ok());
217    }
218}