Skip to main content

punch_skills/
lib.rs

1//! # punch-skills
2//!
3//! Skill/move system for the Punch Agent Combat System.
4//!
5//! Skills are bundles of tools, requirements, and domain-specific prompts
6//! that can be loaded into a fighter to grant it new capabilities.
7
8pub 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// ---------------------------------------------------------------------------
44// Core types
45// ---------------------------------------------------------------------------
46
47/// The kind of requirement a skill needs.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum RequirementKind {
51    /// A binary must be available on PATH.
52    Binary,
53    /// An environment variable must be set.
54    EnvVar,
55    /// An API key must be configured.
56    ApiKey,
57}
58
59/// A single requirement for a skill to function.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SkillRequirement {
62    /// Human-readable name of the requirement.
63    pub name: String,
64    /// What kind of requirement this is.
65    pub kind: RequirementKind,
66    /// Optional command to run to check if the requirement is met.
67    pub check_command: Option<String>,
68}
69
70/// A skill manifest describes a loadable skill package.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SkillManifest {
73    /// Unique name of the skill.
74    pub name: String,
75    /// Version string (semver).
76    pub version: String,
77    /// Human-readable description.
78    pub description: String,
79    /// Author or team.
80    pub author: String,
81    /// Tools this skill provides.
82    #[serde(default)]
83    pub tools: Vec<ToolDefinition>,
84    /// Requirements that must be met for the skill to work.
85    #[serde(default)]
86    pub requirements: Vec<SkillRequirement>,
87    /// Domain expertise text injected into the system prompt.
88    #[serde(default)]
89    pub skill_prompt: String,
90}
91
92// ---------------------------------------------------------------------------
93// Registry
94// ---------------------------------------------------------------------------
95
96/// Registry of available skills.
97pub struct SkillRegistry {
98    skills: HashMap<String, SkillManifest>,
99}
100
101impl SkillRegistry {
102    /// Create an empty registry.
103    pub fn new() -> Self {
104        Self {
105            skills: HashMap::new(),
106        }
107    }
108
109    /// Load bundled skill manifests.
110    ///
111    /// Populates the registry with all built-in skills that ship with Punch.
112    /// Each skill is converted from its marketplace listing into a manifest
113    /// and registered.
114    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    /// Register a skill manifest.
136    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    /// Get a skill by name.
142    pub fn get_skill(&self, name: &str) -> Option<&SkillManifest> {
143        self.skills.get(name)
144    }
145
146    /// List all registered skill names.
147    pub fn list_skills(&self) -> Vec<String> {
148        self.skills.keys().cloned().collect()
149    }
150
151    /// Search for skills whose name or description contains the query string.
152    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                // Just verify the category is accessible (no panic).
398                let _ = format!("{:?}", tool.category);
399            }
400        }
401    }
402}