Skip to main content

oxios_kernel/skill/
types.rs

1#![allow(missing_docs)]
2//! Domain types for the skill system.
3
4use super::format::SkillFormat;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Requirements {
11    #[serde(default)]
12    pub bins: Vec<String>,
13    #[serde(default, rename = "anyBins")]
14    pub any_bins: Vec<String>,
15    #[serde(default)]
16    pub env: Vec<String>,
17    #[serde(default)]
18    pub config: Vec<String>,
19    /// Integration IDs this skill hard-requires (RFC-041 D10). The skill is
20    /// ineligible unless every listed integration's credential is satisfied.
21    /// Checked where the registry is available (API/UI), not in the
22    /// host-local `check_requirements`.
23    #[serde(default)]
24    pub integrations: Vec<String>,
25    /// Integration IDs of which at least one must be satisfied (soft, mirrors
26    /// `any_bins`). Empty = no constraint.
27    #[serde(default, rename = "anyIntegrations")]
28    pub any_integrations: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SkillInstallSpec {
33    pub kind: InstallKind,
34    #[serde(default)]
35    pub formula: Option<String>,
36    #[serde(default)]
37    pub package: Option<String>,
38    #[serde(default)]
39    pub module: Option<String>,
40    #[serde(default)]
41    pub url: Option<String>,
42    #[serde(default)]
43    pub archive: Option<String>,
44    #[serde(default)]
45    pub extract: Option<bool>,
46    #[serde(default, rename = "stripComponents")]
47    pub strip_components: Option<u32>,
48    #[serde(default, rename = "targetDir")]
49    pub target_dir: Option<String>,
50    #[serde(default)]
51    pub os: Vec<String>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum InstallKind {
57    Brew,
58    Node,
59    Bun,
60    Cargo,
61    Pip,
62    Go,
63    #[serde(rename = "uv")]
64    Uv,
65    Download,
66}
67
68impl std::fmt::Display for InstallKind {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            InstallKind::Brew => write!(f, "brew"),
72            InstallKind::Node => write!(f, "node"),
73            InstallKind::Bun => write!(f, "bun"),
74            InstallKind::Cargo => write!(f, "cargo"),
75            InstallKind::Pip => write!(f, "pip"),
76            InstallKind::Go => write!(f, "go"),
77            InstallKind::Uv => write!(f, "uv"),
78            InstallKind::Download => write!(f, "download"),
79        }
80    }
81}
82
83#[derive(Debug, Clone, Default, Serialize)]
84pub struct RequirementsCheck {
85    pub missing_bins: Vec<String>,
86    pub missing_any_bins: Vec<String>,
87    pub missing_env: Vec<String>,
88    pub missing_config: Vec<String>,
89    pub missing_os: Vec<String>,
90    pub eligible: bool,
91    pub config_checks: Vec<ConfigCheck>,
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct ConfigCheck {
96    pub path: String,
97    pub satisfied: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum SkillStatus {
103    Ready,
104    NeedsSetup,
105    Disabled,
106}
107
108impl std::fmt::Display for SkillStatus {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            SkillStatus::Ready => write!(f, "ready"),
112            SkillStatus::NeedsSetup => write!(f, "needs_setup"),
113            SkillStatus::Disabled => write!(f, "disabled"),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum SkillSource {
121    Bundled,
122    Managed,
123    Workspace,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct SkillInvocationPolicy {
128    #[serde(default = "default_true")]
129    pub user_invocable: bool,
130    #[serde(default)]
131    pub disable_model_invocation: bool,
132}
133impl Default for SkillInvocationPolicy {
134    fn default() -> Self {
135        Self {
136            user_invocable: true,
137            disable_model_invocation: false,
138        }
139    }
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct SkillMetadata {
144    #[serde(default)]
145    pub author: Option<String>,
146    #[serde(default)]
147    pub version: Option<String>,
148    #[serde(default)]
149    pub emoji: Option<String>,
150    #[serde(default)]
151    pub homepage: Option<String>,
152    #[serde(default)]
153    pub requires: Requirements,
154    #[serde(default)]
155    pub os: Vec<String>,
156    #[serde(default)]
157    pub install: Vec<SkillInstallSpec>,
158    #[serde(default)]
159    pub always: bool,
160    /// RFC-031 §6.3: this skill may fire unattended during a token-maxing
161    /// window. Defaults false — frequency is never the gate, only this flag.
162    #[serde(default)]
163    pub autonomous: bool,
164    #[serde(default, rename = "primaryEnv")]
165    pub primary_env: Option<String>,
166    #[serde(default, rename = "skillKey")]
167    pub skill_key: Option<String>,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171pub struct SkillConfig {
172    #[serde(default = "default_true")]
173    pub enabled: bool,
174    #[serde(default)]
175    pub env: HashMap<String, String>,
176    #[serde(default)]
177    pub config: HashMap<String, String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct SkillState {
182    pub enabled: bool,
183    pub installed_at: String,
184    pub last_modified: String,
185}
186impl Default for SkillState {
187    fn default() -> Self {
188        let now = chrono::Utc::now().to_rfc3339();
189        Self {
190            enabled: true,
191            installed_at: now.clone(),
192            last_modified: now,
193        }
194    }
195}
196
197#[derive(Debug, Clone)]
198pub struct Skill {
199    pub name: String,
200    pub description: String,
201    pub content: String,
202    pub path: PathBuf,
203    pub base_dir: PathBuf,
204    pub file_path: PathBuf,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct SkillMeta {
209    pub name: String,
210    pub description: String,
211}
212impl From<&Skill> for SkillMeta {
213    fn from(s: &Skill) -> Self {
214        SkillMeta {
215            name: s.name.clone(),
216            description: s.description.clone(),
217        }
218    }
219}
220
221#[derive(Debug, Clone)]
222pub struct SkillEntry {
223    pub skill: Skill,
224    pub metadata: Option<SkillMetadata>,
225    pub eligibility: RequirementsCheck,
226    pub status: SkillStatus,
227    pub bundled: bool,
228    pub source: SkillSource,
229    pub invocation: SkillInvocationPolicy,
230    pub format: SkillFormat,
231    pub raw_yaml: serde_yaml::Value,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct SkillRef {
236    pub name: String,
237    pub description: String,
238    pub file_path: String,
239    pub primary_env: Option<String>,
240    pub required_env: Vec<String>,
241    /// Integration IDs this skill hard-requires (RFC-041). The UI cross-
242    /// references these against `/api/integrations` credential status.
243    pub required_integrations: Vec<String>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct SkillSnapshot {
248    pub prompt: String,
249    pub skills: Vec<SkillRef>,
250    pub skill_filter: Option<Vec<String>>,
251}
252
253pub(crate) fn default_true() -> bool {
254    true
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_install_kind_display() {
263        assert_eq!(InstallKind::Brew.to_string(), "brew");
264        assert_eq!(InstallKind::Node.to_string(), "node");
265        assert_eq!(InstallKind::Go.to_string(), "go");
266        assert_eq!(InstallKind::Uv.to_string(), "uv");
267        assert_eq!(InstallKind::Download.to_string(), "download");
268    }
269
270    #[test]
271    fn test_install_kind_serialization() {
272        for (kind, expected) in [
273            (InstallKind::Brew, "\"brew\""),
274            (InstallKind::Node, "\"node\""),
275            (InstallKind::Go, "\"go\""),
276            (InstallKind::Uv, "\"uv\""),
277            (InstallKind::Download, "\"download\""),
278        ] {
279            let json = serde_json::to_string(&kind).unwrap();
280            assert_eq!(json, expected);
281            let restored: InstallKind = serde_json::from_str(&json).unwrap();
282            assert_eq!(kind, restored);
283        }
284    }
285
286    #[test]
287    fn test_skill_status_display() {
288        assert_eq!(SkillStatus::Ready.to_string(), "ready");
289        assert_eq!(SkillStatus::NeedsSetup.to_string(), "needs_setup");
290        assert_eq!(SkillStatus::Disabled.to_string(), "disabled");
291    }
292
293    #[test]
294    fn test_skill_status_serialization() {
295        for status in [
296            SkillStatus::Ready,
297            SkillStatus::NeedsSetup,
298            SkillStatus::Disabled,
299        ] {
300            let json = serde_json::to_string(&status).unwrap();
301            let restored: SkillStatus = serde_json::from_str(&json).unwrap();
302            assert_eq!(status, restored);
303        }
304    }
305
306    #[test]
307    fn test_requirements_default() {
308        let req = Requirements::default();
309        assert!(req.bins.is_empty());
310        assert!(req.any_bins.is_empty());
311        assert!(req.env.is_empty());
312        assert!(req.config.is_empty());
313    }
314
315    #[test]
316    fn test_requirements_serialization() {
317        let req = Requirements {
318            bins: vec!["cargo".to_string(), "node".to_string()],
319            any_bins: vec!["python3".to_string()],
320            env: vec!["API_KEY".to_string()],
321            config: vec!["server.host".to_string()],
322            integrations: vec!["github".to_string()],
323            any_integrations: vec![],
324        };
325        let json = serde_json::to_string(&req).unwrap();
326        let restored: Requirements = serde_json::from_str(&json).unwrap();
327        assert_eq!(restored.bins, req.bins);
328        assert_eq!(restored.any_bins, req.any_bins);
329        assert_eq!(restored.env, req.env);
330        assert_eq!(restored.config, req.config);
331    }
332
333    #[test]
334    fn test_skill_install_spec_minimal() {
335        let spec = SkillInstallSpec {
336            kind: InstallKind::Brew,
337            formula: Some("git".to_string()),
338            package: None,
339            module: None,
340            url: None,
341            archive: None,
342            extract: None,
343            strip_components: None,
344            target_dir: None,
345            os: vec![],
346        };
347        let json = serde_json::to_string(&spec).unwrap();
348        let restored: SkillInstallSpec = serde_json::from_str(&json).unwrap();
349        assert_eq!(restored.kind, InstallKind::Brew);
350        assert_eq!(restored.formula.as_deref(), Some("git"));
351    }
352
353    #[test]
354    fn test_requirements_check_default() {
355        let check = RequirementsCheck::default();
356        assert!(check.missing_bins.is_empty());
357        assert!(check.missing_any_bins.is_empty());
358        assert!(check.missing_env.is_empty());
359        assert!(check.missing_config.is_empty());
360        assert!(check.missing_os.is_empty());
361        // Default eligible is false (no derive default_true for bool)
362        assert!(!check.eligible);
363        assert!(check.config_checks.is_empty());
364    }
365
366    #[test]
367    fn test_requirements_check_ineligible() {
368        let check = RequirementsCheck {
369            missing_bins: vec!["nonexistent".to_string()],
370            missing_any_bins: vec![],
371            missing_env: vec!["SECRET_KEY".to_string()],
372            missing_config: vec![],
373            missing_os: vec![],
374            eligible: false,
375            config_checks: vec![],
376        };
377        assert!(!check.eligible);
378        assert_eq!(check.missing_bins.len(), 1);
379        assert_eq!(check.missing_env.len(), 1);
380    }
381
382    #[test]
383    fn test_skill_invocation_policy_default() {
384        let policy = SkillInvocationPolicy::default();
385        assert!(policy.user_invocable);
386        assert!(!policy.disable_model_invocation);
387    }
388
389    #[test]
390    fn test_skill_config_default() {
391        let config = SkillConfig::default();
392        // Default derived: enabled is false (serde default_true only applies on deserialization)
393        assert!(!config.enabled);
394        assert!(config.env.is_empty());
395        assert!(config.config.is_empty());
396    }
397
398    #[test]
399    fn test_skill_config_deserialization_default_enabled() {
400        // When deserializing from empty JSON, default_true should kick in
401        let json = "{}";
402        let config: SkillConfig = serde_json::from_str(json).unwrap();
403        assert!(config.enabled);
404        assert!(config.env.is_empty());
405    }
406
407    #[test]
408    fn test_skill_state_default() {
409        let state = SkillState::default();
410        assert!(state.enabled);
411        assert!(!state.installed_at.is_empty());
412        assert!(!state.last_modified.is_empty());
413    }
414
415    #[test]
416    fn test_skill_metadata_default() {
417        let meta = SkillMetadata::default();
418        assert!(meta.author.is_none());
419        assert!(meta.version.is_none());
420        assert!(meta.emoji.is_none());
421        assert!(meta.homepage.is_none());
422        assert!(meta.install.is_empty());
423        assert!(!meta.always);
424        assert!(meta.primary_env.is_none());
425    }
426
427    #[test]
428    fn test_skill_meta_from_skill() {
429        let skill = Skill {
430            name: "test".to_string(),
431            description: "desc".to_string(),
432            content: "body".to_string(),
433            path: PathBuf::from("/tmp"),
434            base_dir: PathBuf::from("/tmp"),
435            file_path: PathBuf::from("/tmp/SKILL.md"),
436        };
437        let meta = SkillMeta::from(&skill);
438        assert_eq!(meta.name, "test");
439        assert_eq!(meta.description, "desc");
440    }
441
442    #[test]
443    fn test_skill_snapshot_serialization() {
444        let snap = SkillSnapshot {
445            prompt: "You are helpful".to_string(),
446            skills: vec![SkillRef {
447                name: "bash".to_string(),
448                description: "shell".to_string(),
449                file_path: "/skills/bash.md".to_string(),
450                primary_env: None,
451                required_env: vec![],
452                required_integrations: vec![],
453            }],
454            skill_filter: Some(vec!["bash".to_string()]),
455        };
456        let json = serde_json::to_string(&snap).unwrap();
457        let restored: SkillSnapshot = serde_json::from_str(&json).unwrap();
458        assert_eq!(restored.prompt, "You are helpful");
459        assert_eq!(restored.skills.len(), 1);
460        assert_eq!(restored.skill_filter.as_ref().unwrap().len(), 1);
461    }
462
463    #[test]
464    fn test_config_check() {
465        let check = ConfigCheck {
466            path: "server.port".to_string(),
467            satisfied: true,
468        };
469        assert_eq!(check.path, "server.port");
470        assert!(check.satisfied);
471    }
472}