Skip to main content

nika_engine/ast/
skill_def.rs

1//! Skill definition types for workflow
2//!
3//! The `skills:` block in a workflow allows loading prompt augmentation files.
4//! Skills are loaded at workflow start and injected into agent system prompts.
5//!
6//! # Example
7//!
8//! ```yaml
9//! skills:
10//!   seo: ./skills/seo-writer.skill.md       # Single skill file
11//!   brand: ./skills/brand-voice.skill.md    # Another skill
12//! ```
13//!
14//! Skills can be referenced in agent tasks:
15//!
16//! ```yaml
17//! tasks:
18//!   - id: generate_seo
19//!     agent:
20//!       prompt: "Write SEO content"
21//!       skill: seo           # Single skill
22//!       # OR
23//!       skills: [seo, brand] # Multiple skills
24//! ```
25//!
26//! ## pkg: URI Support
27//!
28//! Skills can also be loaded from the package registry using `pkg:` URIs:
29//!
30//! ```yaml
31//! skills:
32//!   rust: pkg:@supernovae/skills@1.0.0/rust.md
33//!   seo: pkg:skills/seo-writer.md  # Uses default scope and latest version
34//! ```
35
36use serde::Deserialize;
37use std::path::{Path, PathBuf};
38
39use super::pkg_resolver::PkgUri;
40use crate::error::NikaError;
41
42/// Resolve a skill path, handling both local paths and pkg: URIs
43///
44/// # Arguments
45/// * `skill_path` - The skill path from YAML (local path or `pkg:` URI)
46/// * `base_dir` - Base directory for resolving relative local paths
47///
48/// # Returns
49/// * `Ok(PathBuf)` - Resolved absolute path to skill file
50/// * `Err(NikaError)` - If pkg: URI is invalid or path resolution fails
51///
52/// # Examples
53/// ```
54/// use std::path::{Path, PathBuf};
55/// use nika::ast::skill_def::resolve_skill_path;
56///
57/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
58/// // Local path
59/// let path = resolve_skill_path("./skills/seo.skill.md", Path::new("/project"))?;
60/// assert_eq!(path, PathBuf::from("/project/./skills/seo.skill.md"));
61///
62/// // pkg: URI
63/// let path = resolve_skill_path("pkg:@supernovae/skills@1.0.0/rust.md", Path::new("/project"))?;
64/// // Returns ~/.nika/packages/@supernovae/skills/1.0.0/rust.md
65/// # Ok(())
66/// # }
67/// ```
68pub fn resolve_skill_path(skill_path: &str, base_dir: &Path) -> Result<PathBuf, NikaError> {
69    if skill_path.starts_with("pkg:") {
70        // Parse and resolve pkg: URI
71        let uri = PkgUri::parse(skill_path)?;
72        uri.resolve()
73    } else {
74        // Local path - resolve relative to base_dir
75        let path = Path::new(skill_path);
76        if path.is_absolute() {
77            Ok(path.to_path_buf())
78        } else {
79            Ok(base_dir.join(path))
80        }
81    }
82}
83
84/// Check if a skill path is a pkg: URI
85///
86/// # Examples
87/// ```
88/// use nika::ast::skill_def::is_pkg_uri;
89///
90/// assert!(is_pkg_uri("pkg:@supernovae/skills@1.0.0/rust.md"));
91/// assert!(is_pkg_uri("pkg:skills/seo.md"));
92/// assert!(!is_pkg_uri("./skills/local.skill.md"));
93/// ```
94pub fn is_pkg_uri(skill_path: &str) -> bool {
95    skill_path.starts_with("pkg:")
96}
97
98/// Skill definition
99///
100/// A skill is a path to a skill file (.skill.md) containing prompt augmentation.
101pub type SkillDef = String;
102
103/// Skill reference for agent tasks
104///
105/// Agents can reference skills by name (single or multiple).
106#[derive(Debug, Clone, Deserialize, PartialEq)]
107#[serde(untagged)]
108pub enum SkillRef {
109    /// Single skill reference
110    Single(String),
111
112    /// Multiple skill references
113    Multiple(Vec<String>),
114}
115
116impl SkillRef {
117    /// Get all skill names as a vector
118    pub fn names(&self) -> Vec<&str> {
119        match self {
120            SkillRef::Single(name) => vec![name.as_str()],
121            SkillRef::Multiple(names) => names.iter().map(|s| s.as_str()).collect(),
122        }
123    }
124
125    /// Check if this reference includes a specific skill
126    pub fn contains(&self, skill_name: &str) -> bool {
127        match self {
128            SkillRef::Single(name) => name == skill_name,
129            SkillRef::Multiple(names) => names.iter().any(|n| n == skill_name),
130        }
131    }
132
133    /// Get the count of referenced skills
134    pub fn len(&self) -> usize {
135        match self {
136            SkillRef::Single(_) => 1,
137            SkillRef::Multiple(names) => names.len(),
138        }
139    }
140
141    /// Check if no skills are referenced
142    pub fn is_empty(&self) -> bool {
143        match self {
144            SkillRef::Single(_) => false,
145            SkillRef::Multiple(names) => names.is_empty(),
146        }
147    }
148}
149
150impl Default for SkillRef {
151    fn default() -> Self {
152        SkillRef::Multiple(Vec::new())
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::serde_yaml;
160
161    #[test]
162    fn test_skill_def_is_string() {
163        let skill: SkillDef = "./skills/seo-writer.skill.md".to_string();
164        assert!(skill.ends_with(".skill.md"));
165    }
166
167    #[test]
168    fn test_skill_ref_single() {
169        let yaml = r#""seo""#;
170        let skill_ref: SkillRef = serde_yaml::from_str(yaml).unwrap();
171        assert!(matches!(skill_ref, SkillRef::Single(_)));
172        assert_eq!(skill_ref.names(), vec!["seo"]);
173        assert!(skill_ref.contains("seo"));
174        assert!(!skill_ref.contains("brand"));
175        assert_eq!(skill_ref.len(), 1);
176        assert!(!skill_ref.is_empty());
177    }
178
179    #[test]
180    fn test_skill_ref_multiple() {
181        let yaml = r#"["seo", "brand", "tone"]"#;
182        let skill_ref: SkillRef = serde_yaml::from_str(yaml).unwrap();
183        assert!(matches!(skill_ref, SkillRef::Multiple(_)));
184        assert_eq!(skill_ref.names(), vec!["seo", "brand", "tone"]);
185        assert!(skill_ref.contains("seo"));
186        assert!(skill_ref.contains("brand"));
187        assert!(!skill_ref.contains("unknown"));
188        assert_eq!(skill_ref.len(), 3);
189        assert!(!skill_ref.is_empty());
190    }
191
192    #[test]
193    fn test_skill_ref_empty_multiple() {
194        let yaml = r#"[]"#;
195        let skill_ref: SkillRef = serde_yaml::from_str(yaml).unwrap();
196        assert!(matches!(skill_ref, SkillRef::Multiple(_)));
197        assert!(skill_ref.names().is_empty());
198        assert_eq!(skill_ref.len(), 0);
199        assert!(skill_ref.is_empty());
200    }
201
202    #[test]
203    fn test_skill_ref_default() {
204        let skill_ref = SkillRef::default();
205        assert!(skill_ref.is_empty());
206        assert_eq!(skill_ref.len(), 0);
207    }
208
209    #[test]
210    fn test_skill_ref_in_context() {
211        // Test how it would appear in a task YAML
212        #[derive(Debug, Deserialize)]
213        struct TestTask {
214            skill: Option<SkillRef>,
215            skills: Option<SkillRef>,
216        }
217
218        // Single skill via 'skill' field
219        let yaml = r#"
220skill: seo
221"#;
222        let task: TestTask = serde_yaml::from_str(yaml).unwrap();
223        assert!(task.skill.is_some());
224        assert!(task.skill.as_ref().unwrap().contains("seo"));
225
226        // Multiple skills via 'skills' field
227        let yaml = r#"
228skills: [seo, brand]
229"#;
230        let task: TestTask = serde_yaml::from_str(yaml).unwrap();
231        assert!(task.skills.is_some());
232        assert_eq!(task.skills.as_ref().unwrap().len(), 2);
233    }
234
235    #[test]
236    fn test_is_pkg_uri_with_pkg_prefix() {
237        assert!(is_pkg_uri("pkg:@supernovae/skills@1.0.0/rust.md"));
238        assert!(is_pkg_uri("pkg:skills/seo.md"));
239        assert!(is_pkg_uri("pkg:@scope/name/path.md"));
240    }
241
242    #[test]
243    fn test_is_pkg_uri_with_local_path() {
244        assert!(!is_pkg_uri("./skills/local.skill.md"));
245        assert!(!is_pkg_uri("../skills/up.skill.md"));
246        assert!(!is_pkg_uri("/absolute/path/skill.md"));
247        assert!(!is_pkg_uri("relative/skill.md"));
248    }
249
250    #[test]
251    fn test_resolve_skill_path_local_relative() {
252        let base_dir = Path::new("/project");
253        let result = resolve_skill_path("./skills/seo.skill.md", base_dir).unwrap();
254        assert_eq!(result, PathBuf::from("/project/./skills/seo.skill.md"));
255    }
256
257    #[test]
258    fn test_resolve_skill_path_local_absolute() {
259        let base_dir = Path::new("/project");
260        let result = resolve_skill_path("/absolute/path/skill.md", base_dir).unwrap();
261        assert_eq!(result, PathBuf::from("/absolute/path/skill.md"));
262    }
263
264    #[test]
265    fn test_resolve_skill_path_pkg_uri() {
266        let base_dir = Path::new("/project");
267        let result = resolve_skill_path("pkg:@supernovae/skills@1.0.0/rust.md", base_dir).unwrap();
268        let expected = dirs::home_dir()
269            .unwrap()
270            .join(".nika/packages/@supernovae/skills/1.0.0/rust.md");
271        assert_eq!(result, expected);
272    }
273
274    #[test]
275    fn test_resolve_skill_path_pkg_uri_no_version() {
276        let base_dir = Path::new("/project");
277        let result = resolve_skill_path("pkg:@supernovae/skills/rust.md", base_dir).unwrap();
278        let expected = dirs::home_dir()
279            .unwrap()
280            .join(".nika/packages/@supernovae/skills/latest/rust.md");
281        assert_eq!(result, expected);
282    }
283
284    #[test]
285    fn test_resolve_skill_path_pkg_uri_no_scope() {
286        let base_dir = Path::new("/project");
287        let result = resolve_skill_path("pkg:skills/seo.md", base_dir).unwrap();
288        let expected = dirs::home_dir()
289            .unwrap()
290            .join(".nika/packages/@default/skills/latest/seo.md");
291        assert_eq!(result, expected);
292    }
293
294    #[test]
295    fn test_resolve_skill_path_invalid_pkg_uri() {
296        let base_dir = Path::new("/project");
297        let result = resolve_skill_path("pkg:", base_dir);
298        assert!(result.is_err());
299    }
300}