agent_config/spec/
skill.rs1use std::path::PathBuf;
4
5use crate::error::AgentConfigError;
6
7use super::validate::{validate_identifier, IdentifierKind};
8
9fn validate_frontmatter_scalar(value: &str) -> Result<(), AgentConfigError> {
14 for c in value.chars() {
15 if c == '\n' || c == '\r' || c == '\t' {
16 return Err(AgentConfigError::InvalidTag {
17 tag: value.to_string(),
18 reason: "skill frontmatter must not contain newlines or tabs",
19 });
20 }
21 if (c as u32) < 0x20 && c != ' ' {
22 return Err(AgentConfigError::InvalidTag {
23 tag: value.to_string(),
24 reason: "skill frontmatter must not contain control characters",
25 });
26 }
27 if c == '\u{007F}' {
28 return Err(AgentConfigError::InvalidTag {
29 tag: value.to_string(),
30 reason: "skill frontmatter must not contain DEL (0x7F)",
31 });
32 }
33 }
34 Ok(())
35}
36
37#[derive(Debug, Clone)]
46pub struct SkillSpec {
47 pub name: String,
50
51 pub owner_tag: String,
55
56 pub frontmatter: SkillFrontmatter,
59
60 pub body: String,
63
64 pub assets: Vec<SkillAsset>,
68
69 pub adopt_unowned: bool,
73}
74
75impl SkillSpec {
76 pub fn builder(name: impl Into<String>) -> SkillSpecBuilder {
78 let name = name.into();
79 SkillSpecBuilder {
80 name: name.clone(),
81 owner_tag: None,
82 frontmatter: SkillFrontmatter {
83 name,
84 description: String::new(),
85 allowed_tools: None,
86 },
87 body: String::new(),
88 assets: Vec::new(),
89 adopt_unowned: false,
90 }
91 }
92
93 pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
95 Self::validate_name(&self.name)?;
96 if self.frontmatter.description.trim().is_empty() {
97 return Err(AgentConfigError::MissingSpecField {
98 id: "<skill spec>",
99 field: "frontmatter.description",
100 });
101 }
102 if self.body.trim().is_empty() {
103 return Err(AgentConfigError::MissingSpecField {
104 id: "<skill spec>",
105 field: "body",
106 });
107 }
108 validate_frontmatter_scalar(&self.frontmatter.name)?;
109 validate_frontmatter_scalar(&self.frontmatter.description)?;
110 if let Some(tools) = &self.frontmatter.allowed_tools {
111 for t in tools {
112 validate_frontmatter_scalar(t)?;
113 }
114 }
115 validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)
116 }
117
118 pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
120 validate_identifier(name, IdentifierKind::SkillName)
121 }
122}
123
124#[derive(Debug, Clone)]
127pub struct SkillFrontmatter {
128 pub name: String,
131
132 pub description: String,
136
137 pub allowed_tools: Option<Vec<String>>,
140}
141
142#[derive(Debug, Clone)]
144pub struct SkillAsset {
145 pub relative_path: PathBuf,
149
150 pub bytes: Vec<u8>,
153
154 pub executable: bool,
157}
158
159#[derive(Debug, Clone)]
161pub struct SkillSpecBuilder {
162 name: String,
163 owner_tag: Option<String>,
164 frontmatter: SkillFrontmatter,
165 body: String,
166 assets: Vec<SkillAsset>,
167 adopt_unowned: bool,
168}
169
170impl SkillSpecBuilder {
171 pub fn owner(mut self, tag: impl Into<String>) -> Self {
173 self.owner_tag = Some(tag.into());
174 self
175 }
176
177 pub fn adopt_unowned(mut self, adopt: bool) -> Self {
180 self.adopt_unowned = adopt;
181 self
182 }
183
184 pub fn description(mut self, d: impl Into<String>) -> Self {
186 self.frontmatter.description = d.into();
187 self
188 }
189
190 pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
192 where
193 I: IntoIterator<Item = S>,
194 S: Into<String>,
195 {
196 self.frontmatter.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
197 self
198 }
199
200 pub fn body(mut self, body: impl Into<String>) -> Self {
202 self.body = body.into();
203 self
204 }
205
206 pub fn asset(mut self, asset: SkillAsset) -> Self {
208 self.assets.push(asset);
209 self
210 }
211
212 pub fn build(self) -> SkillSpec {
222 self.try_build().expect("SkillSpec missing required field")
223 }
224
225 pub fn try_build(self) -> Result<SkillSpec, AgentConfigError> {
240 let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
241 id: "<skill builder>",
242 field: "owner",
243 })?;
244 let spec = SkillSpec {
245 name: self.name,
246 owner_tag,
247 frontmatter: self.frontmatter,
248 body: self.body,
249 assets: self.assets,
250 adopt_unowned: self.adopt_unowned,
251 };
252 spec.validate()?;
253 Ok(spec)
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn validate_rejects_newline_in_description() {
263 let err = SkillSpec::builder("alpha")
264 .owner("appA")
265 .description("line1\nline2")
266 .body("body")
267 .try_build()
268 .unwrap_err();
269 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
270 }
271
272 #[test]
273 fn validate_rejects_tab_in_name() {
274 let mut spec = SkillSpec::builder("alpha")
279 .owner("appA")
280 .description("ok")
281 .body("body")
282 .try_build()
283 .expect("base spec valid");
284 spec.frontmatter.name = "bad\tname".into();
285 let err = spec.validate().unwrap_err();
286 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
287 }
288
289 #[test]
290 fn validate_rejects_control_char_in_allowed_tools() {
291 let err = SkillSpec::builder("alpha")
292 .owner("appA")
293 .description("ok")
294 .body("body")
295 .allowed_tools(["ed\u{0001}it"])
296 .try_build()
297 .unwrap_err();
298 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
299 }
300
301 #[test]
302 fn validate_rejects_del_in_description() {
303 let err = SkillSpec::builder("alpha")
304 .owner("appA")
305 .description("evil\u{007F}")
306 .body("body")
307 .try_build()
308 .unwrap_err();
309 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
310 }
311
312 #[test]
313 fn validate_accepts_normal_description_with_punctuation() {
314 SkillSpec::builder("alpha")
315 .owner("appA")
316 .description("Format Git commit messages: subject + body.")
317 .body("body")
318 .try_build()
319 .expect("valid");
320 }
321
322 #[test]
323 fn validate_rejects_empty_body() {
324 let err = SkillSpec::builder("alpha")
325 .owner("appA")
326 .description("Use this skill")
327 .try_build()
328 .unwrap_err();
329 assert!(matches!(
330 err,
331 AgentConfigError::MissingSpecField { field: "body", .. }
332 ));
333 }
334
335 #[test]
336 fn validate_rejects_whitespace_only_body() {
337 let err = SkillSpec::builder("alpha")
338 .owner("appA")
339 .description("Use this skill")
340 .body(" \n\t \n")
341 .try_build()
342 .unwrap_err();
343 assert!(matches!(
344 err,
345 AgentConfigError::MissingSpecField { field: "body", .. }
346 ));
347 }
348
349 #[test]
350 fn adopt_unowned_defaults_false_and_round_trips() {
351 let default_spec = SkillSpec::builder("alpha")
352 .owner("appA")
353 .description("Use this skill")
354 .body("body")
355 .build();
356 assert!(!default_spec.adopt_unowned);
357
358 let opted = SkillSpec::builder("alpha")
359 .owner("appA")
360 .description("Use this skill")
361 .body("body")
362 .adopt_unowned(true)
363 .build();
364 assert!(opted.adopt_unowned);
365 }
366}