Skip to main content

mur_common/skill/
local.rs

1//! Local skill store helpers — list installed, resolve, remove, search, trust.
2
3use crate::skill::store::{agent_skill_dir, global_skill_dir};
4use crate::skill::types::TrustLevel;
5use crate::skill::{SkillManifest, StoreError, read_from_dir};
6use crate::trust::skills::SkillTrustStore;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub fn list_installed(mur_home: &Path) -> Result<Vec<String>, StoreError> {
11    let skills_dir = mur_home.join("skills");
12    if !skills_dir.exists() {
13        return Ok(vec![]);
14    }
15    let mut names: Vec<_> = fs::read_dir(&skills_dir)
16        .map_err(StoreError::Io)?
17        .filter_map(|e| {
18            let e = e.ok()?;
19            if e.file_type().ok()?.is_dir() {
20                let name = e.file_name().to_str()?.to_string();
21                // ponytail: skip, don't migrate. Pre-fix fleet runs ledgered to
22                // skills/fleet:<name>/ (see event_log_path); those directories
23                // hold run history, never a manifest, and are not skills.
24                // The other pre-fix offender, a bare `parallel-jobs`, is left
25                // visible on purpose: `mur skill remove` is the right advice
26                // for an ephemeral fan-out log nothing reads.
27                (!name.starts_with("fleet:")).then_some(name)
28            } else {
29                None
30            }
31        })
32        .collect();
33    names.sort();
34    Ok(names)
35}
36
37pub fn load_installed(mur_home: &Path, name: &str) -> Result<SkillManifest, StoreError> {
38    read_from_dir(&global_skill_dir(mur_home, name))
39}
40
41pub fn list_installed_agent(mur_home: &Path, agent_name: &str) -> Result<Vec<String>, StoreError> {
42    let dir = agent_skill_dir(mur_home, agent_name);
43    if !dir.exists() {
44        return Ok(vec![]);
45    }
46    let mut names: Vec<_> = fs::read_dir(&dir)
47        .map_err(StoreError::Io)?
48        .filter_map(|e| {
49            let e = e.ok()?;
50            if e.file_type().ok()?.is_dir() {
51                e.file_name().to_str().map(str::to_string)
52            } else {
53                None
54            }
55        })
56        .collect();
57    names.sort();
58    Ok(names)
59}
60
61pub fn load_installed_agent(
62    mur_home: &Path,
63    agent_name: &str,
64    skill: &str,
65) -> Result<SkillManifest, StoreError> {
66    read_from_dir(&agent_skill_dir(mur_home, agent_name).join(skill))
67}
68
69pub fn installed_path(mur_home: &Path, name: &str) -> PathBuf {
70    global_skill_dir(mur_home, name)
71}
72
73pub fn remove_installed(mur_home: &Path, name: &str) -> Result<(), StoreError> {
74    let dir = installed_path(mur_home, name);
75    if dir.exists() {
76        fs::remove_dir_all(&dir).map_err(StoreError::Io)?;
77    }
78    // Remove trust entry by name
79    if let Ok(mut trust) = SkillTrustStore::load(mur_home) {
80        trust.entries.retain(|_k, v| v.name != name);
81        let _ = trust.save(mur_home);
82    }
83    Ok(())
84}
85
86pub fn search_installed(
87    mur_home: &Path,
88    query: &str,
89) -> Result<Vec<(String, SkillManifest)>, StoreError> {
90    let q = query.to_lowercase();
91    let mut results = Vec::new();
92    for name in list_installed(mur_home)? {
93        if let Ok(m) = load_installed(mur_home, &name)
94            && (name.to_lowercase().contains(&q)
95                || m.description.to_lowercase().contains(&q)
96                || m.tags.iter().any(|t| t.to_lowercase().contains(&q)))
97        {
98            results.push((name, m));
99        }
100    }
101    Ok(results)
102}
103
104pub fn set_trust_level(
105    mur_home: &Path,
106    name: &str,
107    level: TrustLevel,
108) -> Result<(), Box<dyn std::error::Error>> {
109    let mut trust = SkillTrustStore::load(mur_home)?;
110    let keys: Vec<String> = trust
111        .entries
112        .iter()
113        .filter(|(_k, v)| v.name == name)
114        .map(|(k, _)| k.clone())
115        .collect();
116    for k in keys {
117        if let Some(e) = trust.entries.get_mut(&k) {
118            e.level = level;
119        }
120    }
121    trust.save(mur_home)?;
122    Ok(())
123}
124
125pub fn get_trust_level(
126    mur_home: &Path,
127    name: &str,
128) -> Result<TrustLevel, Box<dyn std::error::Error>> {
129    let trust = SkillTrustStore::load(mur_home)?;
130    for entry in trust.entries.values() {
131        if entry.name == name {
132            return Ok(entry.level);
133        }
134    }
135    Ok(TrustLevel::Sandboxed)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::skill::{parse_canonical, write_to_dir};
142    use tempfile::tempdir;
143
144    fn sample(name: &str) -> SkillManifest {
145        parse_canonical(&format!(
146            r#"name: {name}
147version: 1.0.0
148publisher: human:t
149description: test skill for {name}
150category: context
151content:
152  abstract: hi
153  context: body
154tags: [test, {name}]
155"#
156        ))
157        .unwrap()
158    }
159
160    #[test]
161    fn list_returns_installed() {
162        let dir = tempdir().unwrap();
163        write_to_dir(&global_skill_dir(dir.path(), "a"), &sample("a")).unwrap();
164        write_to_dir(&global_skill_dir(dir.path(), "b"), &sample("b")).unwrap();
165        assert_eq!(list_installed(dir.path()).unwrap(), vec!["a", "b"]);
166    }
167
168    #[test]
169    fn empty_dir_returns_empty() {
170        assert!(
171            list_installed(tempdir().unwrap().path())
172                .unwrap()
173                .is_empty()
174        );
175    }
176
177    #[test]
178    fn search_finds_by_name() {
179        let dir = tempdir().unwrap();
180        write_to_dir(
181            &global_skill_dir(dir.path(), "my-prices"),
182            &sample("my-prices"),
183        )
184        .unwrap();
185        assert_eq!(search_installed(dir.path(), "price").unwrap().len(), 1);
186    }
187
188    #[test]
189    fn search_finds_by_tag() {
190        let dir = tempdir().unwrap();
191        write_to_dir(&global_skill_dir(dir.path(), "web"), &sample("web")).unwrap();
192        assert_eq!(search_installed(dir.path(), "test").unwrap().len(), 1);
193    }
194
195    #[test]
196    fn remove_cleans_dir() {
197        let dir = tempdir().unwrap();
198        write_to_dir(&global_skill_dir(dir.path(), "rm-me"), &sample("rm-me")).unwrap();
199        remove_installed(dir.path(), "rm-me").unwrap();
200        assert!(list_installed(dir.path()).unwrap().is_empty());
201    }
202
203    // Unix-only, and not as a workaround: the filter keys on a `fleet:` name
204    // prefix, so exercising it means creating a directory whose name contains a
205    // colon. Windows reads `:` as the start of an NTFS alternate data stream, so
206    // `create_dir_all` fails there with ERROR_DIRECTORY (os error 267). The same
207    // rule means the debris this skips can never exist on Windows either, so
208    // there is no coverage to lose — the filter is inert on that platform.
209    #[cfg(unix)]
210    #[test]
211    fn list_installed_ignores_legacy_fleet_ledgers() {
212        let dir = tempdir().unwrap();
213        write_to_dir(&global_skill_dir(dir.path(), "real"), &sample("real")).unwrap();
214        // Pre-fix debris: a run ledger, no manifest.
215        let legacy = dir.path().join("skills").join("fleet:builder");
216        fs::create_dir_all(&legacy).unwrap();
217        fs::write(legacy.join("events.jsonl"), "{}\n").unwrap();
218        assert_eq!(list_installed(dir.path()).unwrap(), vec!["real"]);
219    }
220
221    #[test]
222    fn list_installed_agent_finds_agent_skills() {
223        let dir = tempdir().unwrap();
224        let agent_dir = agent_skill_dir(dir.path(), "alice");
225        write_to_dir(&agent_dir.join("foo"), &sample("foo")).unwrap();
226        let names = list_installed_agent(dir.path(), "alice").unwrap();
227        assert_eq!(names, vec!["foo"]);
228    }
229
230    #[test]
231    fn list_installed_agent_empty_when_dir_missing() {
232        let dir = tempdir().unwrap();
233        let names = list_installed_agent(dir.path(), "nobody").unwrap();
234        assert!(names.is_empty());
235    }
236}