1use crate::compiler::CompiledConfig;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12pub const SKILL_PACK_SCHEMA_VERSION: &str = "cellstate.skills.pack.v1";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AgentSkill {
18 pub name: String,
20 pub description: String,
22 pub version: String,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub tags: Vec<String>,
27 pub instructions: String,
29 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub resources: BTreeMap<String, SkillResourceKind>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum SkillResourceKind {
38 Script,
39 Reference,
40 Asset,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SkillPack {
46 #[serde(default = "default_skill_pack_schema_version")]
48 pub schema_version: String,
49 #[serde(default)]
51 pub skills: Vec<AgentSkill>,
52}
53
54fn default_skill_pack_schema_version() -> String {
55 SKILL_PACK_SCHEMA_VERSION.to_string()
56}
57
58pub fn generate_skill_pack(config: &CompiledConfig) -> SkillPack {
70 let version = config
71 .pack_meta_version
72 .as_deref()
73 .unwrap_or("1.0")
74 .to_string();
75
76 let skills: Vec<AgentSkill> = config
77 .pack_agents
78 .iter()
79 .filter(|agent| agent.enabled)
80 .map(|agent| {
81 let description = agent
82 .description
83 .clone()
84 .unwrap_or_else(|| format!("CELLSTATE agent: {}", agent.name));
85
86 let mut resources = BTreeMap::new();
88 for toolset in &agent.toolsets {
89 resources.insert(
90 format!("toolsets/{}", toolset),
91 SkillResourceKind::Reference,
92 );
93 }
94
95 AgentSkill {
96 name: agent.name.clone(),
97 description,
98 version: version.clone(),
99 tags: agent.tags.clone(),
100 instructions: format!(
101 "<!-- Source: {} -->\n\n\
102 Agent prompt loaded from: {}\n\n\
103 Profile: {}\n\
104 Token budget: {}\n\
105 Toolsets: {}",
106 agent.prompt_md,
107 agent.prompt_md,
108 agent.profile,
109 agent
110 .token_budget
111 .map(|b| b.to_string())
112 .unwrap_or_else(|| "default".to_string()),
113 if agent.toolsets.is_empty() {
114 "none".to_string()
115 } else {
116 agent.toolsets.join(", ")
117 },
118 ),
119 resources,
120 }
121 })
122 .collect();
123
124 SkillPack {
125 schema_version: SKILL_PACK_SCHEMA_VERSION.to_string(),
126 skills,
127 }
128}
129
130pub fn render_skill_md(skill: &AgentSkill) -> String {
132 let mut out = String::new();
133 out.push_str("---\n");
134 out.push_str(&format!("name: {}\n", skill.name));
135 if skill.description.contains(':')
137 || skill.description.contains('#')
138 || skill.description.contains('\n')
139 {
140 out.push_str(&format!(
141 "description: |\n {}\n",
142 skill.description.replace('\n', "\n ")
143 ));
144 } else {
145 out.push_str(&format!("description: {}\n", skill.description));
146 }
147 if !skill.version.is_empty() {
148 out.push_str(&format!("version: {}\n", skill.version));
149 }
150 if !skill.tags.is_empty() {
151 out.push_str("tags:\n");
152 for tag in &skill.tags {
153 out.push_str(&format!(" - {}\n", tag));
154 }
155 }
156 out.push_str("---\n\n");
157 out.push_str(&skill.instructions);
158 if !skill.instructions.ends_with('\n') {
159 out.push('\n');
160 }
161 out
162}
163
164pub fn parse_skill_md(content: &str) -> Result<AgentSkill, SkillParseError> {
176 let content = content.trim();
177 if !content.starts_with("---") {
178 return Err(SkillParseError::MissingFrontmatter);
179 }
180
181 let rest = &content[3..];
183 let end = rest
184 .find("\n---")
185 .ok_or(SkillParseError::MissingFrontmatter)?;
186
187 let frontmatter = &rest[..end].trim();
188 let body = rest[end + 4..].trim();
189
190 let meta: SkillFrontmatter = serde_yaml::from_str(frontmatter)
192 .map_err(|e| SkillParseError::InvalidYaml(e.to_string()))?;
193
194 Ok(AgentSkill {
195 name: meta.name.ok_or(SkillParseError::MissingName)?,
196 description: meta
197 .description
198 .ok_or(SkillParseError::MissingDescription)?,
199 version: meta.version.unwrap_or_else(|| "1.0".to_string()),
200 tags: meta.tags.unwrap_or_default(),
201 instructions: body.to_string(),
202 resources: BTreeMap::new(),
203 })
204}
205
206#[derive(Debug, Deserialize)]
207struct SkillFrontmatter {
208 name: Option<String>,
209 description: Option<String>,
210 version: Option<String>,
211 tags: Option<Vec<String>>,
212}
213
214#[derive(Debug, Clone)]
216pub enum SkillParseError {
217 MissingFrontmatter,
218 InvalidYaml(String),
219 MissingName,
220 MissingDescription,
221}
222
223impl std::fmt::Display for SkillParseError {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 match self {
226 SkillParseError::MissingFrontmatter => {
227 write!(f, "SKILL.md must start with YAML frontmatter (---)")
228 }
229 SkillParseError::InvalidYaml(e) => write!(f, "Invalid YAML frontmatter: {}", e),
230 SkillParseError::MissingName => write!(f, "SKILL.md frontmatter missing 'name' field"),
231 SkillParseError::MissingDescription => {
232 write!(f, "SKILL.md frontmatter missing 'description' field")
233 }
234 }
235 }
236}
237
238impl std::error::Error for SkillParseError {}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn test_render_skill_md_basic() {
246 let skill = AgentSkill {
247 name: "researcher".to_string(),
248 description: "Research agent for web and document analysis".to_string(),
249 version: "1.0".to_string(),
250 tags: vec!["research".to_string(), "analysis".to_string()],
251 instructions: "You are a research agent.\n\nSearch the web and analyze documents."
252 .to_string(),
253 resources: BTreeMap::new(),
254 };
255
256 let md = render_skill_md(&skill);
257
258 assert!(md.starts_with("---\n"));
259 assert!(md.contains("name: researcher"));
260 assert!(md.contains("description: Research agent"));
261 assert!(md.contains("version: 1.0"));
262 assert!(md.contains(" - research"));
263 assert!(md.contains(" - analysis"));
264 assert!(md.contains("You are a research agent."));
265 }
266
267 #[test]
268 fn test_parse_skill_md_roundtrip() {
269 let original = AgentSkill {
270 name: "support".to_string(),
271 description: "Customer support agent".to_string(),
272 version: "2.0".to_string(),
273 tags: vec!["support".to_string()],
274 instructions: "Help customers with their issues.".to_string(),
275 resources: BTreeMap::new(),
276 };
277
278 let rendered = render_skill_md(&original);
279 let parsed = parse_skill_md(&rendered).expect("should parse");
280
281 assert_eq!(parsed.name, original.name);
282 assert_eq!(parsed.description, original.description);
283 assert_eq!(parsed.version, original.version);
284 assert_eq!(parsed.tags, original.tags);
285 assert_eq!(parsed.instructions, original.instructions);
286 }
287
288 #[test]
289 fn test_parse_skill_md_missing_frontmatter() {
290 let result = parse_skill_md("No frontmatter here");
291 assert!(result.is_err());
292 }
293
294 #[test]
295 fn test_parse_skill_md_missing_name() {
296 let md = "---\ndescription: test\n---\n\nBody";
297 let result = parse_skill_md(md);
298 assert!(matches!(result, Err(SkillParseError::MissingName)));
299 }
300
301 #[test]
302 fn test_parse_skill_md_missing_description() {
303 let md = "---\nname: test\n---\n\nBody";
304 let result = parse_skill_md(md);
305 assert!(matches!(result, Err(SkillParseError::MissingDescription)));
306 }
307
308 #[test]
309 fn test_generate_skill_pack_empty_config() {
310 let config = CompiledConfig::default();
311 let pack = generate_skill_pack(&config);
312 assert_eq!(pack.schema_version, SKILL_PACK_SCHEMA_VERSION);
313 assert!(pack.skills.is_empty());
314 }
315
316 #[test]
317 fn test_skill_pack_json_includes_schema_version() {
318 let config = CompiledConfig::default();
319 let pack = generate_skill_pack(&config);
320 let json = serde_json::to_string(&pack).expect("serialize skill pack");
321 assert!(json.contains("schema_version"));
322 assert!(json.contains(SKILL_PACK_SCHEMA_VERSION));
323 }
324}