1#![allow(missing_docs)]
2use 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 #[serde(default)]
24 pub integrations: Vec<String>,
25 #[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 #[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 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 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 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 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}