Skip to main content

navi_core/skills/
mod.rs

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/// Origin of a skill record.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "camelCase")]
16pub enum SkillSource {
17    /// Shipped with the engine binary.
18    Builtin,
19    /// Stored on disk under `data_dir/skills/<id>/SKILL.md` (or project `.navi/skills/`).
20    #[default]
21    Store,
22}
23
24/// A discovered skill (SQLite store or builtin).
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SkillManifest {
27    /// Unique skill identifier.
28    pub id: String,
29    /// Human-readable skill name.
30    pub name: String,
31    /// Optional description for pickers / catalogs.
32    pub description: Option<String>,
33    /// Optional version string (e.g. "1.0.0").
34    pub version: Option<String>,
35    /// Optional author or maintainer.
36    pub author: Option<String>,
37    /// Tags for categorization and filtering.
38    pub tags: Vec<String>,
39    /// Skill ids that must be active for this skill to work.
40    pub requires: Vec<String>,
41    /// When non-empty and skill is active, only these tools are exposed (intersection across skills).
42    #[serde(default)]
43    pub allow_tools: Vec<String>,
44    /// Tools to hide while this skill is active.
45    #[serde(default)]
46    pub deny_tools: Vec<String>,
47    /// When true, this skill is treated as a harness and materialized into a pack.
48    #[serde(default)]
49    pub harness: bool,
50    /// Optional skill pool (folder). Empty/None = root-level skill.
51    #[serde(default)]
52    pub pool: Option<String>,
53    /// Path to the skill file or `builtin:…` marker.
54    pub path: PathBuf,
55    /// Instruction body when the skill is active.
56    pub instructions: String,
57    /// Where the skill was loaded from.
58    #[serde(default)]
59    pub source: SkillSource,
60    /// User vs project scope (store / writes).
61    #[serde(default)]
62    pub scope: SkillWriteScope,
63}
64
65/// Discovers skills: builtins + filesystem store (root + all pools).
66pub 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
89/// Catalog surface for the model prompt: **root skills + pools** (not pool members).
90///
91/// Pool members are listed only after the model opens a pool via `skill_list` with `pool`.
92pub 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    // Builtin skills that declare a pool become virtual pool members; ensure the
108    // pool appears in the catalog even if no POOL.md exists on disk yet.
109    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/// Top-level catalog: root skills + pool folders (no nested skill bodies).
152#[derive(Debug, Clone, Default)]
153pub struct CatalogEntries {
154    pub root_skills: Vec<SkillManifest>,
155    pub pools: Vec<SkillPool>,
156}
157
158/// Stable project key for project-scoped store rows.
159pub 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    // Short hash-like key from path (no extra deps).
164    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
171/// Compute the tool allowlist from skill policies.
172///
173/// Catalog-active skills (visible in the Available Skills list) do **not** lock
174/// session tools. Tool allowlists apply only when a host explicitly requests a
175/// skill-tool policy for an execution context — not merely because a skill is
176/// installed and visible.
177///
178/// - If no skill sets `allow_tools`, returns `None` (no skill-based filter).
179/// - Otherwise returns the **intersection** of all non-empty `allow_tools` lists,
180///   minus any `deny_tools`.
181pub 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
203/// Resolve which discovered skills are **active for the catalog**.
204///
205/// Semantics (product rule):
206/// - Installed / discovered skills are active by default → they appear in the
207///   Available Skills catalog (metadata only; no instruction body).
208/// - If `session_active` is non-empty, only those ids/names are catalog-active.
209/// - Else if `configured_active` is non-empty, only those are catalog-active.
210/// - Else (both empty) → **all** discovered skills are catalog-active.
211///
212/// Being catalog-active does **not** inject skill instructions into the prompt.
213/// The model loads full content with the `load_skill` tool.
214pub 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        // Default: every discovered skill is catalog-active.
225        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
239/// Renders the Available Skills catalog for the system/developer prompt.
240///
241/// Shows **root-level skills** and **skill pools** (folders). Pool members are
242/// **not** listed here — the model opens a pool with `skill_list` (`pool` arg)
243/// and loads bodies with `load_skill`. Returns `None` if empty.
244pub fn render_available_skills(skills: &[SkillManifest]) -> Option<String> {
245    // Backward-compatible path: treat input as flat list (filter out pool members).
246    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
256/// Renders root skills + pools for the prompt (preferred).
257pub 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: skill instruction bodies must not be injected into the prompt.
305/// Kept as a thin alias of [`render_available_skills`] for API compatibility.
306#[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/// Where a user-authored skill is stored (SQLite scope).
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
313#[serde(rename_all = "camelCase")]
314pub enum SkillWriteScope {
315    /// User skill under `data_dir/skills/<id>/SKILL.md` — shared across Desktop + TUI.
316    #[default]
317    User,
318    /// Project-scoped skill under `{project}/.navi/skills/<id>/SKILL.md`.
319    Project,
320}
321
322/// Payload for creating or updating a skill on the filesystem skill store.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct SkillWriteRequest {
326    /// Skill id. If empty, derived from `name` via [`slugify_skill_id`].
327    #[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    /// Tools available while this skill is active (empty = no skill tool lock).
341    #[serde(default)]
342    pub allow_tools: Vec<String>,
343    #[serde(default)]
344    pub deny_tools: Vec<String>,
345    /// When true, materialize a harness pack for this skill after saving.
346    #[serde(default)]
347    pub harness: bool,
348    /// Optional pool folder id (e.g. `navi`). Empty = root-level skill.
349    #[serde(default)]
350    pub pool: Option<String>,
351    /// Markdown body (instructions). Required non-empty after trim.
352    pub instructions: String,
353    #[serde(default)]
354    pub scope: SkillWriteScope,
355}
356
357/// Result of writing a skill to disk.
358#[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
366/// Resolve a validated skill id from a write request.
367pub 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
386/// Normalize a free-form name into a stable skill directory id.
387pub 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
406/// Create or update a skill as markdown on disk (shared Desktop + TUI).
407pub 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
416/// Load full skill content (including instructions) by id from discovered skills.
417///
418/// Accepts bare id/name, or `pool/id` form (e.g. `navi/navi-create-skill`).
419pub 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    // Prefer store resolution for pool paths / scoped ids.
431    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
458/// Delete a skill from the filesystem store (never deletes builtins).
459pub 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
471/// Whether a skill can be edited/deleted from the UI (store-backed, not builtin).
472pub fn skill_is_editable(skill: &SkillManifest) -> bool {
473    matches!(skill.source, SkillSource::Store)
474}
475
476/// Fields extracted from a skill markdown or TOML file before write.
477#[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
493/// Parse a skill file by path extension (`.md` / `.markdown` / `.toml`).
494///
495/// When `name` is missing from the file, uses `fallback_name` (typically the file stem).
496pub 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
511/// Parse skill markdown with optional YAML-ish frontmatter between `---` fences.
512///
513/// Supported frontmatter keys: `name`, `description`, `version`, `tags`, `id`,
514/// `author`, `allow_tools`, `deny_tools`, `harness`. Body after the closing `---` is instructions.
515pub 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        // Allow optional whitespace/newlines after opening fence.
519        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
602/// Parse a skill TOML file (`name`, `description`, `version`, `tags`, `instructions`, …).
603pub 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
669/// List builtins + store skills regardless of `[skills].enabled`.
670///
671/// Used by `navi skill list` so the store remains inspectable when discovery is off.
672pub 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
705/// Parse a simple YAML list value: `[a, b]`, comma-separated, or a single token.
706fn 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        // create-skill description steers natural-language "add a skill" path.
772        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        // create-skill + harness-author + skill-pools
795        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        // Production path: apply_harness_for_skills on catalog builtins must leave
809        // tools unrestricted (root session unlock contract).
810        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        // Empty active lists → all discovered skills are catalog-active.
1006        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        // Instruction body must never appear in the catalog.
1012        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}