use serde::{Deserialize, Deserializer, Serialize};
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de;
struct StringOrVec;
impl<'de> de::Visitor<'de> for StringOrVec {
type Value = Option<Vec<String>>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a string or list of strings")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
let items: Vec<String> = s.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
if items.is_empty() { Ok(None) } else { Ok(Some(items)) }
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut v = Vec::new();
while let Some(item) = seq.next_element::<String>()? {
v.push(item);
}
if v.is_empty() { Ok(None) } else { Ok(Some(v)) }
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
deserializer.deserialize_any(StringOrVec)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillReference {
pub name: String,
pub path: String,
pub content: String,
pub category: String, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_string_or_vec")]
pub tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_turns: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_string_or_vec")]
pub skills: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<String>,
#[serde(skip_deserializing)]
pub body: String,
#[serde(skip_deserializing)]
pub file_path: String,
#[serde(skip_deserializing)]
pub scope: String,
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_invocable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_string_or_vec")]
pub allowed_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<serde_json::Value>,
#[serde(skip_deserializing)]
pub body: String,
#[serde(skip_deserializing)]
pub file_path: String,
#[serde(skip_deserializing)]
pub scope: String,
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
#[serde(skip_deserializing, default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<SkillReference>,
}
pub fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return (None, content);
}
let after_first = match trimmed.strip_prefix("---") {
Some(rest) => rest.trim_start_matches(['\r', '\n']),
None => return (None, content),
};
if let Some(end_idx) = after_first.find("\n---") {
let yaml = &after_first[..end_idx];
let body_start = end_idx + 4; let body = after_first[body_start..].trim_start_matches(['\r', '\n']);
(Some(yaml), body)
} else {
(None, content)
}
}
pub fn parse_agent(content: &str, file_path: &str, scope: &str, project_name: Option<&str>) -> Option<AgentConfig> {
let (yaml, body) = split_frontmatter(content);
let mut config: AgentConfig = match yaml {
Some(y) => serde_yaml::from_str(y).ok()?,
None => {
let name = std::path::Path::new(file_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
AgentConfig {
name,
description: None,
tools: None,
model: None,
permission_mode: None,
max_turns: None,
skills: None,
hooks: None,
memory: None,
background: None,
isolation: None,
body: String::new(),
file_path: String::new(),
scope: String::new(),
project_name: None,
}
}
};
config.body = body.to_string();
config.file_path = file_path.to_string();
config.scope = scope.to_string();
config.project_name = project_name.map(|s| s.to_string());
Some(config)
}
pub fn parse_skill(content: &str, file_path: &str, scope: &str, project_name: Option<&str>) -> Option<SkillConfig> {
let (yaml, body) = split_frontmatter(content);
let mut config: SkillConfig = match yaml {
Some(y) => serde_yaml::from_str(y).ok()?,
None => {
let name = std::path::Path::new(file_path)
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
SkillConfig {
name,
description: None,
user_invocable: None,
allowed_tools: None,
model: None,
context: None,
agent: None,
hooks: None,
body: String::new(),
file_path: String::new(),
scope: String::new(),
project_name: None,
references: Vec::new(),
}
}
};
config.body = body.to_string();
config.file_path = file_path.to_string();
config.scope = scope.to_string();
config.project_name = project_name.map(|s| s.to_string());
Some(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_frontmatter() {
let content = "---\nname: test-agent\ndescription: A test\n---\n\n# System prompt\nDo things.";
let (yaml, body) = split_frontmatter(content);
assert!(yaml.is_some());
assert!(yaml.unwrap().contains("name: test-agent"));
assert!(body.contains("# System prompt"));
}
#[test]
fn test_split_no_frontmatter() {
let content = "# Just markdown\nNo frontmatter here.";
let (yaml, body) = split_frontmatter(content);
assert!(yaml.is_none());
assert_eq!(body, content);
}
#[test]
fn test_parse_agent() {
let content = "---\nname: code-reviewer\ndescription: Reviews code\nmodel: claude-sonnet-4-6\n---\nYou are a code reviewer.";
let agent = parse_agent(content, "/path/to/agent.md", "global", None).unwrap();
assert_eq!(agent.name, "code-reviewer");
assert_eq!(agent.description.as_deref(), Some("Reviews code"));
assert_eq!(agent.model.as_deref(), Some("claude-sonnet-4-6"));
assert!(agent.body.contains("code reviewer"));
}
#[test]
fn test_parse_skill() {
let content = "---\nname: commit\ndescription: Create git commits\nuser_invocable: true\n---\nCommit instructions.";
let skill = parse_skill(content, "/path/to/commit/SKILL.md", "global", None).unwrap();
assert_eq!(skill.name, "commit");
assert_eq!(skill.user_invocable, Some(true));
}
#[test]
fn test_parse_agent_with_csv_tools() {
let content = "---\nname: data-researcher\ndescription: Researches data\ntools: Read, Grep, Glob, WebFetch, WebSearch\nmodel: haiku\n---\nYou are a researcher.";
let agent = parse_agent(content, "/path/to/AGENTS.md", "global", None);
assert!(agent.is_some(), "Agent with CSV tools field should parse successfully");
let agent = agent.unwrap();
assert_eq!(agent.name, "data-researcher");
assert!(agent.tools.is_some(), "tools should be parsed");
}
#[test]
fn test_parse_agent_with_hooks() {
let content = r#"---
name: safe-agent
description: Agent with hooks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate-readonly-query.sh"
---
You are a safe agent."#;
let agent = parse_agent(content, "/path/to/AGENTS.md", "global", None);
assert!(agent.is_some(), "Agent with hooks should parse successfully");
let agent = agent.unwrap();
assert_eq!(agent.name, "safe-agent");
assert!(agent.hooks.is_some(), "hooks should be parsed");
let hooks = agent.hooks.unwrap();
assert!(hooks.get("PreToolUse").is_some(), "PreToolUse key should exist");
}
}