1use anyhow::{Context, Result};
10use git2::{FetchOptions, RemoteCallbacks, Repository};
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use tracing::{debug, info, warn};
15
16use crate::git_source::GitSource;
17
18#[derive(Debug, Clone, PartialEq)]
20pub enum SkillType {
21 PrebuiltWasm(PathBuf),
23 JavaScript(PathBuf),
25 TypeScript(PathBuf),
27 Rust,
29 Python(PathBuf),
31 Unknown,
33}
34
35impl std::fmt::Display for SkillType {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 SkillType::PrebuiltWasm(_) => write!(f, "Pre-built WASM"),
39 SkillType::JavaScript(_) => write!(f, "JavaScript"),
40 SkillType::TypeScript(_) => write!(f, "TypeScript"),
41 SkillType::Rust => write!(f, "Rust"),
42 SkillType::Python(_) => write!(f, "Python"),
43 SkillType::Unknown => write!(f, "Unknown"),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct ClonedSkill {
51 pub source: GitSource,
53 pub local_path: PathBuf,
55 pub skill_type: SkillType,
57 pub skill_name: String,
59 pub version: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct SourceCache {
66 pub entries: std::collections::HashMap<String, SourceCacheEntry>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct SourceCacheEntry {
71 pub url: String,
72 pub git_ref: String,
73 pub commit: String,
74 pub cloned_at: chrono::DateTime<chrono::Utc>,
75 pub skill_name: String,
76}
77
78pub struct GitSkillLoader {
80 sources_dir: PathBuf,
82 cache_path: PathBuf,
84}
85
86impl GitSkillLoader {
87 pub fn new() -> Result<Self> {
89 let home = dirs::home_dir().context("Failed to get home directory")?;
90 let base_dir = home.join(".skill-engine");
91 let sources_dir = base_dir.join("sources");
92 let cache_path = base_dir.join("sources.json");
93
94 std::fs::create_dir_all(&sources_dir)
95 .with_context(|| format!("Failed to create sources directory: {}", sources_dir.display()))?;
96
97 Ok(Self {
98 sources_dir,
99 cache_path,
100 })
101 }
102
103 pub fn get_repo_dir(&self, source: &GitSource) -> PathBuf {
105 self.sources_dir.join(&source.owner).join(&source.repo)
106 }
107
108 pub fn is_cloned(&self, source: &GitSource) -> bool {
110 self.get_repo_dir(source).join(".git").exists()
111 }
112
113 pub async fn clone_skill(&self, source: &GitSource, force: bool) -> Result<ClonedSkill> {
115 let repo_dir = self.get_repo_dir(source);
116
117 if force && repo_dir.exists() {
118 info!(path = %repo_dir.display(), "Force flag set, removing existing clone");
119 std::fs::remove_dir_all(&repo_dir)?;
120 }
121
122 if repo_dir.join(".git").exists() {
124 info!(
125 repo = %source.repo,
126 path = %repo_dir.display(),
127 "Repository already cloned, checking ref..."
128 );
129 self.checkout_ref(&repo_dir, source)?;
130 } else {
131 info!(
132 url = %source.url,
133 path = %repo_dir.display(),
134 "Cloning repository..."
135 );
136 self.clone_repo(source, &repo_dir)?;
137 }
138
139 let skill_type = self.detect_skill_type(&repo_dir)?;
141 info!(skill_type = %skill_type, "Detected skill type");
142
143 let (skill_name, version) = self.extract_metadata(&repo_dir, source)?;
145
146 self.update_cache(source, &repo_dir, &skill_name)?;
148
149 Ok(ClonedSkill {
150 source: source.clone(),
151 local_path: repo_dir,
152 skill_type,
153 skill_name,
154 version,
155 })
156 }
157
158 pub async fn build_skill(&self, cloned: &ClonedSkill) -> Result<PathBuf> {
160 match &cloned.skill_type {
161 SkillType::PrebuiltWasm(path) => {
162 info!(path = %path.display(), "Using pre-built WASM");
163 Ok(path.clone())
164 }
165 SkillType::JavaScript(entry) => {
166 self.build_js_skill(&cloned.local_path, entry, false).await
167 }
168 SkillType::TypeScript(entry) => {
169 self.build_js_skill(&cloned.local_path, entry, true).await
170 }
171 SkillType::Rust => self.build_rust_skill(&cloned.local_path).await,
172 SkillType::Python(entry) => {
173 self.build_python_skill(&cloned.local_path, entry).await
174 }
175 SkillType::Unknown => {
176 anyhow::bail!(
177 "Cannot determine how to build this skill.\n\
178 Expected one of:\n\
179 - skill.wasm (pre-built)\n\
180 - Cargo.toml (Rust)\n\
181 - package.json + *.ts/*.js (JavaScript/TypeScript)\n\
182 - pyproject.toml + *.py (Python)"
183 )
184 }
185 }
186 }
187
188 pub fn remove_source(&self, source: &GitSource) -> Result<()> {
190 let repo_dir = self.get_repo_dir(source);
191 if repo_dir.exists() {
192 std::fs::remove_dir_all(&repo_dir)?;
193 info!(path = %repo_dir.display(), "Removed cloned repository");
194 }
195 Ok(())
196 }
197
198 fn clone_repo(&self, source: &GitSource, dest: &Path) -> Result<()> {
201 std::fs::create_dir_all(dest.parent().unwrap())?;
202
203 let mut callbacks = RemoteCallbacks::new();
205 callbacks.transfer_progress(|progress| {
206 debug!(
207 "Receiving objects: {}/{}",
208 progress.received_objects(),
209 progress.total_objects()
210 );
211 true
212 });
213
214 let mut fetch_options = FetchOptions::new();
215 fetch_options.remote_callbacks(callbacks);
216
217 let mut builder = git2::build::RepoBuilder::new();
219 builder.fetch_options(fetch_options);
220
221 let repo = builder
222 .clone(&source.url, dest)
223 .with_context(|| format!("Failed to clone repository: {}", source.url))?;
224
225 if let Some(refspec) = source.git_ref.as_refspec() {
227 self.checkout_ref_in_repo(&repo, refspec)?;
228 }
229
230 Ok(())
231 }
232
233 fn checkout_ref(&self, repo_dir: &Path, source: &GitSource) -> Result<()> {
234 let repo = Repository::open(repo_dir)
235 .with_context(|| format!("Failed to open repository: {}", repo_dir.display()))?;
236
237 if !source.git_ref.is_pinned() {
239 debug!("Fetching updates from origin...");
240 let mut remote = repo.find_remote("origin")?;
241 remote.fetch(&["refs/heads/*:refs/heads/*"], None, None)?;
242 }
243
244 if let Some(refspec) = source.git_ref.as_refspec() {
245 self.checkout_ref_in_repo(&repo, refspec)?;
246 }
247
248 Ok(())
249 }
250
251 fn checkout_ref_in_repo(&self, repo: &Repository, refspec: &str) -> Result<()> {
252 info!(refspec = %refspec, "Checking out ref");
253
254 let reference = repo
256 .resolve_reference_from_short_name(refspec)
257 .or_else(|_| repo.find_reference(&format!("refs/tags/{}", refspec)))
258 .or_else(|_| repo.find_reference(&format!("refs/heads/{}", refspec)))
259 .with_context(|| format!("Could not find ref: {}", refspec))?;
260
261 let commit = reference.peel_to_commit()?;
262
263 repo.checkout_tree(commit.as_object(), None)?;
265 repo.set_head_detached(commit.id())?;
266
267 Ok(())
268 }
269
270 fn detect_skill_type(&self, repo_dir: &Path) -> Result<SkillType> {
271 let wasm_candidates = [
275 repo_dir.join("skill.wasm"),
276 repo_dir.join("dist/skill.wasm"),
277 repo_dir.join("build/skill.wasm"),
278 ];
279 for candidate in &wasm_candidates {
280 if candidate.exists() {
281 return Ok(SkillType::PrebuiltWasm(candidate.clone()));
282 }
283 }
284
285 let cargo_toml = repo_dir.join("Cargo.toml");
287 if cargo_toml.exists() {
288 let content = std::fs::read_to_string(&cargo_toml)?;
289 if content.contains("cdylib") || content.contains("wasm32") || content.contains("wasm") {
291 return Ok(SkillType::Rust);
292 }
293 }
294
295 let package_json = repo_dir.join("package.json");
297 if package_json.exists() {
298 let ts_candidates = [
300 repo_dir.join("skill.ts"),
301 repo_dir.join("src/skill.ts"),
302 repo_dir.join("src/index.ts"),
303 repo_dir.join("index.ts"),
304 ];
305 for candidate in ts_candidates {
306 if candidate.exists() {
307 return Ok(SkillType::TypeScript(candidate));
308 }
309 }
310
311 let js_candidates = [
313 repo_dir.join("skill.js"),
314 repo_dir.join("src/skill.js"),
315 repo_dir.join("src/index.js"),
316 repo_dir.join("index.js"),
317 ];
318 for candidate in js_candidates {
319 if candidate.exists() {
320 return Ok(SkillType::JavaScript(candidate));
321 }
322 }
323 }
324
325 let has_python_config =
327 repo_dir.join("pyproject.toml").exists() || repo_dir.join("requirements.txt").exists();
328 if has_python_config {
329 let py_candidates = [
330 repo_dir.join("skill.py"),
331 repo_dir.join("src/main.py"),
332 repo_dir.join("main.py"),
333 repo_dir.join("src/skill.py"),
334 ];
335 for candidate in py_candidates {
336 if candidate.exists() {
337 return Ok(SkillType::Python(candidate));
338 }
339 }
340 }
341
342 Ok(SkillType::Unknown)
343 }
344
345 fn extract_metadata(
346 &self,
347 repo_dir: &Path,
348 source: &GitSource,
349 ) -> Result<(String, Option<String>)> {
350 let skill_yaml_path = repo_dir.join("skill.yaml");
352 if skill_yaml_path.exists() {
353 let contents = std::fs::read_to_string(&skill_yaml_path)?;
354 if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) {
355 let name = yaml["name"]
356 .as_str()
357 .unwrap_or(&source.repo)
358 .to_string();
359 let version = yaml["version"].as_str().map(|s| s.to_string());
360 return Ok((name, version));
361 }
362 }
363
364 let skill_md_path = repo_dir.join("SKILL.md");
366 if skill_md_path.exists() {
367 let contents = std::fs::read_to_string(&skill_md_path)?;
368 if let Some(frontmatter) = extract_yaml_frontmatter(&contents) {
369 if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
370 let name = yaml["name"]
371 .as_str()
372 .unwrap_or(&source.repo)
373 .to_string();
374 let version = yaml["version"].as_str().map(|s| s.to_string());
375 return Ok((name, version));
376 }
377 }
378 }
379
380 let package_json_path = repo_dir.join("package.json");
382 if package_json_path.exists() {
383 let contents = std::fs::read_to_string(&package_json_path)?;
384 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
385 let name = json["name"]
386 .as_str()
387 .unwrap_or(&source.repo)
388 .to_string();
389 let version = json["version"].as_str().map(|s| s.to_string());
390 return Ok((name, version));
391 }
392 }
393
394 let cargo_toml_path = repo_dir.join("Cargo.toml");
396 if cargo_toml_path.exists() {
397 let contents = std::fs::read_to_string(&cargo_toml_path)?;
398 if let Ok(toml) = toml::from_str::<toml::Value>(&contents) {
399 if let Some(package) = toml.get("package") {
400 let name = package["name"]
401 .as_str()
402 .unwrap_or(&source.repo)
403 .to_string();
404 let version = package["version"].as_str().map(|s| s.to_string());
405 return Ok((name, version));
406 }
407 }
408 }
409
410 Ok((source.repo.clone(), None))
412 }
413
414 fn update_cache(
415 &self,
416 source: &GitSource,
417 repo_dir: &Path,
418 skill_name: &str,
419 ) -> Result<()> {
420 let mut cache = self.load_cache();
421
422 let commit = if let Ok(repo) = Repository::open(repo_dir) {
424 repo.head()
425 .ok()
426 .and_then(|h| h.peel_to_commit().ok())
427 .map(|c| c.id().to_string())
428 .unwrap_or_default()
429 } else {
430 String::new()
431 };
432
433 cache.entries.insert(
434 source.cache_key(),
435 SourceCacheEntry {
436 url: source.url.clone(),
437 git_ref: source.git_ref.to_string(),
438 commit,
439 cloned_at: chrono::Utc::now(),
440 skill_name: skill_name.to_string(),
441 },
442 );
443
444 self.save_cache(&cache)?;
445 Ok(())
446 }
447
448 fn load_cache(&self) -> SourceCache {
449 std::fs::read_to_string(&self.cache_path)
450 .ok()
451 .and_then(|s| serde_json::from_str(&s).ok())
452 .unwrap_or_default()
453 }
454
455 fn save_cache(&self, cache: &SourceCache) -> Result<()> {
456 let content = serde_json::to_string_pretty(cache)?;
457 std::fs::write(&self.cache_path, content)?;
458 Ok(())
459 }
460
461 async fn build_js_skill(
462 &self,
463 repo_dir: &Path,
464 entry: &Path,
465 _is_typescript: bool,
466 ) -> Result<PathBuf> {
467 info!(entry = %entry.display(), "Building JavaScript/TypeScript skill");
468
469 if !repo_dir.join("node_modules").exists() {
471 info!("Installing npm dependencies...");
472 let status = Command::new("npm")
473 .args(["install"])
474 .current_dir(repo_dir)
475 .status()
476 .context("Failed to run npm install. Is npm installed?")?;
477
478 if !status.success() {
479 anyhow::bail!("npm install failed");
480 }
481 }
482
483 let package_json: serde_json::Value = serde_json::from_str(
485 &std::fs::read_to_string(repo_dir.join("package.json"))?,
486 )?;
487
488 if package_json
490 .get("scripts")
491 .and_then(|s| s.get("build"))
492 .is_some()
493 {
494 info!("Running npm build...");
495 let status = Command::new("npm")
496 .args(["run", "build"])
497 .current_dir(repo_dir)
498 .status()?;
499
500 if !status.success() {
501 warn!("npm build failed, attempting direct componentize");
502 }
503 }
504
505 if package_json
507 .get("scripts")
508 .and_then(|s| s.get("componentize"))
509 .is_some()
510 {
511 info!("Running componentize script...");
512 let status = Command::new("npm")
513 .args(["run", "componentize"])
514 .current_dir(repo_dir)
515 .status()?;
516
517 if status.success() {
518 let wasm_candidates = [
520 repo_dir.join("skill.wasm"),
521 repo_dir.join("dist/skill.wasm"),
522 ];
523 for candidate in wasm_candidates {
524 if candidate.exists() {
525 return Ok(candidate);
526 }
527 }
528 }
529 }
530
531 let output_wasm = repo_dir.join("skill.wasm");
533
534 info!("Running jco componentize...");
535 let status = Command::new("npx")
536 .args([
537 "@bytecodealliance/jco",
538 "componentize",
539 entry.to_str().unwrap(),
540 "-o",
541 output_wasm.to_str().unwrap(),
542 ])
543 .current_dir(repo_dir)
544 .status()
545 .context("Failed to run jco componentize. Is jco installed?")?;
546
547 if !status.success() {
548 anyhow::bail!("jco componentize failed");
549 }
550
551 Ok(output_wasm)
552 }
553
554 async fn build_rust_skill(&self, repo_dir: &Path) -> Result<PathBuf> {
555 info!("Building Rust skill...");
556
557 let status = Command::new("cargo")
558 .args(["build", "--release", "--target", "wasm32-wasip1"])
559 .current_dir(repo_dir)
560 .status()
561 .context("Failed to run cargo build. Is cargo and wasm32-wasip1 target installed?")?;
562
563 if !status.success() {
564 anyhow::bail!(
565 "cargo build failed. Make sure you have the wasm32-wasip1 target:\n\
566 rustup target add wasm32-wasip1"
567 );
568 }
569
570 let target_dir = repo_dir.join("target/wasm32-wasip1/release");
572 for entry in std::fs::read_dir(&target_dir)? {
573 let entry = entry?;
574 let path = entry.path();
575 if path.extension().map_or(false, |e| e == "wasm") {
576 info!(wasm = %path.display(), "Found compiled WASM");
577 return Ok(path);
578 }
579 }
580
581 anyhow::bail!(
582 "No .wasm file found in target/wasm32-wasip1/release/\n\
583 Make sure Cargo.toml has crate-type = [\"cdylib\"]"
584 )
585 }
586
587 async fn build_python_skill(&self, repo_dir: &Path, entry: &Path) -> Result<PathBuf> {
588 info!(entry = %entry.display(), "Building Python skill");
589
590 let output_wasm = repo_dir.join("skill.wasm");
591
592 let wit_candidates = [
594 repo_dir.join("skill.wit"),
595 repo_dir.join("wit/skill.wit"),
596 repo_dir.join("skill-interface.wit"),
597 ];
598
599 let wit_path = wit_candidates
600 .iter()
601 .find(|p| p.exists())
602 .context("No WIT interface file found. Expected skill.wit or wit/skill.wit")?;
603
604 let status = Command::new("componentize-py")
605 .args([
606 "-d",
607 wit_path.to_str().unwrap(),
608 "-w",
609 "skill",
610 "componentize",
611 entry.to_str().unwrap(),
612 "-o",
613 output_wasm.to_str().unwrap(),
614 ])
615 .current_dir(repo_dir)
616 .status()
617 .context("Failed to run componentize-py. Install it with: pip install componentize-py")?;
618
619 if !status.success() {
620 anyhow::bail!("componentize-py failed");
621 }
622
623 Ok(output_wasm)
624 }
625}
626
627impl Default for GitSkillLoader {
628 fn default() -> Self {
629 Self::new().expect("Failed to create GitSkillLoader")
630 }
631}
632
633fn extract_yaml_frontmatter(content: &str) -> Option<&str> {
634 if !content.starts_with("---") {
635 return None;
636 }
637 let rest = &content[3..];
638 let end = rest.find("---")?;
639 Some(rest[..end].trim())
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn test_skill_type_display() {
648 assert_eq!(format!("{}", SkillType::Rust), "Rust");
649 assert_eq!(
650 format!("{}", SkillType::PrebuiltWasm(PathBuf::from("test.wasm"))),
651 "Pre-built WASM"
652 );
653 }
654
655 #[test]
656 fn test_extract_yaml_frontmatter() {
657 let content = "---\nname: test\nversion: 1.0\n---\n\n# Test";
658 let fm = extract_yaml_frontmatter(content);
659 assert!(fm.is_some());
660 assert!(fm.unwrap().contains("name: test"));
661 }
662
663 #[test]
664 fn test_no_frontmatter() {
665 let content = "# Just markdown\n\nNo frontmatter here.";
666 assert!(extract_yaml_frontmatter(content).is_none());
667 }
668}