agentctl/skill/
lifecycle.rs1use 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 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 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 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 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 assert!(execute_update(&lf, &base_vars(), true, false, |_| true, |_| Ok(())).is_ok());
217 }
218}