1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::agent::{
5 self,
6 frontmatter::{FrontMatter, FrontMatterMapper, split_frontmatter, tokenize_tools},
7};
8use crate::spec::{Rule, RuleKind, Source};
9
10use super::{
11 ExpandedItem, ExpandedKind, SystemFile, fetch_github, hash_content, read_collection_spec,
12};
13
14pub fn expand_rule(root: &Path, rule: &Rule) -> anyhow::Result<Vec<ExpandedItem>> {
19 match &rule.kind {
20 RuleKind::Collection {
21 include,
22 exclude,
23 schema_override,
24 } => expand_collection(root, rule, include, exclude, schema_override),
25 _ => expand_local_or_github(root, rule),
26 }
27}
28
29fn expand_local_or_github(root: &Path, rule: &Rule) -> anyhow::Result<Vec<ExpandedItem>> {
31 let (project_root, abs_path) = materialize(root, &rule.source)?;
32
33 match &rule.kind {
34 RuleKind::SkillSet { include, exclude } => {
35 expand_skill_set(&project_root, rule, &abs_path, include, exclude)
36 }
37 RuleKind::AgentSet { include, exclude } => {
38 expand_agent_set(&project_root, rule, &abs_path, include, exclude)
39 }
40 RuleKind::Skill {
41 name,
42 description,
43 allowed_tools,
44 } => expand_single_skill(
45 rule,
46 &abs_path,
47 name.as_deref(),
48 description.as_deref(),
49 allowed_tools.as_deref(),
50 ),
51 RuleKind::Agent {
52 name,
53 description,
54 allowed_tools,
55 } => expand_single_agent(
56 rule,
57 &abs_path,
58 name.as_deref(),
59 description.as_deref(),
60 allowed_tools.as_deref(),
61 ),
62 RuleKind::System => expand_single_system(rule, &abs_path),
63 RuleKind::Collection { .. } => unreachable!("handled in expand_rule"),
64 }
65}
66
67fn expand_collection(
71 root: &Path,
72 rule: &Rule,
73 include: &[String],
74 exclude: &[String],
75 schema_override: &Option<crate::spec::AgentName>,
76) -> anyhow::Result<Vec<ExpandedItem>> {
77 let collection_root = match &rule.source {
79 Source::Local(rel) => {
80 let abs = if rel.is_absolute() {
81 rel.clone()
82 } else {
83 root.join(rel)
84 };
85 if !abs.is_dir() {
86 anyhow::bail!(
87 "collection '{}': source directory not found: {}",
88 rule.id,
89 abs.display()
90 );
91 }
92 abs
93 }
94 Source::Github(g) => fetch_github(g)?,
95 };
96 let remote_spec = read_collection_spec(&collection_root)?;
97
98 let mut all_items = Vec::new();
99 for remote_rule in &remote_spec.rules {
100 if !collection_passes_filter(&remote_rule.id, include, exclude) {
101 continue;
102 }
103 let effective_schema = schema_override
106 .clone()
107 .unwrap_or_else(|| remote_rule.schema_agent.clone());
108
109 let synthetic = Rule {
110 id: format!("{}/{}", rule.id, remote_rule.id),
111 source: remote_rule.source.clone(),
112 schema_agent: effective_schema,
113 on_target_modified: rule.on_target_modified,
114 kind: remote_rule.kind.clone(),
115 };
116
117 let items = expand_local_or_github(&collection_root, &synthetic)?;
119
120 for mut item in items {
122 item.rule_id = synthetic.id.clone();
123 all_items.push(item);
124 }
125 }
126 Ok(all_items)
127}
128
129fn collection_passes_filter(rule_id: &str, include: &[String], exclude: &[String]) -> bool {
131 if !include.is_empty() {
132 return include.iter().any(|p| p == rule_id);
133 }
134 !exclude.iter().any(|p| p == rule_id)
135}
136
137fn passes_filter(name: &str, include: &[String], exclude: &[String]) -> bool {
141 if !include.is_empty() {
142 return include.iter().any(|p| p == name);
143 }
144 !exclude.iter().any(|p| p == name)
145}
146
147fn apply_overrides(
149 fm: &mut FrontMatter,
150 mapper: &dyn FrontMatterMapper,
151 name: Option<&str>,
152 description: Option<&str>,
153 allowed_tools: Option<&str>,
154) {
155 if let Some(n) = name {
156 fm.name = Some(n.to_string());
157 }
158 if let Some(d) = description {
159 fm.description = Some(d.to_string());
160 }
161 if let Some(t) = allowed_tools {
162 fm.allowed_tools = tokenize_tools(t)
163 .into_iter()
164 .map(|tok| mapper.parse_permission(&tok))
165 .collect();
166 }
167}
168
169fn materialize(root: &Path, source: &Source) -> anyhow::Result<(PathBuf, PathBuf)> {
175 match source {
176 Source::Local(rel) => Ok((root.to_path_buf(), root.join(rel))),
177 Source::Github(g) => {
178 let cache_root = fetch_github(g)?;
179 let filter = if g.path.is_empty() {
180 cache_root.clone()
181 } else {
182 cache_root.join(&g.path)
183 };
184 Ok((cache_root, filter))
185 } }
188}
189
190fn expand_skill_set(
191 root: &Path,
192 rule: &Rule,
193 abs_path: &Path,
194 include: &[String],
195 exclude: &[String],
196) -> anyhow::Result<Vec<ExpandedItem>> {
197 let parser = agent::skills_parser(&rule.schema_agent)
198 .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
199 let skills = parser.parse_skills(root)?;
200 let mut items = Vec::new();
201 for skill in &skills {
202 if !skill.dir.starts_with(abs_path) {
203 continue;
204 }
205 if !passes_filter(&skill.name, include, exclude) {
206 continue;
207 }
208 let content = fs::read_to_string(&skill.file)?;
209 let hash = hash_content(&content);
210 items.push(ExpandedItem {
211 rule_id: rule.id.clone(),
212 source: rule.source.clone(),
213 source_content: content,
214 source_hash: hash,
215 kind: ExpandedKind::Skill(skill.clone()),
216 });
217 }
218 Ok(items)
219}
220
221fn expand_agent_set(
222 root: &Path,
223 rule: &Rule,
224 abs_path: &Path,
225 include: &[String],
226 exclude: &[String],
227) -> anyhow::Result<Vec<ExpandedItem>> {
228 let parser = agent::agents_parser(&rule.schema_agent)
229 .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
230 let agents = parser.parse_agents(root)?;
231 let mut items = Vec::new();
232 for ag in &agents {
233 if !ag.file.starts_with(abs_path) {
234 continue;
235 }
236 if !passes_filter(&ag.name, include, exclude) {
237 continue;
238 }
239 let content = fs::read_to_string(&ag.file)?;
240 let hash = hash_content(&content);
241 items.push(ExpandedItem {
242 rule_id: rule.id.clone(),
243 source: rule.source.clone(),
244 source_content: content,
245 source_hash: hash,
246 kind: ExpandedKind::Agent(ag.clone()),
247 });
248 }
249 Ok(items)
250}
251
252fn expand_single_skill(
253 rule: &Rule,
254 abs_path: &Path,
255 override_name: Option<&str>,
256 override_description: Option<&str>,
257 override_allowed_tools: Option<&str>,
258) -> anyhow::Result<Vec<ExpandedItem>> {
259 let mapper = agent::frontmatter_mapper(&rule.schema_agent)
260 .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
261
262 let skill_file = abs_path.join("SKILL.md");
263 if !skill_file.is_file() {
264 anyhow::bail!("skill file not found: {}", skill_file.display());
265 }
266 let name = abs_path
267 .file_name()
268 .ok_or_else(|| anyhow::anyhow!("skill path has no name"))?
269 .to_string_lossy()
270 .into_owned();
271 let content = fs::read_to_string(&skill_file)?;
272 let (yaml, body) = split_frontmatter(&content);
273 let mut front_matter = match yaml {
274 Some(y) => mapper.parse_frontmatter(y)?,
275 None => FrontMatter::default(),
276 };
277 apply_overrides(
278 &mut front_matter,
279 mapper.as_ref(),
280 override_name,
281 override_description,
282 override_allowed_tools,
283 );
284 let hash = hash_content(&content);
285 let skill = crate::agent::Skill {
286 name,
287 dir: abs_path.to_path_buf(),
288 file: skill_file,
289 front_matter,
290 body: body.to_string(),
291 };
292 Ok(vec![ExpandedItem {
293 rule_id: rule.id.clone(),
294 source: rule.source.clone(),
295 source_content: content,
296 source_hash: hash,
297 kind: ExpandedKind::Skill(skill),
298 }])
299}
300
301fn expand_single_system(rule: &Rule, abs_path: &Path) -> anyhow::Result<Vec<ExpandedItem>> {
302 if !abs_path.is_file() {
303 anyhow::bail!("system file not found: {}", abs_path.display());
304 }
305 let content = fs::read_to_string(abs_path)?;
306 let hash = hash_content(&content);
307 let system = SystemFile {
308 file: abs_path.to_path_buf(),
309 body: content.clone(),
310 };
311 Ok(vec![ExpandedItem {
312 rule_id: rule.id.clone(),
313 source: rule.source.clone(),
314 source_content: content,
315 source_hash: hash,
316 kind: ExpandedKind::System(system),
317 }])
318}
319
320fn expand_single_agent(
321 rule: &Rule,
322 abs_path: &Path,
323 override_name: Option<&str>,
324 override_description: Option<&str>,
325 override_allowed_tools: Option<&str>,
326) -> anyhow::Result<Vec<ExpandedItem>> {
327 let mapper = agent::frontmatter_mapper(&rule.schema_agent)
328 .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
329
330 if !abs_path.is_file() {
331 anyhow::bail!("agent file not found: {}", abs_path.display());
332 }
333 let file_name = abs_path
334 .file_name()
335 .ok_or_else(|| anyhow::anyhow!("agent path has no file name"))?
336 .to_string_lossy();
337 let agent_name = file_name
338 .strip_suffix(".agent.md")
339 .ok_or_else(|| anyhow::anyhow!("agent file must end with .agent.md: {}", file_name))?
340 .to_owned();
341 let content = fs::read_to_string(abs_path)?;
342 let (yaml, body) = split_frontmatter(&content);
343 let mut front_matter = match yaml {
344 Some(y) => mapper.parse_frontmatter(y)?,
345 None => FrontMatter::default(),
346 };
347 apply_overrides(
348 &mut front_matter,
349 mapper.as_ref(),
350 override_name,
351 override_description,
352 override_allowed_tools,
353 );
354 let hash = hash_content(&content);
355 let agent = crate::agent::Agent {
356 name: agent_name,
357 file: abs_path.to_path_buf(),
358 front_matter,
359 body: body.to_string(),
360 };
361 Ok(vec![ExpandedItem {
362 rule_id: rule.id.clone(),
363 source: rule.source.clone(),
364 source_content: content,
365 source_hash: hash,
366 kind: ExpandedKind::Agent(agent),
367 }])
368}