1use chrono::{DateTime, Utc};
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::LazyLock;
12use tracing::warn;
13
14static INDEXED_ARGS_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"\$ARGUMENTS\[([0-9]+)\]").unwrap());
17
18static COMMAND_INJECTION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!`([^`]+)`").unwrap());
20
21use crate::typed_id::SkillId;
22
23#[cfg(feature = "openapi")]
24use utoipa::ToSchema;
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[cfg_attr(feature = "openapi", derive(ToSchema))]
29#[serde(rename_all = "lowercase")]
30pub enum SkillSourceType {
31 Markdown,
33 Archive,
35}
36
37impl std::fmt::Display for SkillSourceType {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 SkillSourceType::Markdown => write!(f, "markdown"),
41 SkillSourceType::Archive => write!(f, "archive"),
42 }
43 }
44}
45
46impl From<&str> for SkillSourceType {
47 fn from(s: &str) -> Self {
48 match s {
49 "archive" => SkillSourceType::Archive,
50 _ => SkillSourceType::Markdown,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[cfg_attr(feature = "openapi", derive(ToSchema))]
58#[serde(rename_all = "lowercase")]
59pub enum SkillStatus {
60 Active,
61 Disabled,
62 Archived,
63 Deleted,
64}
65
66impl std::fmt::Display for SkillStatus {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 SkillStatus::Active => write!(f, "active"),
70 SkillStatus::Disabled => write!(f, "disabled"),
71 SkillStatus::Archived => write!(f, "archived"),
72 SkillStatus::Deleted => write!(f, "deleted"),
73 }
74 }
75}
76
77impl From<&str> for SkillStatus {
78 fn from(s: &str) -> Self {
79 match s {
80 "disabled" => SkillStatus::Disabled,
81 "archived" => SkillStatus::Archived,
82 "deleted" => SkillStatus::Deleted,
83 _ => SkillStatus::Active,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "openapi", derive(ToSchema))]
91pub struct Skill {
92 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "skill_01933b5a00007000800000000000001"))]
94 pub id: SkillId,
95 #[cfg_attr(feature = "openapi", schema(example = "pdf-processing"))]
97 pub name: String,
98 #[cfg_attr(
100 feature = "openapi",
101 schema(example = "Extract text and tables from PDF files.")
102 )]
103 pub description: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub license: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub compatibility: Option<String>,
110 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112 pub metadata: HashMap<String, serde_json::Value>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub allowed_tools: Option<String>,
116 pub source_type: SkillSourceType,
118 pub status: SkillStatus,
120 pub version: String,
122 #[serde(default = "default_true")]
124 pub user_invocable: bool,
125 #[serde(default)]
127 pub disable_model_invocation: bool,
128 pub created_at: DateTime<Utc>,
130 pub updated_at: DateTime<Utc>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub archived_at: Option<DateTime<Utc>>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub deleted_at: Option<DateTime<Utc>>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146#[derive(Default)]
147pub enum SkillContext {
148 #[default]
150 Inline,
151 Fork,
153}
154
155impl std::fmt::Display for SkillContext {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 match self {
158 SkillContext::Inline => write!(f, "inline"),
159 SkillContext::Fork => write!(f, "fork"),
160 }
161 }
162}
163
164#[derive(Debug, Clone)]
166pub struct ParsedSkillMd {
167 pub name: String,
169 pub description: String,
171 pub license: Option<String>,
173 pub compatibility: Option<String>,
175 pub metadata: HashMap<String, serde_json::Value>,
177 pub allowed_tools: Option<String>,
179 pub version: String,
181 pub instructions: String,
183 pub user_invocable: bool,
185 pub disable_model_invocation: bool,
187 pub argument_hint: Option<String>,
189 pub context: SkillContext,
191 pub agent: Option<String>,
193 pub model: Option<String>,
195}
196
197#[derive(Debug, Deserialize)]
199struct SkillFrontmatter {
200 name: Option<String>,
201 description: Option<String>,
202 license: Option<String>,
203 compatibility: Option<String>,
204 #[serde(default)]
205 metadata: HashMap<String, serde_json::Value>,
206 #[serde(rename = "allowed-tools")]
207 allowed_tools: Option<String>,
208 #[serde(rename = "user-invocable", default = "default_true")]
210 user_invocable: bool,
211 #[serde(rename = "disable-model-invocation", default)]
213 disable_model_invocation: bool,
214 #[serde(rename = "argument-hint")]
216 argument_hint: Option<String>,
217 context: Option<String>,
219 agent: Option<String>,
221 model: Option<String>,
223}
224
225fn default_true() -> bool {
226 true
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(ToSchema))]
232pub struct SkillContent {
233 pub skill_md: String,
234 pub files: Vec<SkillFileEntry>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239#[cfg_attr(feature = "openapi", derive(ToSchema))]
240pub struct SkillFileEntry {
241 pub path: String,
242 pub content: String,
243}
244
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250#[cfg_attr(feature = "openapi", derive(ToSchema))]
251pub struct SkillUsage {
252 pub agents: u64,
253 pub harnesses: u64,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(ToSchema))]
259pub struct SkillValidationResult {
260 pub valid: bool,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub name: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub description: Option<String>,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub errors: Vec<String>,
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub warnings: Vec<String>,
274}
275
276pub fn parse_skill_md(content: &str) -> Result<ParsedSkillMd, Vec<String>> {
286 let (frontmatter_str, body) = extract_frontmatter(content)?;
287 let fm: SkillFrontmatter = match serde_yaml::from_str(&frontmatter_str) {
288 Ok(fm) => fm,
289 Err(strict_err) => match try_lenient_yaml_parse(&frontmatter_str) {
290 Ok(fm) => {
291 warn!(
292 strict_error = %strict_err,
293 "SKILL.md YAML frontmatter required lenient parsing; skill authors should fix their YAML."
294 );
295 fm
296 }
297 Err(_) => {
298 return Err(vec![format!("invalid YAML frontmatter: {strict_err}")]);
299 }
300 },
301 };
302
303 let mut errors = Vec::new();
304
305 let name = match &fm.name {
306 Some(n) => {
307 if let Err(name_errors) = validate_skill_name(n) {
308 errors.extend(name_errors);
309 }
310 n.clone()
311 }
312 None => {
313 errors.push("name: required field missing".to_string());
314 String::new()
315 }
316 };
317
318 let description = match &fm.description {
319 Some(d) if d.trim().is_empty() => {
320 errors.push("description: must not be empty".to_string());
321 String::new()
322 }
323 Some(d) if d.len() > 1024 => {
324 errors.push("description: exceeds 1024 character limit".to_string());
325 d.clone()
326 }
327 Some(d) => d.clone(),
328 None => {
329 errors.push("description: required field missing".to_string());
330 String::new()
331 }
332 };
333
334 if let Some(ref license) = fm.license
335 && license.len() > 500
336 {
337 errors.push("license: exceeds 500 character limit".to_string());
338 }
339
340 if let Some(ref compat) = fm.compatibility
341 && compat.len() > 500
342 {
343 errors.push("compatibility: exceeds 500 character limit".to_string());
344 }
345
346 if let Some(ref hint) = fm.argument_hint
347 && hint.len() > 128
348 {
349 errors.push("argument-hint: exceeds 128 character limit".to_string());
350 }
351
352 let context = match fm.context.as_deref() {
354 Some("fork") => SkillContext::Fork,
355 Some("inline") | None => SkillContext::Inline,
356 Some(other) => {
357 errors.push(format!(
358 "context: invalid value \"{other}\", must be \"fork\" or \"inline\""
359 ));
360 SkillContext::Inline
361 }
362 };
363
364 if fm.agent.is_some() && context != SkillContext::Fork {
366 errors.push("agent: field is only meaningful when context is \"fork\"".to_string());
367 }
368
369 if body.len() > 100 * 1024 {
370 errors.push("instructions: exceeds 100 KB limit".to_string());
371 }
372
373 if !errors.is_empty() {
374 return Err(errors);
375 }
376
377 let version = fm
378 .metadata
379 .get("version")
380 .and_then(|v| v.as_str())
381 .unwrap_or("1.0")
382 .to_string();
383
384 Ok(ParsedSkillMd {
385 name,
386 description,
387 license: fm.license,
388 compatibility: fm.compatibility,
389 metadata: fm.metadata,
390 allowed_tools: fm.allowed_tools,
391 version,
392 instructions: body,
393 user_invocable: fm.user_invocable,
394 disable_model_invocation: fm.disable_model_invocation,
395 argument_hint: fm.argument_hint,
396 context,
397 agent: fm.agent,
398 model: fm.model,
399 })
400}
401
402pub fn validate_skill_md(content: &str) -> SkillValidationResult {
404 match parse_skill_md(content) {
405 Ok(parsed) => {
406 let mut warnings = Vec::new();
407 let line_count = parsed.instructions.lines().count();
408 if line_count > 500 {
409 warnings.push(format!(
410 "Instructions exceed 500 lines ({line_count} lines). Consider splitting into references."
411 ));
412 }
413 if !parsed.user_invocable && parsed.disable_model_invocation {
414 warnings.push(
415 "Skill is unreachable: user-invocable is false and disable-model-invocation is true. \
416 Neither users nor the model can invoke this skill."
417 .to_string(),
418 );
419 }
420 if parsed.context == SkillContext::Fork && parsed.agent.is_none() {
421 warnings.push(
422 "context: fork without agent field — will use default \"general-purpose\" agent."
423 .to_string(),
424 );
425 }
426 if parsed.model.is_some() && parsed.context != SkillContext::Fork {
427 warnings.push(
428 "model: field is only supported with context: fork. \
429 Inline skills ignore the model override."
430 .to_string(),
431 );
432 }
433 SkillValidationResult {
434 valid: true,
435 name: Some(parsed.name),
436 description: Some(parsed.description),
437 errors: vec![],
438 warnings,
439 }
440 }
441 Err(errors) => SkillValidationResult {
442 valid: false,
443 name: None,
444 description: None,
445 errors,
446 warnings: vec![],
447 },
448 }
449}
450
451pub fn validate_skill_name(name: &str) -> Result<(), Vec<String>> {
453 let mut errors = Vec::new();
454
455 if name.is_empty() || name.len() > 64 {
456 errors.push("name: must be 1-64 characters".to_string());
457 }
458
459 if !name
460 .chars()
461 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
462 {
463 errors.push("name: must contain only lowercase letters, numbers, and hyphens".to_string());
464 }
465
466 if name.starts_with('-') || name.ends_with('-') {
467 errors.push("name: must not start or end with hyphen".to_string());
468 }
469
470 if name.contains("--") {
471 errors.push("name: must not contain consecutive hyphens".to_string());
472 }
473
474 if errors.is_empty() {
475 Ok(())
476 } else {
477 Err(errors)
478 }
479}
480
481fn extract_frontmatter(content: &str) -> Result<(String, String), Vec<String>> {
484 let trimmed = content.trim_start();
485 if !trimmed.starts_with("---") {
486 return Err(vec![
487 "SKILL.md must start with YAML frontmatter (--- delimiter)".to_string(),
488 ]);
489 }
490
491 let after_first = &trimmed[3..];
493 let closing = after_first
494 .find("\n---")
495 .ok_or_else(|| vec!["SKILL.md frontmatter missing closing --- delimiter".to_string()])?;
496
497 let frontmatter = &after_first[..closing];
498 let body_start = closing + 4; let body = if body_start < after_first.len() {
500 after_first[body_start..]
501 .trim_start_matches('\n')
502 .to_string()
503 } else {
504 String::new()
505 };
506
507 Ok((frontmatter.to_string(), body))
508}
509
510fn try_lenient_yaml_parse(frontmatter: &str) -> Result<SkillFrontmatter, serde_yaml::Error> {
515 let fixed = fix_yaml_values(frontmatter);
516 serde_yaml::from_str(&fixed)
517}
518
519fn fix_yaml_values(frontmatter: &str) -> String {
526 let problematic_chars: &[char] = &[':', '{', '}', '[', ']', '#'];
527
528 frontmatter
529 .lines()
530 .map(|line| {
531 let line: String = line
533 .chars()
534 .filter(|c| !c.is_control() || *c == '\t')
535 .collect();
536
537 if let Some(colon_pos) = line.find(": ") {
539 let key = &line[..colon_pos];
540 let value = line[colon_pos + 2..].trim();
541
542 if value.is_empty()
544 || value.starts_with('"')
545 || value.starts_with('\'')
546 || value.starts_with('|')
547 || value.starts_with('>')
548 || key.starts_with(' ')
549 || key.starts_with('\t')
550 {
551 return line;
552 }
553
554 if value.contains(problematic_chars)
557 && !value.starts_with('{')
558 && !value.starts_with('[')
559 {
560 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
561 return format!("{key}: \"{escaped}\"");
562 }
563 }
564
565 line
566 })
567 .collect::<Vec<_>>()
568 .join("\n")
569}
570
571fn split_skill_args(raw: &str) -> Vec<String> {
580 let mut args = Vec::new();
581 let mut current = String::new();
582 let mut in_quote: Option<char> = None;
583 for c in raw.chars() {
584 match (c, in_quote) {
585 ('"' | '\'', None) => in_quote = Some(c),
586 (q, Some(open)) if q == open => in_quote = None,
587 (c, Some(_)) => current.push(c),
588 (c, None) if c.is_whitespace() => {
589 if !current.is_empty() {
590 args.push(std::mem::take(&mut current));
591 }
592 }
593 (c, None) => current.push(c),
594 }
595 }
596 if !current.is_empty() {
597 args.push(current);
598 }
599 args
600}
601
602pub fn expand_skill_arguments(content: &str, raw_args: &str) -> String {
612 if raw_args.is_empty() {
613 return content.to_string();
614 }
615
616 let args = split_skill_args(raw_args);
617 let mut result = content.to_string();
618 let mut had_placeholder = false;
619
620 if INDEXED_ARGS_RE.is_match(&result) {
622 had_placeholder = true;
623 result = INDEXED_ARGS_RE
624 .replace_all(&result, |caps: ®ex::Captures| {
625 let idx: usize = caps[1].parse().unwrap_or(usize::MAX);
626 args.get(idx).cloned().unwrap_or_default()
627 })
628 .to_string();
629 }
630
631 if result.contains("$ARGUMENTS") {
633 had_placeholder = true;
634 result = result.replace("$ARGUMENTS", raw_args);
635 }
636
637 let chars: Vec<char> = result.chars().collect();
639 let mut new_result = String::with_capacity(result.len());
640 let mut found_shorthand = false;
641 let mut i = 0;
642
643 while i < chars.len() {
644 if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
645 let digit = chars[i + 1];
646 let next_is_word = i + 2 < chars.len()
647 && (chars[i + 2].is_ascii_alphanumeric() || chars[i + 2] == '_');
648
649 if !next_is_word {
650 found_shorthand = true;
651 let idx = (digit as u8 - b'0') as usize;
652 new_result.push_str(args.get(idx).map(|s| s.as_str()).unwrap_or(""));
653 } else {
654 new_result.push('$');
655 new_result.push(digit);
656 }
657 i += 2;
658 } else {
659 new_result.push(chars[i]);
660 i += 1;
661 }
662 }
663
664 if found_shorthand {
665 had_placeholder = true;
666 result = new_result;
667 }
668
669 if !had_placeholder {
671 result.push_str(&format!("\n\nARGUMENTS: {}", raw_args));
672 }
673
674 result
675}
676
677pub fn substitute_activation_vars(content: &str, session_id: &str, skill_dir: &str) -> String {
689 content
690 .replace("${SESSION_ID}", session_id)
691 .replace("${SKILL_DIR}", skill_dir)
692}
693
694pub struct CommandResult {
724 pub stdout: String,
725 pub exit_code: i32,
726}
727
728#[async_trait::async_trait]
736pub trait CommandExecutor: Send + Sync {
737 async fn execute_command(&self, command: &str) -> CommandResult;
738}
739
740pub struct ProcessCommandExecutor {
742 pub timeout_secs: u64,
744}
745
746impl Default for ProcessCommandExecutor {
747 fn default() -> Self {
748 Self { timeout_secs: 30 }
749 }
750}
751
752#[async_trait::async_trait]
753impl CommandExecutor for ProcessCommandExecutor {
754 async fn execute_command(&self, command: &str) -> CommandResult {
755 let timeout = std::time::Duration::from_secs(self.timeout_secs);
756 let child = tokio::process::Command::new("bash")
757 .arg("-c")
758 .arg(command)
759 .stdout(std::process::Stdio::piped())
760 .stderr(std::process::Stdio::piped())
761 .spawn();
762
763 let child = match child {
764 Ok(c) => c,
765 Err(_) => {
766 return CommandResult {
767 stdout: String::new(),
768 exit_code: -1,
769 };
770 }
771 };
772
773 match tokio::time::timeout(timeout, child.wait_with_output()).await {
774 Ok(Ok(output)) => CommandResult {
775 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
776 exit_code: output.status.code().unwrap_or(-1),
777 },
778 Ok(Err(_)) => CommandResult {
779 stdout: String::new(),
780 exit_code: -1,
781 },
782 Err(_) => CommandResult {
783 stdout: format!(
784 "[Command timed out after {}s: {command}]",
785 self.timeout_secs
786 ),
787 exit_code: -1,
788 },
789 }
790 }
791}
792
793pub const MAX_COMMAND_PLACEHOLDERS_PER_SKILL: usize = 32;
798
799const COMMAND_EXECUTION_CONCURRENCY: usize = 4;
802
803pub async fn preprocess_command_injections(
822 content: &str,
823 executor: &dyn CommandExecutor,
824) -> String {
825 use futures::stream::StreamExt;
826
827 let all_matches: Vec<(String, std::ops::Range<usize>)> = COMMAND_INJECTION_RE
828 .captures_iter(content)
829 .map(|cap| {
830 let full = cap.get(0).unwrap();
831 let cmd = cap[1].to_string();
832 (cmd, full.start()..full.end())
833 })
834 .collect();
835
836 if all_matches.is_empty() {
837 return content.to_string();
838 }
839
840 let exec_count = all_matches.len().min(MAX_COMMAND_PLACEHOLDERS_PER_SKILL);
844
845 let cmds_to_run: Vec<String> = all_matches[..exec_count]
850 .iter()
851 .map(|(cmd, _)| cmd.clone())
852 .collect();
853 let results: Vec<CommandResult> = futures::stream::iter(cmds_to_run)
854 .map(|cmd| async move { executor.execute_command(&cmd).await })
855 .buffered(COMMAND_EXECUTION_CONCURRENCY)
856 .collect()
857 .await;
858
859 let mut result = content.to_string();
860 for (idx, (cmd, range)) in all_matches.iter().enumerate().rev() {
864 let replacement = if idx < exec_count {
865 let cmd_result = &results[idx];
866 if cmd_result.exit_code != 0 && cmd_result.stdout.starts_with('[') {
867 cmd_result.stdout.clone()
868 } else if cmd_result.exit_code != 0 {
869 format!(
870 "[Command failed: {} (exit code {})]",
871 cmd, cmd_result.exit_code
872 )
873 } else if cmd_result.stdout.is_empty() {
874 "[No output]".to_string()
875 } else {
876 cmd_result.stdout.trim_end().to_string()
877 }
878 } else {
879 format!(
880 "[Too many command placeholders: limit is {}]",
881 MAX_COMMAND_PLACEHOLDERS_PER_SKILL
882 )
883 };
884 result.replace_range(range.clone(), &replacement);
885 }
886
887 result
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn test_parse_valid_skill_md() {
896 let content = r#"---
897name: pdf-processing
898description: Extract text from PDF files.
899---
900
901# PDF Processing
902
903Use pdfplumber to extract text.
904"#;
905 let parsed = parse_skill_md(content).unwrap();
906 assert_eq!(parsed.name, "pdf-processing");
907 assert_eq!(parsed.description, "Extract text from PDF files.");
908 assert!(parsed.instructions.contains("# PDF Processing"));
909 assert_eq!(parsed.version, "1.0");
910 }
911
912 #[test]
913 fn test_parse_with_optional_fields() {
914 let content = r#"---
915name: data-analysis
916description: Analyze datasets.
917license: MIT
918compatibility: Python 3.10+
919metadata:
920 version: "2.0"
921 author: test
922allowed-tools: bash python
923---
924
925Instructions here.
926"#;
927 let parsed = parse_skill_md(content).unwrap();
928 assert_eq!(parsed.name, "data-analysis");
929 assert_eq!(parsed.license.as_deref(), Some("MIT"));
930 assert_eq!(parsed.compatibility.as_deref(), Some("Python 3.10+"));
931 assert_eq!(parsed.version, "2.0");
932 assert_eq!(parsed.allowed_tools.as_deref(), Some("bash python"));
933 }
934
935 #[test]
936 fn test_parse_missing_name() {
937 let content = r#"---
938description: No name here.
939---
940
941Body.
942"#;
943 let err = parse_skill_md(content).unwrap_err();
944 assert!(err.iter().any(|e| e.contains("name: required")));
945 }
946
947 #[test]
948 fn test_parse_missing_description() {
949 let content = r#"---
950name: test-skill
951---
952
953Body.
954"#;
955 let err = parse_skill_md(content).unwrap_err();
956 assert!(err.iter().any(|e| e.contains("description: required")));
957 }
958
959 #[test]
960 fn test_parse_no_frontmatter() {
961 let content = "# Just markdown, no frontmatter";
962 let err = parse_skill_md(content).unwrap_err();
963 assert!(err.iter().any(|e| e.contains("frontmatter")));
964 }
965
966 #[test]
967 fn test_validate_name_valid() {
968 assert!(validate_skill_name("pdf-processing").is_ok());
969 assert!(validate_skill_name("a").is_ok());
970 assert!(validate_skill_name("my-skill-123").is_ok());
971 }
972
973 #[test]
974 fn test_validate_name_invalid() {
975 assert!(validate_skill_name("").is_err());
976 assert!(validate_skill_name("-leading").is_err());
977 assert!(validate_skill_name("trailing-").is_err());
978 assert!(validate_skill_name("double--hyphen").is_err());
979 assert!(validate_skill_name("UPPERCASE").is_err());
980 assert!(validate_skill_name("has spaces").is_err());
981 assert!(validate_skill_name("has_underscores").is_err());
982 }
983
984 #[test]
985 fn test_validate_skill_md() {
986 let content = r#"---
987name: test-skill
988description: A test skill.
989---
990
991Instructions.
992"#;
993 let result = validate_skill_md(content);
994 assert!(result.valid);
995 assert_eq!(result.name.as_deref(), Some("test-skill"));
996 assert!(result.errors.is_empty());
997 }
998
999 #[test]
1000 fn test_validate_skill_md_invalid() {
1001 let content = r#"---
1002name: INVALID
1003---
1004
1005Body.
1006"#;
1007 let result = validate_skill_md(content);
1008 assert!(!result.valid);
1009 assert!(!result.errors.is_empty());
1010 }
1011
1012 #[test]
1013 fn test_parse_user_invocable_default_true() {
1014 let content = r#"---
1015name: my-skill
1016description: A skill without explicit invocable field.
1017---
1018
1019Instructions.
1020"#;
1021 let parsed = parse_skill_md(content).unwrap();
1022 assert!(
1023 parsed.user_invocable,
1024 "user_invocable should default to true"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_parse_user_invocable_explicit_true() {
1030 let content = r#"---
1031name: my-skill
1032description: An invocable skill.
1033user-invocable: true
1034---
1035
1036Instructions.
1037"#;
1038 let parsed = parse_skill_md(content).unwrap();
1039 assert!(parsed.user_invocable);
1040 }
1041
1042 #[test]
1043 fn test_parse_user_invocable_false() {
1044 let content = r#"---
1045name: background-context
1046description: Context the agent should know but not a user command.
1047user-invocable: false
1048---
1049
1050Instructions.
1051"#;
1052 let parsed = parse_skill_md(content).unwrap();
1053 assert!(!parsed.user_invocable);
1054 }
1055
1056 #[test]
1057 fn test_parse_disable_model_invocation_default_false() {
1058 let content = r#"---
1059name: my-skill
1060description: A skill without disable-model-invocation field.
1061---
1062
1063Instructions.
1064"#;
1065 let parsed = parse_skill_md(content).unwrap();
1066 assert!(
1067 !parsed.disable_model_invocation,
1068 "disable_model_invocation should default to false"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_parse_disable_model_invocation_true() {
1074 let content = r#"---
1075name: manual-only
1076description: A skill that cannot be auto-invoked by the model.
1077disable-model-invocation: true
1078---
1079
1080Instructions.
1081"#;
1082 let parsed = parse_skill_md(content).unwrap();
1083 assert!(parsed.disable_model_invocation);
1084 assert!(parsed.user_invocable); }
1086
1087 #[test]
1088 fn test_validate_warns_unreachable_skill() {
1089 let content = r#"---
1090name: unreachable
1091description: Neither user nor model can invoke.
1092user-invocable: false
1093disable-model-invocation: true
1094---
1095
1096Instructions.
1097"#;
1098 let result = validate_skill_md(content);
1099 assert!(result.valid);
1100 assert!(
1101 result.warnings.iter().any(|w| w.contains("unreachable")),
1102 "Should warn about unreachable skill"
1103 );
1104 }
1105
1106 #[test]
1107 fn test_skill_source_type_display() {
1108 assert_eq!(SkillSourceType::Markdown.to_string(), "markdown");
1109 assert_eq!(SkillSourceType::Archive.to_string(), "archive");
1110 }
1111
1112 #[test]
1113 fn test_skill_status_display() {
1114 assert_eq!(SkillStatus::Active.to_string(), "active");
1115 assert_eq!(SkillStatus::Disabled.to_string(), "disabled");
1116 }
1117
1118 #[test]
1119 fn test_skill_source_type_from_str() {
1120 assert_eq!(SkillSourceType::from("archive"), SkillSourceType::Archive);
1121 assert_eq!(SkillSourceType::from("markdown"), SkillSourceType::Markdown);
1122 assert_eq!(SkillSourceType::from("other"), SkillSourceType::Markdown);
1123 }
1124
1125 #[test]
1126 fn test_parse_argument_hint() {
1127 let content = r#"---
1128name: fix-issue
1129description: Fix a GitHub issue.
1130argument-hint: "<issue-number>"
1131---
1132
1133Fix issue $ARGUMENTS.
1134"#;
1135 let parsed = parse_skill_md(content).unwrap();
1136 assert_eq!(parsed.argument_hint.as_deref(), Some("<issue-number>"));
1137 }
1138
1139 #[test]
1140 fn test_parse_argument_hint_default_none() {
1141 let content = r#"---
1142name: my-skill
1143description: A skill.
1144---
1145
1146Body.
1147"#;
1148 let parsed = parse_skill_md(content).unwrap();
1149 assert!(parsed.argument_hint.is_none());
1150 }
1151
1152 #[test]
1157 fn test_parse_context_fork() {
1158 let content = r#"---
1159name: deep-research
1160description: Research a topic thoroughly.
1161context: fork
1162---
1163
1164Research $ARGUMENTS.
1165"#;
1166 let parsed = parse_skill_md(content).unwrap();
1167 assert_eq!(parsed.context, SkillContext::Fork);
1168 assert!(parsed.agent.is_none());
1169 }
1170
1171 #[test]
1172 fn test_parse_context_fork_with_agent() {
1173 let content = r#"---
1174name: explore-code
1175description: Explore codebase.
1176context: fork
1177agent: Explore
1178---
1179
1180Explore $ARGUMENTS.
1181"#;
1182 let parsed = parse_skill_md(content).unwrap();
1183 assert_eq!(parsed.context, SkillContext::Fork);
1184 assert_eq!(parsed.agent.as_deref(), Some("Explore"));
1185 }
1186
1187 #[test]
1188 fn test_parse_context_inline_explicit() {
1189 let content = r#"---
1190name: my-skill
1191description: A skill.
1192context: inline
1193---
1194
1195Body.
1196"#;
1197 let parsed = parse_skill_md(content).unwrap();
1198 assert_eq!(parsed.context, SkillContext::Inline);
1199 }
1200
1201 #[test]
1202 fn test_parse_context_default_inline() {
1203 let content = r#"---
1204name: my-skill
1205description: A skill.
1206---
1207
1208Body.
1209"#;
1210 let parsed = parse_skill_md(content).unwrap();
1211 assert_eq!(parsed.context, SkillContext::Inline);
1212 assert!(parsed.agent.is_none());
1213 }
1214
1215 #[test]
1216 fn test_parse_context_invalid_value() {
1217 let content = r#"---
1218name: my-skill
1219description: A skill.
1220context: parallel
1221---
1222
1223Body.
1224"#;
1225 let err = parse_skill_md(content).unwrap_err();
1226 assert!(err.iter().any(|e| e.contains("context: invalid value")));
1227 }
1228
1229 #[test]
1230 fn test_parse_agent_without_fork_is_error() {
1231 let content = r#"---
1232name: my-skill
1233description: A skill.
1234agent: Explore
1235---
1236
1237Body.
1238"#;
1239 let err = parse_skill_md(content).unwrap_err();
1240 assert!(
1241 err.iter()
1242 .any(|e| e.contains("agent: field is only meaningful"))
1243 );
1244 }
1245
1246 #[test]
1247 fn test_validate_warns_fork_without_agent() {
1248 let content = r#"---
1249name: my-skill
1250description: A skill.
1251context: fork
1252---
1253
1254Body.
1255"#;
1256 let result = validate_skill_md(content);
1257 assert!(result.valid);
1258 assert!(
1259 result
1260 .warnings
1261 .iter()
1262 .any(|w| w.contains("general-purpose"))
1263 );
1264 }
1265
1266 #[test]
1267 fn test_skill_context_display() {
1268 assert_eq!(SkillContext::Inline.to_string(), "inline");
1269 assert_eq!(SkillContext::Fork.to_string(), "fork");
1270 }
1271
1272 #[test]
1273 fn test_skill_context_default() {
1274 assert_eq!(SkillContext::default(), SkillContext::Inline);
1275 }
1276
1277 #[test]
1280 fn test_parse_model_with_fork() {
1281 let content = r#"---
1282name: quick-lint
1283description: Fast lint check.
1284context: fork
1285model: claude-haiku-4-5-20251001
1286---
1287
1288Lint instructions.
1289"#;
1290 let parsed = parse_skill_md(content).unwrap();
1291 assert_eq!(parsed.model.as_deref(), Some("claude-haiku-4-5-20251001"));
1292 assert_eq!(parsed.context, SkillContext::Fork);
1293 }
1294
1295 #[test]
1296 fn test_parse_model_without_fork() {
1297 let content = r#"---
1298name: my-skill
1299description: A skill.
1300model: gpt-4o
1301---
1302
1303Body.
1304"#;
1305 let parsed = parse_skill_md(content).unwrap();
1306 assert_eq!(parsed.model.as_deref(), Some("gpt-4o"));
1307 assert_eq!(parsed.context, SkillContext::Inline);
1308 }
1309
1310 #[test]
1311 fn test_parse_no_model_field() {
1312 let content = r#"---
1313name: my-skill
1314description: A skill.
1315---
1316
1317Body.
1318"#;
1319 let parsed = parse_skill_md(content).unwrap();
1320 assert!(parsed.model.is_none());
1321 }
1322
1323 #[test]
1324 fn test_validate_warns_model_without_fork() {
1325 let content = r#"---
1326name: my-skill
1327description: A skill.
1328model: gpt-4o
1329---
1330
1331Body.
1332"#;
1333 let result = validate_skill_md(content);
1334 assert!(result.valid);
1335 assert!(
1336 result
1337 .warnings
1338 .iter()
1339 .any(|w| w.contains("model:") && w.contains("context: fork"))
1340 );
1341 }
1342
1343 #[test]
1344 fn test_validate_no_warning_model_with_fork() {
1345 let content = r#"---
1346name: my-skill
1347description: A skill.
1348context: fork
1349agent: Explore
1350model: claude-haiku-4-5-20251001
1351---
1352
1353Body.
1354"#;
1355 let result = validate_skill_md(content);
1356 assert!(result.valid);
1357 assert!(
1358 !result
1359 .warnings
1360 .iter()
1361 .any(|w| w.contains("model:") && w.contains("context: fork"))
1362 );
1363 }
1364
1365 #[test]
1370 fn test_expand_full_arguments() {
1371 let content = "Process $ARGUMENTS now.";
1372 let result = expand_skill_arguments(content, "SearchBar React");
1373 assert_eq!(result, "Process SearchBar React now.");
1374 }
1375
1376 #[test]
1377 fn test_expand_indexed_arguments() {
1378 let content = "Migrate $ARGUMENTS[0] from $ARGUMENTS[1] to $ARGUMENTS[2].";
1379 let result = expand_skill_arguments(content, "SearchBar React Vue");
1380 assert_eq!(result, "Migrate SearchBar from React to Vue.");
1381 }
1382
1383 #[test]
1384 fn test_expand_shorthand_arguments() {
1385 let content = "Component: $0, from: $1, to: $2.";
1386 let result = expand_skill_arguments(content, "SearchBar React Vue");
1387 assert_eq!(result, "Component: SearchBar, from: React, to: Vue.");
1388 }
1389
1390 #[test]
1391 fn test_expand_quoted_arguments() {
1392 let content = "File: $0, message: $1.";
1393 let result = expand_skill_arguments(content, "app.js \"hello world\"");
1394 assert_eq!(result, "File: app.js, message: hello world.");
1395 }
1396
1397 #[test]
1398 fn test_expand_out_of_bounds() {
1399 let content = "A: $0, B: $1, C: $5.";
1400 let result = expand_skill_arguments(content, "only-one");
1401 assert_eq!(result, "A: only-one, B: , C: .");
1402 }
1403
1404 #[test]
1405 fn test_expand_no_placeholders_appends() {
1406 let content = "Do the thing.";
1407 let result = expand_skill_arguments(content, "some args");
1408 assert_eq!(result, "Do the thing.\n\nARGUMENTS: some args");
1409 }
1410
1411 #[test]
1412 fn test_expand_empty_args() {
1413 let content = "Content with $ARGUMENTS placeholder.";
1414 let result = expand_skill_arguments(content, "");
1415 assert_eq!(result, "Content with $ARGUMENTS placeholder.");
1416 }
1417
1418 #[test]
1419 fn test_expand_shorthand_no_word_collision() {
1420 let content = "Variable $NAME and $0.";
1422 let result = expand_skill_arguments(content, "first");
1423 assert_eq!(result, "Variable $NAME and first.");
1424 }
1425
1426 #[test]
1427 fn test_expand_dollar_followed_by_multi_digit() {
1428 let content = "Value: $10 and $1.";
1430 let result = expand_skill_arguments(content, "a b");
1431 assert_eq!(result, "Value: $10 and b.");
1433 }
1434
1435 #[test]
1436 fn test_split_skill_args_basic() {
1437 let args = split_skill_args("a b c");
1438 assert_eq!(args, vec!["a", "b", "c"]);
1439 }
1440
1441 #[test]
1442 fn test_split_skill_args_quoted() {
1443 let args = split_skill_args("\"hello world\" foo 'bar baz'");
1444 assert_eq!(args, vec!["hello world", "foo", "bar baz"]);
1445 }
1446
1447 #[test]
1448 fn test_split_skill_args_empty() {
1449 let args = split_skill_args("");
1450 assert!(args.is_empty());
1451 }
1452
1453 #[test]
1454 fn test_split_skill_args_extra_whitespace() {
1455 let args = split_skill_args(" a b ");
1456 assert_eq!(args, vec!["a", "b"]);
1457 }
1458
1459 #[test]
1464 fn test_substitute_session_id() {
1465 let content = "Session: ${SESSION_ID}";
1466 let result = substitute_activation_vars(content, "session_01abc123", "/some/dir");
1467 assert_eq!(result, "Session: session_01abc123");
1468 }
1469
1470 #[test]
1471 fn test_substitute_skill_dir_filesystem() {
1472 let content = "Dir: ${SKILL_DIR}";
1473 let result = substitute_activation_vars(content, "session_x", "/home/user/skills/my-skill");
1474 assert_eq!(result, "Dir: /home/user/skills/my-skill");
1475 }
1476
1477 #[test]
1478 fn test_substitute_skill_dir_db_backed() {
1479 let content = "Dir: ${SKILL_DIR}";
1480 let result = substitute_activation_vars(content, "session_x", "/.agents/skills/my-skill");
1481 assert_eq!(result, "Dir: /.agents/skills/my-skill");
1482 }
1483
1484 #[test]
1485 fn test_substitute_both_vars() {
1486 let content = "Run: ${SKILL_DIR}/run.sh --session ${SESSION_ID}";
1487 let result =
1488 substitute_activation_vars(content, "session_01abc", "/.agents/skills/data-tool");
1489 assert_eq!(
1490 result,
1491 "Run: /.agents/skills/data-tool/run.sh --session session_01abc"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_substitute_no_vars() {
1497 let content = "No variables here.";
1498 let result = substitute_activation_vars(content, "session_x", "/dir");
1499 assert_eq!(result, "No variables here.");
1500 }
1501
1502 #[test]
1503 fn test_substitute_multiple_occurrences() {
1504 let content = "${SESSION_ID} and ${SESSION_ID} again";
1505 let result = substitute_activation_vars(content, "session_abc", "/dir");
1506 assert_eq!(result, "session_abc and session_abc again");
1507 }
1508
1509 struct MockExecutor {
1515 responses: std::collections::HashMap<String, CommandResult>,
1516 }
1517
1518 impl MockExecutor {
1519 fn new() -> Self {
1520 Self {
1521 responses: std::collections::HashMap::new(),
1522 }
1523 }
1524
1525 fn add_response(&mut self, cmd: &str, stdout: &str, exit_code: i32) {
1526 self.responses.insert(
1527 cmd.to_string(),
1528 CommandResult {
1529 stdout: stdout.to_string(),
1530 exit_code,
1531 },
1532 );
1533 }
1534 }
1535
1536 #[async_trait::async_trait]
1537 impl CommandExecutor for MockExecutor {
1538 async fn execute_command(&self, command: &str) -> CommandResult {
1539 self.responses
1540 .get(command)
1541 .map(|r| CommandResult {
1542 stdout: r.stdout.clone(),
1543 exit_code: r.exit_code,
1544 })
1545 .unwrap_or(CommandResult {
1546 stdout: String::new(),
1547 exit_code: 127,
1548 })
1549 }
1550 }
1551
1552 #[tokio::test]
1553 async fn test_preprocess_single_command() {
1554 let mut exec = MockExecutor::new();
1555 exec.add_response("echo hello", "hello\n", 0);
1556
1557 let content = "Output: !`echo hello`";
1558 let result = preprocess_command_injections(content, &exec).await;
1559 assert_eq!(result, "Output: hello");
1560 }
1561
1562 #[tokio::test]
1563 async fn test_preprocess_multiple_commands() {
1564 let mut exec = MockExecutor::new();
1565 exec.add_response("git status", "clean\n", 0);
1566 exec.add_response("date", "2026-03-19\n", 0);
1567
1568 let content = "Status: !`git status`\nDate: !`date`";
1569 let result = preprocess_command_injections(content, &exec).await;
1570 assert_eq!(result, "Status: clean\nDate: 2026-03-19");
1571 }
1572
1573 #[tokio::test]
1574 async fn test_preprocess_command_failure() {
1575 let mut exec = MockExecutor::new();
1576 exec.add_response("bad-cmd", "error output\n", 1);
1577
1578 let content = "Result: !`bad-cmd`";
1579 let result = preprocess_command_injections(content, &exec).await;
1580 assert_eq!(result, "Result: [Command failed: bad-cmd (exit code 1)]");
1581 }
1582
1583 #[tokio::test]
1584 async fn test_preprocess_empty_output() {
1585 let mut exec = MockExecutor::new();
1586 exec.add_response("true", "", 0);
1587
1588 let content = "Result: !`true`";
1589 let result = preprocess_command_injections(content, &exec).await;
1590 assert_eq!(result, "Result: [No output]");
1591 }
1592
1593 #[tokio::test]
1594 async fn test_preprocess_no_commands() {
1595 let exec = MockExecutor::new();
1596
1597 let content = "No commands here. Just `code` and text.";
1598 let result = preprocess_command_injections(content, &exec).await;
1599 assert_eq!(result, content);
1600 }
1601
1602 #[tokio::test]
1603 async fn test_preprocess_preserves_regular_backticks() {
1604 let mut exec = MockExecutor::new();
1605 exec.add_response("echo hi", "hi\n", 0);
1606
1607 let content = "Use `code` and !`echo hi` here.";
1608 let result = preprocess_command_injections(content, &exec).await;
1609 assert_eq!(result, "Use `code` and hi here.");
1610 }
1611
1612 #[tokio::test]
1613 async fn test_preprocess_with_process_executor() {
1614 let exec = ProcessCommandExecutor::default();
1615
1616 let content = "Result: !`echo hello world`";
1617 let result = preprocess_command_injections(content, &exec).await;
1618 assert_eq!(result, "Result: hello world");
1619 }
1620
1621 #[tokio::test]
1622 async fn test_preprocess_command_not_found() {
1623 let exec = MockExecutor::new(); let content = "Result: !`unknown-cmd`";
1626 let result = preprocess_command_injections(content, &exec).await;
1627 assert!(result.contains("[Command failed: unknown-cmd"));
1628 }
1629
1630 #[test]
1633 fn test_lenient_parse_unquoted_colon_in_description() {
1634 let content = r#"---
1635name: my-skill
1636description: Use this skill: it handles edge cases
1637---
1638
1639Instructions.
1640"#;
1641 let parsed = parse_skill_md(content).unwrap();
1642 assert_eq!(parsed.name, "my-skill");
1643 assert_eq!(parsed.description, "Use this skill: it handles edge cases");
1644 }
1645
1646 #[test]
1647 fn test_lenient_parse_hash_in_value() {
1648 let content = "---\nname: my-skill\ndescription: Process C# files\n---\n\nBody.\n";
1649 let parsed = parse_skill_md(content).unwrap();
1650 assert_eq!(parsed.description, "Process C# files");
1651 }
1652
1653 #[test]
1654 fn test_lenient_parse_brackets_in_value() {
1655 let content =
1656 "---\nname: my-skill\ndescription: Parse [markdown] and {templates}\n---\n\nBody.\n";
1657 let parsed = parse_skill_md(content).unwrap();
1658 assert_eq!(parsed.description, "Parse [markdown] and {templates}");
1659 }
1660
1661 #[test]
1662 fn test_lenient_parse_already_quoted_value_unchanged() {
1663 let content = "---\nname: my-skill\ndescription: \"Already quoted: value\"\n---\n\nBody.\n";
1664 let parsed = parse_skill_md(content).unwrap();
1665 assert_eq!(parsed.description, "Already quoted: value");
1666 }
1667
1668 #[test]
1669 fn test_fix_yaml_values_preserves_clean_yaml() {
1670 let input = "name: my-skill\ndescription: A simple skill";
1671 assert_eq!(fix_yaml_values(input), input);
1672 }
1673
1674 #[test]
1675 fn test_fix_yaml_values_quotes_colons() {
1676 let input = "name: my-skill\ndescription: Use this: it works";
1677 let fixed = fix_yaml_values(input);
1678 assert!(fixed.contains("description: \"Use this: it works\""));
1679 }
1680
1681 #[test]
1682 fn test_fix_yaml_values_escapes_inner_quotes() {
1683 let input = "name: my-skill\ndescription: Say \"hello\": world";
1684 let fixed = fix_yaml_values(input);
1685 assert!(fixed.contains(r#"description: "Say \"hello\": world""#));
1686 }
1687
1688 #[test]
1689 fn test_fix_yaml_values_skips_nested_keys() {
1690 let input = "metadata:\n version: 1.0\n key: value: nested";
1691 let fixed = fix_yaml_values(input);
1692 assert!(fixed.contains(" version: 1.0"));
1694 assert!(fixed.contains(" key: value: nested"));
1695 }
1696
1697 #[test]
1698 fn test_fix_yaml_values_preserves_flow_collections() {
1699 let input = "name: my-skill\nmetadata: { version: \"1.0\" }\ntags: [a, b]";
1700 let fixed = fix_yaml_values(input);
1701 assert!(fixed.contains("metadata: { version: \"1.0\" }"));
1703 assert!(fixed.contains("tags: [a, b]"));
1704 }
1705}