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
9pub 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 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 if skill_path.extension().map_or(false, |ext| ext == "wasm") {
39 return engine.load_component(skill_path).await;
40 }
41
42 if skill_path.is_dir() {
44 return self.load_from_directory(skill_path, engine).await;
45 }
46
47 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 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 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 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 let cache_key = self.generate_cache_key(&source_abs)?;
104 let cached_wasm = self.cache_dir.join(format!("{}.wasm", cache_key));
105
106 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 tracing::info!(source = %source_abs.display(), "Compiling to WASM");
123 self.compile_to_wasm(&source_abs, &cached_wasm).await?;
124
125 engine.load_component(&cached_wasm).await
127 }
128
129 async fn compile_to_wasm(&self, source: &Path, output: &Path) -> Result<()> {
131 let is_typescript = source.extension().map_or(false, |ext| ext == "ts");
133
134 let js_file = if is_typescript {
135 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 let wit_file = self.find_wit_interface(source)?;
145
146 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 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 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 fn find_wit_interface(&self, source: &Path) -> Result<PathBuf> {
217 let source_dir = source.parent().context("No parent directory")?;
218
219 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 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 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 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 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 pub fn load_skill_md(&self, skill_path: impl AsRef<Path>) -> Option<SkillMdContent> {
287 let skill_path = skill_path.as_ref();
288
289 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 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 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 let component = self.load_skill(skill_path, engine).await?;
332
333 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}