1use 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
37fn validate_optional_frontmatter_scalar(value: Option<&String>) -> Result<(), AgentConfigError> {
38 if let Some(value) = value {
39 validate_frontmatter_scalar(value)?;
40 }
41 Ok(())
42}
43
44fn validate_optional_frontmatter_list(
45 values: Option<&Vec<String>>,
46) -> Result<(), AgentConfigError> {
47 if let Some(values) = values {
48 for value in values {
49 validate_frontmatter_scalar(value)?;
50 }
51 }
52 Ok(())
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum SkillEffort {
59 Low,
61 Medium,
63 High,
65 XHigh,
67 Max,
69}
70
71impl SkillEffort {
72 pub(crate) const fn as_yaml(self) -> &'static str {
73 match self {
74 Self::Low => "low",
75 Self::Medium => "medium",
76 Self::High => "high",
77 Self::XHigh => "xhigh",
78 Self::Max => "max",
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum SkillContext {
87 Fork,
89}
90
91impl SkillContext {
92 pub(crate) const fn as_yaml(self) -> &'static str {
93 match self {
94 Self::Fork => "fork",
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum SkillShell {
103 Bash,
105 PowerShell,
107}
108
109impl SkillShell {
110 pub(crate) const fn as_yaml(self) -> &'static str {
111 match self {
112 Self::Bash => "bash",
113 Self::PowerShell => "powershell",
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
127pub struct SkillSpec {
128 pub name: String,
131
132 pub owner_tag: String,
136
137 pub frontmatter: SkillFrontmatter,
140
141 pub body: String,
144
145 pub assets: Vec<SkillAsset>,
149
150 pub adopt_unowned: bool,
154}
155
156impl SkillSpec {
157 pub fn builder(name: impl Into<String>) -> SkillSpecBuilder {
159 let name = name.into();
160 SkillSpecBuilder {
161 name: name.clone(),
162 owner_tag: None,
163 frontmatter: SkillFrontmatter {
164 name,
165 description: String::new(),
166 when_to_use: None,
167 argument_hint: None,
168 arguments: None,
169 disable_model_invocation: None,
170 user_invocable: None,
171 allowed_tools: None,
172 disallowed_tools: None,
173 model: None,
174 effort: None,
175 context: None,
176 agent: None,
177 paths: None,
178 shell: None,
179 },
180 body: String::new(),
181 assets: Vec::new(),
182 adopt_unowned: false,
183 }
184 }
185
186 pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
188 Self::validate_name(&self.name)?;
189 if self.frontmatter.description.trim().is_empty() {
190 return Err(AgentConfigError::MissingSpecField {
191 id: "<skill spec>",
192 field: "frontmatter.description",
193 });
194 }
195 if self.body.trim().is_empty() {
196 return Err(AgentConfigError::MissingSpecField {
197 id: "<skill spec>",
198 field: "body",
199 });
200 }
201 validate_frontmatter_scalar(&self.frontmatter.name)?;
202 validate_frontmatter_scalar(&self.frontmatter.description)?;
203 validate_optional_frontmatter_scalar(self.frontmatter.when_to_use.as_ref())?;
204 validate_optional_frontmatter_scalar(self.frontmatter.argument_hint.as_ref())?;
205 validate_optional_frontmatter_list(self.frontmatter.arguments.as_ref())?;
206 validate_optional_frontmatter_list(self.frontmatter.allowed_tools.as_ref())?;
207 validate_optional_frontmatter_list(self.frontmatter.disallowed_tools.as_ref())?;
208 validate_optional_frontmatter_scalar(self.frontmatter.model.as_ref())?;
209 validate_optional_frontmatter_scalar(self.frontmatter.agent.as_ref())?;
210 validate_optional_frontmatter_list(self.frontmatter.paths.as_ref())?;
211 validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)
212 }
213
214 pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
216 validate_identifier(name, IdentifierKind::SkillName)
217 }
218}
219
220#[derive(Debug, Clone)]
225pub struct SkillFrontmatter {
226 pub name: String,
229
230 pub description: String,
234
235 pub when_to_use: Option<String>,
237
238 pub argument_hint: Option<String>,
240
241 pub arguments: Option<Vec<String>>,
243
244 pub disable_model_invocation: Option<bool>,
246
247 pub user_invocable: Option<bool>,
249
250 pub allowed_tools: Option<Vec<String>>,
253
254 pub disallowed_tools: Option<Vec<String>>,
256
257 pub model: Option<String>,
259
260 pub effort: Option<SkillEffort>,
262
263 pub context: Option<SkillContext>,
265
266 pub agent: Option<String>,
268
269 pub paths: Option<Vec<String>>,
271
272 pub shell: Option<SkillShell>,
274}
275
276#[derive(Debug, Clone)]
278pub struct SkillAsset {
279 pub relative_path: PathBuf,
283
284 pub bytes: Vec<u8>,
287
288 pub executable: bool,
291}
292
293#[derive(Debug, Clone)]
295pub struct SkillSpecBuilder {
296 name: String,
297 owner_tag: Option<String>,
298 frontmatter: SkillFrontmatter,
299 body: String,
300 assets: Vec<SkillAsset>,
301 adopt_unowned: bool,
302}
303
304impl SkillSpecBuilder {
305 pub fn owner(mut self, tag: impl Into<String>) -> Self {
307 self.owner_tag = Some(tag.into());
308 self
309 }
310
311 pub fn adopt_unowned(mut self, adopt: bool) -> Self {
314 self.adopt_unowned = adopt;
315 self
316 }
317
318 pub fn description(mut self, d: impl Into<String>) -> Self {
320 self.frontmatter.description = d.into();
321 self
322 }
323
324 pub fn when_to_use(mut self, value: impl Into<String>) -> Self {
326 self.frontmatter.when_to_use = Some(value.into());
327 self
328 }
329
330 pub fn argument_hint(mut self, value: impl Into<String>) -> Self {
332 self.frontmatter.argument_hint = Some(value.into());
333 self
334 }
335
336 pub fn arguments<I, S>(mut self, arguments: I) -> Self
338 where
339 I: IntoIterator<Item = S>,
340 S: Into<String>,
341 {
342 self.frontmatter.arguments = Some(arguments.into_iter().map(Into::into).collect());
343 self
344 }
345
346 pub fn disable_model_invocation(mut self, disable: bool) -> Self {
348 self.frontmatter.disable_model_invocation = Some(disable);
349 self
350 }
351
352 pub fn user_invocable(mut self, invocable: bool) -> Self {
354 self.frontmatter.user_invocable = Some(invocable);
355 self
356 }
357
358 pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
360 where
361 I: IntoIterator<Item = S>,
362 S: Into<String>,
363 {
364 self.frontmatter.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
365 self
366 }
367
368 pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
370 where
371 I: IntoIterator<Item = S>,
372 S: Into<String>,
373 {
374 self.frontmatter.disallowed_tools = Some(tools.into_iter().map(Into::into).collect());
375 self
376 }
377
378 pub fn model(mut self, model: impl Into<String>) -> Self {
380 self.frontmatter.model = Some(model.into());
381 self
382 }
383
384 pub fn effort(mut self, effort: SkillEffort) -> Self {
386 self.frontmatter.effort = Some(effort);
387 self
388 }
389
390 pub fn context(mut self, context: SkillContext) -> Self {
392 self.frontmatter.context = Some(context);
393 self
394 }
395
396 pub fn agent(mut self, agent: impl Into<String>) -> Self {
398 self.frontmatter.agent = Some(agent.into());
399 self
400 }
401
402 pub fn paths<I, S>(mut self, paths: I) -> Self
404 where
405 I: IntoIterator<Item = S>,
406 S: Into<String>,
407 {
408 self.frontmatter.paths = Some(paths.into_iter().map(Into::into).collect());
409 self
410 }
411
412 pub fn shell(mut self, shell: SkillShell) -> Self {
414 self.frontmatter.shell = Some(shell);
415 self
416 }
417
418 pub fn body(mut self, body: impl Into<String>) -> Self {
420 self.body = body.into();
421 self
422 }
423
424 pub fn asset(mut self, asset: SkillAsset) -> Self {
426 self.assets.push(asset);
427 self
428 }
429
430 pub fn build(self) -> SkillSpec {
440 self.try_build().expect("SkillSpec missing required field")
441 }
442
443 pub fn try_build(self) -> Result<SkillSpec, AgentConfigError> {
458 let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
459 id: "<skill builder>",
460 field: "owner",
461 })?;
462 let spec = SkillSpec {
463 name: self.name,
464 owner_tag,
465 frontmatter: self.frontmatter,
466 body: self.body,
467 assets: self.assets,
468 adopt_unowned: self.adopt_unowned,
469 };
470 spec.validate()?;
471 Ok(spec)
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn validate_rejects_newline_in_description() {
481 let err = SkillSpec::builder("alpha")
482 .owner("appA")
483 .description("line1\nline2")
484 .body("body")
485 .try_build()
486 .unwrap_err();
487 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
488 }
489
490 #[test]
491 fn validate_rejects_tab_in_name() {
492 let mut spec = SkillSpec::builder("alpha")
497 .owner("appA")
498 .description("ok")
499 .body("body")
500 .try_build()
501 .expect("base spec valid");
502 spec.frontmatter.name = "bad\tname".into();
503 let err = spec.validate().unwrap_err();
504 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
505 }
506
507 #[test]
508 fn validate_rejects_control_char_in_allowed_tools() {
509 let err = SkillSpec::builder("alpha")
510 .owner("appA")
511 .description("ok")
512 .body("body")
513 .allowed_tools(["ed\u{0001}it"])
514 .try_build()
515 .unwrap_err();
516 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
517 }
518
519 #[test]
520 fn validate_rejects_control_char_in_current_frontmatter_fields() {
521 let newline = SkillSpec::builder("alpha")
522 .owner("appA")
523 .description("ok")
524 .when_to_use("line1\nline2")
525 .body("body")
526 .try_build()
527 .unwrap_err();
528 assert!(matches!(newline, AgentConfigError::InvalidTag { .. }));
529
530 let tab = SkillSpec::builder("alpha")
531 .owner("appA")
532 .description("ok")
533 .disallowed_tools(["Write\tFile"])
534 .body("body")
535 .try_build()
536 .unwrap_err();
537 assert!(matches!(tab, AgentConfigError::InvalidTag { .. }));
538 }
539
540 #[test]
541 fn validate_rejects_del_in_description() {
542 let err = SkillSpec::builder("alpha")
543 .owner("appA")
544 .description("evil\u{007F}")
545 .body("body")
546 .try_build()
547 .unwrap_err();
548 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
549 }
550
551 #[test]
552 fn validate_accepts_normal_description_with_punctuation() {
553 SkillSpec::builder("alpha")
554 .owner("appA")
555 .description("Format Git commit messages: subject + body.")
556 .body("body")
557 .try_build()
558 .expect("valid");
559 }
560
561 #[test]
562 fn validate_rejects_empty_body() {
563 let err = SkillSpec::builder("alpha")
564 .owner("appA")
565 .description("Use this skill")
566 .try_build()
567 .unwrap_err();
568 assert!(matches!(
569 err,
570 AgentConfigError::MissingSpecField { field: "body", .. }
571 ));
572 }
573
574 #[test]
575 fn validate_rejects_whitespace_only_body() {
576 let err = SkillSpec::builder("alpha")
577 .owner("appA")
578 .description("Use this skill")
579 .body(" \n\t \n")
580 .try_build()
581 .unwrap_err();
582 assert!(matches!(
583 err,
584 AgentConfigError::MissingSpecField { field: "body", .. }
585 ));
586 }
587
588 #[test]
589 fn adopt_unowned_defaults_false_and_round_trips() {
590 let default_spec = SkillSpec::builder("alpha")
591 .owner("appA")
592 .description("Use this skill")
593 .body("body")
594 .build();
595 assert!(!default_spec.adopt_unowned);
596
597 let opted = SkillSpec::builder("alpha")
598 .owner("appA")
599 .description("Use this skill")
600 .body("body")
601 .adopt_unowned(true)
602 .build();
603 assert!(opted.adopt_unowned);
604 }
605}