Skip to main content

slack_rs/skills/
mod.rs

1//! Skill installation module
2//!
3//! This module provides functionality to install agent skills from embedded resources
4//! or local filesystem paths. Skills are deployed to .agents/skills/
5//! and tracked in a lock file.
6
7use 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/// Source of skill installation
48#[derive(Debug, Clone, PartialEq)]
49pub enum Source {
50    /// Embedded skill (skills/slack-rs)
51    SelfEmbedded,
52    /// Local filesystem path
53    Local(PathBuf),
54}
55
56impl Source {
57    /// Parse source string into Source enum
58    ///
59    /// # Arguments
60    /// * `s` - Source string (empty/"self" or "local:<path>")
61    ///
62    /// # Returns
63    /// * `Ok(Source)` - Parsed source
64    /// * `Err(SkillError)` - Invalid or unknown source scheme
65    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            // Unknown scheme - reject immediately
73            Err(SkillError::UnknownScheme(s.to_string()))
74        }
75    }
76}
77
78/// Installed skill information
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct InstalledSkill {
81    pub name: String,
82    pub path: String,
83    pub source_type: String,
84}
85
86/// Lock file structure
87#[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        // Remove existing entry with same name if present
99        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
110/// Resolve .agents base directory.
111///
112/// - global=true  => ~/.agents
113/// - global=false => <current-project>/.agents
114fn 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
128/// Get the skills directory path
129fn get_skills_dir(global: bool) -> Result<PathBuf, SkillError> {
130    Ok(resolve_agents_base_dir(global)?.join("skills"))
131}
132
133/// Get the lock file path
134fn get_lock_file_path(global: bool) -> Result<PathBuf, SkillError> {
135    Ok(resolve_agents_base_dir(global)?.join(".skill-lock.json"))
136}
137
138/// Load lock file
139fn 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    // Current format: { "skills": [ ... ] }
149    if let Ok(lock) = serde_json::from_str::<SkillLock>(&contents) {
150        return Ok(lock);
151    }
152
153    // Compatibility: map format used by some installers
154    // {
155    //   "skills": {
156    //     "name": {"path": "...", "source_type": "self"}
157    //   }
158    // }
159    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
192/// Save lock file
193fn save_lock(lock: &SkillLock, global: bool) -> Result<(), SkillError> {
194    let lock_path = get_lock_file_path(global)?;
195
196    // Ensure parent directory exists
197    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
206/// Deploy embedded skill files to target directory
207fn 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        // Create parent directories if needed
214        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
224/// Deploy local skill directory to target using symlink (preferred) or copy (fallback)
225fn 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    // Remove existing target if present
234    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    // Ensure parent directory exists
244    if let Some(parent) = target_dir.parent() {
245        fs::create_dir_all(parent)?;
246    }
247
248    // Try symlink first
249    #[cfg(unix)]
250    {
251        if std::os::unix::fs::symlink(source_dir, target_dir).is_ok() {
252            return Ok(());
253        }
254    }
255
256    // Fall back to recursive copy
257    copy_dir_all(source_dir, target_dir)?;
258    Ok(())
259}
260
261/// Recursively copy directory
262fn 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
281/// Install skill from source
282///
283/// # Arguments
284/// * `source` - Source to install from (None defaults to self)
285/// * `global` - Whether to install into ~/.agents (true) or ./.agents (false)
286///
287/// # Returns
288/// * `Ok(InstalledSkill)` - Successfully installed skill info
289/// * `Err(SkillError)` - Installation failed
290pub fn install_skill(source: Option<&str>, global: bool) -> Result<InstalledSkill, SkillError> {
291    // Default to self if no source provided
292    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    // Determine target directory
313    let skills_dir = get_skills_dir(global)?;
314    let target_dir = skills_dir.join(&skill_name);
315
316    // Deploy based on source type
317    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    // Update lock file
327    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        // Test empty string defaults to self
346        assert_eq!(Source::parse("").unwrap(), Source::SelfEmbedded);
347
348        // Test explicit "self"
349        assert_eq!(Source::parse("self").unwrap(), Source::SelfEmbedded);
350
351        // Test local path
352        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        // When no source is provided to install_skill, it should default to "self"
391        // We can't easily test the full install without filesystem setup,
392        // but we can verify the parse logic
393        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        // Verify that embedded skill data is available
400        assert!(!EMBEDDED_SKILL_DATA.is_empty());
401        assert_eq!(EMBEDDED_SKILL_NAME, "slack-rs");
402
403        // Verify we have the expected files
404        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        // Create a temporary directory for testing
414        let temp_dir = TempDir::new().unwrap();
415        let _temp_path = temp_dir.path();
416
417        // Mock the config paths by using environment variables or test helpers
418        // For now, we'll test the core logic separately
419
420        // Test that SkillLock can be created and modified
421        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        // Test that adding same skill again replaces it
435        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        // This test verifies the copy fallback logic exists
449        // We can't easily test actual symlink failure without OS-specific setup,
450        // but we can verify copy_dir_all works
451
452        use tempfile::TempDir;
453
454        let src_dir = TempDir::new().unwrap();
455        let dst_dir = TempDir::new().unwrap();
456
457        // Create test file in source
458        let test_file = src_dir.path().join("test.txt");
459        fs::write(&test_file, b"test content").unwrap();
460
461        // Copy directory
462        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        // Verify file was copied
467        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}