Skip to main content

navi_core/skills/
mod.rs

1mod builtin;
2mod store;
3
4pub use builtin::{CREATE_SKILL_ID, builtin_skills};
5pub use store::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 in `data_dir/skills.sqlite` (canonical user store).
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    /// Path to the SQLite store or `builtin:…` marker.
48    pub path: PathBuf,
49    /// Instruction body when the skill is active.
50    pub instructions: String,
51    /// Where the skill was loaded from.
52    #[serde(default)]
53    pub source: SkillSource,
54    /// User vs project scope (store / writes).
55    #[serde(default)]
56    pub scope: SkillWriteScope,
57}
58
59/// Discovers skills: builtins + SQLite store only.
60pub fn discover_configured_skills(
61    config: &SkillsConfig,
62    project_dir: &Path,
63    data_dir: &Path,
64) -> Result<Vec<SkillManifest>> {
65    if !config.enabled {
66        return Ok(Vec::new());
67    }
68
69    let mut skills = builtin_skills();
70
71    if let Ok(store) = SkillStore::open(data_dir) {
72        let project_key = project_skill_key(project_dir);
73        match store.list_for_discovery(Some(&project_key)) {
74            Ok(stored) => skills.extend(stored),
75            Err(err) => tracing::warn!(error = %err, "failed to list skills from store"),
76        }
77    }
78
79    skills.sort_by(|a, b| a.id.cmp(&b.id));
80    skills.dedup_by(|a, b| a.id == b.id);
81    Ok(skills)
82}
83
84/// Stable project key for project-scoped store rows.
85pub fn project_skill_key(project_dir: &Path) -> String {
86    let canon = project_dir
87        .canonicalize()
88        .unwrap_or_else(|_| project_dir.to_path_buf());
89    // Short hash-like key from path (no extra deps).
90    use std::collections::hash_map::DefaultHasher;
91    use std::hash::{Hash, Hasher};
92    let mut h = DefaultHasher::new();
93    canon.to_string_lossy().hash(&mut h);
94    format!("{:x}", h.finish())
95}
96
97/// Compute the tool allowlist from active skill policies.
98///
99/// - If no active skill sets `allow_tools`, returns `None` (no skill-based filter).
100/// - Otherwise returns the **intersection** of all non-empty `allow_tools` lists,
101///   minus any `deny_tools` from active skills.
102pub fn skill_tool_allowlist(active: &[SkillManifest]) -> Option<Vec<String>> {
103    let with_allow: Vec<&SkillManifest> = active
104        .iter()
105        .filter(|s| !s.allow_tools.is_empty())
106        .collect();
107    if with_allow.is_empty() {
108        return None;
109    }
110    let mut set: HashSet<String> = with_allow[0].allow_tools.iter().cloned().collect();
111    for skill in with_allow.iter().skip(1) {
112        set.retain(|t| skill.allow_tools.iter().any(|a| a == t));
113    }
114    for skill in active {
115        for deny in &skill.deny_tools {
116            set.remove(deny);
117        }
118    }
119    let mut list: Vec<String> = set.into_iter().collect();
120    list.sort();
121    Some(list)
122}
123
124/// Filters discovered skills to only those that are explicitly active in config
125/// or included in the `active` list.
126pub fn active_skills(
127    available: &[SkillManifest],
128    configured_active: &[String],
129    session_active: &[String],
130) -> Vec<SkillManifest> {
131    let requested = if session_active.is_empty() {
132        configured_active
133    } else {
134        session_active
135    };
136    if requested.is_empty() {
137        return Vec::new();
138    }
139
140    available
141        .iter()
142        .filter(|skill| {
143            requested
144                .iter()
145                .any(|name| name == &skill.id || name == &skill.name)
146        })
147        .cloned()
148        .collect()
149}
150
151/// Renders active skills into a text block for injection into the system prompt.
152/// Returns `None` if there are no active skills.
153pub fn render_active_skills(skills: &[SkillManifest]) -> Option<String> {
154    if skills.is_empty() {
155        return None;
156    }
157
158    let mut output = String::from("=== Active Skills ===\n");
159    for skill in skills {
160        output.push_str(&format!("- id: {}; name: {}\n", skill.id, skill.name));
161        if let Some(description) = &skill.description {
162            output.push_str(&format!("  description: {}\n", description.trim()));
163        }
164        if let Some(version) = &skill.version {
165            output.push_str(&format!("  version: {}\n", version));
166        }
167        if let Some(author) = &skill.author {
168            output.push_str(&format!("  author: {}\n", author));
169        }
170        if !skill.tags.is_empty() {
171            output.push_str(&format!("  tags: {}\n", skill.tags.join(", ")));
172        }
173        if !skill.requires.is_empty() {
174            output.push_str(&format!("  requires: {}\n", skill.requires.join(", ")));
175        }
176        output.push_str(skill.instructions.trim());
177        output.push_str("\n\n");
178    }
179    Some(output)
180}
181
182/// Renders a catalog of available skills without exposing their instruction text.
183/// Returns `None` if there are no available skills.
184pub fn render_available_skills(skills: &[SkillManifest]) -> Option<String> {
185    if skills.is_empty() {
186        return None;
187    }
188
189    let mut output = String::from(
190        "=== Available Skills ===\nThese skills are available. Use the `load_skill` tool with a skill id when you decide a skill is relevant. The instruction text is not included here.\n",
191    );
192    for skill in skills {
193        output.push_str(&format!("- id: {}; name: {}\n", skill.id, skill.name));
194        if let Some(description) = &skill.description {
195            output.push_str(&format!("  description: {}\n", description.trim()));
196        }
197        if let Some(version) = &skill.version {
198            output.push_str(&format!("  version: {}\n", version));
199        }
200        if let Some(author) = &skill.author {
201            output.push_str(&format!("  author: {}\n", author));
202        }
203        if !skill.tags.is_empty() {
204            output.push_str(&format!("  tags: {}\n", skill.tags.join(", ")));
205        }
206        if !skill.requires.is_empty() {
207            output.push_str(&format!("  requires: {}\n", skill.requires.join(", ")));
208        }
209    }
210    Some(output)
211}
212
213/// Where a user-authored skill is stored (SQLite scope).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
215#[serde(rename_all = "camelCase")]
216pub enum SkillWriteScope {
217    /// User skill in `data_dir/skills.sqlite` — shared across Desktop + TUI.
218    #[default]
219    User,
220    /// Project-scoped row in the same database (keyed by project path hash).
221    Project,
222}
223
224/// Payload for creating or updating a skill in the SQLite skill store.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct SkillWriteRequest {
228    /// Skill id. If empty, derived from `name` via [`slugify_skill_id`].
229    #[serde(default)]
230    pub id: String,
231    pub name: String,
232    #[serde(default)]
233    pub description: Option<String>,
234    #[serde(default)]
235    pub version: Option<String>,
236    #[serde(default)]
237    pub author: Option<String>,
238    #[serde(default)]
239    pub tags: Vec<String>,
240    #[serde(default)]
241    pub requires: Vec<String>,
242    /// Tools available while this skill is active (empty = no skill tool lock).
243    #[serde(default)]
244    pub allow_tools: Vec<String>,
245    #[serde(default)]
246    pub deny_tools: Vec<String>,
247    /// Markdown body (instructions). Required non-empty after trim.
248    pub instructions: String,
249    #[serde(default)]
250    pub scope: SkillWriteScope,
251}
252
253/// Result of writing a skill to disk.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct SkillWriteResult {
257    pub skill: SkillManifest,
258    pub path: PathBuf,
259    pub created: bool,
260}
261
262/// Resolve a validated skill id from a write request.
263pub fn resolve_skill_id(request: &SkillWriteRequest) -> Result<String> {
264    let name = request.name.trim();
265    if name.is_empty() {
266        return Err(anyhow::anyhow!("skill name is required"));
267    }
268    let id = {
269        let raw = request.id.trim();
270        if raw.is_empty() {
271            slugify_skill_id(name)
272        } else {
273            slugify_skill_id(raw)
274        }
275    };
276    if id.is_empty() || id == "." || id == ".." || id.contains('/') || id.contains('\\') {
277        return Err(anyhow::anyhow!("invalid skill id"));
278    }
279    Ok(id)
280}
281
282/// Normalize a free-form name into a stable skill directory id.
283pub fn slugify_skill_id(raw: &str) -> String {
284    let mut out = String::new();
285    let mut prev_dash = false;
286    for ch in raw.trim().chars() {
287        let c = ch.to_ascii_lowercase();
288        if c.is_ascii_alphanumeric() {
289            out.push(c);
290            prev_dash = false;
291        } else if matches!(c, '-' | '_' | ' ' | '/' | '.') && !prev_dash && !out.is_empty() {
292            out.push('-');
293            prev_dash = true;
294        }
295    }
296    while out.ends_with('-') {
297        out.pop();
298    }
299    if out.is_empty() { "skill".into() } else { out }
300}
301
302/// Create or update a skill in the SQLite skill store (shared Desktop + TUI).
303pub fn write_skill(
304    request: &SkillWriteRequest,
305    project_dir: &Path,
306    data_dir: &Path,
307) -> Result<SkillWriteResult> {
308    let store = SkillStore::open(data_dir)?;
309    let project_key = match request.scope {
310        SkillWriteScope::Project => Some(project_skill_key(project_dir)),
311        SkillWriteScope::User => None,
312    };
313    store.upsert(request, project_key.as_deref())
314}
315
316/// Load full skill content (including instructions) by id from discovered skills.
317pub fn load_skill_by_id(
318    config: &SkillsConfig,
319    project_dir: &Path,
320    data_dir: &Path,
321    skill_id: &str,
322) -> Result<SkillManifest> {
323    let skills = discover_configured_skills(config, project_dir, data_dir)?;
324    skills
325        .into_iter()
326        .find(|s| s.id == skill_id || s.name == skill_id)
327        .ok_or_else(|| anyhow::anyhow!("skill `{skill_id}` not found"))
328}
329
330/// Delete a skill from the SQLite store (never deletes builtins).
331pub fn delete_skill(skill_id: &str, _project_dir: &Path, data_dir: &Path) -> Result<bool> {
332    let id = slugify_skill_id(skill_id);
333    if id.is_empty() {
334        return Err(anyhow::anyhow!("invalid skill id"));
335    }
336    if builtin_skills().iter().any(|s| s.id == id) {
337        return Err(anyhow::anyhow!("cannot delete built-in skill `{id}`"));
338    }
339    let store = SkillStore::open(data_dir)?;
340    store.delete(&id)
341}
342
343/// Whether a skill can be edited/deleted from the UI (store-backed, not builtin).
344pub fn skill_is_editable(skill: &SkillManifest) -> bool {
345    matches!(skill.source, SkillSource::Store)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn cfg() -> SkillsConfig {
353        SkillsConfig {
354            enabled: true,
355            active: Vec::new(),
356        }
357    }
358
359    #[test]
360    fn discovers_builtin_create_skill() {
361        let tempdir = tempfile::tempdir().expect("tempdir");
362        let skills =
363            discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
364        assert!(skills.iter().any(|s| s.id == CREATE_SKILL_ID));
365        let create = skills.iter().find(|s| s.id == CREATE_SKILL_ID).unwrap();
366        assert!(!create.allow_tools.is_empty());
367        assert!(create.allow_tools.iter().any(|t| t == "skill_save"));
368    }
369
370    #[test]
371    fn write_and_discover_store_skill() {
372        let tempdir = tempfile::tempdir().expect("tempdir");
373        let data = tempdir.path().join("data");
374        let project = tempdir.path().join("proj");
375        std::fs::create_dir_all(&project).unwrap();
376
377        let result = write_skill(
378            &SkillWriteRequest {
379                id: String::new(),
380                name: "My Helper".into(),
381                description: Some("Helps with X".into()),
382                version: None,
383                author: None,
384                tags: vec!["util".into()],
385                requires: vec![],
386                allow_tools: vec!["read_file".into()],
387                deny_tools: vec![],
388                instructions: "Do the thing carefully.".into(),
389                scope: SkillWriteScope::User,
390            },
391            &project,
392            &data,
393        )
394        .expect("write");
395        assert!(result.created);
396        assert_eq!(result.skill.id, "my-helper");
397        assert_eq!(result.skill.source, SkillSource::Store);
398
399        let skills = discover_configured_skills(&cfg(), &project, &data).expect("discover");
400        assert!(skills.iter().any(|s| s.id == "my-helper"));
401        assert!(skills.iter().any(|s| s.id == CREATE_SKILL_ID));
402    }
403
404    #[test]
405    fn write_roundtrip_preserves_tool_policy() {
406        let tempdir = tempfile::tempdir().expect("tempdir");
407        write_skill(
408            &SkillWriteRequest {
409                id: "reviewer".into(),
410                name: "Code Reviewer".into(),
411                description: Some("Reviews PRs".into()),
412                version: Some("1.0.0".into()),
413                author: Some("NAVI".into()),
414                tags: vec!["code".into(), "review".into()],
415                requires: vec!["socratic".into()],
416                allow_tools: vec!["read_file".into(), "bash".into()],
417                deny_tools: vec![],
418                instructions: "Review thoroughly.".into(),
419                scope: SkillWriteScope::User,
420            },
421            tempdir.path(),
422            tempdir.path(),
423        )
424        .expect("write");
425        let loaded =
426            load_skill_by_id(&cfg(), tempdir.path(), tempdir.path(), "reviewer").expect("load");
427        assert_eq!(loaded.name, "Code Reviewer");
428        assert_eq!(loaded.allow_tools, vec!["read_file", "bash"]);
429        assert_eq!(loaded.requires, vec!["socratic"]);
430    }
431
432    #[test]
433    fn skill_tool_allowlist_intersects() {
434        let a = SkillManifest {
435            id: "a".into(),
436            name: "A".into(),
437            description: None,
438            version: None,
439            author: None,
440            tags: vec![],
441            requires: vec![],
442            allow_tools: vec!["read_file".into(), "bash".into()],
443            deny_tools: vec![],
444            path: PathBuf::from("a"),
445            instructions: "a".into(),
446            source: SkillSource::Store,
447            scope: SkillWriteScope::User,
448        };
449        let b = SkillManifest {
450            id: "b".into(),
451            name: "B".into(),
452            description: None,
453            version: None,
454            author: None,
455            tags: vec![],
456            requires: vec![],
457            allow_tools: vec!["read_file".into(), "skill_save".into()],
458            deny_tools: vec![],
459            path: PathBuf::from("b"),
460            instructions: "b".into(),
461            source: SkillSource::Store,
462            scope: SkillWriteScope::User,
463        };
464        assert_eq!(skill_tool_allowlist(&[a, b]).unwrap(), vec!["read_file"]);
465    }
466
467    #[test]
468    fn cannot_delete_builtin() {
469        let tempdir = tempfile::tempdir().expect("tempdir");
470        let err = delete_skill(CREATE_SKILL_ID, tempdir.path(), tempdir.path()).unwrap_err();
471        assert!(err.to_string().contains("built-in"));
472    }
473
474    #[test]
475    fn returns_empty_when_disabled() {
476        let tempdir = tempfile::tempdir().expect("tempdir");
477        write_skill(
478            &SkillWriteRequest {
479                id: "x".into(),
480                name: "X".into(),
481                description: None,
482                version: None,
483                author: None,
484                tags: vec![],
485                requires: vec![],
486                allow_tools: vec![],
487                deny_tools: vec![],
488                instructions: "body".into(),
489                scope: SkillWriteScope::User,
490            },
491            tempdir.path(),
492            tempdir.path(),
493        )
494        .unwrap();
495        let config = SkillsConfig {
496            enabled: false,
497            active: Vec::new(),
498        };
499        let skills =
500            discover_configured_skills(&config, tempdir.path(), tempdir.path()).expect("skills");
501        assert!(skills.is_empty());
502    }
503
504    #[test]
505    fn active_skills_match_by_id() {
506        let tempdir = tempfile::tempdir().expect("tempdir");
507        write_skill(
508            &SkillWriteRequest {
509                id: "socratic".into(),
510                name: "Socratic".into(),
511                description: None,
512                version: None,
513                author: None,
514                tags: vec![],
515                requires: vec![],
516                allow_tools: vec![],
517                deny_tools: vec![],
518                instructions: "Ask one question first.".into(),
519                scope: SkillWriteScope::User,
520            },
521            tempdir.path(),
522            tempdir.path(),
523        )
524        .unwrap();
525        let skills =
526            discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
527        let active = active_skills(&skills, &["socratic".into()], &[]);
528        assert_eq!(active.len(), 1);
529        let rendered = render_active_skills(&active).unwrap();
530        assert!(rendered.contains("Ask one question first."));
531    }
532}