microclaw_engine/
skill_management.rs1use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::clawhub::install::InstallOptions;
7use crate::clawhub::service::{ClawHubGateway, RegistryClawHubGateway};
8use crate::config::Config;
9use crate::skills::{validate_agentskills_name, SkillManager};
10use crate::tools::{sync_skills::SyncSkillsTool, Tool};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SkillInstallOutcome {
14 pub message: String,
15 pub warnings: Vec<String>,
16}
17
18pub async fn install_skill(
20 config: &Config,
21 manager: &SkillManager,
22 reference: &str,
23) -> Result<SkillInstallOutcome, String> {
24 let reference = reference.trim();
25 if reference.is_empty() {
26 return Err("skill source is required".into());
27 }
28 let skills_dir = PathBuf::from(config.skills_data_dir());
29 fs::create_dir_all(&skills_dir).map_err(|error| error.to_string())?;
30
31 let outcome = if Path::new(reference).is_dir() {
32 SkillInstallOutcome {
33 message: import_local_skill(Path::new(reference), &skills_dir)?,
34 warnings: Vec::new(),
35 }
36 } else if reference.contains("github.com/") || reference.split('/').count() >= 3 {
37 let result = SyncSkillsTool::new(&config.skills_data_dir())
38 .execute(serde_json::json!({"skill_name": reference}))
39 .await;
40 if result.is_error {
41 return Err(result.content);
42 }
43 SkillInstallOutcome {
44 message: result.content,
45 warnings: Vec::new(),
46 }
47 } else {
48 let result = RegistryClawHubGateway::from_config(config)
49 .install(
50 reference,
51 None,
52 &skills_dir,
53 &config.clawhub_lockfile_path(),
54 &InstallOptions {
55 force: true,
56 skip_gates: false,
57 skip_security: false,
58 },
59 )
60 .await
61 .map_err(|error| error.to_string())?;
62 if !result.success {
63 return Err(result.message);
64 }
65 SkillInstallOutcome {
66 message: result.message,
67 warnings: result.warnings,
68 }
69 };
70 manager.reload();
71 Ok(outcome)
72}
73
74pub fn archive_skill(manager: &SkillManager, name: &str) -> Result<PathBuf, String> {
76 validate_agentskills_name(name)?;
77 if !manager.has_skill(name) {
78 return Err(format!("Skill not found: {name}"));
79 }
80 let source = manager.skills_dir().join(name);
81 let archive_root = manager.skills_dir().join(".archived");
82 fs::create_dir_all(&archive_root).map_err(|error| error.to_string())?;
83 let suffix = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
84 let target = archive_root.join(format!("{name}-{suffix}"));
85 fs::rename(&source, &target).map_err(|error| error.to_string())?;
86 manager.reload();
87 Ok(target)
88}
89
90fn import_local_skill(source: &Path, skills_dir: &Path) -> Result<String, String> {
91 let name = source
92 .file_name()
93 .and_then(|value| value.to_str())
94 .ok_or_else(|| "invalid local Skill directory name".to_string())?;
95 validate_agentskills_name(name)?;
96 if !source.join("SKILL.md").is_file() {
97 return Err("local Skill directory must contain SKILL.md".into());
98 }
99 let staging = skills_dir.join(format!(".{name}.sdk-import"));
100 let backup = skills_dir.join(format!(".{name}.sdk-backup"));
101 let _ = fs::remove_dir_all(&staging);
102 let _ = fs::remove_dir_all(&backup);
103 copy_skill_tree(source, &staging)?;
104 if let Err(error) = scan_skill_tree(&staging) {
105 let _ = fs::remove_dir_all(&staging);
106 return Err(error);
107 }
108 let target = skills_dir.join(name);
109 if target.exists() {
110 fs::rename(&target, &backup).map_err(|error| error.to_string())?;
111 }
112 if let Err(error) = fs::rename(&staging, &target) {
113 if backup.exists() {
114 let _ = fs::rename(&backup, &target);
115 }
116 return Err(error.to_string());
117 }
118 let _ = fs::remove_dir_all(&backup);
119 Ok(format!(
120 "Imported local Skill {name} from {}",
121 source.display()
122 ))
123}
124
125fn copy_skill_tree(source: &Path, target: &Path) -> Result<(), String> {
126 fs::create_dir_all(target).map_err(|error| error.to_string())?;
127 for entry in fs::read_dir(source).map_err(|error| error.to_string())? {
128 let entry = entry.map_err(|error| error.to_string())?;
129 let file_type = entry.file_type().map_err(|error| error.to_string())?;
130 if file_type.is_symlink() {
131 return Err(format!(
132 "local Skill contains unsupported symlink: {}",
133 entry.path().display()
134 ));
135 }
136 let destination = target.join(entry.file_name());
137 if file_type.is_dir() {
138 copy_skill_tree(&entry.path(), &destination)?;
139 } else if file_type.is_file() {
140 fs::copy(entry.path(), destination).map_err(|error| error.to_string())?;
141 }
142 }
143 Ok(())
144}
145
146fn scan_skill_tree(root: &Path) -> Result<(), String> {
147 let mut pending = vec![root.to_path_buf()];
148 while let Some(directory) = pending.pop() {
149 for entry in fs::read_dir(directory).map_err(|error| error.to_string())? {
150 let entry = entry.map_err(|error| error.to_string())?;
151 let path = entry.path();
152 let kind = entry.file_type().map_err(|error| error.to_string())?;
153 if kind.is_dir() {
154 pending.push(path);
155 } else if kind.is_file() {
156 let Ok(content) = fs::read_to_string(&path) else {
157 continue;
158 };
159 if let Err(reason) = microclaw_core::injection_scan::scan_for_injection(&content) {
160 return Err(format!(
161 "refusing local Skill import: {} was flagged by the security scan ({reason})",
162 path.strip_prefix(root).unwrap_or(&path).display()
163 ));
164 }
165 }
166 }
167 }
168 Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn local_import_is_staged_scanned_and_archived_recoverably() {
177 let directory = tempfile::tempdir().unwrap();
178 let source = directory.path().join("review-skill");
179 let skills = directory.path().join("skills");
180 let runtime = directory.path().join("runtime");
181 fs::create_dir_all(&source).unwrap();
182 fs::write(
183 source.join("SKILL.md"),
184 "---\nname: review-skill\ndescription: review code\n---\nReview carefully.\n",
185 )
186 .unwrap();
187 let mut config = Config::test_defaults();
188 config.skills_dir = Some(skills.display().to_string());
189 config.data_dir = runtime.display().to_string();
190 let manager = SkillManager::from_skills_and_runtime(
191 &config.skills_data_dir(),
192 &config.runtime_data_dir(),
193 );
194 let outcome = tokio::runtime::Runtime::new()
195 .unwrap()
196 .block_on(install_skill(&config, &manager, source.to_str().unwrap()))
197 .unwrap();
198 assert!(outcome.message.contains("review-skill"));
199 assert!(manager.has_skill("review-skill"));
200 let archived = archive_skill(&manager, "review-skill").unwrap();
201 assert!(archived.join("SKILL.md").is_file());
202 assert!(!manager.has_skill("review-skill"));
203 }
204}