1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::fs;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12
13const EMBEDDED_SKILL_NAME: &str = "slack-rs";
14const EMBEDDED_SKILL_DATA: &[(&str, &[u8])] = &[
15 ("SKILL.md", include_bytes!("../../skills/slack-rs/SKILL.md")),
16 (
17 "README.md",
18 include_bytes!("../../skills/slack-rs/README.md"),
19 ),
20 (
21 "references/recipes.md",
22 include_bytes!("../../skills/slack-rs/references/recipes.md"),
23 ),
24];
25
26#[derive(Debug, Error)]
27pub enum SkillError {
28 #[error("Invalid source: {0}")]
29 InvalidSource(String),
30
31 #[error("Unknown source scheme: {0}. Allowed schemes: 'self', 'local:<path>'")]
32 UnknownScheme(String),
33
34 #[error("IO error: {0}")]
35 IoError(#[from] std::io::Error),
36
37 #[error("Serialization error: {0}")]
38 SerializationError(#[from] serde_json::Error),
39
40 #[error("Skill not found: {0}")]
41 SkillNotFound(String),
42
43 #[error("Path error: {0}")]
44 PathError(String),
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub enum Source {
50 SelfEmbedded,
52 Local(PathBuf),
54}
55
56impl Source {
57 pub fn parse(s: &str) -> Result<Self, SkillError> {
66 if s.is_empty() || s == "self" {
67 Ok(Source::SelfEmbedded)
68 } else if let Some(path_str) = s.strip_prefix("local:") {
69 let path = PathBuf::from(path_str);
70 Ok(Source::Local(path))
71 } else {
72 Err(SkillError::UnknownScheme(s.to_string()))
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct InstalledSkill {
81 pub name: String,
82 pub path: String,
83 pub source_type: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SkillLock {
89 pub skills: Vec<InstalledSkill>,
90}
91
92impl SkillLock {
93 pub fn new() -> Self {
94 SkillLock { skills: Vec::new() }
95 }
96
97 pub fn add_skill(&mut self, skill: InstalledSkill) {
98 self.skills.retain(|s| s.name != skill.name);
100 self.skills.push(skill);
101 }
102}
103
104impl Default for SkillLock {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110fn resolve_agents_base_dir(global: bool) -> Result<PathBuf, SkillError> {
115 if global {
116 let home = directories::BaseDirs::new()
117 .ok_or_else(|| SkillError::PathError("Cannot determine home directory".to_string()))?
118 .home_dir()
119 .to_path_buf();
120 return Ok(home.join(".agents"));
121 }
122
123 let cwd = std::env::current_dir()
124 .map_err(|e| SkillError::PathError(format!("Cannot determine current directory: {}", e)))?;
125 Ok(cwd.join(".agents"))
126}
127
128fn get_skills_dir(global: bool) -> Result<PathBuf, SkillError> {
130 Ok(resolve_agents_base_dir(global)?.join("skills"))
131}
132
133fn get_lock_file_path(global: bool) -> Result<PathBuf, SkillError> {
135 Ok(resolve_agents_base_dir(global)?.join(".skill-lock.json"))
136}
137
138fn load_lock(global: bool) -> Result<SkillLock, SkillError> {
140 let lock_path = get_lock_file_path(global)?;
141
142 if !lock_path.exists() {
143 return Ok(SkillLock::new());
144 }
145
146 let contents = fs::read_to_string(&lock_path)?;
147
148 if let Ok(lock) = serde_json::from_str::<SkillLock>(&contents) {
150 return Ok(lock);
151 }
152
153 let value: Value = serde_json::from_str(&contents)?;
160 if let Some(skills_obj) = value.get("skills").and_then(|v| v.as_object()) {
161 let mut lock = SkillLock::new();
162 for (name, entry) in skills_obj {
163 let path = entry
164 .get("path")
165 .and_then(|v| v.as_str())
166 .unwrap_or_default()
167 .to_string();
168 let source_type = entry
169 .get("source_type")
170 .or_else(|| entry.get("sourceType"))
171 .and_then(|v| v.as_str())
172 .unwrap_or("unknown")
173 .to_string();
174
175 lock.add_skill(InstalledSkill {
176 name: name.clone(),
177 path,
178 source_type,
179 });
180 }
181 return Ok(lock);
182 }
183
184 Err(SkillError::SerializationError(serde_json::Error::io(
185 std::io::Error::new(
186 std::io::ErrorKind::InvalidData,
187 "Unrecognized lock file format",
188 ),
189 )))
190}
191
192fn save_lock(lock: &SkillLock, global: bool) -> Result<(), SkillError> {
194 let lock_path = get_lock_file_path(global)?;
195
196 if let Some(parent) = lock_path.parent() {
198 fs::create_dir_all(parent)?;
199 }
200
201 let contents = serde_json::to_string_pretty(lock)?;
202 fs::write(&lock_path, contents)?;
203 Ok(())
204}
205
206fn deploy_embedded_skill(target_dir: &Path) -> Result<(), SkillError> {
208 fs::create_dir_all(target_dir)?;
209
210 for (rel_path, data) in EMBEDDED_SKILL_DATA {
211 let target_file = target_dir.join(rel_path);
212
213 if let Some(parent) = target_file.parent() {
215 fs::create_dir_all(parent)?;
216 }
217
218 fs::write(target_file, data)?;
219 }
220
221 Ok(())
222}
223
224fn deploy_local_skill(source_dir: &Path, target_dir: &Path) -> Result<(), SkillError> {
226 if !source_dir.exists() {
227 return Err(SkillError::SkillNotFound(format!(
228 "Source directory does not exist: {}",
229 source_dir.display()
230 )));
231 }
232
233 if target_dir.exists() {
235 match fs::remove_dir_all(target_dir) {
236 Ok(_) => {}
237 Err(_) => {
238 fs::remove_file(target_dir)?;
239 }
240 }
241 }
242
243 if let Some(parent) = target_dir.parent() {
245 fs::create_dir_all(parent)?;
246 }
247
248 #[cfg(unix)]
250 {
251 if std::os::unix::fs::symlink(source_dir, target_dir).is_ok() {
252 return Ok(());
253 }
254 }
255
256 copy_dir_all(source_dir, target_dir)?;
258 Ok(())
259}
260
261fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), SkillError> {
263 fs::create_dir_all(dst)?;
264
265 for entry in fs::read_dir(src)? {
266 let entry = entry?;
267 let file_type = entry.file_type()?;
268 let src_path = entry.path();
269 let dst_path = dst.join(entry.file_name());
270
271 if file_type.is_dir() {
272 copy_dir_all(&src_path, &dst_path)?;
273 } else {
274 fs::copy(&src_path, &dst_path)?;
275 }
276 }
277
278 Ok(())
279}
280
281pub fn install_skill(source: Option<&str>, global: bool) -> Result<InstalledSkill, SkillError> {
291 let source_str = source.unwrap_or("self");
293 let parsed_source = Source::parse(source_str)?;
294
295 let (skill_name, source_type) = match &parsed_source {
296 Source::SelfEmbedded => (EMBEDDED_SKILL_NAME.to_string(), "self".to_string()),
297 Source::Local(path) => {
298 let name = path
299 .file_name()
300 .and_then(|n| n.to_str())
301 .ok_or_else(|| {
302 SkillError::PathError(format!(
303 "Cannot extract skill name from path: {}",
304 path.display()
305 ))
306 })?
307 .to_string();
308 (name, "local".to_string())
309 }
310 };
311
312 let skills_dir = get_skills_dir(global)?;
314 let target_dir = skills_dir.join(&skill_name);
315
316 match parsed_source {
318 Source::SelfEmbedded => {
319 deploy_embedded_skill(&target_dir)?;
320 }
321 Source::Local(ref path) => {
322 deploy_local_skill(path, &target_dir)?;
323 }
324 }
325
326 let mut lock = load_lock(global)?;
328 let installed = InstalledSkill {
329 name: skill_name,
330 path: target_dir.to_string_lossy().to_string(),
331 source_type,
332 };
333 lock.add_skill(installed.clone());
334 save_lock(&lock, global)?;
335
336 Ok(installed)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn parse_source_accepts_self_and_local() {
345 assert_eq!(Source::parse("").unwrap(), Source::SelfEmbedded);
347
348 assert_eq!(Source::parse("self").unwrap(), Source::SelfEmbedded);
350
351 let local_result = Source::parse("local:/path/to/skill").unwrap();
353 match local_result {
354 Source::Local(path) => {
355 assert_eq!(path, PathBuf::from("/path/to/skill"));
356 }
357 _ => panic!("Expected Local variant"),
358 }
359 }
360
361 #[test]
362 fn parse_source_rejects_unknown_scheme() {
363 let result = Source::parse("github:user/repo");
364 assert!(result.is_err());
365 match result.unwrap_err() {
366 SkillError::UnknownScheme(s) => {
367 assert_eq!(s, "github:user/repo");
368 }
369 _ => panic!("Expected UnknownScheme error"),
370 }
371 }
372
373 #[test]
374 fn unknown_scheme_error_includes_allowed_schemes() {
375 let result = Source::parse("foo:bar");
376 assert!(result.is_err());
377 let err_msg = format!("{}", result.unwrap_err());
378 assert!(
379 err_msg.contains("self"),
380 "Error should mention 'self' scheme"
381 );
382 assert!(
383 err_msg.contains("local:"),
384 "Error should mention 'local:<path>' scheme"
385 );
386 }
387
388 #[test]
389 fn default_source_is_self() {
390 let default_source = Source::parse("").unwrap();
394 assert_eq!(default_source, Source::SelfEmbedded);
395 }
396
397 #[test]
398 fn self_source_uses_embedded_skill() {
399 assert!(!EMBEDDED_SKILL_DATA.is_empty());
401 assert_eq!(EMBEDDED_SKILL_NAME, "slack-rs");
402
403 let file_names: Vec<&str> = EMBEDDED_SKILL_DATA.iter().map(|(name, _)| *name).collect();
405 assert!(file_names.contains(&"SKILL.md"));
406 assert!(file_names.contains(&"README.md"));
407 }
408
409 #[test]
410 fn install_writes_skill_dir_and_lock_file() {
411 use tempfile::TempDir;
412
413 let temp_dir = TempDir::new().unwrap();
415 let _temp_path = temp_dir.path();
416
417 let mut lock = SkillLock::new();
422 assert_eq!(lock.skills.len(), 0);
423
424 let test_skill = InstalledSkill {
425 name: "test-skill".to_string(),
426 path: "/tmp/test-skill".to_string(),
427 source_type: "self".to_string(),
428 };
429
430 lock.add_skill(test_skill.clone());
431 assert_eq!(lock.skills.len(), 1);
432 assert_eq!(lock.skills[0].name, "test-skill");
433
434 let updated_skill = InstalledSkill {
436 name: "test-skill".to_string(),
437 path: "/tmp/test-skill-updated".to_string(),
438 source_type: "local".to_string(),
439 };
440
441 lock.add_skill(updated_skill);
442 assert_eq!(lock.skills.len(), 1);
443 assert_eq!(lock.skills[0].path, "/tmp/test-skill-updated");
444 }
445
446 #[test]
447 fn falls_back_to_copy_when_symlink_fails() {
448 use tempfile::TempDir;
453
454 let src_dir = TempDir::new().unwrap();
455 let dst_dir = TempDir::new().unwrap();
456
457 let test_file = src_dir.path().join("test.txt");
459 fs::write(&test_file, b"test content").unwrap();
460
461 let dst_path = dst_dir.path().join("copied");
463 let result = copy_dir_all(src_dir.path(), &dst_path);
464 assert!(result.is_ok());
465
466 let copied_file = dst_dir.path().join("copied").join("test.txt");
468 assert!(copied_file.exists());
469 let contents = fs::read_to_string(copied_file).unwrap();
470 assert_eq!(contents, "test content");
471 }
472
473 #[test]
474 fn parse_legacy_map_lock_format() {
475 let json = r#"{
476 "skills": {
477 "slack-rs": {
478 "path": "/tmp/.agents/skills/slack-rs",
479 "source_type": "self"
480 }
481 }
482 }"#;
483
484 let value: Value = serde_json::from_str(json).unwrap();
485 let skills_obj = value.get("skills").unwrap().as_object().unwrap();
486
487 let mut lock = SkillLock::new();
488 for (name, entry) in skills_obj {
489 lock.add_skill(InstalledSkill {
490 name: name.clone(),
491 path: entry
492 .get("path")
493 .and_then(|v| v.as_str())
494 .unwrap_or_default()
495 .to_string(),
496 source_type: entry
497 .get("source_type")
498 .and_then(|v| v.as_str())
499 .unwrap_or("unknown")
500 .to_string(),
501 });
502 }
503
504 assert_eq!(lock.skills.len(), 1);
505 assert_eq!(lock.skills[0].name, "slack-rs");
506 assert_eq!(lock.skills[0].source_type, "self");
507 }
508}