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}