Skip to main content

tempo_ext/installer/
skill.rs

1//! Agent skill installation and removal across coding assistants.
2
3use minisign_verify::PublicKey;
4use std::fs;
5
6use crate::installer::{
7    error::InstallerError,
8    file_url_to_path, home_dir, http_client,
9    verify::{sha256_hex, verify_signature},
10};
11
12const AGENT_SKILL_DIRS: &[(&str, &str)] = &[
13    (".agents", "universal"),
14    (".claude", "Claude Code"),
15    (".config/agents", "Amp"),
16    (".cursor", "Cursor"),
17    (".copilot", "GitHub Copilot"),
18    (".codex", "Codex"),
19    (".gemini", "Gemini CLI"),
20    (".config/opencode", "OpenCode"),
21    (".config/goose", "Goose"),
22    (".windsurf", "Windsurf"),
23    (".codeium/windsurf", "Windsurf"),
24    (".continue", "Continue"),
25    (".roo", "Roo"),
26    (".kiro", "Kiro"),
27    (".augment", "Augment"),
28    (".trae", "Trae"),
29];
30
31/// Downloads, verifies, and installs an extension's agent skill file into every
32/// detected coding assistant's skills directory.
33#[allow(clippy::too_many_arguments)]
34pub(super) fn install_skill(
35    extension: &str,
36    version: &str,
37    url: &str,
38    expected_sha256: Option<&str>,
39    encoded_signature: Option<&str>,
40    public_key: &PublicKey,
41    dry_run: bool,
42    quiet: bool,
43) {
44    let skill_dir_name = format!("tempo-{extension}");
45
46    if dry_run {
47        println!("dry-run: install skill from {url}");
48        return;
49    }
50
51    let content = match download_skill(url) {
52        Ok(content) => content,
53        Err(err) => {
54            tracing::warn!("skill download failed for tempo-{extension}: {err}");
55            return;
56        }
57    };
58
59    if let Some(expected) = expected_sha256 {
60        let actual = sha256_hex(content.as_bytes());
61        if actual != expected.to_lowercase() {
62            tracing::warn!("skill checksum mismatch for tempo-{extension}, skipping");
63            return;
64        }
65        tracing::debug!("skill checksum ok for tempo-{extension}");
66    }
67
68    let skill_name = format!("tempo-{extension} skill");
69    let expected_comment = format!("skill:tempo-{extension}");
70    let version_comment = format!("version:{version}");
71    match encoded_signature {
72        Some(sig) => {
73            if let Err(err) = verify_signature(
74                &skill_name,
75                content.as_bytes(),
76                sig,
77                public_key,
78                &[&expected_comment, &version_comment],
79            ) {
80                tracing::warn!("{err}, skipping skill install");
81                return;
82            }
83            tracing::debug!("skill signature ok for tempo-{extension}");
84        }
85        None => {
86            tracing::warn!("skill signature missing for tempo-{extension}, skipping skill install");
87            return;
88        }
89    }
90
91    let home = match home_dir() {
92        Some(h) => h,
93        None => {
94            tracing::warn!("skill install skipped for tempo-{extension}: home directory not found");
95            return;
96        }
97    };
98
99    let mut installed_names: Vec<&str> = Vec::new();
100    for &(parent_rel, agent_name) in AGENT_SKILL_DIRS {
101        let parent = home.join(parent_rel);
102        if !parent.is_dir() {
103            continue;
104        }
105        let skill_dir = parent.join("skills").join(&skill_dir_name);
106        if fs::create_dir_all(&skill_dir).is_err() {
107            continue;
108        }
109        if fs::write(skill_dir.join("SKILL.md"), &content).is_ok() {
110            installed_names.push(agent_name);
111        }
112    }
113
114    if !quiet && !installed_names.is_empty() {
115        println!(
116            "installed tempo-{extension} skill to {} agent(s): {}",
117            installed_names.len(),
118            installed_names.join(", ")
119        );
120    }
121}
122
123/// Fetches the skill file content from `url` (HTTPS or `file://`).
124fn download_skill(url: &str) -> Result<String, InstallerError> {
125    tracing::debug!("downloading skill from {url}");
126
127    if url.starts_with("https://") {
128        Ok(http_client()?.get(url).send()?.error_for_status()?.text()?)
129    } else if let Some(path) = file_url_to_path(url) {
130        Ok(fs::read_to_string(path)?)
131    } else {
132        Err(InstallerError::InsecureDownloadUrl(url.to_string()))
133    }
134}
135
136/// Removes an extension's skill directory from all detected coding assistants.
137pub(super) fn remove_skill(extension: &str, dry_run: bool) {
138    let skill_dir_name = format!("tempo-{extension}");
139
140    let home = match home_dir() {
141        Some(h) => h,
142        None => return,
143    };
144
145    for &(parent_rel, _) in AGENT_SKILL_DIRS {
146        let skill_dir = home.join(parent_rel).join("skills").join(&skill_dir_name);
147        if skill_dir.is_dir() {
148            if dry_run {
149                println!("dry-run: remove skill {}", skill_dir.display());
150            } else if fs::remove_dir_all(&skill_dir).is_ok() {
151                tracing::debug!("removed skill {}", skill_dir.display());
152            }
153        }
154    }
155}