1pub mod client;
9pub mod loader;
10pub mod lockfile;
11pub mod marketplace;
12pub mod packs;
13pub mod publisher;
14pub mod registry;
15pub mod scanner;
16pub mod verifier;
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21use tracing::info;
22
23use punch_types::ToolDefinition;
24
25pub use client::IndexClient;
26pub use loader::{
27 LoadedSkill, SkillFrontmatter, SkillPrecedence, load_all_skills,
28 load_all_skills_with_marketplace, load_skill_from_dir, load_skills_from_dir, parse_skill_md,
29 render_skills_prompt,
30};
31pub use lockfile::{LockedMove, MoveLockfile};
32pub use marketplace::{
33 InstalledSkill, SkillListing, SkillMarketplace, SkillSource, builtin_skills,
34};
35pub use packs::{
36 InstallResult, PackMcpServer, SkillPack, available_packs, find_bundled_pack, install_pack,
37 load_bundled_packs, load_pack_from_path,
38};
39pub use registry::{IndexEntry, IndexMeta, ScanFinding, ScanVerdict};
40pub use scanner::SkillScanner;
41pub use verifier::{verify_and_scan, verify_checksum, verify_signature};
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum RequirementKind {
51 Binary,
53 EnvVar,
55 ApiKey,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SkillRequirement {
62 pub name: String,
64 pub kind: RequirementKind,
66 pub check_command: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SkillManifest {
73 pub name: String,
75 pub version: String,
77 pub description: String,
79 pub author: String,
81 #[serde(default)]
83 pub tools: Vec<ToolDefinition>,
84 #[serde(default)]
86 pub requirements: Vec<SkillRequirement>,
87 #[serde(default)]
89 pub skill_prompt: String,
90}
91
92pub struct SkillRegistry {
98 skills: HashMap<String, SkillManifest>,
99}
100
101impl SkillRegistry {
102 pub fn new() -> Self {
104 Self {
105 skills: HashMap::new(),
106 }
107 }
108
109 pub fn load_bundled() -> Self {
115 info!("loading bundled skill manifests");
116 let mut registry = Self::new();
117
118 for listing in builtin_skills() {
119 let manifest = SkillManifest {
120 name: listing.name,
121 version: listing.version,
122 description: listing.description,
123 author: listing.author,
124 tools: listing.tool_definitions,
125 requirements: Vec::new(),
126 skill_prompt: String::new(),
127 };
128 registry.register(manifest);
129 }
130
131 info!(count = registry.skills.len(), "bundled skills loaded");
132 registry
133 }
134
135 pub fn register(&mut self, manifest: SkillManifest) {
137 info!(skill = %manifest.name, "registering skill");
138 self.skills.insert(manifest.name.clone(), manifest);
139 }
140
141 pub fn get_skill(&self, name: &str) -> Option<&SkillManifest> {
143 self.skills.get(name)
144 }
145
146 pub fn list_skills(&self) -> Vec<String> {
148 self.skills.keys().cloned().collect()
149 }
150
151 pub fn search_skills(&self, query: &str) -> Vec<&SkillManifest> {
153 let query_lower = query.to_lowercase();
154 self.skills
155 .values()
156 .filter(|s| {
157 s.name.to_lowercase().contains(&query_lower)
158 || s.description.to_lowercase().contains(&query_lower)
159 })
160 .collect()
161 }
162}
163
164impl Default for SkillRegistry {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use punch_types::ToolCategory;
174
175 fn sample_manifest(name: &str) -> SkillManifest {
176 SkillManifest {
177 name: name.to_string(),
178 version: "1.0.0".to_string(),
179 description: format!("A skill called {name}"),
180 author: "tester".to_string(),
181 tools: vec![],
182 requirements: vec![],
183 skill_prompt: String::new(),
184 }
185 }
186
187 #[test]
188 fn test_registry_new_empty() {
189 let registry = SkillRegistry::new();
190 assert!(registry.list_skills().is_empty());
191 }
192
193 #[test]
194 fn test_registry_default_empty() {
195 let registry = SkillRegistry::default();
196 assert!(registry.list_skills().is_empty());
197 }
198
199 #[test]
200 fn test_register_and_get() {
201 let mut registry = SkillRegistry::new();
202 registry.register(sample_manifest("test-skill"));
203
204 let skill = registry.get_skill("test-skill");
205 assert!(skill.is_some());
206 assert_eq!(skill.unwrap().name, "test-skill");
207 }
208
209 #[test]
210 fn test_get_nonexistent() {
211 let registry = SkillRegistry::new();
212 assert!(registry.get_skill("missing").is_none());
213 }
214
215 #[test]
216 fn test_list_skills() {
217 let mut registry = SkillRegistry::new();
218 registry.register(sample_manifest("alpha"));
219 registry.register(sample_manifest("beta"));
220
221 let mut names = registry.list_skills();
222 names.sort();
223 assert_eq!(names, vec!["alpha", "beta"]);
224 }
225
226 #[test]
227 fn test_register_overwrites() {
228 let mut registry = SkillRegistry::new();
229 let mut m1 = sample_manifest("skill");
230 m1.description = "original".to_string();
231 registry.register(m1);
232
233 let mut m2 = sample_manifest("skill");
234 m2.description = "updated".to_string();
235 registry.register(m2);
236
237 let skill = registry.get_skill("skill").unwrap();
238 assert_eq!(skill.description, "updated");
239 assert_eq!(registry.list_skills().len(), 1);
240 }
241
242 #[test]
243 fn test_search_by_name() {
244 let mut registry = SkillRegistry::new();
245 registry.register(sample_manifest("filesystem-tools"));
246 registry.register(sample_manifest("web-tools"));
247 registry.register(sample_manifest("shell-exec"));
248
249 let results = registry.search_skills("tool");
250 assert_eq!(results.len(), 2);
251 }
252
253 #[test]
254 fn test_search_by_description() {
255 let mut registry = SkillRegistry::new();
256 let mut m = sample_manifest("custom");
257 m.description = "Handles HTTP requests".to_string();
258 registry.register(m);
259
260 let results = registry.search_skills("http");
261 assert_eq!(results.len(), 1);
262 assert_eq!(results[0].name, "custom");
263 }
264
265 #[test]
266 fn test_search_case_insensitive() {
267 let mut registry = SkillRegistry::new();
268 registry.register(sample_manifest("FileSystem"));
269
270 let results = registry.search_skills("filesystem");
271 assert_eq!(results.len(), 1);
272 }
273
274 #[test]
275 fn test_search_no_match() {
276 let mut registry = SkillRegistry::new();
277 registry.register(sample_manifest("alpha"));
278
279 let results = registry.search_skills("zzz");
280 assert!(results.is_empty());
281 }
282
283 #[test]
284 fn test_skill_manifest_serde() {
285 let manifest = SkillManifest {
286 name: "test".to_string(),
287 version: "1.0.0".to_string(),
288 description: "desc".to_string(),
289 author: "author".to_string(),
290 tools: vec![ToolDefinition {
291 name: "my_tool".to_string(),
292 description: "a tool".to_string(),
293 input_schema: serde_json::json!({"type": "object"}),
294 category: ToolCategory::Shell,
295 }],
296 requirements: vec![SkillRequirement {
297 name: "git".to_string(),
298 kind: RequirementKind::Binary,
299 check_command: Some("git --version".to_string()),
300 }],
301 skill_prompt: "You are a test skill.".to_string(),
302 };
303 let json = serde_json::to_string(&manifest).unwrap();
304 let restored: SkillManifest = serde_json::from_str(&json).unwrap();
305 assert_eq!(restored.name, "test");
306 assert_eq!(restored.tools.len(), 1);
307 assert_eq!(restored.requirements.len(), 1);
308 }
309
310 #[test]
311 fn test_requirement_kind_serde() {
312 let kinds = vec![
313 RequirementKind::Binary,
314 RequirementKind::EnvVar,
315 RequirementKind::ApiKey,
316 ];
317 for kind in &kinds {
318 let json = serde_json::to_string(kind).unwrap();
319 let restored: RequirementKind = serde_json::from_str(&json).unwrap();
320 assert_eq!(&restored, kind);
321 }
322 }
323
324 #[test]
325 fn test_load_bundled_returns_populated() {
326 let registry = SkillRegistry::load_bundled();
327 let skills = registry.list_skills();
328 assert!(
329 skills.len() >= 8,
330 "expected at least 8 bundled skills, got {}",
331 skills.len()
332 );
333 assert!(registry.get_skill("Filesystem Tools").is_some());
334 assert!(registry.get_skill("Shell Tools").is_some());
335 assert!(registry.get_skill("Web Tools").is_some());
336 assert!(registry.get_skill("Memory Tools").is_some());
337 assert!(registry.get_skill("Knowledge Graph").is_some());
338 assert!(registry.get_skill("Agent Coordination").is_some());
339 assert!(registry.get_skill("Browser Tools").is_some());
340 assert!(registry.get_skill("Patch Tools").is_some());
341 }
342
343 #[test]
344 fn test_load_bundled_skills_have_descriptions() {
345 let registry = SkillRegistry::load_bundled();
346 for name in registry.list_skills() {
347 let skill = registry.get_skill(&name).unwrap();
348 assert!(
349 !skill.description.is_empty(),
350 "skill '{}' should have a non-empty description",
351 name
352 );
353 }
354 }
355
356 #[test]
357 fn test_load_bundled_skills_have_tools() {
358 let registry = SkillRegistry::load_bundled();
359 for name in registry.list_skills() {
360 let skill = registry.get_skill(&name).unwrap();
361 assert!(
362 !skill.tools.is_empty(),
363 "skill '{}' should have at least one tool",
364 name
365 );
366 }
367 }
368
369 #[test]
370 fn test_load_bundled_skills_have_valid_schemas() {
371 let registry = SkillRegistry::load_bundled();
372 for name in registry.list_skills() {
373 let skill = registry.get_skill(&name).unwrap();
374 for tool in &skill.tools {
375 assert!(
376 tool.input_schema.is_object(),
377 "tool '{}' in skill '{}' should have an object input schema",
378 tool.name,
379 name
380 );
381 assert!(
382 tool.input_schema.get("type").is_some(),
383 "tool '{}' in skill '{}' should have a 'type' field in schema",
384 tool.name,
385 name
386 );
387 }
388 }
389 }
390
391 #[test]
392 fn test_load_bundled_skills_categories_assigned() {
393 let registry = SkillRegistry::load_bundled();
394 for name in registry.list_skills() {
395 let skill = registry.get_skill(&name).unwrap();
396 for tool in &skill.tools {
397 let _ = format!("{:?}", tool.category);
399 }
400 }
401 }
402}