tempo_ext/installer/
skill.rs1use 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#[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
123fn 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
136pub(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}