skill_runtime/
git_loader.rs

1// Git Skill Loader - Clone and build skills from Git repositories
2//
3// Supports:
4// - Cloning via git2 (pure Rust, no CLI dependency)
5// - Auto-detection of skill type (Rust, JS/TS, Python, pre-built WASM)
6// - Caching cloned repositories for fast subsequent access
7// - Version pinning via tags, branches, or commits
8
9use 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/// Skill type detected from repository structure
19#[derive(Debug, Clone, PartialEq)]
20pub enum SkillType {
21    /// Pre-built WASM component (no build needed)
22    PrebuiltWasm(PathBuf),
23    /// JavaScript skill (needs jco componentize)
24    JavaScript(PathBuf),
25    /// TypeScript skill (needs tsc + jco)
26    TypeScript(PathBuf),
27    /// Rust skill (needs cargo build --target wasm32-wasip1)
28    Rust,
29    /// Python skill (needs componentize-py)
30    Python(PathBuf),
31    /// Unknown - cannot determine how to build
32    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/// Metadata about a cloned skill repository
49#[derive(Debug, Clone)]
50pub struct ClonedSkill {
51    /// Original Git source
52    pub source: GitSource,
53    /// Local path to cloned repository
54    pub local_path: PathBuf,
55    /// Detected skill type
56    pub skill_type: SkillType,
57    /// Skill name (from manifest or repo name)
58    pub skill_name: String,
59    /// Skill version (if found in manifest)
60    pub version: Option<String>,
61}
62
63/// Cache metadata for tracking cloned repositories
64#[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
78/// Loads skills from Git repositories
79pub struct GitSkillLoader {
80    /// Directory for cloned repositories
81    sources_dir: PathBuf,
82    /// Cache file path
83    cache_path: PathBuf,
84}
85
86impl GitSkillLoader {
87    /// Create a new GitSkillLoader
88    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    /// Get the directory for a cloned repo
104    pub fn get_repo_dir(&self, source: &GitSource) -> PathBuf {
105        self.sources_dir.join(&source.owner).join(&source.repo)
106    }
107
108    /// Check if a repo is already cloned
109    pub fn is_cloned(&self, source: &GitSource) -> bool {
110        self.get_repo_dir(source).join(".git").exists()
111    }
112
113    /// Clone or update a Git repository and prepare for loading
114    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        // Clone or update
123        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        // Detect skill type
140        let skill_type = self.detect_skill_type(&repo_dir)?;
141        info!(skill_type = %skill_type, "Detected skill type");
142
143        // Extract metadata
144        let (skill_name, version) = self.extract_metadata(&repo_dir, source)?;
145
146        // Update cache
147        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    /// Build the skill if necessary and return the WASM component path
159    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    /// Remove a cloned repository
189    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    // --- Private methods ---
199
200    fn clone_repo(&self, source: &GitSource, dest: &Path) -> Result<()> {
201        std::fs::create_dir_all(dest.parent().unwrap())?;
202
203        // Set up callbacks for progress
204        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        // Clone the repository
218        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        // Checkout specific ref if not default branch
226        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        // Fetch updates if not a pinned ref
238        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        // Try to find the reference
255        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        // Checkout the commit
264        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        // Priority order for detection
272
273        // 1. Pre-built WASM
274        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        // 2. Check for Cargo.toml (Rust)
286        let cargo_toml = repo_dir.join("Cargo.toml");
287        if cargo_toml.exists() {
288            let content = std::fs::read_to_string(&cargo_toml)?;
289            // Check if it's likely a WASM project
290            if content.contains("cdylib") || content.contains("wasm32") || content.contains("wasm") {
291                return Ok(SkillType::Rust);
292            }
293        }
294
295        // 3. Check for package.json (JS/TS)
296        let package_json = repo_dir.join("package.json");
297        if package_json.exists() {
298            // Look for TypeScript first
299            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            // Then JavaScript
312            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        // 4. Check for Python (pyproject.toml or requirements.txt + main.py)
326        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        // Try to read skill.yaml
351        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        // Try SKILL.md frontmatter
365        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        // Try package.json
381        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        // Try Cargo.toml
395        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        // Fall back to repo name
411        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        // Get current commit
423        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        // Install dependencies if node_modules doesn't exist
470        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        // Check if there's a build script
484        let package_json: serde_json::Value = serde_json::from_str(
485            &std::fs::read_to_string(repo_dir.join("package.json"))?,
486        )?;
487
488        // Run build if available
489        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        // Check for componentize script
506        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                // Look for output WASM
519                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        // Direct componentize with jco
532        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        // Find the output WASM
571        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        // Find WIT file
593        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}