agentzero_tools/skills/
store.rs1use 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}