1use std::path::PathBuf;
15
16use anyhow::{Context, Result};
17use serde::Serialize;
18use tokio::process::Command;
19
20use crate::skill::{InstallKind, SkillInstallSpec};
21
22#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct InstallOutput {
26 pub success: bool,
28 pub command: String,
30 pub output: String,
32 pub exit_code: Option<i32>,
34}
35
36pub fn build_command(spec: &SkillInstallSpec) -> Option<(String, Vec<String>)> {
41 let (bin, args): (&str, Vec<String>) = match spec.kind {
42 InstallKind::Brew => {
43 let formula = spec
44 .formula
45 .as_deref()
46 .context("brew spec missing formula")
47 .ok()?;
48 ("brew", vec!["install".into(), formula.into()])
49 }
50 InstallKind::Node => {
51 let pkg = spec
52 .package
53 .as_deref()
54 .context("node spec missing package")
55 .ok()?;
56 ("npm", vec!["install".into(), "-g".into(), pkg.into()])
57 }
58 InstallKind::Bun => {
59 let pkg = spec
60 .package
61 .as_deref()
62 .context("bun spec missing package")
63 .ok()?;
64 ("bun", vec!["install".into(), "-g".into(), pkg.into()])
65 }
66 InstallKind::Cargo => {
67 let pkg = spec
68 .package
69 .as_deref()
70 .context("cargo spec missing package")
71 .ok()?;
72 ("cargo", vec!["install".into(), pkg.into()])
73 }
74 InstallKind::Pip => {
75 let pkg = spec
76 .package
77 .as_deref()
78 .context("pip spec missing package")
79 .ok()?;
80 ("pip", vec!["install".into(), "--user".into(), pkg.into()])
81 }
82 InstallKind::Go => {
83 let module = spec
84 .module
85 .as_deref()
86 .context("go spec missing module")
87 .ok()?;
88 ("go", vec!["install".into(), module.into()])
89 }
90 InstallKind::Uv => {
91 let pkg = spec
92 .package
93 .as_deref()
94 .context("uv spec missing package")
95 .ok()?;
96 ("uv", vec!["tool".into(), "install".into(), pkg.into()])
97 }
98 InstallKind::Download => {
99 return None;
102 }
103 };
104 Some((bin.to_string(), args))
105}
106
107fn fmt_cmd(bin: &str, args: &[String]) -> String {
109 let mut s = String::from(bin);
110 for a in args {
111 s.push(' ');
112 s.push_str(a);
113 }
114 s
115}
116
117pub async fn install_manager(spec: &SkillInstallSpec) -> Result<InstallOutput> {
119 let (bin, args) = build_command(spec)
120 .context("install spec is incomplete (missing formula/package/module)")?;
121 let cmd_str = fmt_cmd(&bin, &args);
122 tracing::info!(command = %cmd_str, "provisioning: running install");
123
124 let output = Command::new(&bin)
125 .args(&args)
126 .output()
127 .await
128 .with_context(|| format!("failed to spawn `{cmd_str}`"))?;
129
130 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
131 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
132 let combined = if stderr.is_empty() {
133 stdout
134 } else if stdout.is_empty() {
135 stderr
136 } else {
137 format!("{stdout}\n--- stderr ---\n{stderr}")
138 };
139 let success = output.status.success();
140 Ok(InstallOutput {
141 success,
142 command: cmd_str,
143 output: combined,
144 exit_code: output.status.code(),
145 })
146}
147
148pub async fn install_download(spec: &SkillInstallSpec) -> Result<InstallOutput> {
154 let url = spec.url.as_deref().context("download spec missing url")?;
155 let target = spec
156 .target_dir
157 .as_deref()
158 .map(PathBuf::from)
159 .unwrap_or_else(|| PathBuf::from("."));
160 let strip = spec.strip_components.unwrap_or(0) as usize;
161
162 tracing::info!(url = %url, target = %target.display(), "provisioning: downloading archive");
163 let resp = reqwest::get(url)
164 .await
165 .context("fetching download archive")?;
166 let bytes = resp
167 .bytes()
168 .await
169 .context("reading download archive body")?;
170
171 std::fs::create_dir_all(&target).with_context(|| format!("creating {}", target.display()))?;
173 let is_gz = url.ends_with(".gz") || url.ends_with(".tgz");
174 let is_tar = url.ends_with(".tar") || url.ends_with(".tar.gz") || url.ends_with(".tgz");
175
176 let archive_bytes: &[u8] = &bytes;
177 let decoder;
178 if is_tar && is_gz {
179 decoder = flate2::read::GzDecoder::new(archive_bytes);
180 let mut tar = tar::Archive::new(decoder);
181 tar.set_overwrite(true);
182 unpack_with_strip(&mut tar, &target, strip)?;
183 } else if is_tar {
184 let mut tar = tar::Archive::new(archive_bytes);
185 tar.set_overwrite(true);
186 unpack_with_strip(&mut tar, &target, strip)?;
187 } else {
188 let name = url.rsplit('/').next().unwrap_or("download");
190 std::fs::write(target.join(name), &bytes).context("writing downloaded file")?;
191 let _ = archive_bytes; }
193
194 Ok(InstallOutput {
195 success: true,
196 command: format!("download+extract {url} → {}", target.display()),
197 output: format!("extracted to {}", target.display()),
198 exit_code: None,
199 })
200}
201
202fn unpack_with_strip<R: std::io::Read>(
204 tar: &mut tar::Archive<R>,
205 target: &std::path::Path,
206 strip: usize,
207) -> Result<()> {
208 for entry in tar.entries()? {
209 let mut entry = entry.context("reading tar entry")?;
210 let path = entry.path()?.into_owned();
211 let mut comps = path.components();
212 for _ in 0..strip {
213 comps.next();
214 }
215 let stripped: PathBuf = comps.collect();
216 if stripped.as_os_str().is_empty() {
217 continue;
218 }
219 if stripped.is_absolute() || stripped.components().any(|c| c.as_os_str() == "..") {
221 anyhow::bail!("unsafe entry path in archive: {}", stripped.display());
222 }
223 let entry_type = entry.header().entry_type();
224 anyhow::ensure!(
225 entry_type.is_file() || entry_type.is_dir(),
226 "unsupported archive entry type for {}",
227 stripped.display()
228 );
229 let destination = target.join(&stripped);
230 if let Some(parent) = destination.parent() {
231 std::fs::create_dir_all(parent)
232 .with_context(|| format!("creating {}", parent.display()))?;
233 }
234 entry
235 .unpack(&destination)
236 .with_context(|| format!("unpacking {} to {}", stripped.display(), target.display()))?;
237 }
238 Ok(())
239}
240
241pub async fn install(specs: &[SkillInstallSpec]) -> Result<InstallOutput> {
245 anyhow::ensure!(!specs.is_empty(), "no install specs provided");
246 let pick = specs
248 .iter()
249 .find(|s| manager_available(s.kind))
250 .or_else(|| specs.first())
251 .context("no install spec")?;
252 match pick.kind {
253 InstallKind::Download => install_download(pick).await,
254 _ => install_manager(pick).await,
255 }
256}
257
258fn manager_available(kind: InstallKind) -> bool {
260 let bin = match kind {
261 InstallKind::Brew => "brew",
262 InstallKind::Node => "npm",
263 InstallKind::Bun => "bun",
264 InstallKind::Cargo => "cargo",
265 InstallKind::Pip => "pip3",
266 InstallKind::Go => "go",
267 InstallKind::Uv => "uv",
268 InstallKind::Download => return true, };
270 which_sync(bin).is_some()
271}
272
273fn which_sync(name: &str) -> Option<PathBuf> {
275 let sep = if cfg!(windows) { ';' } else { ':' };
276 let path = std::env::var("PATH").ok()?;
277 for dir in path.split(sep) {
278 let p = PathBuf::from(dir);
279 if cfg!(windows) {
280 for ext in ["exe", "cmd", "bat"] {
281 let c = p.join(format!("{name}.{ext}"));
282 if c.is_file() {
283 return Some(c);
284 }
285 }
286 } else if p.join(name).is_file() {
287 return Some(p.join(name));
288 }
289 }
290 None
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn spec(
298 kind: InstallKind,
299 formula: Option<&str>,
300 package: Option<&str>,
301 module: Option<&str>,
302 ) -> SkillInstallSpec {
303 SkillInstallSpec {
304 kind,
305 formula: formula.map(String::from),
306 package: package.map(String::from),
307 module: module.map(String::from),
308 url: None,
309 archive: None,
310 extract: None,
311 strip_components: None,
312 target_dir: None,
313 os: vec![],
314 }
315 }
316
317 #[test]
318 fn builds_brew_command() {
319 let s = spec(InstallKind::Brew, Some("gh"), None, None);
320 let (bin, args) = build_command(&s).unwrap();
321 assert_eq!(bin, "brew");
322 assert_eq!(args, vec!["install", "gh"]);
323 }
324
325 #[test]
326 fn builds_cargo_command() {
327 let s = spec(InstallKind::Cargo, None, Some("ripgrep"), None);
328 let (bin, args) = build_command(&s).unwrap();
329 assert_eq!(bin, "cargo");
330 assert_eq!(args, vec!["install", "ripgrep"]);
331 }
332
333 #[test]
334 fn builds_bun_command() {
335 let s = spec(InstallKind::Bun, None, Some("tsx"), None);
336 let (bin, args) = build_command(&s).unwrap();
337 assert_eq!(bin, "bun");
338 assert_eq!(args, vec!["install", "-g", "tsx"]);
339 }
340
341 #[test]
342 fn incomplete_spec_returns_none() {
343 let s = spec(InstallKind::Brew, None, None, None);
345 assert!(build_command(&s).is_none());
346 }
347
348 #[test]
349 fn download_has_no_command() {
350 let s = spec(InstallKind::Download, None, None, None);
351 assert!(build_command(&s).is_none());
352 }
353
354 #[test]
355 fn unpack_applies_strip_components() {
356 use std::io::Cursor;
357
358 let mut builder = tar::Builder::new(Vec::new());
359 let contents = b"hello";
360 let mut header = tar::Header::new_gnu();
361 header.set_size(contents.len() as u64);
362 header.set_mode(0o644);
363 header.set_cksum();
364 builder
365 .append_data(&mut header, "tool-v1/bin/tool", Cursor::new(contents))
366 .unwrap();
367 let bytes = builder.into_inner().unwrap();
368
369 let tmp = tempfile::tempdir().unwrap();
370 let mut archive = tar::Archive::new(Cursor::new(bytes));
371 unpack_with_strip(&mut archive, tmp.path(), 1).unwrap();
372
373 assert_eq!(
374 std::fs::read(tmp.path().join("bin/tool")).unwrap(),
375 contents
376 );
377 assert!(!tmp.path().join("tool-v1/bin/tool").exists());
378 }
379
380 #[test]
381 fn fmt_cmd_joins_args() {
382 assert_eq!(
383 fmt_cmd("brew", &["install".into(), "gh".into()]),
384 "brew install gh"
385 );
386 }
387}