Skip to main content

microclaw_engine/tools/
sync_skills.rs

1use async_trait::async_trait;
2use chrono::Utc;
3use serde_json::json;
4
5use microclaw_core::llm_types::ToolDefinition;
6
7use super::{schema_object, Tool, ToolResult};
8
9pub struct SyncSkillsTool {
10    skills_dir: std::path::PathBuf,
11}
12
13impl SyncSkillsTool {
14    pub fn new(skills_dir: &str) -> Self {
15        Self {
16            skills_dir: std::path::PathBuf::from(skills_dir),
17        }
18    }
19
20    async fn fetch_skill_content(
21        source_repo: &str,
22        skill_name: &str,
23        git_ref: &str,
24    ) -> Result<String, String> {
25        let candidates = [
26            format!(
27                "https://raw.githubusercontent.com/{}/{}/skills/{}/SKILL.md",
28                source_repo, git_ref, skill_name
29            ),
30            format!(
31                "https://raw.githubusercontent.com/{}/{}/{}/SKILL.md",
32                source_repo, git_ref, skill_name
33            ),
34            format!(
35                "https://raw.githubusercontent.com/{}/{}/{}.md",
36                source_repo, git_ref, skill_name
37            ),
38        ];
39
40        let client = reqwest::Client::builder()
41            .timeout(std::time::Duration::from_secs(20))
42            .build()
43            .map_err(|e| e.to_string())?;
44
45        let mut errors = Vec::new();
46        for url in candidates {
47            match client
48                .get(&url)
49                .header("User-Agent", "MicroClaw/1.0")
50                .send()
51                .await
52            {
53                Ok(resp) if resp.status().is_success() => {
54                    let text = resp.text().await.map_err(|e| e.to_string())?;
55                    if !text.trim().is_empty() {
56                        return Ok(text);
57                    }
58                }
59                Ok(resp) => errors.push(format!("{} -> HTTP {}", url, resp.status())),
60                Err(e) => errors.push(format!("{} -> {}", url, e)),
61            }
62        }
63
64        Err(format!(
65            "Failed to fetch skill '{skill_name}' from {source_repo}@{git_ref}. Tried URLs:\n{}",
66            errors.join("\n")
67        ))
68    }
69
70    fn split_frontmatter(content: &str) -> (Option<serde_yaml::Value>, String) {
71        let trimmed = content.trim_start_matches('\u{feff}');
72        if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
73            return (None, trimmed.to_string());
74        }
75
76        let mut lines = trimmed.lines();
77        let _ = lines.next(); // opening ---
78        let mut yaml_block = String::new();
79        let mut consumed = 0usize;
80        for line in lines {
81            consumed += line.len() + 1;
82            if line.trim() == "---" || line.trim() == "..." {
83                break;
84            }
85            yaml_block.push_str(line);
86            yaml_block.push('\n');
87        }
88
89        let header_len = if let Some(idx) = trimmed.find("\n---\n") {
90            idx + 5
91        } else if let Some(idx) = trimmed.find("\n...\n") {
92            idx + 5
93        } else {
94            4 + consumed
95        };
96
97        let body = trimmed
98            .get(header_len..)
99            .unwrap_or_default()
100            .trim()
101            .to_string();
102
103        if yaml_block.trim().is_empty() {
104            (None, body)
105        } else {
106            (
107                serde_yaml::from_str::<serde_yaml::Value>(&yaml_block).ok(),
108                body,
109            )
110        }
111    }
112
113    fn str_seq(value: Option<&serde_yaml::Value>) -> Vec<String> {
114        match value {
115            Some(serde_yaml::Value::Sequence(items)) => items
116                .iter()
117                .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
118                .filter(|s| !s.is_empty())
119                .collect(),
120            _ => Vec::new(),
121        }
122    }
123
124    fn normalize_skill_markdown(
125        raw: &str,
126        source_repo: &str,
127        git_ref: &str,
128        skill_name: &str,
129        target_name: &str,
130    ) -> String {
131        let (fm, body) = Self::split_frontmatter(raw);
132        let fm = fm.unwrap_or(serde_yaml::Value::Null);
133
134        let get = |k: &str| fm.get(k).and_then(|v| v.as_str()).unwrap_or("");
135
136        let description = if !get("description").trim().is_empty() {
137            get("description").trim().to_string()
138        } else {
139            format!("Synced from {source_repo} skill '{skill_name}' and adapted for MicroClaw.")
140        };
141
142        let mut platforms = Self::str_seq(fm.get("platforms"));
143        if platforms.is_empty() {
144            platforms = Self::str_seq(fm.get("compatibility").and_then(|c| c.get("os")));
145        }
146
147        let mut deps = Self::str_seq(fm.get("deps"));
148        if deps.is_empty() {
149            deps = Self::str_seq(fm.get("compatibility").and_then(|c| c.get("deps")));
150        }
151
152        let mut frontmatter = vec![
153            "---".to_string(),
154            format!("name: {}", target_name),
155            format!("description: {}", description),
156            format!("source: remote:{}", source_repo),
157            format!("version: {}", git_ref),
158            format!("updated_at: {}", Utc::now().to_rfc3339()),
159            "license: Proprietary. LICENSE.txt has complete terms".to_string(),
160        ];
161
162        if !platforms.is_empty() {
163            frontmatter.push("platforms:".to_string());
164            for p in platforms {
165                frontmatter.push(format!("  - {}", p));
166            }
167        }
168        if !deps.is_empty() {
169            frontmatter.push("deps:".to_string());
170            for d in deps {
171                frontmatter.push(format!("  - {}", d));
172            }
173        }
174
175        frontmatter.push("---".to_string());
176        frontmatter.push(String::new());
177        if body.is_empty() {
178            frontmatter.push(format!(
179                "# {}\n\nSynced from `{}` (`{}`).",
180                target_name, source_repo, git_ref
181            ));
182        } else {
183            frontmatter.push(body);
184        }
185
186        frontmatter.join("\n")
187    }
188
189    /// Parse a skill reference that may contain a full GitHub URL or owner/repo/skill path.
190    /// Returns (source_repo, skill_name, git_ref_override).
191    fn parse_skill_reference(raw: &str) -> Option<(String, String, Option<String>)> {
192        let raw = raw.trim();
193
194        // Handle full GitHub URLs:
195        // https://github.com/owner/repo/tree/branch/skills/skill-name
196        // https://github.com/owner/repo/tree/branch/skill-name
197        if let Some(rest) = raw
198            .strip_prefix("https://github.com/")
199            .or_else(|| raw.strip_prefix("http://github.com/"))
200        {
201            let parts: Vec<&str> = rest.splitn(5, '/').collect();
202            if parts.len() >= 4 && parts[2] == "tree" {
203                let repo = format!("{}/{}", parts[0], parts[1]);
204                let branch = parts[3].to_string();
205                let skill_path = parts.get(4).unwrap_or(&"").to_string();
206                // Strip leading "skills/" prefix if present
207                let skill = skill_path
208                    .strip_prefix("skills/")
209                    .unwrap_or(&skill_path)
210                    .trim_end_matches('/')
211                    .to_string();
212                if !skill.is_empty() {
213                    return Some((repo, skill, Some(branch)));
214                }
215            }
216        }
217
218        // Handle raw.githubusercontent.com URLs
219        if let Some(rest) = raw.strip_prefix("https://raw.githubusercontent.com/") {
220            let parts: Vec<&str> = rest.splitn(4, '/').collect();
221            if parts.len() >= 4 {
222                let repo = format!("{}/{}", parts[0], parts[1]);
223                let branch = parts[2].to_string();
224                let skill_path = parts[3]
225                    .strip_prefix("skills/")
226                    .unwrap_or(parts[3])
227                    .trim_end_matches("/SKILL.md")
228                    .trim_end_matches(".md")
229                    .to_string();
230                if !skill_path.is_empty() {
231                    return Some((repo, skill_path, Some(branch)));
232                }
233            }
234        }
235
236        // Handle owner/repo/skill patterns (3+ segments with no source_repo override):
237        // "omer-metin/skills-for-antigravity/viral-hooks" -> repo=omer-metin/skills-for-antigravity, skill=viral-hooks
238        let segments: Vec<&str> = raw.split('/').collect();
239        if segments.len() >= 3 {
240            let repo = format!("{}/{}", segments[0], segments[1]);
241            let skill = segments[2..].join("/");
242            return Some((repo, skill, None));
243        }
244
245        None
246    }
247}
248
249#[async_trait]
250impl Tool for SyncSkillsTool {
251    fn name(&self) -> &str {
252        "sync_skills"
253    }
254
255    fn definition(&self) -> ToolDefinition {
256        ToolDefinition {
257            name: "sync_skills".into(),
258            description: "Sync a skill from a GitHub repository into local ~/.microclaw/skills (or configured skills dir). Use ONLY for skills from GitHub repos (owner/repo format or full GitHub URL). For ClawHub skills, use clawhub_install instead. Auto-detects the source repo from the path.".into(),
259            input_schema: schema_object(
260                json!({
261                    "skill_name": {
262                        "type": "string",
263                        "description": "Skill name, owner/repo/skill path, or full GitHub URL"
264                    },
265                    "target_name": {
266                        "type": "string",
267                        "description": "Optional local skill directory/name (defaults to last segment of skill_name)"
268                    },
269                    "source_repo": {
270                        "type": "string",
271                        "description": "GitHub repo in owner/name format (auto-detected from skill_name if not provided)"
272                    },
273                    "git_ref": {
274                        "type": "string",
275                        "description": "Branch/tag/commit (default: main)"
276                    }
277                }),
278                &["skill_name"],
279            ),
280        }
281    }
282
283    async fn execute(&self, input: serde_json::Value) -> ToolResult {
284        let raw_skill_name = match input.get("skill_name").and_then(|v| v.as_str()) {
285            Some(v) if !v.trim().is_empty() => v.trim(),
286            _ => return ToolResult::error("Missing required parameter: skill_name".into()),
287        };
288
289        let explicit_repo = input
290            .get("source_repo")
291            .and_then(|v| v.as_str())
292            .filter(|v| !v.trim().is_empty());
293
294        let explicit_ref = input
295            .get("git_ref")
296            .and_then(|v| v.as_str())
297            .filter(|v| !v.trim().is_empty());
298
299        // Auto-detect repo from skill_name if no explicit source_repo was given
300        let (source_repo, skill_name, git_ref) = if let Some(repo) = explicit_repo {
301            (
302                repo.trim().to_string(),
303                raw_skill_name.to_string(),
304                explicit_ref.unwrap_or("main").to_string(),
305            )
306        } else if let Some((repo, skill, ref_override)) =
307            Self::parse_skill_reference(raw_skill_name)
308        {
309            let git_ref = explicit_ref
310                .map(|s| s.to_string())
311                .or(ref_override)
312                .unwrap_or_else(|| "main".to_string());
313            (repo, skill, git_ref)
314        } else {
315            (
316                "vercel-labs/skills".to_string(),
317                raw_skill_name.to_string(),
318                explicit_ref.unwrap_or("main").to_string(),
319            )
320        };
321
322        let target_name = input
323            .get("target_name")
324            .and_then(|v| v.as_str())
325            .filter(|v| !v.trim().is_empty())
326            .map(|s| s.trim().to_string())
327            .unwrap_or_else(|| {
328                // Use just the last segment as the target name
329                skill_name
330                    .rsplit('/')
331                    .next()
332                    .unwrap_or(&skill_name)
333                    .to_string()
334            });
335
336        let raw = match Self::fetch_skill_content(&source_repo, &skill_name, &git_ref).await {
337            Ok(v) => v,
338            Err(e) => return ToolResult::error(e).with_error_type("sync_fetch_failed"),
339        };
340
341        let normalized =
342            Self::normalize_skill_markdown(&raw, &source_repo, &git_ref, &skill_name, &target_name);
343
344        let out_dir = self.skills_dir.join(&target_name);
345        if let Err(e) = std::fs::create_dir_all(&out_dir) {
346            return ToolResult::error(format!("Failed to create skill directory: {e}"))
347                .with_error_type("sync_write_failed");
348        }
349
350        let out_file = out_dir.join("SKILL.md");
351        if let Err(e) = std::fs::write(&out_file, normalized) {
352            return ToolResult::error(format!("Failed to write SKILL.md: {e}"))
353                .with_error_type("sync_write_failed");
354        }
355
356        ToolResult::success(format!(
357            "Skill synced: {} -> {}\nSource: {}@{}\nPath: {}",
358            skill_name,
359            target_name,
360            source_repo,
361            git_ref,
362            out_file.display()
363        ))
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use serde_json::json;
371
372    #[test]
373    fn test_sync_skills_definition() {
374        let tool = SyncSkillsTool::new("/tmp/skills");
375        assert_eq!(tool.name(), "sync_skills");
376        let def = tool.definition();
377        assert_eq!(def.name, "sync_skills");
378        assert!(def.input_schema["properties"]["skill_name"].is_object());
379    }
380
381    #[tokio::test]
382    async fn test_sync_skills_missing_name() {
383        let tool = SyncSkillsTool::new("/tmp/skills");
384        let result = tool.execute(json!({})).await;
385        assert!(result.is_error);
386        assert!(result.content.contains("skill_name"));
387    }
388
389    #[test]
390    fn test_parse_skill_reference_github_url() {
391        let result = SyncSkillsTool::parse_skill_reference(
392            "https://github.com/omer-metin/skills-for-antigravity/tree/main/skills/viral-hooks",
393        );
394        let (repo, skill, git_ref) = result.unwrap();
395        assert_eq!(repo, "omer-metin/skills-for-antigravity");
396        assert_eq!(skill, "viral-hooks");
397        assert_eq!(git_ref.unwrap(), "main");
398    }
399
400    #[test]
401    fn test_parse_skill_reference_owner_repo_skill() {
402        let result =
403            SyncSkillsTool::parse_skill_reference("omer-metin/skills-for-antigravity/viral-hooks");
404        let (repo, skill, git_ref) = result.unwrap();
405        assert_eq!(repo, "omer-metin/skills-for-antigravity");
406        assert_eq!(skill, "viral-hooks");
407        assert!(git_ref.is_none());
408    }
409
410    #[test]
411    fn test_parse_skill_reference_simple_name() {
412        // Two segments or less should return None (use default repo)
413        assert!(SyncSkillsTool::parse_skill_reference("viral-hooks").is_none());
414        assert!(SyncSkillsTool::parse_skill_reference("find-skills").is_none());
415    }
416
417    #[test]
418    fn test_normalize_skill_markdown_adds_source_fields() {
419        let raw = "# Demo\n\nBody";
420        let out = SyncSkillsTool::normalize_skill_markdown(
421            raw,
422            "vercel-labs/skills",
423            "main",
424            "demo",
425            "demo",
426        );
427        assert!(out.contains("source: remote:vercel-labs/skills"));
428        assert!(out.contains("version: main"));
429        assert!(out.contains("updated_at:"));
430    }
431}