Skip to main content

microclaw_engine/
skill_management.rs

1//! Shared Skill package lifecycle used by SDK and product UIs.
2
3use 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
18/// Install or update a Skill from a local directory, GitHub reference, or ClawHub slug.
19pub 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
74/// Recoverably remove an unmanaged Skill by moving it under `.archived/`.
75pub 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}