Skip to main content

agentzero_tools/skills/
store.rs

1use super::skillforge::validate_skill_name;
2use agentzero_storage::EncryptedJsonStore;
3use anyhow::{bail, Context};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7const SKILLS_FILE: &str = "skills-state.json";
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct SkillRecord {
11    pub name: String,
12    pub source: String,
13    pub enabled: bool,
14}
15
16#[derive(Debug, Clone)]
17pub struct SkillStore {
18    store: EncryptedJsonStore,
19}
20
21impl SkillStore {
22    pub fn new(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
23        Ok(Self {
24            store: EncryptedJsonStore::in_config_dir(data_dir.as_ref(), SKILLS_FILE)?,
25        })
26    }
27
28    pub fn list(&self) -> anyhow::Result<Vec<SkillRecord>> {
29        self.store.load_or_default()
30    }
31
32    pub fn install(&self, name: &str, source: &str) -> anyhow::Result<SkillRecord> {
33        validate_skill_name(name)?;
34        if source.trim().is_empty() {
35            bail!("skill source cannot be empty");
36        }
37
38        let mut skills = self.list()?;
39        if skills.iter().any(|skill| skill.name == name) {
40            bail!("skill `{name}` already installed");
41        }
42
43        let record = SkillRecord {
44            name: name.to_string(),
45            source: source.to_string(),
46            enabled: true,
47        };
48        skills.push(record.clone());
49        self.store.save(&skills)?;
50        Ok(record)
51    }
52
53    pub fn get(&self, name: &str) -> anyhow::Result<SkillRecord> {
54        let skills = self.list()?;
55        skills
56            .into_iter()
57            .find(|skill| skill.name == name)
58            .with_context(|| format!("skill `{name}` is not installed"))
59    }
60
61    pub fn remove(&self, name: &str) -> anyhow::Result<()> {
62        let mut skills = self.list()?;
63        let previous_len = skills.len();
64        skills.retain(|skill| skill.name != name);
65        if skills.len() == previous_len {
66            bail!("skill `{name}` is not installed");
67        }
68        self.store.save(&skills)?;
69        Ok(())
70    }
71
72    pub fn test(&self, name: &str) -> anyhow::Result<String> {
73        let skills = self.list()?;
74        let skill = skills
75            .iter()
76            .find(|skill| skill.name == name)
77            .with_context(|| format!("skill `{name}` is not installed"))?;
78
79        Ok(format!(
80            "skill `{}` test: source={}, status={}",
81            skill.name,
82            skill.source,
83            if skill.enabled { "enabled" } else { "disabled" }
84        ))
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::SkillStore;
91    use std::fs;
92    use std::sync::atomic::{AtomicU64, Ordering};
93    use std::time::{SystemTime, UNIX_EPOCH};
94
95    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
96
97    fn temp_dir() -> std::path::PathBuf {
98        let nanos = SystemTime::now()
99            .duration_since(UNIX_EPOCH)
100            .expect("time should move forward")
101            .as_nanos();
102        let seq = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
103        let dir = std::env::temp_dir().join(format!(
104            "agentzero-skills-test-{}-{nanos}-{seq}",
105            std::process::id()
106        ));
107        fs::create_dir_all(&dir).expect("temp dir should be created");
108        dir
109    }
110
111    #[test]
112    fn install_list_test_remove_success_path() {
113        let dir = temp_dir();
114        let store = SkillStore::new(&dir).expect("store should create");
115
116        let installed = store
117            .install("my_skill", "local")
118            .expect("install should succeed");
119        assert_eq!(installed.name, "my_skill");
120
121        let listed = store.list().expect("list should succeed");
122        assert_eq!(listed.len(), 1);
123
124        let output = store.test("my_skill").expect("test should succeed");
125        assert!(output.contains("status=enabled"));
126
127        store.remove("my_skill").expect("remove should succeed");
128        assert!(store.list().expect("list should succeed").is_empty());
129
130        fs::remove_dir_all(dir).expect("temp dir should be removed");
131    }
132
133    #[test]
134    fn install_duplicate_fails_negative_path() {
135        let dir = temp_dir();
136        let store = SkillStore::new(&dir).expect("store should create");
137        store
138            .install("my_skill", "local")
139            .expect("first install should succeed");
140
141        let err = store
142            .install("my_skill", "local")
143            .expect_err("duplicate install should fail");
144        assert!(err.to_string().contains("already installed"));
145
146        fs::remove_dir_all(dir).expect("temp dir should be removed");
147    }
148}