use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::LazyLock;
use tracing::warn;
#[expect(clippy::unwrap_used, reason = "the pattern is a valid literal regex")]
static ARGUMENTS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\$ARGUMENTS(?:\[([0-9]+)\])?|\$([0-9])").unwrap());
#[expect(clippy::unwrap_used, reason = "the pattern is a valid literal regex")]
static COMMAND_INJECTION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!`([^`]+)`").unwrap());
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum SkillContext {
#[default]
Inline,
Fork,
}
impl std::fmt::Display for SkillContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillContext::Inline => write!(f, "inline"),
SkillContext::Fork => write!(f, "fork"),
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedSkillMd {
pub name: String,
pub description: String,
pub license: Option<String>,
pub compatibility: Option<String>,
pub metadata: HashMap<String, serde_json::Value>,
pub allowed_tools: Option<String>,
pub version: String,
pub instructions: String,
pub user_invocable: bool,
pub disable_model_invocation: bool,
pub argument_hint: Option<String>,
pub context: SkillContext,
pub agent: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
name: Option<String>,
description: Option<String>,
license: Option<String>,
compatibility: Option<String>,
#[serde(default)]
metadata: HashMap<String, serde_json::Value>,
#[serde(rename = "allowed-tools")]
allowed_tools: Option<String>,
#[serde(rename = "user-invocable", default = "default_true")]
user_invocable: bool,
#[serde(rename = "disable-model-invocation", default)]
disable_model_invocation: bool,
#[serde(rename = "argument-hint")]
argument_hint: Option<String>,
context: Option<String>,
agent: Option<String>,
model: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SkillContent {
pub skill_md: String,
pub files: Vec<SkillFileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SkillFileEntry {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SkillValidationResult {
pub valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
pub fn parse_skill_md(content: &str) -> Result<ParsedSkillMd, Vec<String>> {
let (frontmatter_str, body) = extract_frontmatter(content)?;
let fm: SkillFrontmatter = match serde_yaml::from_str(&frontmatter_str) {
Ok(fm) => fm,
Err(strict_err) => match try_lenient_yaml_parse(&frontmatter_str) {
Ok(fm) => {
warn!(
strict_error = %strict_err,
"SKILL.md YAML frontmatter required lenient parsing; skill authors should fix their YAML."
);
fm
}
Err(_) => {
return Err(vec![format!("invalid YAML frontmatter: {strict_err}")]);
}
},
};
let mut errors = Vec::new();
let name = match &fm.name {
Some(n) => {
if let Err(name_errors) = validate_skill_name(n) {
errors.extend(name_errors);
}
n.clone()
}
None => {
errors.push("name: required field missing".to_string());
String::new()
}
};
let description = match &fm.description {
Some(d) if d.trim().is_empty() => {
errors.push("description: must not be empty".to_string());
String::new()
}
Some(d) if d.len() > 1024 => {
errors.push("description: exceeds 1024 character limit".to_string());
d.clone()
}
Some(d) => d.clone(),
None => {
errors.push("description: required field missing".to_string());
String::new()
}
};
if let Some(ref license) = fm.license
&& license.len() > 500
{
errors.push("license: exceeds 500 character limit".to_string());
}
if let Some(ref compat) = fm.compatibility
&& compat.len() > 500
{
errors.push("compatibility: exceeds 500 character limit".to_string());
}
if let Some(ref hint) = fm.argument_hint
&& hint.len() > 128
{
errors.push("argument-hint: exceeds 128 character limit".to_string());
}
let context = match fm.context.as_deref() {
Some("fork") => SkillContext::Fork,
Some("inline") | None => SkillContext::Inline,
Some(other) => {
errors.push(format!(
"context: invalid value \"{other}\", must be \"fork\" or \"inline\""
));
SkillContext::Inline
}
};
if fm.agent.is_some() && context != SkillContext::Fork {
errors.push("agent: field is only meaningful when context is \"fork\"".to_string());
}
if body.len() > 100 * 1024 {
errors.push("instructions: exceeds 100 KB limit".to_string());
}
if !errors.is_empty() {
return Err(errors);
}
let version = fm
.metadata
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("1.0")
.to_string();
Ok(ParsedSkillMd {
name,
description,
license: fm.license,
compatibility: fm.compatibility,
metadata: fm.metadata,
allowed_tools: fm.allowed_tools,
version,
instructions: body,
user_invocable: fm.user_invocable,
disable_model_invocation: fm.disable_model_invocation,
argument_hint: fm.argument_hint,
context,
agent: fm.agent,
model: fm.model,
})
}
pub fn validate_skill_md(content: &str) -> SkillValidationResult {
match parse_skill_md(content) {
Ok(parsed) => {
let mut warnings = Vec::new();
let line_count = parsed.instructions.lines().count();
if line_count > 500 {
warnings.push(format!(
"Instructions exceed 500 lines ({line_count} lines). Consider splitting into references."
));
}
if !parsed.user_invocable && parsed.disable_model_invocation {
warnings.push(
"Skill is unreachable: user-invocable is false and disable-model-invocation is true. \
Neither users nor the model can invoke this skill."
.to_string(),
);
}
if parsed.context == SkillContext::Fork && parsed.agent.is_none() {
warnings.push(
"context: fork without agent field — will use default \"general-purpose\" agent."
.to_string(),
);
}
if parsed.model.is_some() && parsed.context != SkillContext::Fork {
warnings.push(
"model: field is only supported with context: fork. \
Inline skills ignore the model override."
.to_string(),
);
}
SkillValidationResult {
valid: true,
name: Some(parsed.name),
description: Some(parsed.description),
errors: vec![],
warnings,
}
}
Err(errors) => SkillValidationResult {
valid: false,
name: None,
description: None,
errors,
warnings: vec![],
},
}
}
pub fn validate_skill_name(name: &str) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if name.is_empty() || name.len() > 64 {
errors.push("name: must be 1-64 characters".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
errors.push("name: must contain only lowercase letters, numbers, and hyphens".to_string());
}
if name.starts_with('-') || name.ends_with('-') {
errors.push("name: must not start or end with hyphen".to_string());
}
if name.contains("--") {
errors.push("name: must not contain consecutive hyphens".to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn extract_frontmatter(content: &str) -> Result<(String, String), Vec<String>> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err(vec![
"SKILL.md must start with YAML frontmatter (--- delimiter)".to_string(),
]);
}
let after_first = &trimmed[3..];
let closing = after_first
.find("\n---")
.ok_or_else(|| vec!["SKILL.md frontmatter missing closing --- delimiter".to_string()])?;
let frontmatter = &after_first[..closing];
let body_start = closing + 4; let body = if body_start < after_first.len() {
after_first[body_start..]
.trim_start_matches('\n')
.to_string()
} else {
String::new()
};
Ok((frontmatter.to_string(), body))
}
fn try_lenient_yaml_parse(frontmatter: &str) -> Result<SkillFrontmatter, serde_yaml::Error> {
let fixed = fix_yaml_values(frontmatter);
serde_yaml::from_str(&fixed)
}
fn fix_yaml_values(frontmatter: &str) -> String {
let problematic_chars: &[char] = &[':', '{', '}', '[', ']', '#'];
frontmatter
.lines()
.map(|line| {
let line: String = line
.chars()
.filter(|c| !c.is_control() || *c == '\t')
.collect();
if let Some(colon_pos) = line.find(": ") {
let key = &line[..colon_pos];
let value = line[colon_pos + 2..].trim();
if value.is_empty()
|| value.starts_with('"')
|| value.starts_with('\'')
|| value.starts_with('|')
|| value.starts_with('>')
|| key.starts_with(' ')
|| key.starts_with('\t')
{
return line;
}
if value.contains(problematic_chars)
&& !value.starts_with('{')
&& !value.starts_with('[')
{
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
return format!("{key}: \"{escaped}\"");
}
}
line
})
.collect::<Vec<_>>()
.join("\n")
}
fn split_skill_args(raw: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quote: Option<char> = None;
let mut token_started = false;
for c in raw.chars() {
match (c, in_quote) {
('"' | '\'', None) => {
in_quote = Some(c);
token_started = true;
}
(q, Some(open)) if q == open => in_quote = None,
(c, Some(_)) => current.push(c),
(c, None) if c.is_whitespace() => {
if token_started {
args.push(std::mem::take(&mut current));
token_started = false;
}
}
(c, None) => {
current.push(c);
token_started = true;
}
}
}
if token_started {
args.push(current);
}
args
}
pub fn expand_skill_arguments(content: &str, raw_args: &str) -> String {
if raw_args.is_empty() {
return content.to_string();
}
let args = split_skill_args(raw_args);
let mut had_placeholder = false;
let mut result = ARGUMENTS_RE
.replace_all(content, |caps: ®ex::Captures| {
let matched = caps.get_match();
if let Some(digit) = caps.get(2) {
if content[matched.end()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
{
return matched.as_str().to_string();
}
had_placeholder = true;
let index = (digit.as_str().as_bytes()[0] - b'0') as usize;
args.get(index).cloned().unwrap_or_default()
} else if let Some(index) = caps.get(1) {
had_placeholder = true;
let index = index.as_str().parse::<usize>().unwrap_or(usize::MAX);
args.get(index).cloned().unwrap_or_default()
} else {
had_placeholder = true;
raw_args.to_string()
}
})
.to_string();
if !had_placeholder {
result.push_str(&format!("\n\nARGUMENTS: {}", raw_args));
}
result
}
pub fn substitute_activation_vars(content: &str, session_id: &str, skill_dir: &str) -> String {
content
.replace("${SESSION_ID}", session_id)
.replace("${SKILL_DIR}", skill_dir)
}
pub struct CommandResult {
pub stdout: String,
pub exit_code: i32,
}
#[async_trait::async_trait]
pub trait CommandExecutor: Send + Sync {
async fn execute_command(&self, command: &str) -> CommandResult;
}
pub const MAX_COMMAND_PLACEHOLDERS_PER_SKILL: usize = 32;
const COMMAND_EXECUTION_CONCURRENCY: usize = 4;
pub async fn preprocess_command_injections(
content: &str,
executor: &dyn CommandExecutor,
) -> String {
use futures::stream::StreamExt;
let all_matches: Vec<(String, std::ops::Range<usize>)> = COMMAND_INJECTION_RE
.captures_iter(content)
.map(|cap| {
let full = cap.get_match();
let cmd = cap[1].to_string();
(cmd, full.start()..full.end())
})
.collect();
if all_matches.is_empty() {
return content.to_string();
}
let exec_count = all_matches.len().min(MAX_COMMAND_PLACEHOLDERS_PER_SKILL);
let cmds_to_run: Vec<String> = all_matches[..exec_count]
.iter()
.map(|(cmd, _)| cmd.clone())
.collect();
let results: Vec<CommandResult> = futures::stream::iter(cmds_to_run)
.map(|cmd| async move { executor.execute_command(&cmd).await })
.buffered(COMMAND_EXECUTION_CONCURRENCY)
.collect()
.await;
let mut result = content.to_string();
for (idx, (cmd, range)) in all_matches.iter().enumerate().rev() {
let replacement = if idx < exec_count {
let cmd_result = &results[idx];
if cmd_result.exit_code != 0 && cmd_result.stdout.starts_with('[') {
cmd_result.stdout.clone()
} else if cmd_result.exit_code != 0 {
format!(
"[Command failed: {} (exit code {})]",
cmd, cmd_result.exit_code
)
} else if cmd_result.stdout.is_empty() {
"[No output]".to_string()
} else {
cmd_result.stdout.trim_end().to_string()
}
} else {
format!(
"[Too many command placeholders: limit is {}]",
MAX_COMMAND_PLACEHOLDERS_PER_SKILL
)
};
result.replace_range(range.clone(), &replacement);
}
result
}
#[cfg(test)]
#[path = "skill_tests.rs"]
mod tests;