use std::fs;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::agent::{
self,
frontmatter::{FrontMatter, FrontMatterMapper, split_frontmatter, tokenize_tools},
};
use crate::spec::{Rule, RuleKind, Source};
use super::{
ExpandedItem, ExpandedKind, SystemFile, fetch_github, hash_content, read_collection_spec,
};
pub fn expand_rule(root: &Path, rule: &Rule) -> anyhow::Result<Vec<ExpandedItem>> {
match &rule.kind {
RuleKind::Collection {
include,
exclude,
schema_override,
} => expand_collection(root, rule, include, exclude, schema_override),
_ => expand_local_or_github(root, rule),
}
}
fn expand_local_or_github(root: &Path, rule: &Rule) -> anyhow::Result<Vec<ExpandedItem>> {
let (_, abs_path) = materialize(root, &rule.source)?;
match &rule.kind {
RuleKind::SkillSet { include, exclude } => {
expand_skill_set(rule, &abs_path, include, exclude)
}
RuleKind::AgentSet { include, exclude } => {
expand_agent_set(rule, &abs_path, include, exclude)
}
RuleKind::Skill {
name,
description,
allowed_tools,
} => expand_single_skill(
rule,
&abs_path,
name.as_deref(),
description.as_deref(),
allowed_tools.as_deref(),
),
RuleKind::Agent {
name,
description,
allowed_tools,
} => expand_single_agent(
rule,
&abs_path,
name.as_deref(),
description.as_deref(),
allowed_tools.as_deref(),
),
RuleKind::System => expand_single_system(rule, &abs_path),
RuleKind::Collection { .. } => unreachable!("handled in expand_rule"),
}
}
fn expand_collection(
root: &Path,
rule: &Rule,
include: &[String],
exclude: &[String],
schema_override: &Option<crate::spec::AgentName>,
) -> anyhow::Result<Vec<ExpandedItem>> {
let collection_root = match &rule.source {
Source::Local(rel) => {
let abs = if rel.is_absolute() {
rel.clone()
} else {
root.join(rel)
};
if !abs.is_dir() {
anyhow::bail!(
"collection '{}': source directory not found: {}",
rule.id,
abs.display()
);
}
abs
}
Source::Github(g) => fetch_github(g)?,
};
let remote_spec = read_collection_spec(&collection_root)?;
let mut all_items = Vec::new();
for remote_rule in &remote_spec.rules {
if !collection_passes_filter(&remote_rule.id, include, exclude) {
continue;
}
let effective_schema = schema_override
.clone()
.unwrap_or_else(|| remote_rule.schema_agent.clone());
let synthetic = Rule {
id: format!("{}/{}", rule.id, remote_rule.id),
source: remote_rule.source.clone(),
schema_agent: effective_schema,
on_target_modified: rule.on_target_modified,
kind: remote_rule.kind.clone(),
};
let items = expand_local_or_github(&collection_root, &synthetic)?;
for mut item in items {
item.rule_id = synthetic.id.clone();
all_items.push(item);
}
}
Ok(all_items)
}
fn collection_passes_filter(rule_id: &str, include: &[String], exclude: &[String]) -> bool {
if !include.is_empty() {
return include.iter().any(|p| p == rule_id);
}
!exclude.iter().any(|p| p == rule_id)
}
fn passes_filter(name: &str, include: &[String], exclude: &[String]) -> bool {
if !include.is_empty() {
return include.iter().any(|p| p == name);
}
!exclude.iter().any(|p| p == name)
}
fn apply_overrides(
fm: &mut FrontMatter,
mapper: &dyn FrontMatterMapper,
name: Option<&str>,
description: Option<&str>,
allowed_tools: Option<&str>,
) {
if let Some(n) = name {
fm.name = Some(n.to_string());
}
if let Some(d) = description {
fm.description = Some(d.to_string());
}
if let Some(t) = allowed_tools {
fm.allowed_tools = tokenize_tools(t)
.into_iter()
.map(|tok| mapper.parse_permission(&tok))
.collect();
}
}
fn materialize(root: &Path, source: &Source) -> anyhow::Result<(PathBuf, PathBuf)> {
match source {
Source::Local(rel) => Ok((root.to_path_buf(), root.join(rel))),
Source::Github(g) => {
let cache_root = fetch_github(g)?;
let filter = if g.path.is_empty() {
cache_root.clone()
} else {
cache_root.join(&g.path)
};
Ok((cache_root, filter))
} }
}
fn expand_skill_set(
rule: &Rule,
abs_path: &Path,
include: &[String],
exclude: &[String],
) -> anyhow::Result<Vec<ExpandedItem>> {
let mapper = agent::frontmatter_mapper(&rule.schema_agent)
.ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
let skills = agent::parse_standard_skills(abs_path, mapper.as_ref())?;
let mut items = Vec::new();
for skill in &skills {
if !passes_filter(&skill.name, include, exclude) {
continue;
}
let content = fs::read_to_string(&skill.file)?;
let hash = hash_content(&content);
items.push(ExpandedItem {
rule_id: rule.id.clone(),
source: rule.source.clone(),
source_content: content,
source_hash: hash,
kind: ExpandedKind::Skill(skill.clone()),
});
items.extend(expand_skill_assets(rule, &skill.name, &skill.dir)?);
}
Ok(items)
}
fn expand_agent_set(
rule: &Rule,
abs_path: &Path,
include: &[String],
exclude: &[String],
) -> anyhow::Result<Vec<ExpandedItem>> {
let mapper = agent::frontmatter_mapper(&rule.schema_agent)
.ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
let agents = if rule.schema_agent.as_str() == "pi" {
agent::PiParser::parse_agents_in(abs_path, mapper.as_ref())?
} else {
agent::parse_standard_agents(abs_path, mapper.as_ref())?
};
let mut items = Vec::new();
for ag in &agents {
if !passes_filter(&ag.name, include, exclude) {
continue;
}
let content = fs::read_to_string(&ag.file)?;
let hash = hash_content(&content);
items.push(ExpandedItem {
rule_id: rule.id.clone(),
source: rule.source.clone(),
source_content: content,
source_hash: hash,
kind: ExpandedKind::Agent(ag.clone()),
});
}
Ok(items)
}
fn expand_skill_assets(
rule: &Rule,
skill_name: &str,
skill_dir: &Path,
) -> anyhow::Result<Vec<ExpandedItem>> {
let walker = WalkBuilder::new(skill_dir)
.hidden(false)
.git_ignore(true)
.git_exclude(false)
.git_global(false)
.parents(true)
.require_git(false)
.build();
let mut files = Vec::new();
for entry in walker {
let entry = entry?;
let path = entry.path();
if path == skill_dir || path == skill_dir.join("SKILL.md") {
continue;
}
if entry.file_type().is_some_and(|kind| kind.is_file()) {
files.push(path.to_path_buf());
}
}
files.sort();
let mut items = Vec::new();
for file in files {
let relative_path = file.strip_prefix(skill_dir)?.to_path_buf();
let content = fs::read_to_string(&file)?;
let hash = hash_content(&content);
items.push(ExpandedItem {
rule_id: rule.id.clone(),
source: rule.source.clone(),
source_content: content.clone(),
source_hash: hash,
kind: ExpandedKind::SkillAsset(super::SkillAsset {
skill_name: skill_name.to_string(),
source_file: file,
relative_path,
content,
}),
});
}
Ok(items)
}
fn expand_single_skill(
rule: &Rule,
abs_path: &Path,
override_name: Option<&str>,
override_description: Option<&str>,
override_allowed_tools: Option<&str>,
) -> anyhow::Result<Vec<ExpandedItem>> {
let mapper = agent::frontmatter_mapper(&rule.schema_agent)
.ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
let skill_file = abs_path.join("SKILL.md");
if !skill_file.is_file() {
anyhow::bail!("skill file not found: {}", skill_file.display());
}
let name = abs_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("skill path has no name"))?
.to_string_lossy()
.into_owned();
let content = fs::read_to_string(&skill_file)?;
let (yaml, body) = split_frontmatter(&content);
let mut front_matter = match yaml {
Some(y) => mapper.parse_frontmatter(y)?,
None => FrontMatter::default(),
};
apply_overrides(
&mut front_matter,
mapper.as_ref(),
override_name,
override_description,
override_allowed_tools,
);
let hash = hash_content(&content);
let skill = crate::agent::Skill {
name: name.clone(),
dir: abs_path.to_path_buf(),
file: skill_file,
front_matter,
body: body.to_string(),
};
let mut items = vec![ExpandedItem {
rule_id: rule.id.clone(),
source: rule.source.clone(),
source_content: content,
source_hash: hash,
kind: ExpandedKind::Skill(skill),
}];
items.extend(expand_skill_assets(rule, &name, abs_path)?);
Ok(items)
}
fn expand_single_system(rule: &Rule, abs_path: &Path) -> anyhow::Result<Vec<ExpandedItem>> {
if !abs_path.is_file() {
anyhow::bail!("system file not found: {}", abs_path.display());
}
let content = fs::read_to_string(abs_path)?;
let hash = hash_content(&content);
let system = SystemFile {
file: abs_path.to_path_buf(),
body: content.clone(),
};
Ok(vec![ExpandedItem {
rule_id: rule.id.clone(),
source: rule.source.clone(),
source_content: content,
source_hash: hash,
kind: ExpandedKind::System(system),
}])
}
fn expand_single_agent(
rule: &Rule,
abs_path: &Path,
override_name: Option<&str>,
override_description: Option<&str>,
override_allowed_tools: Option<&str>,
) -> anyhow::Result<Vec<ExpandedItem>> {
let mapper = agent::frontmatter_mapper(&rule.schema_agent)
.ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
if !abs_path.is_file() {
anyhow::bail!("agent file not found: {}", abs_path.display());
}
let file_name = abs_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("agent path has no file name"))?
.to_string_lossy();
let agent_name = if rule.schema_agent.as_str() == "pi" {
file_name
.strip_suffix(".md")
.ok_or_else(|| anyhow::anyhow!("pi agent file must end with .md: {}", file_name))?
.to_owned()
} else {
file_name
.strip_suffix(".agent.md")
.ok_or_else(|| anyhow::anyhow!("agent file must end with .agent.md: {}", file_name))?
.to_owned()
};
let content = fs::read_to_string(abs_path)?;
let (yaml, body) = split_frontmatter(&content);
let mut front_matter = match yaml {
Some(y) => mapper.parse_frontmatter(y)?,
None => FrontMatter::default(),
};
apply_overrides(
&mut front_matter,
mapper.as_ref(),
override_name,
override_description,
override_allowed_tools,
);
let hash = hash_content(&content);
let agent = crate::agent::Agent {
name: agent_name,
file: abs_path.to_path_buf(),
front_matter,
body: body.to_string(),
};
Ok(vec![ExpandedItem {
rule_id: rule.id.clone(),
source: rule.source.clone(),
source_content: content,
source_hash: hash,
kind: ExpandedKind::Agent(agent),
}])
}