skill_runtime/
local_loader.rs

1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use wasmtime::component::Component;
5
6use crate::engine::SkillEngine;
7use crate::skill_md::{find_skill_md, parse_skill_md, SkillMdContent};
8
9/// Loads skills from local directories with automatic compilation
10pub struct LocalSkillLoader {
11    cache_dir: PathBuf,
12}
13
14impl LocalSkillLoader {
15    pub fn new() -> Result<Self> {
16        let home = dirs::home_dir().context("Failed to get home directory")?;
17        let cache_dir = home.join(".skill-engine").join("local-cache");
18        std::fs::create_dir_all(&cache_dir)?;
19
20        Ok(Self { cache_dir })
21    }
22
23    /// Load a skill from a local directory
24    /// Supports:
25    /// - Pre-compiled .wasm files
26    /// - skill.js files (compiled on-demand)
27    /// - skill.ts files (compiled on-demand)
28    pub async fn load_skill(
29        &self,
30        skill_path: impl AsRef<Path>,
31        engine: &SkillEngine,
32    ) -> Result<Component> {
33        let skill_path = skill_path.as_ref();
34
35        tracing::info!(path = %skill_path.display(), "Loading local skill");
36
37        // Check if it's a direct .wasm file
38        if skill_path.extension().map_or(false, |ext| ext == "wasm") {
39            return engine.load_component(skill_path).await;
40        }
41
42        // Check if it's a directory
43        if skill_path.is_dir() {
44            return self.load_from_directory(skill_path, engine).await;
45        }
46
47        // Check if it's a .js or .ts file
48        if let Some(ext) = skill_path.extension() {
49            if ext == "js" || ext == "ts" {
50                return self.compile_and_load(skill_path, engine).await;
51            }
52        }
53
54        anyhow::bail!("Unsupported skill format: {}", skill_path.display());
55    }
56
57    /// Load skill from a directory
58    /// Searches for: skill.wasm, skill.js, skill.ts, index.js, index.ts
59    async fn load_from_directory(
60        &self,
61        dir: &Path,
62        engine: &SkillEngine,
63    ) -> Result<Component> {
64        tracing::debug!(dir = %dir.display(), "Loading from directory");
65
66        // Priority order: pre-compiled WASM, then source files
67        let candidates = vec![
68            dir.join("skill.wasm"),
69            dir.join("dist/skill.wasm"),
70            dir.join("skill.js"),
71            dir.join("skill.ts"),
72            dir.join("index.js"),
73            dir.join("index.ts"),
74            dir.join("src/index.js"),
75            dir.join("src/index.ts"),
76        ];
77
78        for candidate in candidates {
79            if candidate.exists() {
80                tracing::info!(file = %candidate.display(), "Found skill file");
81
82                if candidate.extension().map_or(false, |ext| ext == "wasm") {
83                    return engine.load_component(&candidate).await;
84                } else {
85                    return self.compile_and_load(&candidate, engine).await;
86                }
87            }
88        }
89
90        anyhow::bail!("No skill file found in directory: {}", dir.display());
91    }
92
93    /// Compile JavaScript/TypeScript to WASM and load
94    async fn compile_and_load(
95        &self,
96        source_file: &Path,
97        engine: &SkillEngine,
98    ) -> Result<Component> {
99        let source_abs = std::fs::canonicalize(source_file)
100            .with_context(|| format!("Failed to resolve path: {}", source_file.display()))?;
101
102        // Generate cache key from file path and modification time
103        let cache_key = self.generate_cache_key(&source_abs)?;
104        let cached_wasm = self.cache_dir.join(format!("{}.wasm", cache_key));
105
106        // Check if cached version exists and is up-to-date
107        if cached_wasm.exists() {
108            let cache_mtime = std::fs::metadata(&cached_wasm)?.modified()?;
109            let source_mtime = std::fs::metadata(&source_abs)?.modified()?;
110
111            if cache_mtime >= source_mtime {
112                tracing::info!(
113                    source = %source_abs.display(),
114                    cached = %cached_wasm.display(),
115                    "Using cached WASM"
116                );
117                return engine.load_component(&cached_wasm).await;
118            }
119        }
120
121        // Compile source to WASM
122        tracing::info!(source = %source_abs.display(), "Compiling to WASM");
123        self.compile_to_wasm(&source_abs, &cached_wasm).await?;
124
125        // Load compiled WASM
126        engine.load_component(&cached_wasm).await
127    }
128
129    /// Compile JavaScript/TypeScript to WASM using jco componentize
130    async fn compile_to_wasm(&self, source: &Path, output: &Path) -> Result<()> {
131        // Determine if TypeScript compilation is needed
132        let is_typescript = source.extension().map_or(false, |ext| ext == "ts");
133
134        let js_file = if is_typescript {
135            // Compile TypeScript to JavaScript first
136            let js_output = output.with_extension("js");
137            self.compile_typescript(source, &js_output)?;
138            js_output
139        } else {
140            source.to_path_buf()
141        };
142
143        // Find WIT interface file
144        let wit_file = self.find_wit_interface(source)?;
145
146        // Use jco componentize to create WASM component
147        tracing::info!(
148            js = %js_file.display(),
149            wit = %wit_file.display(),
150            output = %output.display(),
151            "Running jco componentize"
152        );
153
154        let status = Command::new("npx")
155            .args([
156                "-y",
157                "@bytecodealliance/jco",
158                "componentize",
159                js_file.to_str().unwrap(),
160                "-w",
161                wit_file.to_str().unwrap(),
162                "-o",
163                output.to_str().unwrap(),
164            ])
165            .status()
166            .context("Failed to run jco componentize. Is Node.js installed?")?;
167
168        if !status.success() {
169            anyhow::bail!("jco componentize failed with status: {}", status);
170        }
171
172        // Clean up temporary JS file if we compiled from TypeScript
173        if is_typescript {
174            let _ = std::fs::remove_file(&js_file);
175        }
176
177        tracing::info!(output = %output.display(), "Compilation successful");
178        Ok(())
179    }
180
181    /// Compile TypeScript to JavaScript
182    fn compile_typescript(&self, source: &Path, output: &Path) -> Result<()> {
183        tracing::info!(
184            source = %source.display(),
185            output = %output.display(),
186            "Compiling TypeScript"
187        );
188
189        let status = Command::new("npx")
190            .args([
191                "-y",
192                "typescript",
193                "tsc",
194                source.to_str().unwrap(),
195                "--outFile",
196                output.to_str().unwrap(),
197                "--target",
198                "ES2020",
199                "--module",
200                "ES2020",
201                "--moduleResolution",
202                "node",
203            ])
204            .status()
205            .context("Failed to run tsc. Is Node.js installed?")?;
206
207        if !status.success() {
208            anyhow::bail!("TypeScript compilation failed");
209        }
210
211        Ok(())
212    }
213
214    /// Find WIT interface file
215    /// Searches in: current dir, parent dir, project root
216    fn find_wit_interface(&self, source: &Path) -> Result<PathBuf> {
217        let source_dir = source.parent().context("No parent directory")?;
218
219        // Search locations (check both skill.wit and skill-interface.wit)
220        let candidates = vec![
221            source_dir.join("skill.wit"),
222            source_dir.join("skill-interface.wit"),
223            source_dir.join("../skill.wit"),
224            source_dir.join("../skill-interface.wit"),
225            source_dir.join("../wit/skill.wit"),
226            source_dir.join("../wit/skill-interface.wit"),
227            source_dir.join("../../wit/skill.wit"),
228            source_dir.join("../../wit/skill-interface.wit"),
229        ];
230
231        for candidate in candidates {
232            if let Ok(path) = std::fs::canonicalize(&candidate) {
233                if path.exists() {
234                    return Ok(path);
235                }
236            }
237        }
238
239        // Fall back to global WIT interface in skill-engine project
240        let home = dirs::home_dir().context("Failed to get home directory")?;
241        let global_candidates = vec![
242            home.join(".skill-engine/wit/skill.wit"),
243            home.join(".skill-engine/wit/skill-interface.wit"),
244        ];
245
246        for global_wit in global_candidates {
247            if global_wit.exists() {
248                return Ok(global_wit);
249            }
250        }
251
252        anyhow::bail!(
253            "WIT interface file not found. Searched near: {}",
254            source_dir.display()
255        );
256    }
257
258    /// Generate cache key from file path and content hash
259    fn generate_cache_key(&self, source: &Path) -> Result<String> {
260        use std::collections::hash_map::DefaultHasher;
261        use std::hash::{Hash, Hasher};
262
263        let mut hasher = DefaultHasher::new();
264        source.to_string_lossy().hash(&mut hasher);
265
266        // Include file modification time in hash
267        if let Ok(metadata) = std::fs::metadata(source) {
268            if let Ok(mtime) = metadata.modified() {
269                mtime.hash(&mut hasher);
270            }
271        }
272
273        Ok(format!("{:x}", hasher.finish()))
274    }
275
276    /// Clear the local cache
277    pub fn clear_cache(&self) -> Result<()> {
278        if self.cache_dir.exists() {
279            std::fs::remove_dir_all(&self.cache_dir)?;
280            std::fs::create_dir_all(&self.cache_dir)?;
281        }
282        Ok(())
283    }
284
285    /// Load SKILL.md from a skill directory if it exists
286    pub fn load_skill_md(&self, skill_path: impl AsRef<Path>) -> Option<SkillMdContent> {
287        let skill_path = skill_path.as_ref();
288
289        // Determine the skill directory
290        let skill_dir = if skill_path.is_dir() {
291            skill_path.to_path_buf()
292        } else {
293            skill_path.parent()?.to_path_buf()
294        };
295
296        // Find and parse SKILL.md
297        if let Some(skill_md_path) = find_skill_md(&skill_dir) {
298            match parse_skill_md(&skill_md_path) {
299                Ok(content) => {
300                    tracing::info!(
301                        path = %skill_md_path.display(),
302                        tools = content.tool_docs.len(),
303                        "Loaded SKILL.md"
304                    );
305                    Some(content)
306                }
307                Err(e) => {
308                    tracing::warn!(
309                        path = %skill_md_path.display(),
310                        error = %e,
311                        "Failed to parse SKILL.md"
312                    );
313                    None
314                }
315            }
316        } else {
317            tracing::debug!(dir = %skill_dir.display(), "No SKILL.md found");
318            None
319        }
320    }
321
322    /// Load a skill with its documentation
323    pub async fn load_skill_with_docs(
324        &self,
325        skill_path: impl AsRef<Path>,
326        engine: &SkillEngine,
327    ) -> Result<(Component, Option<SkillMdContent>)> {
328        let skill_path = skill_path.as_ref();
329
330        // Load the WASM component
331        let component = self.load_skill(skill_path, engine).await?;
332
333        // Try to load SKILL.md documentation
334        let skill_md = self.load_skill_md(skill_path);
335
336        Ok((component, skill_md))
337    }
338}
339
340impl Default for LocalSkillLoader {
341    fn default() -> Self {
342        Self::new().expect("Failed to create LocalSkillLoader")
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_cache_key_generation() {
352        let loader = LocalSkillLoader::new().unwrap();
353        let path = PathBuf::from("/tmp/test-skill.js");
354
355        let key1 = loader.generate_cache_key(&path).unwrap();
356        let key2 = loader.generate_cache_key(&path).unwrap();
357
358        assert_eq!(key1, key2);
359    }
360}