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(); 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 fn parse_skill_reference(raw: &str) -> Option<(String, String, Option<String>)> {
192 let raw = raw.trim();
193
194 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 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 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 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 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 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 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}