Skip to main content

agent_diva_manager/
skill_service.rs

1use agent_diva_agent::skills::{SkillSource, SkillsLoader};
2use agent_diva_core::config::ConfigLoader;
3use anyhow::{anyhow, Context};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::fs;
7use std::io::{Cursor, Read};
8use std::path::{Component, Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct SkillDto {
12    pub name: String,
13    pub description: String,
14    pub source: String,
15    pub available: bool,
16    pub active: bool,
17    pub path: String,
18    pub can_delete: bool,
19}
20
21#[derive(Clone)]
22pub struct SkillService {
23    loader: ConfigLoader,
24}
25
26impl SkillService {
27    pub fn new(loader: ConfigLoader) -> Self {
28        Self { loader }
29    }
30
31    pub fn list_skills(&self) -> anyhow::Result<Vec<SkillDto>> {
32        let workspace = self.workspace_dir()?;
33        let loader = SkillsLoader::new(&workspace, None);
34        let available_names: HashSet<String> = loader
35            .list_skills(true)
36            .into_iter()
37            .map(|skill| skill.name)
38            .collect();
39        let active_names: HashSet<String> = loader.get_always_skills().into_iter().collect();
40
41        let mut skills = loader
42            .list_skills(false)
43            .into_iter()
44            .map(|skill| {
45                let description = loader
46                    .get_skill_metadata(&skill.name)
47                    .description
48                    .unwrap_or_else(|| skill.name.clone());
49                let source = match skill.source {
50                    SkillSource::Workspace => "workspace",
51                    SkillSource::Builtin => "builtin",
52                };
53                SkillDto {
54                    name: skill.name.clone(),
55                    description,
56                    source: source.to_string(),
57                    available: available_names.contains(&skill.name),
58                    active: active_names.contains(&skill.name),
59                    path: skill.path.display().to_string(),
60                    can_delete: matches!(skill.source, SkillSource::Workspace),
61                }
62            })
63            .collect::<Vec<_>>();
64        skills.sort_by(|a, b| a.name.cmp(&b.name));
65        Ok(skills)
66    }
67
68    pub fn upload_skill_zip(&self, file_name: &str, bytes: Vec<u8>) -> anyhow::Result<SkillDto> {
69        let workspace = self.workspace_dir()?;
70        let skills_dir = workspace.join("skills");
71        fs::create_dir_all(&skills_dir).with_context(|| {
72            format!("failed to create skills directory {}", skills_dir.display())
73        })?;
74
75        let archive_paths = list_archive_entries(&bytes)?;
76        let single_root = shared_archive_root(&archive_paths);
77        let skill_name = derive_skill_name(file_name, &bytes, single_root.as_deref())?;
78
79        let target_dir = skills_dir.join(&skill_name);
80        let tmp_dir = skills_dir.join(format!(".upload-{}-{}", skill_name, std::process::id()));
81        if tmp_dir.exists() {
82            fs::remove_dir_all(&tmp_dir)
83                .with_context(|| format!("failed to clean temp directory {}", tmp_dir.display()))?;
84        }
85        fs::create_dir_all(&tmp_dir)
86            .with_context(|| format!("failed to create temp directory {}", tmp_dir.display()))?;
87
88        extract_archive(&bytes, &tmp_dir, single_root.as_deref())?;
89
90        let skill_file = tmp_dir.join("SKILL.md");
91        if !skill_file.exists() {
92            let _ = fs::remove_dir_all(&tmp_dir);
93            return Err(anyhow!("uploaded zip must contain SKILL.md"));
94        }
95
96        if target_dir.exists() {
97            fs::remove_dir_all(&target_dir).with_context(|| {
98                format!(
99                    "failed to replace existing skill directory {}",
100                    target_dir.display()
101                )
102            })?;
103        }
104        fs::rename(&tmp_dir, &target_dir).with_context(|| {
105            format!(
106                "failed to move uploaded skill into place: {} -> {}",
107                tmp_dir.display(),
108                target_dir.display()
109            )
110        })?;
111
112        self.list_skills()?
113            .into_iter()
114            .find(|skill| skill.name == skill_name)
115            .ok_or_else(|| anyhow!("uploaded skill was not visible after install"))
116    }
117
118    pub fn delete_skill(&self, name: &str) -> anyhow::Result<()> {
119        let workspace = self.workspace_dir()?;
120        let workspace_dir = workspace.join("skills").join(name);
121        if workspace_dir.exists() {
122            fs::remove_dir_all(&workspace_dir).with_context(|| {
123                format!(
124                    "failed to delete workspace skill directory {}",
125                    workspace_dir.display()
126                )
127            })?;
128            return Ok(());
129        }
130
131        let builtin_exists = self
132            .list_skills()?
133            .into_iter()
134            .any(|skill| skill.name == name && skill.source == "builtin");
135        if builtin_exists {
136            return Err(anyhow!("builtin skills cannot be deleted"));
137        }
138
139        Err(anyhow!("skill not found"))
140    }
141
142    fn workspace_dir(&self) -> anyhow::Result<PathBuf> {
143        let config = self.loader.load()?;
144        Ok(expand_tilde(&config.agents.defaults.workspace))
145    }
146}
147
148fn expand_tilde(path: &str) -> PathBuf {
149    if let Some(stripped) = path.strip_prefix("~/") {
150        if let Some(home) = dirs::home_dir() {
151            return home.join(stripped);
152        }
153    }
154    PathBuf::from(path)
155}
156
157fn list_archive_entries(bytes: &[u8]) -> anyhow::Result<Vec<PathBuf>> {
158    let mut archive =
159        zip::ZipArchive::new(Cursor::new(bytes)).context("failed to open uploaded zip archive")?;
160    let mut paths = Vec::new();
161    for idx in 0..archive.len() {
162        let file = archive
163            .by_index(idx)
164            .context("failed to inspect zip entry")?;
165        let path = PathBuf::from(file.name());
166        if !path.as_os_str().is_empty() {
167            paths.push(path);
168        }
169    }
170    Ok(paths)
171}
172
173fn shared_archive_root(paths: &[PathBuf]) -> Option<String> {
174    let mut root: Option<String> = None;
175    for path in paths {
176        let mut components = path.components();
177        let Some(Component::Normal(first)) = components.next() else {
178            return None;
179        };
180        components.next()?;
181        let first = first.to_string_lossy().to_string();
182        if root.as_deref().is_some_and(|existing| existing != first) {
183            return None;
184        }
185        if root.is_none() {
186            root = Some(first);
187        }
188    }
189    root
190}
191
192fn derive_skill_name(
193    file_name: &str,
194    bytes: &[u8],
195    archive_root: Option<&str>,
196) -> anyhow::Result<String> {
197    if let Some(root) = archive_root {
198        let root = sanitize_skill_name(root);
199        if !root.is_empty() {
200            return Ok(root);
201        }
202    }
203
204    if let Some(name) = skill_name_from_archive(bytes)? {
205        let name = sanitize_skill_name(&name);
206        if !name.is_empty() {
207            return Ok(name);
208        }
209    }
210
211    let fallback = Path::new(file_name)
212        .file_stem()
213        .and_then(|stem| stem.to_str())
214        .map(sanitize_skill_name)
215        .unwrap_or_default();
216    if fallback.is_empty() {
217        return Err(anyhow!("failed to derive skill name from uploaded zip"));
218    }
219    Ok(fallback)
220}
221
222fn sanitize_skill_name(input: &str) -> String {
223    let mut out = String::new();
224    let mut previous_dash = false;
225    for ch in input.chars() {
226        let ch = ch.to_ascii_lowercase();
227        if ch.is_ascii_alphanumeric() {
228            out.push(ch);
229            previous_dash = false;
230        } else if matches!(ch, '-' | '_' | ' ') && !previous_dash && !out.is_empty() {
231            out.push('-');
232            previous_dash = true;
233        }
234    }
235    out.trim_matches('-').to_string()
236}
237
238fn skill_name_from_archive(bytes: &[u8]) -> anyhow::Result<Option<String>> {
239    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
240        .context("failed to reopen uploaded zip archive")?;
241    for idx in 0..archive.len() {
242        let mut file = archive.by_index(idx).context("failed to read zip entry")?;
243        let entry_path = Path::new(file.name());
244        let Some(file_name) = entry_path.file_name().and_then(|value| value.to_str()) else {
245            continue;
246        };
247        if file_name != "SKILL.md" {
248            continue;
249        }
250        let mut content = String::new();
251        file.read_to_string(&mut content)
252            .context("failed to read SKILL.md from archive")?;
253        return Ok(parse_frontmatter_name(&content));
254    }
255    Ok(None)
256}
257
258fn parse_frontmatter_name(content: &str) -> Option<String> {
259    if !content.starts_with("---") {
260        return None;
261    }
262    let mut lines = content.lines();
263    let _ = lines.next();
264    for line in lines {
265        if line.trim() == "---" {
266            break;
267        }
268        let Some((key, value)) = line.split_once(':') else {
269            continue;
270        };
271        if key.trim() == "name" {
272            let name = value.trim().trim_matches('"').trim_matches('\'');
273            if !name.is_empty() {
274                return Some(name.to_string());
275            }
276        }
277    }
278    None
279}
280
281fn extract_archive(
282    bytes: &[u8],
283    target_dir: &Path,
284    archive_root: Option<&str>,
285) -> anyhow::Result<()> {
286    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
287        .context("failed to extract uploaded zip archive")?;
288    for idx in 0..archive.len() {
289        let mut file = archive.by_index(idx).context("failed to read zip entry")?;
290        let entry_path = Path::new(file.name());
291        let relative = normalize_archive_path(entry_path, archive_root)
292            .ok_or_else(|| anyhow!("zip contains invalid path: {}", entry_path.display()))?;
293        if relative.as_os_str().is_empty() {
294            continue;
295        }
296
297        let output_path = target_dir.join(&relative);
298        if file.name().ends_with('/') {
299            fs::create_dir_all(&output_path).with_context(|| {
300                format!(
301                    "failed to create extracted directory {}",
302                    output_path.display()
303                )
304            })?;
305            continue;
306        }
307
308        if let Some(parent) = output_path.parent() {
309            fs::create_dir_all(parent).with_context(|| {
310                format!("failed to create parent directory {}", parent.display())
311            })?;
312        }
313        let mut output = fs::File::create(&output_path).with_context(|| {
314            format!("failed to create extracted file {}", output_path.display())
315        })?;
316        std::io::copy(&mut file, &mut output)
317            .with_context(|| format!("failed to write extracted file {}", output_path.display()))?;
318    }
319    Ok(())
320}
321
322fn normalize_archive_path(path: &Path, archive_root: Option<&str>) -> Option<PathBuf> {
323    let mut parts = Vec::new();
324    for component in path.components() {
325        match component {
326            Component::Normal(value) => parts.push(value.to_os_string()),
327            Component::CurDir => {}
328            _ => return None,
329        }
330    }
331
332    if let Some(root) = archive_root {
333        if parts.first().and_then(|value| value.to_str()) == Some(root) {
334            parts.remove(0);
335        }
336    }
337
338    let mut normalized = PathBuf::new();
339    for part in parts {
340        normalized.push(part);
341    }
342    Some(normalized)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use agent_diva_core::config::Config;
349    use std::io::Write;
350    use tempfile::TempDir;
351    use zip::write::FileOptions;
352
353    fn write_skill(dir: &Path, name: &str, content: &str) {
354        let skill_dir = dir.join("skills").join(name);
355        fs::create_dir_all(&skill_dir).unwrap();
356        fs::write(skill_dir.join("SKILL.md"), content).unwrap();
357    }
358
359    fn write_config(config_dir: &Path, workspace: &Path) {
360        let loader = ConfigLoader::with_dir(config_dir);
361        let mut config = Config::default();
362        config.agents.defaults.workspace = workspace.display().to_string();
363        loader.save(&config).unwrap();
364    }
365
366    fn make_zip(entries: &[(&str, &str)]) -> Vec<u8> {
367        let mut cursor = Cursor::new(Vec::new());
368        {
369            let mut writer = zip::ZipWriter::new(&mut cursor);
370            let options = FileOptions::default();
371            for (path, body) in entries {
372                writer.start_file(*path, options).unwrap();
373                writer.write_all(body.as_bytes()).unwrap();
374            }
375            writer.finish().unwrap();
376        }
377        cursor.into_inner()
378    }
379
380    #[test]
381    fn list_skills_marks_active_and_delete_flags() {
382        let config_dir = TempDir::new().unwrap();
383        let workspace = TempDir::new().unwrap();
384        write_config(config_dir.path(), workspace.path());
385        write_skill(
386            workspace.path(),
387            "active-skill",
388            "---\nname: active-skill\ndescription: Active\nmetadata: '{\"nanobot\":{\"always\":true}}'\n---\n\n# Active\n",
389        );
390
391        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
392        let skills = service.list_skills().unwrap();
393        let active = skills
394            .iter()
395            .find(|skill| skill.name == "active-skill")
396            .unwrap();
397        assert!(active.active);
398        assert!(active.can_delete);
399        assert_eq!(active.source, "workspace");
400    }
401
402    #[test]
403    fn upload_skill_zip_supports_single_root_folder() {
404        let config_dir = TempDir::new().unwrap();
405        let workspace = TempDir::new().unwrap();
406        write_config(config_dir.path(), workspace.path());
407        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
408
409        let bytes = make_zip(&[(
410            "sample-skill/SKILL.md",
411            "---\nname: sample-skill\ndescription: Sample\n---\n\n# Skill\n",
412        )]);
413
414        let uploaded = service.upload_skill_zip("sample-skill.zip", bytes).unwrap();
415        assert_eq!(uploaded.name, "sample-skill");
416        assert!(workspace
417            .path()
418            .join("skills")
419            .join("sample-skill")
420            .join("SKILL.md")
421            .exists());
422    }
423
424    #[test]
425    fn upload_skill_zip_supports_flat_layout_and_frontmatter_name() {
426        let config_dir = TempDir::new().unwrap();
427        let workspace = TempDir::new().unwrap();
428        write_config(config_dir.path(), workspace.path());
429        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
430
431        let bytes = make_zip(&[(
432            "SKILL.md",
433            "---\nname: flat-skill\ndescription: Flat\n---\n\n# Skill\n",
434        )]);
435
436        let uploaded = service.upload_skill_zip("ignored.zip", bytes).unwrap();
437        assert_eq!(uploaded.name, "flat-skill");
438    }
439
440    #[test]
441    fn upload_skill_zip_rejects_missing_skill_file() {
442        let config_dir = TempDir::new().unwrap();
443        let workspace = TempDir::new().unwrap();
444        write_config(config_dir.path(), workspace.path());
445        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
446
447        let err = service
448            .upload_skill_zip("invalid.zip", make_zip(&[("README.md", "# nope\n")]))
449            .unwrap_err();
450
451        assert!(err.to_string().contains("SKILL.md"));
452    }
453
454    #[test]
455    fn upload_skill_zip_rejects_path_traversal() {
456        let config_dir = TempDir::new().unwrap();
457        let workspace = TempDir::new().unwrap();
458        write_config(config_dir.path(), workspace.path());
459        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
460
461        let err = service
462            .upload_skill_zip(
463                "bad.zip",
464                make_zip(&[("../evil/SKILL.md", "---\nname: bad\n---\n\n# Bad\n")]),
465            )
466            .unwrap_err();
467
468        assert!(err.to_string().contains("invalid path"));
469    }
470
471    #[test]
472    fn delete_workspace_skill_and_restore_builtin_view() {
473        let config_dir = TempDir::new().unwrap();
474        let workspace = TempDir::new().unwrap();
475        write_config(config_dir.path(), workspace.path());
476        write_skill(
477            workspace.path(),
478            "weather",
479            "---\nname: weather\ndescription: Workspace Weather\n---\n\n# Workspace\n",
480        );
481        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
482
483        let before = service.list_skills().unwrap();
484        let weather = before.iter().find(|skill| skill.name == "weather").unwrap();
485        assert_eq!(weather.source, "workspace");
486
487        service.delete_skill("weather").unwrap();
488
489        let after = service.list_skills().unwrap();
490        let weather = after.iter().find(|skill| skill.name == "weather").unwrap();
491        assert_eq!(weather.source, "builtin");
492    }
493
494    #[test]
495    fn delete_builtin_skill_is_rejected() {
496        let config_dir = TempDir::new().unwrap();
497        let workspace = TempDir::new().unwrap();
498        write_config(config_dir.path(), workspace.path());
499        let service = SkillService::new(ConfigLoader::with_dir(config_dir.path()));
500
501        let err = service.delete_skill("weather").unwrap_err();
502        assert!(err.to_string().contains("builtin"));
503    }
504}