1mod builtin;
2mod store;
3
4pub use builtin::{CREATE_SKILL_ID, HARNESS_AUTHOR_ID, SKILL_POOLS_ID, builtin_skills};
5pub use store::{SkillPool, SkillStore};
6
7use crate::config::SkillsConfig;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "camelCase")]
16pub enum SkillSource {
17 Builtin,
19 #[default]
21 Store,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SkillManifest {
27 pub id: String,
29 pub name: String,
31 pub description: Option<String>,
33 pub version: Option<String>,
35 pub author: Option<String>,
37 pub tags: Vec<String>,
39 pub requires: Vec<String>,
41 #[serde(default)]
43 pub allow_tools: Vec<String>,
44 #[serde(default)]
46 pub deny_tools: Vec<String>,
47 #[serde(default)]
49 pub harness: bool,
50 #[serde(default)]
52 pub pool: Option<String>,
53 pub path: PathBuf,
55 pub instructions: String,
57 #[serde(default)]
59 pub source: SkillSource,
60 #[serde(default)]
62 pub scope: SkillWriteScope,
63}
64
65pub fn discover_configured_skills(
67 config: &SkillsConfig,
68 project_dir: &Path,
69 data_dir: &Path,
70) -> Result<Vec<SkillManifest>> {
71 if !config.enabled {
72 return Ok(Vec::new());
73 }
74
75 let mut skills = builtin_skills();
76
77 if let Ok(store) = SkillStore::open_with_project(data_dir, project_dir) {
78 match store.list_for_discovery(None) {
79 Ok(stored) => skills.extend(stored),
80 Err(err) => tracing::warn!(error = %err, "failed to list skills from store"),
81 }
82 }
83
84 skills.sort_by(|a, b| a.pool.cmp(&b.pool).then_with(|| a.id.cmp(&b.id)));
85 skills.dedup_by(|a, b| a.id == b.id && a.pool == b.pool);
86 Ok(skills)
87}
88
89pub fn discover_catalog_entries(
93 config: &SkillsConfig,
94 project_dir: &Path,
95 data_dir: &Path,
96) -> Result<CatalogEntries> {
97 if !config.enabled {
98 return Ok(CatalogEntries::default());
99 }
100
101 let mut root_skills = builtin_skills()
102 .into_iter()
103 .filter(|s| s.pool.is_none())
104 .collect::<Vec<_>>();
105 let mut pools = Vec::new();
106
107 let mut builtin_pool_counts: std::collections::BTreeMap<String, usize> =
110 std::collections::BTreeMap::new();
111 for skill in builtin_skills() {
112 if let Some(pool) = skill.pool.clone() {
113 *builtin_pool_counts.entry(pool).or_default() += 1;
114 }
115 }
116
117 if let Ok(store) = SkillStore::open_with_project(data_dir, project_dir) {
118 match store.list_root_skills() {
119 Ok(stored) => root_skills.extend(stored),
120 Err(err) => tracing::warn!(error = %err, "failed to list root skills"),
121 }
122 match store.list_pools() {
123 Ok(p) => pools.extend(p),
124 Err(err) => tracing::warn!(error = %err, "failed to list skill pools"),
125 }
126 }
127
128 for (pool_id, count) in builtin_pool_counts {
129 if !pools.iter().any(|p| p.id == pool_id) {
130 pools.push(SkillPool {
131 id: pool_id.clone(),
132 name: pool_id.clone(),
133 description: Some(format!("Skill pool `{pool_id}`")),
134 scope: SkillWriteScope::User,
135 path: PathBuf::from(format!("builtin-pool:{pool_id}")),
136 skill_count: count,
137 });
138 } else if let Some(p) = pools.iter_mut().find(|p| p.id == pool_id) {
139 p.skill_count = p.skill_count.saturating_add(count);
140 }
141 }
142
143 root_skills.sort_by(|a, b| a.id.cmp(&b.id));
144 root_skills.dedup_by(|a, b| a.id == b.id);
145 pools.sort_by(|a, b| a.id.cmp(&b.id));
146 pools.dedup_by(|a, b| a.id == b.id);
147
148 Ok(CatalogEntries { root_skills, pools })
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct CatalogEntries {
154 pub root_skills: Vec<SkillManifest>,
155 pub pools: Vec<SkillPool>,
156}
157
158pub fn project_skill_key(project_dir: &Path) -> String {
160 let canon = project_dir
161 .canonicalize()
162 .unwrap_or_else(|_| project_dir.to_path_buf());
163 use std::collections::hash_map::DefaultHasher;
165 use std::hash::{Hash, Hasher};
166 let mut h = DefaultHasher::new();
167 canon.to_string_lossy().hash(&mut h);
168 format!("{:x}", h.finish())
169}
170
171pub fn skill_tool_allowlist(skills: &[SkillManifest]) -> Option<Vec<String>> {
182 let with_allow: Vec<&SkillManifest> = skills
183 .iter()
184 .filter(|s| !s.allow_tools.is_empty())
185 .collect();
186 if with_allow.is_empty() {
187 return None;
188 }
189 let mut set: HashSet<String> = with_allow[0].allow_tools.iter().cloned().collect();
190 for skill in with_allow.iter().skip(1) {
191 set.retain(|t| skill.allow_tools.iter().any(|a| a == t));
192 }
193 for skill in skills {
194 for deny in &skill.deny_tools {
195 set.remove(deny);
196 }
197 }
198 let mut list: Vec<String> = set.into_iter().collect();
199 list.sort();
200 Some(list)
201}
202
203pub fn active_skills(
215 available: &[SkillManifest],
216 configured_active: &[String],
217 session_active: &[String],
218) -> Vec<SkillManifest> {
219 let requested = if !session_active.is_empty() {
220 session_active
221 } else if !configured_active.is_empty() {
222 configured_active
223 } else {
224 return available.to_vec();
226 };
227
228 available
229 .iter()
230 .filter(|skill| {
231 requested
232 .iter()
233 .any(|name| name == &skill.id || name == &skill.name)
234 })
235 .cloned()
236 .collect()
237}
238
239pub fn render_available_skills(skills: &[SkillManifest]) -> Option<String> {
245 let root: Vec<&SkillManifest> = skills.iter().filter(|s| s.pool.is_none()).collect();
247 if root.is_empty() {
248 return None;
249 }
250 render_catalog_entries(&CatalogEntries {
251 root_skills: root.into_iter().cloned().collect(),
252 pools: Vec::new(),
253 })
254}
255
256pub fn render_catalog_entries(catalog: &CatalogEntries) -> Option<String> {
258 if catalog.root_skills.is_empty() && catalog.pools.is_empty() {
259 return None;
260 }
261
262 let mut output = String::from(
263 "=== Available Skills ===\n\
264Skills and skill pools for this session. Metadata only — no instruction bodies.\n\
265- Use `skill_list` with `pool` to open a pool (like listing a folder).\n\
266- Use `load_skill` with a skill id (or `pool/id`) to read full instructions.\n",
267 );
268
269 if !catalog.pools.is_empty() {
270 output.push_str("\n## Skill pools (folders)\n");
271 for pool in &catalog.pools {
272 output.push_str(&format!(
273 "- pool: {}; name: {}; skills: {}\n",
274 pool.id, pool.name, pool.skill_count
275 ));
276 if let Some(description) = &pool.description {
277 output.push_str(&format!(" description: {}\n", description.trim()));
278 }
279 }
280 }
281
282 if !catalog.root_skills.is_empty() {
283 output.push_str("\n## Root skills\n");
284 for skill in &catalog.root_skills {
285 output.push_str(&format!("- id: {}; name: {}\n", skill.id, skill.name));
286 if let Some(description) = &skill.description {
287 output.push_str(&format!(" description: {}\n", description.trim()));
288 }
289 if let Some(version) = &skill.version {
290 output.push_str(&format!(" version: {}\n", version));
291 }
292 if !skill.tags.is_empty() {
293 output.push_str(&format!(" tags: {}\n", skill.tags.join(", ")));
294 }
295 if !skill.requires.is_empty() {
296 output.push_str(&format!(" requires: {}\n", skill.requires.join(", ")));
297 }
298 }
299 }
300
301 Some(output)
302}
303
304#[deprecated(note = "use render_available_skills; skill bodies are loaded via load_skill only")]
307pub fn render_active_skills(skills: &[SkillManifest]) -> Option<String> {
308 render_available_skills(skills)
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
313#[serde(rename_all = "camelCase")]
314pub enum SkillWriteScope {
315 #[default]
317 User,
318 Project,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct SkillWriteRequest {
326 #[serde(default)]
328 pub id: String,
329 pub name: String,
330 #[serde(default)]
331 pub description: Option<String>,
332 #[serde(default)]
333 pub version: Option<String>,
334 #[serde(default)]
335 pub author: Option<String>,
336 #[serde(default)]
337 pub tags: Vec<String>,
338 #[serde(default)]
339 pub requires: Vec<String>,
340 #[serde(default)]
342 pub allow_tools: Vec<String>,
343 #[serde(default)]
344 pub deny_tools: Vec<String>,
345 #[serde(default)]
347 pub harness: bool,
348 #[serde(default)]
350 pub pool: Option<String>,
351 pub instructions: String,
353 #[serde(default)]
354 pub scope: SkillWriteScope,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct SkillWriteResult {
361 pub skill: SkillManifest,
362 pub path: PathBuf,
363 pub created: bool,
364}
365
366pub fn resolve_skill_id(request: &SkillWriteRequest) -> Result<String> {
368 let name = request.name.trim();
369 if name.is_empty() {
370 return Err(anyhow::anyhow!("skill name is required"));
371 }
372 let id = {
373 let raw = request.id.trim();
374 if raw.is_empty() {
375 slugify_skill_id(name)
376 } else {
377 slugify_skill_id(raw)
378 }
379 };
380 if id.is_empty() || id == "." || id == ".." || id.contains('/') || id.contains('\\') {
381 return Err(anyhow::anyhow!("invalid skill id"));
382 }
383 Ok(id)
384}
385
386pub fn slugify_skill_id(raw: &str) -> String {
388 let mut out = String::new();
389 let mut prev_dash = false;
390 for ch in raw.trim().chars() {
391 let c = ch.to_ascii_lowercase();
392 if c.is_ascii_alphanumeric() {
393 out.push(c);
394 prev_dash = false;
395 } else if matches!(c, '-' | '_' | ' ' | '/' | '.') && !prev_dash && !out.is_empty() {
396 out.push('-');
397 prev_dash = true;
398 }
399 }
400 while out.ends_with('-') {
401 out.pop();
402 }
403 if out.is_empty() { "skill".into() } else { out }
404}
405
406pub fn write_skill(
408 request: &SkillWriteRequest,
409 project_dir: &Path,
410 data_dir: &Path,
411) -> Result<SkillWriteResult> {
412 let store = SkillStore::open_with_project(data_dir, project_dir)?;
413 store.upsert(request, None)
414}
415
416pub fn load_skill_by_id(
420 config: &SkillsConfig,
421 project_dir: &Path,
422 data_dir: &Path,
423 skill_id: &str,
424) -> Result<SkillManifest> {
425 let raw = skill_id.trim();
426 if raw.is_empty() {
427 return Err(anyhow::anyhow!("skill id is required"));
428 }
429
430 if let Ok(store) = SkillStore::open_with_project(data_dir, project_dir)
432 && let Ok(Some(skill)) = store.get_in_pool(raw, None)
433 {
434 return Ok(skill);
435 }
436
437 let skills = discover_configured_skills(config, project_dir, data_dir)?;
438 let (pool_hint, bare_id) = if let Some((p, id)) = raw.split_once('/') {
439 (Some(p), id)
440 } else {
441 (None, raw)
442 };
443
444 if let Some(skill) = skills.into_iter().find(|s| {
445 let id_match = s.id == bare_id || s.name == bare_id || s.id == raw || s.name == raw;
446 let pool_match = match pool_hint {
447 Some(p) => s.pool.as_deref() == Some(p),
448 None => true,
449 };
450 id_match && pool_match
451 }) {
452 return Ok(skill);
453 }
454
455 Err(anyhow::anyhow!("skill `{skill_id}` not found"))
456}
457
458pub fn delete_skill(skill_id: &str, project_dir: &Path, data_dir: &Path) -> Result<bool> {
460 let id = slugify_skill_id(skill_id);
461 if id.is_empty() {
462 return Err(anyhow::anyhow!("invalid skill id"));
463 }
464 if builtin_skills().iter().any(|s| s.id == id) {
465 return Err(anyhow::anyhow!("cannot delete built-in skill `{id}`"));
466 }
467 let store = SkillStore::open_with_project(data_dir, project_dir)?;
468 store.delete(&id)
469}
470
471pub fn skill_is_editable(skill: &SkillManifest) -> bool {
473 matches!(skill.source, SkillSource::Store)
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Default)]
478pub struct ParsedSkillFile {
479 pub id: Option<String>,
480 pub name: String,
481 pub description: Option<String>,
482 pub version: Option<String>,
483 pub author: Option<String>,
484 pub tags: Vec<String>,
485 pub requires: Vec<String>,
486 pub allow_tools: Vec<String>,
487 pub deny_tools: Vec<String>,
488 pub harness: bool,
489 pub pool: Option<String>,
490 pub instructions: String,
491}
492
493pub fn parse_skill_file(path: &Path, raw: &str, fallback_name: &str) -> Result<ParsedSkillFile> {
497 let ext = path
498 .extension()
499 .and_then(|e| e.to_str())
500 .unwrap_or("")
501 .to_ascii_lowercase();
502 match ext.as_str() {
503 "md" | "markdown" => Ok(parse_skill_md(raw, fallback_name)),
504 "toml" => parse_skill_toml(raw, fallback_name),
505 other => Err(anyhow::anyhow!(
506 "unsupported skill file extension '.{other}' (expected .md, .markdown, or .toml)"
507 )),
508 }
509}
510
511pub fn parse_skill_md(raw: &str, fallback_name: &str) -> ParsedSkillFile {
516 let trimmed = raw.trim_start();
517 if let Some(rest) = trimmed.strip_prefix("---") {
518 let rest = rest.strip_prefix('\r').unwrap_or(rest);
520 let rest = rest.strip_prefix('\n').unwrap_or(rest);
521 if let Some(end) = rest.find("\n---") {
522 let front = &rest[..end];
523 let body = rest[end + 4..]
524 .trim_start_matches('\r')
525 .trim_start_matches('\n')
526 .to_string();
527 let mut parsed = ParsedSkillFile {
528 instructions: body,
529 harness: false,
530 pool: None,
531 ..Default::default()
532 };
533 for line in front.lines() {
534 let line = line.trim();
535 if line.is_empty() || line.starts_with('#') {
536 continue;
537 }
538 let Some((key, value)) = split_yaml_key_value(line) else {
539 continue;
540 };
541 match key {
542 "name" => parsed.name = unquote(value),
543 "description" => {
544 let v = unquote(value);
545 if !v.is_empty() {
546 parsed.description = Some(v);
547 }
548 }
549 "version" => {
550 let v = unquote(value);
551 if !v.is_empty() {
552 parsed.version = Some(v);
553 }
554 }
555 "author" => {
556 let v = unquote(value);
557 if !v.is_empty() {
558 parsed.author = Some(v);
559 }
560 }
561 "id" => {
562 let v = unquote(value);
563 if !v.is_empty() {
564 parsed.id = Some(v);
565 }
566 }
567 "tags" => parsed.tags = parse_yaml_list(value),
568 "requires" => parsed.requires = parse_yaml_list(value),
569 "allow_tools" => parsed.allow_tools = parse_yaml_list(value),
570 "deny_tools" => parsed.deny_tools = parse_yaml_list(value),
571 "harness" => parsed.harness = parse_yaml_bool(value),
572 "pool" => {
573 let v = unquote(value);
574 if !v.is_empty() {
575 parsed.pool = Some(v);
576 }
577 }
578 _ => {}
579 }
580 }
581 if parsed.name.trim().is_empty() {
582 parsed.name = fallback_name.trim().to_string();
583 }
584 if parsed.name.trim().is_empty() {
585 parsed.name = "Imported Skill".into();
586 }
587 return parsed;
588 }
589 }
590
591 let mut name = fallback_name.trim().to_string();
592 if name.is_empty() {
593 name = "Imported Skill".into();
594 }
595 ParsedSkillFile {
596 name,
597 instructions: raw.to_string(),
598 ..Default::default()
599 }
600}
601
602pub fn parse_skill_toml(raw: &str, fallback_name: &str) -> Result<ParsedSkillFile> {
604 #[derive(Deserialize)]
605 struct SkillFile {
606 #[serde(default)]
607 id: Option<String>,
608 #[serde(default)]
609 name: Option<String>,
610 #[serde(default)]
611 description: Option<String>,
612 #[serde(default)]
613 version: Option<String>,
614 #[serde(default)]
615 author: Option<String>,
616 #[serde(default)]
617 tags: Vec<String>,
618 #[serde(default)]
619 requires: Vec<String>,
620 #[serde(default)]
621 allow_tools: Vec<String>,
622 #[serde(default)]
623 deny_tools: Vec<String>,
624 #[serde(default)]
625 harness: bool,
626 #[serde(default)]
627 pool: Option<String>,
628 #[serde(default)]
629 instructions: String,
630 }
631 let file: SkillFile =
632 toml::from_str(raw).map_err(|e| anyhow::anyhow!("failed to parse skill TOML: {e}"))?;
633 let mut name = file
634 .name
635 .map(|s| s.trim().to_string())
636 .filter(|s| !s.is_empty())
637 .unwrap_or_else(|| fallback_name.trim().to_string());
638 if name.is_empty() {
639 name = "Imported Skill".into();
640 }
641 Ok(ParsedSkillFile {
642 id: file
643 .id
644 .map(|s| s.trim().to_string())
645 .filter(|s| !s.is_empty()),
646 name,
647 description: file
648 .description
649 .map(|s| s.trim().to_string())
650 .filter(|s| !s.is_empty()),
651 version: file
652 .version
653 .map(|s| s.trim().to_string())
654 .filter(|s| !s.is_empty()),
655 author: file
656 .author
657 .map(|s| s.trim().to_string())
658 .filter(|s| !s.is_empty()),
659 tags: file.tags,
660 requires: file.requires,
661 allow_tools: file.allow_tools,
662 deny_tools: file.deny_tools,
663 harness: file.harness,
664 pool: file.pool.filter(|s| !s.trim().is_empty()),
665 instructions: file.instructions,
666 })
667}
668
669pub fn list_installed_skills(project_dir: &Path, data_dir: &Path) -> Result<Vec<SkillManifest>> {
673 let mut skills = builtin_skills();
674 if let Ok(store) = SkillStore::open_with_project(data_dir, project_dir) {
675 match store.list_for_discovery(None) {
676 Ok(stored) => skills.extend(stored),
677 Err(err) => tracing::warn!(error = %err, "failed to list skills from store"),
678 }
679 }
680 skills.sort_by(|a, b| a.id.cmp(&b.id));
681 skills.dedup_by(|a, b| a.id == b.id);
682 Ok(skills)
683}
684
685fn split_yaml_key_value(line: &str) -> Option<(&str, &str)> {
686 let (key, value) = line.split_once(':')?;
687 let key = key.trim();
688 if key.is_empty() {
689 return None;
690 }
691 Some((key, value.trim()))
692}
693
694fn unquote(value: &str) -> String {
695 let v = value.trim();
696 if (v.starts_with('"') && v.ends_with('"') && v.len() >= 2)
697 || (v.starts_with('\'') && v.ends_with('\'') && v.len() >= 2)
698 {
699 v[1..v.len() - 1].to_string()
700 } else {
701 v.to_string()
702 }
703}
704
705fn parse_yaml_list(value: &str) -> Vec<String> {
707 let t = value.trim();
708 if t.is_empty() {
709 return Vec::new();
710 }
711 if let Some(inner) = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
712 return inner
713 .split(',')
714 .map(|s| unquote(s.trim()))
715 .filter(|s| !s.is_empty())
716 .collect();
717 }
718 t.split(',')
719 .map(|s| unquote(s.trim()))
720 .filter(|s| !s.is_empty())
721 .collect()
722}
723
724fn parse_yaml_bool(value: &str) -> bool {
725 matches!(
726 value.trim().to_ascii_lowercase().as_str(),
727 "true" | "yes" | "1" | "on"
728 )
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 fn cfg() -> SkillsConfig {
736 SkillsConfig {
737 enabled: true,
738 active: Vec::new(),
739 }
740 }
741
742 #[test]
743 fn discovers_builtin_create_skill() {
744 let tempdir = tempfile::tempdir().expect("tempdir");
745 let skills =
746 discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
747 assert!(skills.iter().any(|s| s.id == CREATE_SKILL_ID));
748 let create = skills.iter().find(|s| s.id == CREATE_SKILL_ID).unwrap();
749 assert!(!create.allow_tools.is_empty());
750 assert!(create.allow_tools.iter().any(|t| t == "skill_save"));
751 assert_eq!(create.pool.as_deref(), Some("navi"));
752 assert!(
753 !create.harness,
754 "create-skill must not be harness-flagged (would soft-lock root)"
755 );
756 }
757
758 #[test]
759 fn discovers_essential_navi_pool_builtins() {
760 let skills = builtin_skills();
761 for id in [CREATE_SKILL_ID, HARNESS_AUTHOR_ID, SKILL_POOLS_ID] {
762 let s = skills
763 .iter()
764 .find(|s| s.id == id)
765 .unwrap_or_else(|| panic!("missing builtin {id}"));
766 assert_eq!(s.pool.as_deref(), Some("navi"), "{id} must be in pool navi");
767 assert!(!s.harness, "{id} must not soft-lock via harness flag");
768 assert_eq!(s.source, SkillSource::Builtin);
769 assert!(!s.instructions.is_empty(), "{id} needs instructions");
770 }
771 let create = skills.iter().find(|s| s.id == CREATE_SKILL_ID).unwrap();
773 let desc = create.description.as_deref().unwrap_or("");
774 assert!(
775 desc.to_ascii_lowercase().contains("skill"),
776 "create-skill description should advertise authoring: {desc}"
777 );
778 }
779
780 #[test]
781 fn catalog_shows_navi_pool_not_create_skill_at_root() {
782 let tempdir = tempfile::tempdir().expect("tempdir");
783 let catalog =
784 discover_catalog_entries(&cfg(), tempdir.path(), tempdir.path()).expect("catalog");
785 assert!(
786 !catalog.root_skills.iter().any(|s| s.id == CREATE_SKILL_ID),
787 "create skill must live under pool, not root catalog"
788 );
789 let navi = catalog
790 .pools
791 .iter()
792 .find(|p| p.id == "navi")
793 .expect("navi pool in catalog");
794 assert!(
796 navi.skill_count >= 3,
797 "navi pool should include essential builtins, got {}",
798 navi.skill_count
799 );
800 let rendered = render_catalog_entries(&catalog).expect("render");
801 assert!(rendered.contains("Skill pools"));
802 assert!(rendered.contains("navi"));
803 assert!(!rendered.contains(CREATE_SKILL_ID));
804 }
805
806 #[test]
807 fn builtin_allow_tools_do_not_imply_session_lock_via_apply() {
808 use crate::harness_pack::apply_harness_for_skills;
811 let dir = tempfile::tempdir().expect("tempdir");
812 let skills = builtin_skills();
813 assert!(skills.iter().any(|s| !s.allow_tools.is_empty()));
814 let applied = apply_harness_for_skills(dir.path(), &skills);
815 assert!(
816 applied.allow_tools.is_none(),
817 "builtin catalog allow_tools must not soft-lock: {:?}",
818 applied.allow_tools
819 );
820 }
821
822 #[test]
823 fn load_skill_by_id_resolves_pool_path() {
824 let tempdir = tempfile::tempdir().expect("tempdir");
825 let skill = load_skill_by_id(
826 &cfg(),
827 tempdir.path(),
828 tempdir.path(),
829 &format!("navi/{CREATE_SKILL_ID}"),
830 )
831 .expect("load pool path");
832 assert_eq!(skill.id, CREATE_SKILL_ID);
833 assert!(skill.instructions.contains("Skill pools"));
834 }
835
836 #[test]
837 fn write_and_discover_store_skill() {
838 let tempdir = tempfile::tempdir().expect("tempdir");
839 let data = tempdir.path().join("data");
840 let project = tempdir.path().join("proj");
841 std::fs::create_dir_all(&project).unwrap();
842
843 let result = write_skill(
844 &SkillWriteRequest {
845 id: String::new(),
846 name: "My Helper".into(),
847 description: Some("Helps with X".into()),
848 version: None,
849 author: None,
850 tags: vec!["util".into()],
851 requires: vec![],
852 allow_tools: vec!["read_file".into()],
853 deny_tools: vec![],
854 harness: false,
855 pool: None,
856 instructions: "Do the thing carefully.".into(),
857 scope: SkillWriteScope::User,
858 },
859 &project,
860 &data,
861 )
862 .expect("write");
863 assert!(result.created);
864 assert_eq!(result.skill.id, "my-helper");
865 assert_eq!(result.skill.source, SkillSource::Store);
866
867 let skills = discover_configured_skills(&cfg(), &project, &data).expect("discover");
868 assert!(skills.iter().any(|s| s.id == "my-helper"));
869 assert!(skills.iter().any(|s| s.id == CREATE_SKILL_ID));
870 }
871
872 #[test]
873 fn write_roundtrip_preserves_tool_policy() {
874 let tempdir = tempfile::tempdir().expect("tempdir");
875 write_skill(
876 &SkillWriteRequest {
877 id: "reviewer".into(),
878 name: "Code Reviewer".into(),
879 description: Some("Reviews PRs".into()),
880 version: Some("1.0.0".into()),
881 author: Some("NAVI".into()),
882 tags: vec!["code".into(), "review".into()],
883 requires: vec!["socratic".into()],
884 allow_tools: vec!["read_file".into(), "bash".into()],
885 deny_tools: vec![],
886 harness: false,
887 pool: None,
888 instructions: "Review thoroughly.".into(),
889 scope: SkillWriteScope::User,
890 },
891 tempdir.path(),
892 tempdir.path(),
893 )
894 .expect("write");
895 let loaded =
896 load_skill_by_id(&cfg(), tempdir.path(), tempdir.path(), "reviewer").expect("load");
897 assert_eq!(loaded.name, "Code Reviewer");
898 assert_eq!(loaded.allow_tools, vec!["read_file", "bash"]);
899 assert_eq!(loaded.requires, vec!["socratic"]);
900 }
901
902 #[test]
903 fn skill_tool_allowlist_intersects() {
904 let a = SkillManifest {
905 id: "a".into(),
906 name: "A".into(),
907 description: None,
908 version: None,
909 author: None,
910 tags: vec![],
911 requires: vec![],
912 allow_tools: vec!["read_file".into(), "bash".into()],
913 deny_tools: vec![],
914 harness: false,
915 pool: None,
916 path: PathBuf::from("a"),
917 instructions: "a".into(),
918 source: SkillSource::Store,
919 scope: SkillWriteScope::User,
920 };
921 let b = SkillManifest {
922 id: "b".into(),
923 name: "B".into(),
924 description: None,
925 version: None,
926 author: None,
927 tags: vec![],
928 requires: vec![],
929 allow_tools: vec!["read_file".into(), "skill_save".into()],
930 deny_tools: vec![],
931 harness: false,
932 pool: None,
933 path: PathBuf::from("b"),
934 instructions: "b".into(),
935 source: SkillSource::Store,
936 scope: SkillWriteScope::User,
937 };
938 assert_eq!(skill_tool_allowlist(&[a, b]).unwrap(), vec!["read_file"]);
939 }
940
941 #[test]
942 fn cannot_delete_builtin() {
943 let tempdir = tempfile::tempdir().expect("tempdir");
944 let err = delete_skill(CREATE_SKILL_ID, tempdir.path(), tempdir.path()).unwrap_err();
945 assert!(err.to_string().contains("built-in"));
946 }
947
948 #[test]
949 fn returns_empty_when_disabled() {
950 let tempdir = tempfile::tempdir().expect("tempdir");
951 write_skill(
952 &SkillWriteRequest {
953 id: "x".into(),
954 name: "X".into(),
955 description: None,
956 version: None,
957 author: None,
958 tags: vec![],
959 requires: vec![],
960 allow_tools: vec![],
961 deny_tools: vec![],
962 harness: false,
963 pool: None,
964 instructions: "body".into(),
965 scope: SkillWriteScope::User,
966 },
967 tempdir.path(),
968 tempdir.path(),
969 )
970 .unwrap();
971 let config = SkillsConfig {
972 enabled: false,
973 active: Vec::new(),
974 };
975 let skills =
976 discover_configured_skills(&config, tempdir.path(), tempdir.path()).expect("skills");
977 assert!(skills.is_empty());
978 }
979
980 #[test]
981 fn active_skills_default_all_discovered() {
982 let tempdir = tempfile::tempdir().expect("tempdir");
983 write_skill(
984 &SkillWriteRequest {
985 id: "socratic".into(),
986 name: "Socratic".into(),
987 description: Some("Asks questions".into()),
988 version: None,
989 author: None,
990 tags: vec!["interview".into()],
991 requires: vec![],
992 allow_tools: vec![],
993 deny_tools: vec![],
994 harness: false,
995 pool: None,
996 instructions: "Ask one question first.".into(),
997 scope: SkillWriteScope::User,
998 },
999 tempdir.path(),
1000 tempdir.path(),
1001 )
1002 .unwrap();
1003 let skills =
1004 discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
1005 let active = active_skills(&skills, &[], &[]);
1007 assert!(active.iter().any(|s| s.id == "socratic"));
1008 let rendered = render_available_skills(&active).unwrap();
1009 assert!(rendered.contains("socratic"));
1010 assert!(rendered.contains("Asks questions"));
1011 assert!(!rendered.contains("Ask one question first."));
1013 }
1014
1015 #[test]
1016 fn active_skills_filter_when_configured() {
1017 let tempdir = tempfile::tempdir().expect("tempdir");
1018 write_skill(
1019 &SkillWriteRequest {
1020 id: "socratic".into(),
1021 name: "Socratic".into(),
1022 description: None,
1023 version: None,
1024 author: None,
1025 tags: vec![],
1026 requires: vec![],
1027 allow_tools: vec![],
1028 deny_tools: vec![],
1029 harness: false,
1030 pool: None,
1031 instructions: "Ask one question first.".into(),
1032 scope: SkillWriteScope::User,
1033 },
1034 tempdir.path(),
1035 tempdir.path(),
1036 )
1037 .unwrap();
1038 write_skill(
1039 &SkillWriteRequest {
1040 id: "other".into(),
1041 name: "Other".into(),
1042 description: None,
1043 version: None,
1044 author: None,
1045 tags: vec![],
1046 requires: vec![],
1047 allow_tools: vec![],
1048 deny_tools: vec![],
1049 harness: false,
1050 pool: None,
1051 instructions: "Other body.".into(),
1052 scope: SkillWriteScope::User,
1053 },
1054 tempdir.path(),
1055 tempdir.path(),
1056 )
1057 .unwrap();
1058 let skills =
1059 discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
1060 let active = active_skills(&skills, &["socratic".into()], &[]);
1061 assert_eq!(active.len(), 1);
1062 assert_eq!(active[0].id, "socratic");
1063 let rendered = render_available_skills(&active).unwrap();
1064 assert!(!rendered.contains("Other body."));
1065 assert!(!rendered.contains("Ask one question first."));
1066 }
1067
1068 #[test]
1069 fn parse_skill_md_frontmatter() {
1070 let raw = r#"---
1071name: Code Reviewer
1072description: Reviews PRs carefully
1073version: "1.0.0"
1074id: reviewer
1075author: NAVI
1076tags: [code, review]
1077allow_tools: [read_file, bash]
1078deny_tools: write_file
1079---
1080Review thoroughly.
1081Use checklists.
1082"#;
1083 let parsed = parse_skill_md(raw, "fallback");
1084 assert_eq!(parsed.name, "Code Reviewer");
1085 assert_eq!(parsed.id.as_deref(), Some("reviewer"));
1086 assert_eq!(parsed.description.as_deref(), Some("Reviews PRs carefully"));
1087 assert_eq!(parsed.version.as_deref(), Some("1.0.0"));
1088 assert_eq!(parsed.author.as_deref(), Some("NAVI"));
1089 assert_eq!(parsed.tags, vec!["code", "review"]);
1090 assert_eq!(parsed.allow_tools, vec!["read_file", "bash"]);
1091 assert_eq!(parsed.deny_tools, vec!["write_file"]);
1092 assert!(parsed.instructions.contains("Review thoroughly."));
1093 assert!(parsed.instructions.contains("Use checklists."));
1094 }
1095
1096 #[test]
1097 fn parse_skill_md_uses_fallback_name_without_frontmatter() {
1098 let parsed = parse_skill_md("Just do the thing.", "my-skill");
1099 assert_eq!(parsed.name, "my-skill");
1100 assert_eq!(parsed.instructions, "Just do the thing.");
1101 }
1102
1103 #[test]
1104 fn parse_skill_toml_and_install_roundtrip() {
1105 let tempdir = tempfile::tempdir().expect("tempdir");
1106 let data = tempdir.path().join("data");
1107 let project = tempdir.path().join("proj");
1108 std::fs::create_dir_all(&project).unwrap();
1109
1110 let raw = r#"
1111name = "Helper"
1112description = "Helps"
1113version = "0.1.0"
1114id = "helper"
1115tags = ["util"]
1116allow_tools = ["read_file"]
1117instructions = "Help carefully."
1118"#;
1119 let path = PathBuf::from("helper.toml");
1120 let parsed = parse_skill_file(&path, raw, "stem").expect("parse");
1121 assert_eq!(parsed.name, "Helper");
1122 assert_eq!(parsed.id.as_deref(), Some("helper"));
1123
1124 let result = write_skill(
1125 &SkillWriteRequest {
1126 id: parsed.id.clone().unwrap_or_default(),
1127 name: parsed.name.clone(),
1128 description: parsed.description.clone(),
1129 version: parsed.version.clone(),
1130 author: parsed.author.clone(),
1131 tags: parsed.tags.clone(),
1132 requires: vec![],
1133 allow_tools: parsed.allow_tools.clone(),
1134 deny_tools: parsed.deny_tools.clone(),
1135 harness: false,
1136 pool: None,
1137 instructions: parsed.instructions.clone(),
1138 scope: SkillWriteScope::User,
1139 },
1140 &project,
1141 &data,
1142 )
1143 .expect("write");
1144 assert!(result.created);
1145 assert_eq!(result.skill.id, "helper");
1146
1147 let listed = list_installed_skills(&project, &data).expect("list");
1148 assert!(listed.iter().any(|s| s.id == "helper"));
1149 assert!(listed.iter().any(|s| s.id == CREATE_SKILL_ID));
1150 }
1151
1152 #[test]
1153 fn list_installed_skills_ignores_enabled_flag() {
1154 let tempdir = tempfile::tempdir().expect("tempdir");
1155 write_skill(
1156 &SkillWriteRequest {
1157 id: "listed".into(),
1158 name: "Listed".into(),
1159 description: None,
1160 version: None,
1161 author: None,
1162 tags: vec![],
1163 requires: vec![],
1164 allow_tools: vec![],
1165 deny_tools: vec![],
1166 harness: false,
1167 pool: None,
1168 instructions: "body".into(),
1169 scope: SkillWriteScope::User,
1170 },
1171 tempdir.path(),
1172 tempdir.path(),
1173 )
1174 .unwrap();
1175 let disabled = SkillsConfig {
1176 enabled: false,
1177 active: Vec::new(),
1178 };
1179 let via_discover =
1180 discover_configured_skills(&disabled, tempdir.path(), tempdir.path()).unwrap();
1181 assert!(via_discover.is_empty());
1182 let listed = list_installed_skills(tempdir.path(), tempdir.path()).unwrap();
1183 assert!(listed.iter().any(|s| s.id == "listed"));
1184 }
1185}