1use super::{
14 ParsedSkillFile, SkillManifest, SkillSource, SkillWriteRequest, SkillWriteResult,
15 SkillWriteScope, parse_skill_md, resolve_skill_id, slugify_skill_id,
16};
17use anyhow::{Context, Result};
18use std::fs;
19use std::path::{Path, PathBuf};
20
21pub struct SkillStore {
23 data_dir: PathBuf,
24 project_dir: Option<PathBuf>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SkillPool {
30 pub id: String,
31 pub name: String,
32 pub description: Option<String>,
33 pub scope: SkillWriteScope,
34 pub path: PathBuf,
35 pub skill_count: usize,
36}
37
38impl SkillStore {
39 pub fn open(data_dir: &Path) -> Result<Self> {
41 let root = data_dir.join("skills");
42 fs::create_dir_all(&root)
43 .with_context(|| format!("failed to create skills dir {}", root.display()))?;
44 let store = Self {
45 data_dir: data_dir.to_path_buf(),
46 project_dir: None,
47 };
48 if let Err(err) = store.migrate_from_sqlite_if_present() {
49 tracing::warn!(error = %err, "legacy skills.sqlite migration skipped");
50 }
51 Ok(store)
52 }
53
54 pub fn open_with_project(data_dir: &Path, project_dir: &Path) -> Result<Self> {
55 let mut store = Self::open(data_dir)?;
56 store.project_dir = Some(project_dir.to_path_buf());
57 Ok(store)
58 }
59
60 #[cfg(test)]
61 pub fn open_memory() -> Result<Self> {
62 let dir = tempfile::tempdir().context("tempdir for skill store")?;
63 let path = dir.keep();
64 Self::open(&path)
65 }
66
67 pub fn path(&self) -> PathBuf {
68 self.data_dir.join("skills")
69 }
70
71 fn user_root(&self) -> PathBuf {
72 self.data_dir.join("skills")
73 }
74
75 fn project_root(&self) -> Option<PathBuf> {
76 self.project_dir
77 .as_ref()
78 .map(|p| p.join(".navi").join("skills"))
79 }
80
81 fn skill_file_path(
82 &self,
83 id: &str,
84 pool: Option<&str>,
85 scope: SkillWriteScope,
86 ) -> Result<PathBuf> {
87 let root = match scope {
88 SkillWriteScope::User => self.user_root(),
89 SkillWriteScope::Project => self.project_root().ok_or_else(|| {
90 anyhow::anyhow!("project-scoped skill requires an active project")
91 })?,
92 };
93 Ok(match pool.filter(|p| !p.is_empty()) {
94 Some(pool) => root.join(pool).join(id).join("SKILL.md"),
95 None => root.join(id).join("SKILL.md"),
96 })
97 }
98
99 pub fn list_all(&self) -> Result<Vec<SkillManifest>> {
101 let mut out = list_skills_recursive(&self.user_root(), SkillWriteScope::User, None)?;
102 if let Some(root) = self.project_root() {
103 out.extend(list_skills_recursive(
104 &root,
105 SkillWriteScope::Project,
106 None,
107 )?);
108 }
109 out.sort_by(|a, b| a.pool.cmp(&b.pool).then_with(|| a.id.cmp(&b.id)));
110 out.dedup_by(|a, b| a.id == b.id && a.pool == b.pool);
111 Ok(out)
112 }
113
114 pub fn list_root_skills(&self) -> Result<Vec<SkillManifest>> {
116 let mut out = list_root_skills_in(&self.user_root(), SkillWriteScope::User)?;
117 if let Some(root) = self.project_root() {
118 out.extend(list_root_skills_in(&root, SkillWriteScope::Project)?);
119 }
120 out.sort_by(|a, b| a.id.cmp(&b.id));
121 out.dedup_by(|a, b| a.id == b.id);
122 Ok(out)
123 }
124
125 pub fn list_pools(&self) -> Result<Vec<SkillPool>> {
127 let mut out = list_pools_in(&self.user_root(), SkillWriteScope::User)?;
128 if let Some(root) = self.project_root() {
129 out.extend(list_pools_in(&root, SkillWriteScope::Project)?);
130 }
131 out.sort_by(|a, b| a.id.cmp(&b.id));
132 out.dedup_by(|a, b| a.id == b.id);
133 Ok(out)
134 }
135
136 pub fn list_pool_skills(&self, pool_id: &str) -> Result<Vec<SkillManifest>> {
138 let pool = slugify_skill_id(pool_id);
139 let mut out = Vec::new();
140 let user_pool = self.user_root().join(&pool);
141 if user_pool.is_dir() {
142 out.extend(list_skills_in_pool_dir(
143 &user_pool,
144 &pool,
145 SkillWriteScope::User,
146 )?);
147 }
148 if let Some(root) = self.project_root() {
149 let p = root.join(&pool);
150 if p.is_dir() {
151 out.extend(list_skills_in_pool_dir(
152 &p,
153 &pool,
154 SkillWriteScope::Project,
155 )?);
156 }
157 }
158 out.sort_by(|a, b| a.id.cmp(&b.id));
159 out.dedup_by(|a, b| a.id == b.id);
160 Ok(out)
161 }
162
163 pub fn list_for_discovery(&self, _project_key: Option<&str>) -> Result<Vec<SkillManifest>> {
164 self.list_all()
165 }
166
167 pub fn get(&self, id: &str) -> Result<Option<SkillManifest>> {
168 self.get_in_pool(id, None)
169 }
170
171 pub fn get_in_pool(&self, id: &str, pool: Option<&str>) -> Result<Option<SkillManifest>> {
173 let (pool_hint, skill_id) = split_pool_skill_ref(id, pool);
174 let skill_id = slugify_skill_id(&skill_id);
175 let pool_hint = pool_hint.map(|p| slugify_skill_id(&p));
176
177 for scope in [SkillWriteScope::Project, SkillWriteScope::User] {
179 if matches!(scope, SkillWriteScope::Project) && self.project_dir.is_none() {
180 continue;
181 }
182 if let Some(ref p) = pool_hint {
183 let path = self.skill_file_path(&skill_id, Some(p), scope)?;
184 if path.is_file() {
185 return Ok(Some(load_skill_md(&path, scope, Some(p))?));
186 }
187 } else {
188 let root_path = self.skill_file_path(&skill_id, None, scope)?;
190 if root_path.is_file() {
191 return Ok(Some(load_skill_md(&root_path, scope, None)?));
192 }
193 let root = match scope {
194 SkillWriteScope::User => self.user_root(),
195 SkillWriteScope::Project => self.project_root().unwrap(),
196 };
197 if let Some(found) = find_skill_in_pools(&root, &skill_id, scope)? {
198 return Ok(Some(found));
199 }
200 }
201 }
202 Ok(None)
203 }
204
205 pub fn upsert(
206 &self,
207 request: &SkillWriteRequest,
208 _project_key: Option<&str>,
209 ) -> Result<SkillWriteResult> {
210 let id = resolve_skill_id(request)?;
211 let name = request.name.trim();
212 if name.is_empty() {
213 return Err(anyhow::anyhow!("skill name is required"));
214 }
215 let instructions = request.instructions.trim();
216 if instructions.is_empty() {
217 return Err(anyhow::anyhow!("skill instructions cannot be empty"));
218 }
219 if matches!(request.scope, SkillWriteScope::Project) && self.project_dir.is_none() {
220 return Err(anyhow::anyhow!(
221 "project-scoped skill requires an active project"
222 ));
223 }
224
225 let pool = request
226 .pool
227 .as_deref()
228 .map(str::trim)
229 .filter(|s| !s.is_empty())
230 .map(slugify_skill_id);
231
232 if let Some(ref p) = pool {
233 ensure_pool_meta(
234 match request.scope {
235 SkillWriteScope::User => self.user_root().join(p),
236 SkillWriteScope::Project => self
237 .project_root()
238 .ok_or_else(|| {
239 anyhow::anyhow!("project-scoped skill requires an active project")
240 })?
241 .join(p),
242 },
243 p,
244 )?;
245 }
246
247 let path = self.skill_file_path(&id, pool.as_deref(), request.scope)?;
248 let created = !path.is_file();
249 if let Some(parent) = path.parent() {
250 fs::create_dir_all(parent)
251 .with_context(|| format!("failed to create skill dir {}", parent.display()))?;
252 }
253
254 let body = render_skill_md(request, &id);
255 fs::write(&path, body)
256 .with_context(|| format!("failed to write skill file {}", path.display()))?;
257
258 let skill = load_skill_md(&path, request.scope, pool.as_deref())?;
259 Ok(SkillWriteResult {
260 skill,
261 path,
262 created,
263 })
264 }
265
266 pub fn delete(&self, id: &str) -> Result<bool> {
267 let (pool_hint, skill_id) = split_pool_skill_ref(id, None);
268 let skill_id = slugify_skill_id(&skill_id);
269 let mut deleted = false;
270
271 if let Some(skill) = self.get_in_pool(&skill_id, pool_hint.as_deref())? {
272 if let Some(parent) = skill.path.parent() {
273 if parent.is_dir() {
274 fs::remove_dir_all(parent).with_context(|| {
275 format!("failed to delete skill dir {}", parent.display())
276 })?;
277 deleted = true;
278 }
279 }
280 }
281 Ok(deleted)
282 }
283
284 fn migrate_from_sqlite_if_present(&self) -> Result<()> {
285 let sqlite_path = self.data_dir.join("skills.sqlite");
286 if !sqlite_path.is_file() {
287 return Ok(());
288 }
289 let conn = rusqlite::Connection::open(&sqlite_path)
290 .with_context(|| format!("open legacy {}", sqlite_path.display()))?;
291 let mut stmt = match conn.prepare(
292 "SELECT id, name, description, version, author, tags, requires, allow_tools, deny_tools,
293 instructions, scope,
294 COALESCE(harness, 0)
295 FROM skills",
296 ) {
297 Ok(s) => s,
298 Err(_) => conn.prepare(
299 "SELECT id, name, description, version, author, tags, requires, allow_tools, deny_tools,
300 instructions, scope, 0
301 FROM skills",
302 )?,
303 };
304 let rows = stmt.query_map([], |row| {
305 Ok((
306 row.get::<_, String>(0)?,
307 row.get::<_, String>(1)?,
308 row.get::<_, Option<String>>(2)?,
309 row.get::<_, Option<String>>(3)?,
310 row.get::<_, Option<String>>(4)?,
311 row.get::<_, String>(5).unwrap_or_else(|_| "[]".into()),
312 row.get::<_, String>(6).unwrap_or_else(|_| "[]".into()),
313 row.get::<_, String>(7).unwrap_or_else(|_| "[]".into()),
314 row.get::<_, String>(8).unwrap_or_else(|_| "[]".into()),
315 row.get::<_, String>(9)?,
316 row.get::<_, String>(10).unwrap_or_else(|_| "user".into()),
317 row.get::<_, i32>(11).unwrap_or(0),
318 ))
319 })?;
320
321 for row in rows {
322 let (
323 id,
324 name,
325 description,
326 version,
327 author,
328 tags_raw,
329 requires_raw,
330 allow_raw,
331 deny_raw,
332 instructions,
333 _scope_raw,
334 harness,
335 ) = row?;
336 let path = self.skill_file_path(&id, None, SkillWriteScope::User)?;
337 if path.is_file() {
338 continue;
339 }
340 let request = SkillWriteRequest {
341 id: id.clone(),
342 name,
343 description,
344 version,
345 author,
346 tags: serde_json::from_str(&tags_raw).unwrap_or_default(),
347 requires: serde_json::from_str(&requires_raw).unwrap_or_default(),
348 allow_tools: serde_json::from_str(&allow_raw).unwrap_or_default(),
349 deny_tools: serde_json::from_str(&deny_raw).unwrap_or_default(),
350 harness: harness != 0,
351 pool: None,
352 instructions,
353 scope: SkillWriteScope::User,
354 };
355 let body = render_skill_md(&request, &id);
356 if let Some(parent) = path.parent() {
357 fs::create_dir_all(parent)?;
358 }
359 fs::write(&path, body)?;
360 tracing::info!(id = %id, path = %path.display(), "migrated skill from skills.sqlite");
361 }
362 Ok(())
363 }
364}
365
366fn split_pool_skill_ref(id: &str, pool: Option<&str>) -> (Option<String>, String) {
367 if let Some(p) = pool.filter(|s| !s.trim().is_empty()) {
368 return (Some(p.to_string()), id.to_string());
369 }
370 if let Some((p, s)) = id.split_once('/') {
371 if !p.is_empty() && !s.is_empty() {
372 return (Some(p.to_string()), s.to_string());
373 }
374 }
375 (None, id.to_string())
376}
377
378fn ensure_pool_meta(pool_dir: PathBuf, pool_id: &str) -> Result<()> {
379 fs::create_dir_all(&pool_dir)
380 .with_context(|| format!("failed to create pool dir {}", pool_dir.display()))?;
381 let meta = pool_dir.join("POOL.md");
382 if !meta.is_file() {
383 let body = format!(
384 "---\nname: {}\nid: {}\ndescription: Skill pool\n---\n\n# {}\n\nSkill pool folder.\n",
385 pool_id, pool_id, pool_id
386 );
387 fs::write(&meta, body)?;
388 }
389 Ok(())
390}
391
392fn list_root_skills_in(root: &Path, scope: SkillWriteScope) -> Result<Vec<SkillManifest>> {
393 if !root.is_dir() {
394 return Ok(Vec::new());
395 }
396 let mut out = Vec::new();
397 for entry in fs::read_dir(root)
398 .with_context(|| format!("failed to read skills dir {}", root.display()))?
399 {
400 let entry = entry?;
401 let path = entry.path();
402 if !path.is_dir() {
403 if path.extension().and_then(|e| e.to_str()) == Some("md")
404 && !path
405 .file_name()
406 .and_then(|n| n.to_str())
407 .unwrap_or("")
408 .eq_ignore_ascii_case("POOL.md")
409 {
410 if let Ok(skill) = load_skill_md(&path, scope, None) {
411 out.push(skill);
412 }
413 }
414 continue;
415 }
416 if path.join("POOL.md").is_file() {
418 continue;
419 }
420 let skill_md = path.join("SKILL.md");
421 if skill_md.is_file() {
422 if let Ok(skill) = load_skill_md(&skill_md, scope, None) {
423 out.push(skill);
424 }
425 }
426 }
427 Ok(out)
428}
429
430fn list_pools_in(root: &Path, scope: SkillWriteScope) -> Result<Vec<SkillPool>> {
431 if !root.is_dir() {
432 return Ok(Vec::new());
433 }
434 let mut out = Vec::new();
435 for entry in fs::read_dir(root)
436 .with_context(|| format!("failed to read skills dir {}", root.display()))?
437 {
438 let entry = entry?;
439 let path = entry.path();
440 if !path.is_dir() {
441 continue;
442 }
443 let is_pool = path.join("POOL.md").is_file()
445 || (!path.join("SKILL.md").is_file() && dir_has_nested_skills(&path));
446 if !is_pool {
447 continue;
448 }
449 let id = path
450 .file_name()
451 .and_then(|n| n.to_str())
452 .unwrap_or("pool")
453 .to_string();
454 let (name, description) = read_pool_meta(&path, &id);
455 let skills = list_skills_in_pool_dir(&path, &id, scope)?;
456 out.push(SkillPool {
457 id: slugify_skill_id(&id),
458 name,
459 description,
460 scope,
461 path: path.clone(),
462 skill_count: skills.len(),
463 });
464 }
465 Ok(out)
466}
467
468fn dir_has_nested_skills(path: &Path) -> bool {
469 let Ok(entries) = fs::read_dir(path) else {
470 return false;
471 };
472 for e in entries.flatten() {
473 let p = e.path();
474 if p.is_dir() && p.join("SKILL.md").is_file() {
475 return true;
476 }
477 }
478 false
479}
480
481fn read_pool_meta(path: &Path, fallback_id: &str) -> (String, Option<String>) {
482 let meta = path.join("POOL.md");
483 if meta.is_file() {
484 if let Ok(raw) = fs::read_to_string(&meta) {
485 let parsed = parse_skill_md(&raw, fallback_id);
486 let name = if parsed.name.trim().is_empty() {
487 fallback_id.to_string()
488 } else {
489 parsed.name
490 };
491 return (name, parsed.description);
492 }
493 }
494 (
495 fallback_id.to_string(),
496 Some(format!("Skill pool `{fallback_id}`")),
497 )
498}
499
500fn list_skills_in_pool_dir(
501 pool_dir: &Path,
502 pool_id: &str,
503 scope: SkillWriteScope,
504) -> Result<Vec<SkillManifest>> {
505 let mut out = Vec::new();
506 if !pool_dir.is_dir() {
507 return Ok(out);
508 }
509 for entry in fs::read_dir(pool_dir)
510 .with_context(|| format!("failed to read pool dir {}", pool_dir.display()))?
511 {
512 let entry = entry?;
513 let path = entry.path();
514 if path
515 .file_name()
516 .and_then(|n| n.to_str())
517 .map(|n| n.eq_ignore_ascii_case("POOL.md"))
518 .unwrap_or(false)
519 {
520 continue;
521 }
522 if path.is_dir() {
523 let skill_md = path.join("SKILL.md");
524 if skill_md.is_file() {
525 if let Ok(skill) = load_skill_md(&skill_md, scope, Some(pool_id)) {
526 out.push(skill);
527 }
528 }
529 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
530 if let Ok(skill) = load_skill_md(&path, scope, Some(pool_id)) {
531 out.push(skill);
532 }
533 }
534 }
535 Ok(out)
536}
537
538fn list_skills_recursive(
539 root: &Path,
540 scope: SkillWriteScope,
541 pool: Option<&str>,
542) -> Result<Vec<SkillManifest>> {
543 if !root.is_dir() {
544 return Ok(Vec::new());
545 }
546 let mut out = Vec::new();
547 out.extend(list_root_skills_in(root, scope)?);
549 for pool_meta in list_pools_in(root, scope)? {
551 out.extend(list_skills_in_pool_dir(
552 &pool_meta.path,
553 &pool_meta.id,
554 scope,
555 )?);
556 }
557 if let Some(p) = pool {
559 out.extend(list_skills_in_pool_dir(root, p, scope)?);
560 }
561 Ok(out)
562}
563
564fn find_skill_in_pools(
565 root: &Path,
566 skill_id: &str,
567 scope: SkillWriteScope,
568) -> Result<Option<SkillManifest>> {
569 for pool in list_pools_in(root, scope)? {
570 let path = pool.path.join(skill_id).join("SKILL.md");
571 if path.is_file() {
572 return Ok(Some(load_skill_md(&path, scope, Some(&pool.id))?));
573 }
574 }
575 Ok(None)
576}
577
578fn load_skill_md(path: &Path, scope: SkillWriteScope, pool: Option<&str>) -> Result<SkillManifest> {
579 let raw = fs::read_to_string(path)
580 .with_context(|| format!("failed to read skill file {}", path.display()))?;
581 let fallback = path.file_stem().and_then(|s| s.to_str()).unwrap_or("skill");
582 let fallback = if fallback.eq_ignore_ascii_case("skill") {
583 path.parent()
584 .and_then(|p| p.file_name())
585 .and_then(|s| s.to_str())
586 .unwrap_or(fallback)
587 } else {
588 fallback
589 };
590 let parsed = parse_skill_md(&raw, fallback);
591 Ok(parsed_to_manifest(parsed, path, scope, pool))
592}
593
594fn parsed_to_manifest(
595 parsed: ParsedSkillFile,
596 path: &Path,
597 scope: SkillWriteScope,
598 pool: Option<&str>,
599) -> SkillManifest {
600 let id = parsed
601 .id
602 .as_deref()
603 .map(str::trim)
604 .filter(|s| !s.is_empty())
605 .map(|s| s.to_string())
606 .unwrap_or_else(|| {
607 path.parent()
608 .and_then(|p| p.file_name())
609 .and_then(|s| s.to_str())
610 .filter(|s| !s.eq_ignore_ascii_case("skills"))
611 .unwrap_or("skill")
612 .to_string()
613 });
614 let pool = pool
615 .map(|p| p.to_string())
616 .or(parsed.pool)
617 .filter(|p| !p.is_empty());
618 SkillManifest {
619 id: slugify_skill_id(&id),
620 name: if parsed.name.trim().is_empty() {
621 id
622 } else {
623 parsed.name
624 },
625 description: parsed.description,
626 version: parsed.version,
627 author: parsed.author,
628 tags: parsed.tags,
629 requires: parsed.requires,
630 allow_tools: parsed.allow_tools,
631 deny_tools: parsed.deny_tools,
632 harness: parsed.harness,
633 pool,
634 path: path.to_path_buf(),
635 instructions: parsed.instructions,
636 source: SkillSource::Store,
637 scope,
638 }
639}
640
641pub fn render_skill_md(request: &SkillWriteRequest, id: &str) -> String {
643 let mut out = String::from("---\n");
644 out.push_str(&format!("name: {}\n", yaml_quote(&request.name)));
645 out.push_str(&format!("id: {}\n", yaml_quote(id)));
646 if let Some(desc) = request
647 .description
648 .as_ref()
649 .map(|s| s.trim())
650 .filter(|s| !s.is_empty())
651 {
652 out.push_str(&format!("description: {}\n", yaml_quote(desc)));
653 }
654 if let Some(version) = request
655 .version
656 .as_ref()
657 .map(|s| s.trim())
658 .filter(|s| !s.is_empty())
659 {
660 out.push_str(&format!("version: {}\n", yaml_quote(version)));
661 }
662 if let Some(author) = request
663 .author
664 .as_ref()
665 .map(|s| s.trim())
666 .filter(|s| !s.is_empty())
667 {
668 out.push_str(&format!("author: {}\n", yaml_quote(author)));
669 }
670 if let Some(pool) = request
671 .pool
672 .as_ref()
673 .map(|s| s.trim())
674 .filter(|s| !s.is_empty())
675 {
676 out.push_str(&format!("pool: {}\n", yaml_quote(pool)));
677 }
678 if !request.tags.is_empty() {
679 out.push_str(&format!("tags: {}\n", yaml_list(&request.tags)));
680 }
681 if !request.requires.is_empty() {
682 out.push_str(&format!("requires: {}\n", yaml_list(&request.requires)));
683 }
684 if !request.allow_tools.is_empty() {
685 out.push_str(&format!(
686 "allow_tools: {}\n",
687 yaml_list(&request.allow_tools)
688 ));
689 }
690 if !request.deny_tools.is_empty() {
691 out.push_str(&format!("deny_tools: {}\n", yaml_list(&request.deny_tools)));
692 }
693 if request.harness {
694 out.push_str("harness: true\n");
695 }
696 out.push_str("---\n\n");
697 out.push_str(request.instructions.trim());
698 out.push('\n');
699 out
700}
701
702fn yaml_quote(value: &str) -> String {
703 if value.is_empty() {
704 return "\"\"".into();
705 }
706 if value.contains(':')
707 || value.contains('#')
708 || value.contains('"')
709 || value.contains('\'')
710 || value.starts_with(' ')
711 || value.ends_with(' ')
712 || value.contains('\n')
713 {
714 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
715 } else {
716 value.to_string()
717 }
718}
719
720fn yaml_list(items: &[String]) -> String {
721 let cleaned: Vec<String> = items
722 .iter()
723 .map(|s| s.trim().to_string())
724 .filter(|s| !s.is_empty())
725 .collect();
726 format!(
727 "[{}]",
728 cleaned
729 .iter()
730 .map(|s| yaml_quote(s))
731 .collect::<Vec<_>>()
732 .join(", ")
733 )
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn upsert_and_list_roundtrip() {
742 let store = SkillStore::open_memory().expect("open");
743 let result = store
744 .upsert(
745 &SkillWriteRequest {
746 id: "demo".into(),
747 name: "Demo".into(),
748 description: Some("desc".into()),
749 version: None,
750 author: None,
751 tags: vec!["t".into()],
752 requires: vec![],
753 allow_tools: vec!["read_file".into(), "skill_save".into()],
754 deny_tools: vec![],
755 harness: false,
756 pool: None,
757 instructions: "Do the demo.".into(),
758 scope: SkillWriteScope::User,
759 },
760 None,
761 )
762 .expect("upsert");
763 assert!(result.created);
764 assert_eq!(result.skill.allow_tools, vec!["read_file", "skill_save"]);
765 assert!(result.path.ends_with("SKILL.md"));
766 let listed = store.list_for_discovery(None).expect("list");
767 assert_eq!(listed.len(), 1);
768 assert_eq!(listed[0].id, "demo");
769 }
770
771 #[test]
772 fn pool_hides_members_from_root_list() {
773 let store = SkillStore::open_memory().expect("open");
774 store
775 .upsert(
776 &SkillWriteRequest {
777 id: "create-skill".into(),
778 name: "Create Skill".into(),
779 description: Some("Author skills".into()),
780 version: None,
781 author: None,
782 tags: vec!["navi".into()],
783 requires: vec![],
784 allow_tools: vec!["skill_save".into()],
785 deny_tools: vec![],
786 harness: false,
787 pool: Some("navi".into()),
788 instructions: "Create carefully.".into(),
789 scope: SkillWriteScope::User,
790 },
791 None,
792 )
793 .expect("upsert");
794 let root = store.list_root_skills().expect("root");
795 assert!(root.is_empty(), "pool members must not be root: {root:?}");
796 let pools = store.list_pools().expect("pools");
797 assert_eq!(pools.len(), 1);
798 assert_eq!(pools[0].id, "navi");
799 assert_eq!(pools[0].skill_count, 1);
800 let members = store.list_pool_skills("navi").expect("members");
801 assert_eq!(members.len(), 1);
802 assert_eq!(members[0].id, "create-skill");
803 assert_eq!(members[0].pool.as_deref(), Some("navi"));
804 let loaded = store
805 .get_in_pool("create-skill", Some("navi"))
806 .expect("get")
807 .expect("present");
808 assert!(loaded.instructions.contains("Create carefully"));
809 }
810}