cersei_tools/skills/
mod.rs1pub mod bundled;
9pub mod discovery;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillMeta {
16 pub name: String,
18 pub description: String,
20 pub path: Option<String>,
22 pub bundled: bool,
24 pub aliases: Vec<String>,
26 pub allowed_tools: Option<Vec<String>>,
28 pub argument_hint: Option<String>,
30 pub format: SkillFormat,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub enum SkillFormat {
37 Commands,
39 Skills,
41 Bundled,
43}
44
45#[derive(Debug, Clone)]
47pub struct LoadedSkill {
48 pub meta: SkillMeta,
49 pub content: String,
51}
52
53impl LoadedSkill {
54 pub fn expand(&self, args: Option<&str>) -> String {
56 let mut result = self.content.clone();
57
58 if let Some(args) = args {
60 result = result.replace("$ARGUMENTS_SUFFIX", &format!(": {}", args));
61 result = result.replace("$ARGUMENTS", args);
62 } else {
63 result = result.replace("$ARGUMENTS_SUFFIX", "");
64 result = result.replace("$ARGUMENTS", "");
65 }
66
67 result
68 }
69}
70
71pub fn strip_frontmatter(content: &str) -> String {
74 if content.starts_with("---") {
75 let after_open = &content[3..];
76 if let Some(close_pos) = after_open.find("\n---") {
77 let rest = &after_open[close_pos + 4..];
78 return rest.trim_start_matches('\n').to_string();
79 }
80 }
81 content.to_string()
82}
83
84pub fn parse_frontmatter(content: &str) -> (std::collections::HashMap<String, String>, String) {
87 let mut map = std::collections::HashMap::new();
88
89 if !content.starts_with("---") {
90 return (map, content.to_string());
91 }
92
93 let after_open = &content[3..];
94 if let Some(close_pos) = after_open.find("\n---") {
95 let yaml_block = &after_open[..close_pos].trim();
96 let body = after_open[close_pos + 4..]
97 .trim_start_matches('\n')
98 .to_string();
99
100 for line in yaml_block.lines() {
102 let line = line.trim();
103 if line.is_empty() || line.starts_with('#') {
104 continue;
105 }
106 if let Some(colon_pos) = line.find(':') {
107 let key = line[..colon_pos].trim().to_string();
108 let value = line[colon_pos + 1..].trim().to_string();
109 map.insert(key, value);
110 }
111 }
112
113 return (map, body);
114 }
115
116 (map, content.to_string())
117}
118
119pub fn extract_description(content: &str) -> String {
122 for line in content.lines() {
123 let trimmed = line.trim();
124 if trimmed.is_empty() || trimmed == "---" {
125 continue;
126 }
127 let trimmed = trimmed.trim_start_matches('#').trim();
129 let desc = if trimmed.len() > 80 {
130 format!("{}...", &trimmed[..77])
131 } else {
132 trimmed.to_string()
133 };
134 return desc;
135 }
136 String::new()
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn test_strip_frontmatter_with_yaml() {
145 let content = "---\nname: test\ndescription: A test skill\n---\n\n# Body\n\nContent here.";
146 let stripped = strip_frontmatter(content);
147 assert!(stripped.starts_with("# Body"));
148 assert!(!stripped.contains("name: test"));
149 }
150
151 #[test]
152 fn test_strip_frontmatter_without_yaml() {
153 let content = "# Just a heading\n\nSome content.";
154 let stripped = strip_frontmatter(content);
155 assert_eq!(stripped, content);
156 }
157
158 #[test]
159 fn test_parse_frontmatter() {
160 let content = "---\nname: my-skill\ndescription: Does things\nallowed-tools: Read, Write\n---\n\nBody";
161 let (fm, body) = parse_frontmatter(content);
162 assert_eq!(fm.get("name").unwrap(), "my-skill");
163 assert_eq!(fm.get("description").unwrap(), "Does things");
164 assert!(body.starts_with("Body"));
165 }
166
167 #[test]
168 fn test_expand_with_arguments() {
169 let skill = LoadedSkill {
170 meta: SkillMeta {
171 name: "test".into(),
172 description: "test".into(),
173 path: None,
174 bundled: true,
175 aliases: vec![],
176 allowed_tools: None,
177 argument_hint: None,
178 format: SkillFormat::Bundled,
179 },
180 content: "Do $ARGUMENTS in the codebase$ARGUMENTS_SUFFIX".into(),
181 };
182
183 let expanded = skill.expand(Some("fix tests"));
184 assert_eq!(expanded, "Do fix tests in the codebase: fix tests");
185
186 let expanded_empty = skill.expand(None);
187 assert_eq!(expanded_empty, "Do in the codebase");
188 }
189
190 #[test]
191 fn test_extract_description() {
192 assert_eq!(
193 extract_description("# Heading\n\nFirst real line here."),
194 "Heading"
195 );
196 assert_eq!(
198 extract_description(&strip_frontmatter("---\nfoo\n---\nContent after FM")),
199 "Content after FM"
200 );
201 assert_eq!(extract_description(""), "");
202 }
203}