1use crate::render::cache::{AGENTS_CACHE_FILE, CACHE_FILE, CacheEntry, RenderCache};
15use crate::render::helpers::{HelperContext, register_all};
16use crate::render::manifest::{Agent, ManifestSet, Skill, SourceRoot};
17use anyhow::{Context, Result, anyhow};
18use nils_markdown::Engine;
19use sha2::{Digest, Sha256};
20use std::fs;
21use std::io::ErrorKind;
22use std::path::{Component, Path, PathBuf};
23use std::sync::Arc;
24
25pub const SKILL_TEMPLATE_FILE: &str = "SKILL.md.tera";
26pub const AGENT_TEMPLATE_FILE: &str = "AGENT.md.tera";
30pub const HOME_PROMPT_FILE: &str = "AGENT_HOME.md";
31pub const NEUTRAL_HOME_PRODUCT: &str = "neutral";
32const TERA_EXT: &str = "tera";
33
34#[derive(Debug)]
38struct SourceFile {
39 rel: PathBuf,
40 abs: PathBuf,
41 #[cfg(unix)]
46 mode: u32,
47}
48
49#[derive(Debug, Default, PartialEq, Eq)]
50pub struct RenderReport {
51 pub product: String,
52 pub output_root: PathBuf,
53 pub rendered: Vec<String>,
54 pub cached: Vec<String>,
55 pub skipped: Vec<String>,
56}
57
58#[derive(Debug, PartialEq, Eq)]
59pub struct HomePromptReport {
60 pub product: String,
61 pub output_path: PathBuf,
62 pub rendered: bool,
63}
64
65pub fn write_product(
72 root: &SourceRoot,
73 manifests: Arc<ManifestSet>,
74 product: &str,
75) -> Result<RenderReport> {
76 let output_root = default_product_output_root(root, product);
77 reject_unsafe_default_output_root(root, &output_root)?;
78 write_product_to(root, manifests, product, &output_root)
79}
80
81pub(crate) fn write_product_to(
94 root: &SourceRoot,
95 manifests: Arc<ManifestSet>,
96 product: &str,
97 output_root: &Path,
98) -> Result<RenderReport> {
99 let mut report = write_skills_to(root, manifests.clone(), product, output_root)?;
100 let agents = write_agents_to(root, manifests, product, output_root)?;
101 report.rendered.extend(agents.rendered);
102 report.cached.extend(agents.cached);
103 report.skipped.extend(agents.skipped);
104 write_home_prompt_to(root, product, output_root, false)?;
105 Ok(report)
106}
107
108pub fn write_home_prompt(
109 root: &SourceRoot,
110 product: &str,
111 require_source: bool,
112) -> Result<HomePromptReport> {
113 let output_root = default_product_output_root(root, product);
114 reject_unsafe_default_output_root(root, &output_root)?;
115 write_home_prompt_to(root, product, &output_root, require_source)
116}
117
118fn default_product_output_root(root: &SourceRoot, product: &str) -> PathBuf {
119 root.path().join("build").join(product)
120}
121
122fn reject_unsafe_default_output_root(root: &SourceRoot, output_root: &Path) -> Result<()> {
123 let source_root = root.path();
124 let build_root = source_root.join("build");
125 reject_existing_symlink(&build_root, "default render build directory")?;
126 reject_existing_symlink(output_root, "default render output root")?;
127
128 if let Some(canonical_build_root) = canonicalize_if_exists(&build_root)? {
129 if !canonical_build_root.starts_with(source_root) {
130 return Err(anyhow!(
131 "default render build directory {} resolves outside the source root \
132 ({} not under {}) — refusing to write",
133 build_root.display(),
134 canonical_build_root.display(),
135 source_root.display(),
136 ));
137 }
138
139 if let Some(canonical_output_root) = canonicalize_if_exists(output_root)?
140 && !canonical_output_root.starts_with(&canonical_build_root)
141 {
142 return Err(anyhow!(
143 "default render output root {} resolves outside the build directory \
144 ({} not under {}) — refusing to write",
145 output_root.display(),
146 canonical_output_root.display(),
147 canonical_build_root.display(),
148 ));
149 }
150 }
151
152 Ok(())
153}
154
155fn reject_existing_symlink(path: &Path, label: &str) -> Result<()> {
156 match fs::symlink_metadata(path) {
157 Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!(
158 "{label} {} is a symlink; refusing to use it as a render root",
159 path.display()
160 )),
161 Ok(_) => Ok(()),
162 Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
163 Err(err) => Err(err).with_context(|| format!("stat {label} {}", path.display())),
164 }
165}
166
167fn canonicalize_if_exists(path: &Path) -> Result<Option<PathBuf>> {
168 match path.canonicalize() {
169 Ok(path) => Ok(Some(path)),
170 Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
171 Err(err) => Err(err).with_context(|| format!("canonicalize {}", path.display())),
172 }
173}
174
175fn write_home_prompt_to(
176 root: &SourceRoot,
177 product: &str,
178 output_root: &Path,
179 require_source: bool,
180) -> Result<HomePromptReport> {
181 let source = root.path().join(HOME_PROMPT_FILE);
182 let output_root = output_root.to_path_buf();
183 let output_path = output_root.join(HOME_PROMPT_FILE);
184 if !source.exists() {
185 if require_source {
186 return Err(anyhow!(
187 "home prompt source {} is missing",
188 source.display()
189 ));
190 }
191 remove_stale_home_prompt(&output_root, &output_path)?;
192 return Ok(HomePromptReport {
193 product: product.to_string(),
194 output_path,
195 rendered: false,
196 });
197 }
198
199 fs::create_dir_all(&output_root)
200 .with_context(|| format!("create_dir_all {}", output_root.display()))?;
201 let canonical_source_root = root.path().to_path_buf();
202 let canonical_output_root = output_root
203 .canonicalize()
204 .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
205 let source = canonicalize_under(&canonical_source_root, &source)?;
206 let body = fs::read_to_string(&source)
207 .with_context(|| format!("read home prompt {}", source.display()))?;
208 let rendered = render_home_prompt_template(product, &body)?;
209 let output_path = guard_write_under(&canonical_output_root, &output_path)?;
210 reject_leaf_symlink(&output_path)?;
211 fs::write(&output_path, rendered.as_bytes())
212 .with_context(|| format!("write {}", output_path.display()))?;
213
214 Ok(HomePromptReport {
215 product: product.to_string(),
216 output_path,
217 rendered: true,
218 })
219}
220
221fn remove_stale_home_prompt(output_root: &Path, output_path: &Path) -> Result<()> {
222 let metadata = match fs::symlink_metadata(output_path) {
223 Ok(metadata) => metadata,
224 Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
225 Err(err) => {
226 return Err(err)
227 .with_context(|| format!("stat stale home prompt {}", output_path.display()));
228 }
229 };
230 let canonical_output_root = output_root
231 .canonicalize()
232 .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
233 let guarded_output = guard_write_under(&canonical_output_root, output_path)?;
234 if !metadata.file_type().is_symlink() {
235 let canonical_output = guarded_output
236 .canonicalize()
237 .with_context(|| format!("canonicalize stale home prompt {}", output_path.display()))?;
238 if !canonical_output.starts_with(&canonical_output_root) {
239 return Err(anyhow!(
240 "stale home prompt output {} resolves outside the build root \
241 ({} not under {}) — refusing to remove",
242 output_path.display(),
243 canonical_output.display(),
244 canonical_output_root.display(),
245 ));
246 }
247 }
248 fs::remove_file(&guarded_output)
249 .with_context(|| format!("remove stale home prompt {}", guarded_output.display()))?;
250 prune_empty_dirs_upward(output_root, &canonical_output_root, HOME_PROMPT_FILE);
251 Ok(())
252}
253
254fn write_skills_to(
258 root: &SourceRoot,
259 manifests: Arc<ManifestSet>,
260 product: &str,
261 output_root: &Path,
262) -> Result<RenderReport> {
263 require_known_product(&manifests, product)?;
264 let output_root = output_root.to_path_buf();
265 fs::create_dir_all(&output_root)
266 .with_context(|| format!("create_dir_all {}", output_root.display()))?;
267
268 let cache_path = output_root.join(CACHE_FILE);
269 let prior_cache = RenderCache::load_or_empty(&cache_path);
270 let manifest_bytes = read_manifest_bundle(root)?;
271 let mut next_cache = RenderCache::empty();
272 let mut report = RenderReport {
273 product: product.to_string(),
274 output_root: output_root.clone(),
275 ..RenderReport::default()
276 };
277
278 let canonical_source_root = root.path().to_path_buf();
279 let canonical_output_root = output_root
280 .canonicalize()
281 .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
282
283 for skill in &manifests.skills.skills {
284 let Some(render) = skill.products.get(product) else {
285 report.skipped.push(skill.id.clone());
286 continue;
287 };
288 validate_render_to(&skill.id, product, &render.render_to)?;
296
297 let source_dir = sandboxed_join(root.path(), &skill.source)?;
298 let canonical_source_dir = canonicalize_under(&canonical_source_root, &source_dir)?;
299 let source_files = walk_skill_source(&canonical_source_dir, &canonical_source_root)
300 .with_context(|| {
301 format!(
302 "walk source for skill {} at {}",
303 skill.id,
304 canonical_source_dir.display()
305 )
306 })?;
307 let template_file = source_files
308 .iter()
309 .find(|f| f.rel == Path::new(SKILL_TEMPLATE_FILE))
310 .ok_or_else(|| {
311 anyhow!(
312 "skill {} source {} is missing required {SKILL_TEMPLATE_FILE}",
313 skill.id,
314 skill.source
315 )
316 })?;
317 let template_body = fs::read_to_string(&template_file.abs).with_context(|| {
318 format!(
319 "read template {} for skill {}",
320 template_file.abs.display(),
321 skill.id
322 )
323 })?;
324
325 let render_to_rel = PathBuf::from(&render.render_to);
330 let output_dir_rel = render_to_rel.parent().ok_or_else(|| {
331 anyhow!(
332 "render_to {:?} for skill {} has no parent dir",
333 render.render_to,
334 skill.id,
335 )
336 })?;
337 let mut planned_outputs: Vec<String> = Vec::with_capacity(source_files.len());
338 for file in &source_files {
339 let rel = if file.rel == Path::new(SKILL_TEMPLATE_FILE) {
340 render_to_rel.clone()
341 } else {
342 output_dir_rel.join(strip_tera_suffix(&file.rel))
343 };
344 planned_outputs.push(rel.to_string_lossy().into_owned());
345 }
346 planned_outputs.sort();
347 planned_outputs.dedup();
348
349 let input_hash = input_hash(
350 product,
351 &skill.id,
352 &render.render_to,
353 &source_files,
354 &canonical_source_dir,
355 &manifest_bytes,
356 )
357 .with_context(|| format!("hash source tree for skill {}", skill.id))?;
358 let entry = CacheEntry {
359 hash: input_hash.clone(),
360 outputs: planned_outputs.clone(),
361 };
362 let output_path = sandboxed_join(&output_root, &render.render_to)?;
363 let cache_hit = prior_cache
364 .skills
365 .get(&skill.id)
366 .is_some_and(|prior| prior == &entry)
367 && output_path.exists();
368
369 if cache_hit {
370 report.cached.push(skill.id.clone());
371 } else {
372 if let Some(prior) = prior_cache.skills.get(&skill.id) {
381 let planned: std::collections::BTreeSet<&String> = planned_outputs.iter().collect();
382 for stale in &prior.outputs {
383 if planned.contains(stale) {
384 continue;
385 }
386 let stale_path = sandboxed_join(&output_root, stale)?;
387 if !stale_path.exists() {
388 continue;
389 }
390 let canonical_stale = stale_path.canonicalize().with_context(|| {
396 format!("canonicalize stale output {}", stale_path.display())
397 })?;
398 if !canonical_stale.starts_with(&canonical_output_root) {
399 return Err(anyhow!(
400 "stale rendered output {} resolves outside the build root \
401 ({} not under {}) — refusing to remove",
402 stale_path.display(),
403 canonical_stale.display(),
404 canonical_output_root.display(),
405 ));
406 }
407 fs::remove_file(&canonical_stale).with_context(|| {
408 format!("remove stale rendered file {}", canonical_stale.display())
409 })?;
410 }
411 }
412
413 if let Some(parent) = output_path.parent() {
415 fs::create_dir_all(parent)
416 .with_context(|| format!("create_dir_all {}", parent.display()))?;
417 }
418 let rendered =
419 render_template(root.path(), &manifests, product, skill, &template_body)?;
420 let output_path_guarded = guard_write_under(&canonical_output_root, &output_path)?;
426 fs::write(&output_path_guarded, rendered.as_bytes())
427 .with_context(|| format!("write {}", output_path_guarded.display()))?;
428
429 for file in &source_files {
435 if file.rel == Path::new(SKILL_TEMPLATE_FILE) {
436 continue;
437 }
438 let dest_rel = strip_tera_suffix(&file.rel);
439 let dest = sandboxed_join(
440 &output_root,
441 &output_dir_rel.join(&dest_rel).to_string_lossy(),
442 )?;
443 if let Some(parent) = dest.parent() {
444 fs::create_dir_all(parent)
445 .with_context(|| format!("create_dir_all {}", parent.display()))?;
446 }
447 let dest = guard_write_under(&canonical_output_root, &dest)?;
448 if file.rel.extension().and_then(|e| e.to_str()) == Some(TERA_EXT) {
449 let body = fs::read_to_string(&file.abs).with_context(|| {
450 format!(
451 "read sibling tera template {} for skill {}",
452 file.abs.display(),
453 skill.id
454 )
455 })?;
456 let rendered = render_template(root.path(), &manifests, product, skill, &body)?;
457 fs::write(&dest, rendered.as_bytes())
458 .with_context(|| format!("write {}", dest.display()))?;
459 } else {
460 fs::copy(&file.abs, &dest).with_context(|| {
466 format!("copy {} -> {}", file.abs.display(), dest.display())
467 })?;
468 #[cfg(unix)]
469 {
470 use std::os::unix::fs::PermissionsExt;
471 let perms = fs::Permissions::from_mode(file.mode);
472 fs::set_permissions(&dest, perms).with_context(|| {
473 format!("set mode {:#o} on {}", file.mode, dest.display())
474 })?;
475 }
476 }
477 }
478 report.rendered.push(skill.id.clone());
479 }
480 next_cache.skills.insert(skill.id.clone(), entry);
481 }
482
483 reconcile_retired_skills(
493 &prior_cache,
494 &next_cache,
495 &output_root,
496 &canonical_output_root,
497 )?;
498
499 next_cache
500 .save(&cache_path)
501 .with_context(|| format!("write {}", cache_path.display()))?;
502 Ok(report)
503}
504
505fn write_agents_to(
513 root: &SourceRoot,
514 manifests: Arc<ManifestSet>,
515 product: &str,
516 output_root: &Path,
517) -> Result<RenderReport> {
518 require_known_product(&manifests, product)?;
519 let output_root = output_root.to_path_buf();
520 fs::create_dir_all(&output_root)
521 .with_context(|| format!("create_dir_all {}", output_root.display()))?;
522
523 let cache_path = output_root.join(AGENTS_CACHE_FILE);
524 let prior_cache = RenderCache::load_or_empty(&cache_path);
525 let manifest_bytes = read_manifest_bundle(root)?;
526 let mut next_cache = RenderCache::empty();
527 let mut report = RenderReport {
528 product: product.to_string(),
529 output_root: output_root.clone(),
530 ..RenderReport::default()
531 };
532
533 let canonical_source_root = root.path().to_path_buf();
534 let canonical_output_root = output_root
535 .canonicalize()
536 .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
537
538 for agent in &manifests.agents.agents {
539 let Some(render) = agent.products.get(product) else {
540 report.skipped.push(agent.id.clone());
541 continue;
542 };
543 validate_render_to(&agent.id, product, &render.render_to)?;
544
545 let source_dir = sandboxed_join(root.path(), &agent.source)?;
546 let canonical_source_dir = canonicalize_under(&canonical_source_root, &source_dir)?;
547 let source_files = walk_skill_source(&canonical_source_dir, &canonical_source_root)
548 .with_context(|| {
549 format!(
550 "walk source for agent {} at {}",
551 agent.id,
552 canonical_source_dir.display()
553 )
554 })?;
555 let template_file = source_files
556 .iter()
557 .find(|f| f.rel == Path::new(AGENT_TEMPLATE_FILE))
558 .ok_or_else(|| {
559 anyhow!(
560 "agent {} source {} is missing required {AGENT_TEMPLATE_FILE}",
561 agent.id,
562 agent.source
563 )
564 })?;
565 let template_body = fs::read_to_string(&template_file.abs).with_context(|| {
566 format!(
567 "read template {} for agent {}",
568 template_file.abs.display(),
569 agent.id
570 )
571 })?;
572
573 let render_to_rel = PathBuf::from(&render.render_to);
574 let output_dir_rel = render_to_rel.parent().ok_or_else(|| {
575 anyhow!(
576 "render_to {:?} for agent {} has no parent dir",
577 render.render_to,
578 agent.id,
579 )
580 })?;
581 let mut planned_outputs: Vec<String> = Vec::with_capacity(source_files.len());
582 for file in &source_files {
583 let rel = if file.rel == Path::new(AGENT_TEMPLATE_FILE) {
584 render_to_rel.clone()
585 } else {
586 output_dir_rel.join(strip_tera_suffix(&file.rel))
587 };
588 planned_outputs.push(rel.to_string_lossy().into_owned());
589 }
590 planned_outputs.sort();
591 planned_outputs.dedup();
592
593 let input_hash = input_hash(
594 product,
595 &agent.id,
596 &render.render_to,
597 &source_files,
598 &canonical_source_dir,
599 &manifest_bytes,
600 )
601 .with_context(|| format!("hash source tree for agent {}", agent.id))?;
602 let entry = CacheEntry {
603 hash: input_hash.clone(),
604 outputs: planned_outputs.clone(),
605 };
606 let output_path = sandboxed_join(&output_root, &render.render_to)?;
607 let cache_hit = prior_cache
608 .skills
609 .get(&agent.id)
610 .is_some_and(|prior| prior == &entry)
611 && output_path.exists();
612
613 if cache_hit {
614 report.cached.push(agent.id.clone());
615 } else {
616 if let Some(prior) = prior_cache.skills.get(&agent.id) {
619 let planned: std::collections::BTreeSet<&String> = planned_outputs.iter().collect();
620 for stale in &prior.outputs {
621 if planned.contains(stale) {
622 continue;
623 }
624 let stale_path = sandboxed_join(&output_root, stale)?;
625 if !stale_path.exists() {
626 continue;
627 }
628 let canonical_stale = stale_path.canonicalize().with_context(|| {
629 format!("canonicalize stale output {}", stale_path.display())
630 })?;
631 if !canonical_stale.starts_with(&canonical_output_root) {
632 return Err(anyhow!(
633 "stale rendered output {} resolves outside the build root \
634 ({} not under {}) — refusing to remove",
635 stale_path.display(),
636 canonical_stale.display(),
637 canonical_output_root.display(),
638 ));
639 }
640 fs::remove_file(&canonical_stale).with_context(|| {
641 format!("remove stale rendered file {}", canonical_stale.display())
642 })?;
643 }
644 }
645
646 if let Some(parent) = output_path.parent() {
647 fs::create_dir_all(parent)
648 .with_context(|| format!("create_dir_all {}", parent.display()))?;
649 }
650 let rendered =
651 render_agent_template(root.path(), &manifests, product, agent, &template_body)?;
652 let output_path_guarded = guard_write_under(&canonical_output_root, &output_path)?;
653 fs::write(&output_path_guarded, rendered.as_bytes())
654 .with_context(|| format!("write {}", output_path_guarded.display()))?;
655
656 for file in &source_files {
657 if file.rel == Path::new(AGENT_TEMPLATE_FILE) {
658 continue;
659 }
660 let dest_rel = strip_tera_suffix(&file.rel);
661 let dest = sandboxed_join(
662 &output_root,
663 &output_dir_rel.join(&dest_rel).to_string_lossy(),
664 )?;
665 if let Some(parent) = dest.parent() {
666 fs::create_dir_all(parent)
667 .with_context(|| format!("create_dir_all {}", parent.display()))?;
668 }
669 let dest = guard_write_under(&canonical_output_root, &dest)?;
670 if file.rel.extension().and_then(|e| e.to_str()) == Some(TERA_EXT) {
671 let body = fs::read_to_string(&file.abs).with_context(|| {
672 format!(
673 "read sibling tera template {} for agent {}",
674 file.abs.display(),
675 agent.id
676 )
677 })?;
678 let rendered =
679 render_agent_template(root.path(), &manifests, product, agent, &body)?;
680 fs::write(&dest, rendered.as_bytes())
681 .with_context(|| format!("write {}", dest.display()))?;
682 } else {
683 fs::copy(&file.abs, &dest).with_context(|| {
684 format!("copy {} -> {}", file.abs.display(), dest.display())
685 })?;
686 #[cfg(unix)]
687 {
688 use std::os::unix::fs::PermissionsExt;
689 let perms = fs::Permissions::from_mode(file.mode);
690 fs::set_permissions(&dest, perms).with_context(|| {
691 format!("set mode {:#o} on {}", file.mode, dest.display())
692 })?;
693 }
694 }
695 }
696 report.rendered.push(agent.id.clone());
697 }
698 next_cache.skills.insert(agent.id.clone(), entry);
699 }
700
701 reconcile_retired_skills(
704 &prior_cache,
705 &next_cache,
706 &output_root,
707 &canonical_output_root,
708 )?;
709
710 next_cache
711 .save(&cache_path)
712 .with_context(|| format!("write {}", cache_path.display()))?;
713 Ok(report)
714}
715
716fn reconcile_retired_skills(
725 prior_cache: &RenderCache,
726 next_cache: &RenderCache,
727 output_root: &Path,
728 canonical_output_root: &Path,
729) -> Result<()> {
730 let live_outputs: std::collections::BTreeSet<&String> = next_cache
733 .skills
734 .values()
735 .flat_map(|entry| entry.outputs.iter())
736 .collect();
737
738 for (skill_id, prior) in &prior_cache.skills {
739 if next_cache.skills.contains_key(skill_id) {
740 continue;
741 }
742 for rel in &prior.outputs {
743 if live_outputs.contains(rel) {
744 continue;
745 }
746 let path = sandboxed_join(output_root, rel)?;
747 if fs::symlink_metadata(&path).is_err() {
751 continue;
752 }
753 let canonical = path
758 .canonicalize()
759 .with_context(|| format!("canonicalize retired output {}", path.display()))?;
760 if !canonical.starts_with(canonical_output_root) {
761 return Err(anyhow!(
762 "retired rendered output {} resolves outside the build root \
763 ({} not under {}) — refusing to remove",
764 path.display(),
765 canonical.display(),
766 canonical_output_root.display(),
767 ));
768 }
769 fs::remove_file(&canonical)
770 .with_context(|| format!("remove retired rendered file {}", canonical.display()))?;
771 }
772 for rel in &prior.outputs {
776 prune_empty_dirs_upward(output_root, canonical_output_root, rel);
777 }
778 }
779 Ok(())
780}
781
782fn prune_empty_dirs_upward(output_root: &Path, canonical_output_root: &Path, rel: &str) {
788 let mut dir = match PathBuf::from(rel).parent() {
789 Some(parent) if !parent.as_os_str().is_empty() => output_root.join(parent),
790 _ => return,
791 };
792 while let Ok(canonical) = dir.canonicalize() {
795 if canonical == *canonical_output_root || !canonical.starts_with(canonical_output_root) {
796 break;
797 }
798 let is_empty = match fs::read_dir(&canonical) {
799 Ok(mut entries) => entries.next().is_none(),
800 Err(_) => break,
801 };
802 if !is_empty {
803 break; }
805 if fs::remove_dir(&canonical).is_err() {
806 break;
807 }
808 match dir.parent() {
809 Some(parent) => dir = parent.to_path_buf(),
810 None => break,
811 }
812 }
813}
814
815fn validate_render_to(skill_id: &str, product: &str, render_to: &str) -> Result<()> {
835 let leading = render_to.split('/').next().unwrap_or(render_to);
836 if leading == "build" {
837 return Err(anyhow!(
838 "render_to {render_to:?} for skill {skill_id} (product {product}) starts with \
839 `build/`; the binary already prepends `build/{product}/` to the value, so this \
840 shape would double the prefix. Use a path relative to `build/{product}/` \
841 (including the rendered filename), e.g. `plugins/<plugin>/skills/<skill>/SKILL.md`.",
842 ));
843 }
844 Ok(())
845}
846
847fn require_known_product(manifests: &ManifestSet, product: &str) -> Result<()> {
848 match product {
849 "codex" | "claude" | "hermes" => Ok(()),
850 other => Err(anyhow!(
851 "unknown --product {other:?}; supported: codex, claude, hermes. \
852 schema_version={}",
853 manifests.product_capabilities.schema_version
854 )),
855 }
856}
857
858fn render_template(
859 source_root: &Path,
860 manifests: &Arc<ManifestSet>,
861 product: &str,
862 skill: &Skill,
863 template_body: &str,
864) -> Result<String> {
865 let ctx = HelperContext {
866 source_root: source_root.to_path_buf(),
867 manifests: manifests.clone(),
868 current_product: product.to_string(),
869 current_skill_id: skill.id.clone(),
870 current_skill_required_clis: skill.required_clis.clone(),
871 current_skill_state_out_mode: skill.state_out_mode,
872 };
873 let mut engine = Engine::builder().build();
874 register_all(&mut engine, Arc::new(ctx));
875 let vars = serde_json::json!({ "product": product });
876 engine
877 .render_str(template_body, &vars)
878 .with_context(|| format!("render skill {}", skill.id))
879}
880
881fn render_agent_template(
889 source_root: &Path,
890 manifests: &Arc<ManifestSet>,
891 product: &str,
892 agent: &Agent,
893 template_body: &str,
894) -> Result<String> {
895 let ctx = HelperContext {
896 source_root: source_root.to_path_buf(),
897 manifests: manifests.clone(),
898 current_product: product.to_string(),
899 current_skill_id: agent.id.clone(),
900 current_skill_required_clis: Default::default(),
901 current_skill_state_out_mode: Default::default(),
902 };
903 let mut engine = Engine::builder().build();
904 register_all(&mut engine, Arc::new(ctx));
905 let vars = serde_json::json!({ "product": product, "id": agent.id });
906 engine
907 .render_str(template_body, &vars)
908 .with_context(|| format!("render agent {}", agent.id))
909}
910
911fn render_home_prompt_template(product: &str, template_body: &str) -> Result<String> {
912 let mut engine = Engine::builder().build();
913 let vars = serde_json::json!({ "product": product });
914 engine
915 .render_str(template_body, &vars)
916 .context("render home prompt")
917}
918
919struct ManifestBytes {
920 skills: Vec<u8>,
921 plugins: Vec<u8>,
922 product_capabilities: Vec<u8>,
923 runtime_roots: Vec<u8>,
924 cli_tools: Vec<u8>,
925 agents: Vec<u8>,
926}
927
928fn read_manifest_bundle(root: &SourceRoot) -> Result<ManifestBytes> {
929 let dir = root.manifests_dir();
930 let read = |name: &str| -> Result<Vec<u8>> {
931 let path = dir.join(name);
932 fs::read(&path).with_context(|| format!("hash-read {}", path.display()))
933 };
934 let read_optional = |name: &str| -> Result<Vec<u8>> {
937 let path = dir.join(name);
938 if !path.exists() {
939 return Ok(Vec::new());
940 }
941 fs::read(&path).with_context(|| format!("hash-read {}", path.display()))
942 };
943 Ok(ManifestBytes {
944 skills: read("skills.yaml")?,
945 plugins: read("plugins.yaml")?,
946 product_capabilities: read("product-capabilities.yaml")?,
947 runtime_roots: read("runtime-roots.yaml")?,
948 cli_tools: read("cli-tools.yaml")?,
949 agents: read_optional("agents.yaml")?,
950 })
951}
952
953fn input_hash(
954 product: &str,
955 id: &str,
956 render_to: &str,
957 source_files: &[SourceFile],
958 canonical_source_dir: &Path,
959 manifests: &ManifestBytes,
960) -> Result<String> {
961 let mut hasher = Sha256::new();
967 hasher.update(b"agent-runtime-cli render v3\0");
968 hasher.update(product.as_bytes());
969 hasher.update(b"\0");
970 hasher.update(id.as_bytes());
971 hasher.update(b"\0");
972 hasher.update(render_to.as_bytes());
973 hasher.update(b"\0");
974 for file in source_files {
979 let rel = file.rel.to_string_lossy();
980 hasher.update(rel.as_bytes());
981 hasher.update(b"\0");
982 #[cfg(unix)]
987 {
988 hasher.update(file.mode.to_le_bytes());
989 }
990 #[cfg(not(unix))]
991 {
992 hasher.update([0u8; 4]);
993 }
994 hasher.update(b"\0");
995 let bytes = fs::read(&file.abs).with_context(|| {
996 format!(
997 "hash-read {} (skill source dir {})",
998 file.abs.display(),
999 canonical_source_dir.display()
1000 )
1001 })?;
1002 hasher.update(&bytes);
1003 hasher.update(b"\0");
1004 }
1005 hasher.update(&manifests.skills);
1009 hasher.update(b"\0");
1010 hasher.update(&manifests.plugins);
1011 hasher.update(b"\0");
1012 hasher.update(&manifests.product_capabilities);
1013 hasher.update(b"\0");
1014 hasher.update(&manifests.runtime_roots);
1015 hasher.update(b"\0");
1016 hasher.update(&manifests.cli_tools);
1017 hasher.update(b"\0");
1018 hasher.update(&manifests.agents);
1019 let digest = hasher.finalize();
1020 let mut out = String::with_capacity(7 + digest.len() * 2);
1021 out.push_str("sha256:");
1022 for byte in digest.iter() {
1023 use std::fmt::Write;
1024 let _ = write!(&mut out, "{byte:02x}");
1025 }
1026 Ok(out)
1027}
1028
1029fn walk_skill_source(skill_dir: &Path, canonical_source_root: &Path) -> Result<Vec<SourceFile>> {
1038 let mut out = Vec::new();
1039 walk_dir(skill_dir, skill_dir, canonical_source_root, &mut out)?;
1040 out.sort_by(|a, b| a.rel.cmp(&b.rel));
1041 Ok(out)
1042}
1043
1044fn walk_dir(
1045 skill_root: &Path,
1046 dir: &Path,
1047 canonical_source_root: &Path,
1048 out: &mut Vec<SourceFile>,
1049) -> Result<()> {
1050 let entries = fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))?;
1051 let mut paths: Vec<PathBuf> = entries
1052 .map(|e| e.map(|entry| entry.path()))
1053 .collect::<std::io::Result<_>>()
1054 .with_context(|| format!("read_dir entries under {}", dir.display()))?;
1055 paths.sort();
1056 for path in paths {
1057 let canonical = canonicalize_under(canonical_source_root, &path)?;
1061 let meta = fs::metadata(&canonical)
1062 .with_context(|| format!("metadata {}", canonical.display()))?;
1063 if meta.is_dir() {
1064 walk_dir(skill_root, &canonical, canonical_source_root, out)?;
1065 continue;
1066 }
1067 if !meta.is_file() {
1068 continue;
1069 }
1070 let rel = canonical.strip_prefix(skill_root).map_err(|err| {
1071 anyhow!(
1072 "source file {} is not under skill root {}: {err}",
1073 canonical.display(),
1074 skill_root.display(),
1075 )
1076 })?;
1077 #[cfg(unix)]
1078 let mode = {
1079 use std::os::unix::fs::PermissionsExt;
1080 meta.permissions().mode() & 0o777
1081 };
1082 let source = SourceFile {
1083 rel: rel.to_path_buf(),
1084 abs: canonical.clone(),
1085 #[cfg(unix)]
1086 mode,
1087 };
1088 out.push(source);
1089 }
1090 Ok(())
1091}
1092
1093fn strip_tera_suffix(rel: &Path) -> PathBuf {
1097 if rel.extension().and_then(|e| e.to_str()) == Some(TERA_EXT) {
1098 rel.with_extension("")
1099 } else {
1100 rel.to_path_buf()
1101 }
1102}
1103
1104pub(crate) fn canonicalize_under(canonical_base: &Path, candidate: &Path) -> Result<PathBuf> {
1110 let resolved = candidate
1111 .canonicalize()
1112 .with_context(|| format!("canonicalize {}", candidate.display()))?;
1113 if !resolved.starts_with(canonical_base) {
1114 return Err(anyhow!(
1115 "path {candidate} resolves outside the source root \
1116 ({resolved} not under {canonical_base}) — likely a symlink escape",
1117 candidate = candidate.display(),
1118 resolved = resolved.display(),
1119 canonical_base = canonical_base.display(),
1120 ));
1121 }
1122 Ok(resolved)
1123}
1124
1125pub(crate) fn guard_write_under(canonical_base: &Path, candidate: &Path) -> Result<PathBuf> {
1131 let parent = candidate
1132 .parent()
1133 .ok_or_else(|| anyhow!("render output path {} has no parent", candidate.display()))?;
1134 let canonical_parent = parent
1135 .canonicalize()
1136 .with_context(|| format!("canonicalize parent of {}", candidate.display()))?;
1137 if !canonical_parent.starts_with(canonical_base) {
1138 return Err(anyhow!(
1139 "render output {} resolves outside the build root \
1140 ({} not under {}) — likely a symlink escape",
1141 candidate.display(),
1142 canonical_parent.display(),
1143 canonical_base.display(),
1144 ));
1145 }
1146 let file_name = candidate.file_name().ok_or_else(|| {
1147 anyhow!(
1148 "render output path {} has no file name",
1149 candidate.display()
1150 )
1151 })?;
1152 Ok(canonical_parent.join(file_name))
1153}
1154
1155fn reject_leaf_symlink(path: &Path) -> Result<()> {
1156 match fs::symlink_metadata(path) {
1157 Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!(
1158 "render output {} is a symlink; refusing to follow a leaf symlink",
1159 path.display()
1160 )),
1161 Ok(_) => Ok(()),
1162 Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
1163 Err(err) => Err(err).with_context(|| format!("stat render output {}", path.display())),
1164 }
1165}
1166
1167pub(crate) fn sandboxed_join(base: &Path, relative: &str) -> Result<PathBuf> {
1171 let rel = PathBuf::from(relative);
1172 for component in rel.components() {
1173 match component {
1174 Component::Normal(_) | Component::CurDir => {}
1175 Component::ParentDir => {
1176 return Err(anyhow!(
1177 "path {relative:?} contains a `..` segment; render must stay under {base}",
1178 base = base.display(),
1179 ));
1180 }
1181 Component::RootDir | Component::Prefix(_) => {
1182 return Err(anyhow!(
1183 "path {relative:?} is absolute; render must stay under {base}",
1184 base = base.display(),
1185 ));
1186 }
1187 }
1188 }
1189 Ok(base.join(rel))
1190}
1191
1192#[cfg(test)]
1195pub(crate) fn snapshot_outputs(output_root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
1196 let mut out = std::collections::BTreeMap::new();
1197 walk(output_root, output_root, &mut out);
1198 out
1199}
1200
1201#[cfg(test)]
1202fn walk(base: &Path, dir: &Path, out: &mut std::collections::BTreeMap<String, Vec<u8>>) {
1203 let Ok(entries) = fs::read_dir(dir) else {
1204 return;
1205 };
1206 let mut entries: Vec<_> = entries.flatten().collect();
1207 entries.sort_by_key(|e| e.path());
1208 for entry in entries {
1209 let path = entry.path();
1210 if path.is_dir() {
1211 walk(base, &path, out);
1212 continue;
1213 }
1214 if path.file_name().and_then(|n| n.to_str()) == Some(CACHE_FILE) {
1215 continue;
1216 }
1217 let bytes = fs::read(&path).unwrap();
1218 let rel = path
1219 .strip_prefix(base)
1220 .unwrap()
1221 .to_string_lossy()
1222 .into_owned();
1223 out.insert(rel, bytes);
1224 }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use crate::render::cache::RenderCache;
1231 use tempfile::TempDir;
1232
1233 fn write(path: &Path, body: &str) {
1234 if let Some(parent) = path.parent() {
1235 fs::create_dir_all(parent).unwrap();
1236 }
1237 fs::write(path, body).unwrap();
1238 }
1239
1240 const SKILLS_FIXTURE: &str = r#"
1241schema_version: 1
1242skills:
1243 - id: market.favorites
1244 domain: market
1245 source: core/skills/market/favorites
1246 products:
1247 codex:
1248 name: /market-favorites
1249 render_to: skills/market/favorites/SKILL.md
1250 claude:
1251 name: market:favorites
1252 render_to: plugins/market/skills/favorites/SKILL.md
1253 required_clis:
1254 agent-out: ">=0.5.0"
1255 market-cli: ">=0.4.0"
1256"#;
1257
1258 fn fixture_source_root(tmp: &TempDir) -> SourceRoot {
1261 let root = tmp.path();
1262 write(&root.join("manifests/skills.yaml"), SKILLS_FIXTURE);
1263 write(
1264 &root.join("manifests/plugins.yaml"),
1265 "schema_version: 1\nplugins: []\n",
1266 );
1267 write(
1268 &root.join("manifests/product-capabilities.yaml"),
1269 PRODUCT_CAPS,
1270 );
1271 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1272 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1273
1274 write(
1277 &root.join("core/skills/market/favorites/SKILL.md.tera"),
1278 r#"# {{ skill_ref(id="market.favorites") }}
1279
1280state: {{ state_out(domain="market", topic="favorites") }}
1281script: {{ script(path="core/scripts/market.sh") }}
1282required: {{ cli_ref(name="agent-out") }} via {{ cli_ref(name="market-cli") }}
1283"#,
1284 );
1285
1286 SourceRoot::from_arg_or_cwd(Some(root)).unwrap()
1287 }
1288
1289 const PRODUCT_CAPS: &str = r#"
1290schema_version: 1
1291products:
1292 codex:
1293 nested_skill_support: true
1294 plugin_manifest:
1295 path_pattern: "ignored"
1296 loaded_at_runtime: false
1297 schema_ref: "ignored"
1298 hooks_model:
1299 config_surface: "ignored"
1300 payload_shape: "ignored"
1301 supports_inline_python: false
1302 config_activation:
1303 - "$CODEX_HOME/AGENTS.md"
1304 runtime_state:
1305 state_home_env: "STATE"
1306 default_path: "/tmp/state"
1307 claude:
1308 nested_skill_support: true
1309 plugin_manifest:
1310 path_pattern: "ignored"
1311 loaded_at_runtime: true
1312 schema_ref: "ignored"
1313 hooks_model:
1314 config_surface: "ignored"
1315 payload_shape: "ignored"
1316 supports_inline_python: true
1317 config_activation:
1318 - "$HOME/.claude/settings.json"
1319 runtime_state:
1320 state_home_env: "STATE"
1321 default_path: "/tmp/state"
1322 hermes:
1323 nested_skill_support: true
1324 plugin_manifest:
1325 path_pattern: "ignored"
1326 loaded_at_runtime: false
1327 schema_ref: "ignored"
1328 hooks_model:
1329 config_surface: "n/a"
1330 payload_shape: "n/a"
1331 supports_inline_python: false
1332 config_activation:
1333 - "$HOME/.hermes/skills"
1334 runtime_state:
1335 state_home_env: "STATE"
1336 default_path: "/tmp/state"
1337"#;
1338
1339 const RUNTIME_ROOTS: &str = r#"
1340schema_version: 1
1341products:
1342 codex:
1343 live_home: "$CODEX_HOME"
1344 docs_home: "$CODEX_HOME"
1345 state_home: "/tmp/state"
1346 plugin_root: "$CODEX_HOME/plugins"
1347 hook_config_strategy: managed-block
1348 min_version: "<TBD: pin during Phase 1>"
1349 recommended_version: "<TBD: pin during Phase 1>"
1350 min_version_effective_from: "<TBD: pin during Phase 1>"
1351 version_probe: "codex --version"
1352 claude:
1353 live_home: "$HOME/.claude"
1354 docs_home: "$HOME/.claude"
1355 state_home: "/tmp/state"
1356 plugin_root_env: "CLAUDE_PLUGIN_ROOT"
1357 hook_config_strategy: settings-json
1358 min_version: "<TBD: pin during Phase 1>"
1359 recommended_version: "<TBD: pin during Phase 1>"
1360 min_version_effective_from: "<TBD: pin during Phase 1>"
1361 version_probe: "claude --version"
1362 hermes:
1363 live_home: "$HOME/.hermes"
1364 docs_home: "$HOME/.hermes"
1365 state_home: "/tmp/state"
1366 min_version: "1.0.0"
1367 recommended_version: "1.0.0"
1368 min_version_effective_from: "<TBD>"
1369 version_probe: "hermes --version"
1370"#;
1371
1372 const CLI_TOOLS: &str = r#"
1373schema_version: 1
1374profiles:
1375 core: [ripgrep]
1376 recommended: [ripgrep]
1377 full: [ripgrep]
1378formulas:
1379 ripgrep:
1380 brew: ripgrep
1381 command: rg
1382 linux_only_alternative: null
1383 categories: [search]
1384"#;
1385
1386 fn load_set(root: &SourceRoot) -> Arc<ManifestSet> {
1387 Arc::new(crate::render::manifest::load_all(root).unwrap())
1388 }
1389
1390 fn add_agent_fixture(root: &SourceRoot) {
1395 write(
1396 &root.path().join("manifests/agents.yaml"),
1397 r#"
1398schema_version: 1
1399agents:
1400 - id: reviewer-quick
1401 source: core/agents/reviewer-quick
1402 products:
1403 codex:
1404 render_to: agents/reviewer-quick.toml
1405 claude:
1406 render_to: agents/reviewer-quick.md
1407"#,
1408 );
1409 write(
1410 &root.path().join("core/agents/reviewer-quick/AGENT.md.tera"),
1411 "{% if product == \"codex\" %}name = \"reviewer-quick\"\n\
1412 {% else %}---\nname: reviewer-quick\n---\n{% endif %}",
1413 );
1414 }
1415
1416 #[test]
1417 fn write_product_renders_codex_agent_into_build_tree() {
1418 let tmp = TempDir::new().unwrap();
1419 let root = fixture_source_root(&tmp);
1420 add_agent_fixture(&root);
1421 let set = load_set(&root);
1422
1423 let report = write_product(&root, set, "codex").unwrap();
1424
1425 let out = report.output_root.join("agents/reviewer-quick.toml");
1426 assert!(out.exists(), "expected agent render at {}", out.display());
1427 let body = fs::read_to_string(&out).unwrap();
1428 assert!(body.contains("name = \"reviewer-quick\""), "{body}");
1429 assert!(
1430 report.rendered.iter().any(|id| id == "reviewer-quick"),
1431 "agent id absent from rendered report: {:?}",
1432 report.rendered
1433 );
1434 }
1435
1436 #[test]
1437 fn write_product_renders_claude_agent_with_product_branch() {
1438 let tmp = TempDir::new().unwrap();
1439 let root = fixture_source_root(&tmp);
1440 add_agent_fixture(&root);
1441 let set = load_set(&root);
1442
1443 let report = write_product(&root, set, "claude").unwrap();
1444
1445 let out = report.output_root.join("agents/reviewer-quick.md");
1448 let body = fs::read_to_string(&out).unwrap();
1449 assert!(body.contains("---\nname: reviewer-quick"), "{body}");
1450 assert!(!body.contains("name = \"reviewer-quick\""), "{body}");
1451 assert!(report.rendered.iter().any(|id| id == "reviewer-quick"));
1452 }
1453
1454 #[test]
1455 fn agent_render_is_cached_on_second_run() {
1456 let tmp = TempDir::new().unwrap();
1457 let root = fixture_source_root(&tmp);
1458 add_agent_fixture(&root);
1459 let set = load_set(&root);
1460
1461 let first = write_product(&root, set.clone(), "codex").unwrap();
1462 assert!(first.rendered.iter().any(|id| id == "reviewer-quick"));
1463
1464 let second = write_product(&root, set, "codex").unwrap();
1467 assert!(
1468 second.cached.iter().any(|id| id == "reviewer-quick"),
1469 "expected agent cache hit, got rendered={:?} cached={:?}",
1470 second.rendered,
1471 second.cached
1472 );
1473 }
1474
1475 #[test]
1476 fn write_product_renders_codex_skill_into_build_tree() {
1477 let tmp = TempDir::new().unwrap();
1478 let root = fixture_source_root(&tmp);
1479 let set = load_set(&root);
1480
1481 let report = write_product(&root, set, "codex").unwrap();
1482
1483 assert_eq!(report.rendered, vec!["market.favorites".to_string()]);
1484 assert!(report.cached.is_empty());
1485 assert!(report.skipped.is_empty());
1486 let out = report.output_root.join("skills/market/favorites/SKILL.md");
1487 let body = fs::read_to_string(&out).unwrap();
1488 assert!(body.contains("# /market-favorites"), "{body}");
1489 assert!(
1490 body.contains("state: agent-out path-for --domain market --topic favorites"),
1491 "{body}",
1492 );
1493 assert!(
1494 body.contains("script: ") && body.contains("/core/scripts/market.sh"),
1495 "{body}",
1496 );
1497 assert!(
1498 body.contains("required: agent-out (>=0.5.0) via market-cli (>=0.4.0)"),
1499 "{body}",
1500 );
1501
1502 let cache = RenderCache::load_or_empty(&report.output_root.join(CACHE_FILE));
1504 assert!(cache.skills.contains_key("market.favorites"));
1505 }
1506
1507 #[test]
1508 fn cache_hit_skips_render_and_keeps_existing_output_bytes() {
1509 let tmp = TempDir::new().unwrap();
1510 let root = fixture_source_root(&tmp);
1511 let set = load_set(&root);
1512
1513 let first = write_product(&root, set.clone(), "codex").unwrap();
1515 let snapshot_first = snapshot_outputs(&first.output_root);
1516 assert_eq!(first.rendered, vec!["market.favorites".to_string()]);
1517
1518 let second = write_product(&root, set.clone(), "codex").unwrap();
1521 assert!(second.rendered.is_empty(), "{:?}", second.rendered);
1522 assert_eq!(second.cached, vec!["market.favorites".to_string()]);
1523 let snapshot_second = snapshot_outputs(&second.output_root);
1524 assert_eq!(snapshot_first, snapshot_second);
1525 }
1526
1527 #[test]
1528 fn cache_miss_after_cache_file_deletion_reproduces_identical_bytes() {
1529 let tmp = TempDir::new().unwrap();
1530 let root = fixture_source_root(&tmp);
1531 let set = load_set(&root);
1532
1533 let first = write_product(&root, set.clone(), "codex").unwrap();
1534 let snapshot_first = snapshot_outputs(&first.output_root);
1535
1536 fs::remove_file(first.output_root.join(CACHE_FILE)).unwrap();
1539 let second = write_product(&root, set, "codex").unwrap();
1540 assert_eq!(second.rendered, vec!["market.favorites".to_string()]);
1541 let snapshot_second = snapshot_outputs(&second.output_root);
1542 assert_eq!(snapshot_first, snapshot_second);
1543 }
1544
1545 #[test]
1546 fn template_change_invalidates_cache_and_re_renders() {
1547 let tmp = TempDir::new().unwrap();
1548 let root = fixture_source_root(&tmp);
1549 let set = load_set(&root);
1550 write_product(&root, set.clone(), "codex").unwrap();
1551
1552 let tpl_path = root
1554 .path()
1555 .join("core/skills/market/favorites/SKILL.md.tera");
1556 let mut body = fs::read_to_string(&tpl_path).unwrap();
1557 body.push_str("\nextra line\n");
1558 fs::write(&tpl_path, body).unwrap();
1559
1560 let set = load_set(&root);
1564 let second = write_product(&root, set, "codex").unwrap();
1565 assert_eq!(second.rendered, vec!["market.favorites".to_string()]);
1566 let rendered =
1567 fs::read_to_string(second.output_root.join("skills/market/favorites/SKILL.md"))
1568 .unwrap();
1569 assert!(rendered.ends_with("extra line\n"), "{rendered}");
1570 }
1571
1572 #[test]
1573 fn skill_without_product_entry_is_skipped() {
1574 let tmp = TempDir::new().unwrap();
1575 let root = tmp.path();
1576 write(
1577 &root.join("manifests/skills.yaml"),
1578 r#"
1579schema_version: 1
1580skills:
1581 - id: codex.only
1582 domain: codex
1583 source: core/skills/codex/only
1584 products:
1585 codex:
1586 render_to: skills/codex-only/SKILL.md
1587 required_clis: {}
1588"#,
1589 );
1590 write(
1591 &root.join("manifests/plugins.yaml"),
1592 "schema_version: 1\nplugins: []\n",
1593 );
1594 write(
1595 &root.join("manifests/product-capabilities.yaml"),
1596 PRODUCT_CAPS,
1597 );
1598 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1599 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1600 write(
1601 &root.join("core/skills/codex/only/SKILL.md.tera"),
1602 "# codex-only\n",
1603 );
1604 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1605 let set = load_set(&source_root);
1606 let report = write_product(&source_root, set, "claude").unwrap();
1607 assert!(report.rendered.is_empty());
1608 assert_eq!(report.skipped, vec!["codex.only".to_string()]);
1609 }
1610
1611 #[test]
1612 fn render_rejects_unknown_product() {
1613 let tmp = TempDir::new().unwrap();
1614 let root = fixture_source_root(&tmp);
1615 let set = load_set(&root);
1616 let err = write_product(&root, set, "unknown").unwrap_err();
1617 assert!(format!("{err:#}").contains("unknown --product"));
1618 }
1619
1620 #[test]
1621 fn sandboxed_join_rejects_parent_segments_and_absolute_paths() {
1622 let base = Path::new("/tmp/source-root");
1623 sandboxed_join(base, "core/scripts/foo.sh").unwrap();
1624 sandboxed_join(base, "./core/scripts/foo.sh").unwrap();
1625 let err = sandboxed_join(base, "../etc/passwd").unwrap_err();
1626 assert!(format!("{err}").contains(".."));
1627 let err = sandboxed_join(base, "/etc/passwd").unwrap_err();
1628 assert!(format!("{err}").contains("absolute"));
1629 }
1630
1631 #[cfg(unix)]
1638 #[test]
1639 fn symlinked_skill_template_outside_source_root_is_rejected() {
1640 let tmp = TempDir::new().unwrap();
1641 let root = fixture_source_root(&tmp);
1642 let set = load_set(&root);
1643 let outside = TempDir::new().unwrap();
1646 let target = outside.path().join("hostile.tera");
1647 fs::write(&target, "# captured from outside\n").unwrap();
1648 let template_path = root
1649 .path()
1650 .join("core/skills/market/favorites/SKILL.md.tera");
1651 fs::remove_file(&template_path).unwrap();
1652 std::os::unix::fs::symlink(&target, &template_path).unwrap();
1653
1654 let err = write_product(&root, set, "codex").unwrap_err();
1655 let msg = format!("{err:#}");
1656 assert!(
1657 msg.contains("symlink") || msg.contains("outside the source root"),
1658 "{msg}",
1659 );
1660 }
1661
1662 #[cfg(unix)]
1667 #[test]
1668 fn symlinked_build_dir_outside_root_is_rejected_for_writes() {
1669 let tmp = TempDir::new().unwrap();
1670 let root = fixture_source_root(&tmp);
1671 let set = load_set(&root);
1672 let build = root.path().join("build/codex");
1675 fs::create_dir_all(&build).unwrap();
1676 let dest = build.join("skills/market/favorites");
1677 fs::create_dir_all(dest.parent().unwrap()).unwrap();
1678 let outside = TempDir::new().unwrap();
1679 let exfil = outside.path().join("favorites");
1680 fs::create_dir(&exfil).unwrap();
1681 std::os::unix::fs::symlink(&exfil, &dest).unwrap();
1682
1683 let err = write_product(&root, set, "codex").unwrap_err();
1684 let msg = format!("{err:#}");
1685 assert!(
1686 msg.contains("outside the build root") || msg.contains("symlink"),
1687 "{msg}",
1688 );
1689 }
1690
1691 #[test]
1692 fn render_rejects_render_to_with_build_prefix() {
1693 let tmp = TempDir::new().unwrap();
1698 let root = tmp.path();
1699 write(
1700 &root.join("manifests/skills.yaml"),
1701 r#"
1702schema_version: 1
1703skills:
1704 - id: market.favorites
1705 domain: market
1706 source: core/skills/market/favorites
1707 products:
1708 codex:
1709 name: /market-favorites
1710 render_to: build/codex/plugins/market/skills/favorites/SKILL.md
1711 required_clis: {}
1712"#,
1713 );
1714 write(
1715 &root.join("manifests/plugins.yaml"),
1716 "schema_version: 1\nplugins: []\n",
1717 );
1718 write(
1719 &root.join("manifests/product-capabilities.yaml"),
1720 PRODUCT_CAPS,
1721 );
1722 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1723 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1724 write(
1725 &root.join("core/skills/market/favorites/SKILL.md.tera"),
1726 "# market\n",
1727 );
1728 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1729 let set = load_set(&source_root);
1730 let err = write_product(&source_root, set, "codex").unwrap_err();
1731 let msg = format!("{err:#}");
1732 assert!(msg.contains("starts with `build/`"), "{msg}");
1733 assert!(msg.contains("market.favorites"), "{msg}");
1734 assert!(
1735 msg.contains("plugins/<plugin>/skills/<skill>/SKILL.md"),
1736 "{msg}"
1737 );
1738 }
1739
1740 #[test]
1741 fn write_product_copies_sibling_files_with_executable_bit() {
1742 let tmp = TempDir::new().unwrap();
1748 let root = tmp.path();
1749 write(
1750 &root.join("manifests/skills.yaml"),
1751 r#"
1752schema_version: 1
1753skills:
1754 - id: tools.topic-radar
1755 domain: tools
1756 source: core/skills/tools/topic-radar
1757 products:
1758 codex:
1759 name: topic-radar
1760 render_to: plugins/tools/skills/topic-radar/SKILL.md
1761 required_clis: {}
1762"#,
1763 );
1764 write(
1765 &root.join("manifests/plugins.yaml"),
1766 "schema_version: 1\nplugins: []\n",
1767 );
1768 write(
1769 &root.join("manifests/product-capabilities.yaml"),
1770 PRODUCT_CAPS,
1771 );
1772 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1773 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1774
1775 let skill_src = root.join("core/skills/tools/topic-radar");
1776 write(&skill_src.join("SKILL.md.tera"), "# topic-radar\n");
1777 write(&skill_src.join("bin/topic_radar.py"), "print('hello')\n");
1778 write(
1779 &skill_src.join("scripts/topic-radar.sh"),
1780 "#!/bin/sh\necho hi\n",
1781 );
1782 write(
1783 &skill_src.join("references/source-strategy.md"),
1784 "# strategy\n",
1785 );
1786 #[cfg(unix)]
1787 {
1788 use std::os::unix::fs::PermissionsExt;
1789 fs::set_permissions(
1790 skill_src.join("scripts/topic-radar.sh"),
1791 fs::Permissions::from_mode(0o755),
1792 )
1793 .unwrap();
1794 }
1795
1796 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1797 let set = load_set(&source_root);
1798 let report = write_product(&source_root, set, "codex").unwrap();
1799 assert_eq!(report.rendered, vec!["tools.topic-radar".to_string()]);
1800
1801 let out_dir = report.output_root.join("plugins/tools/skills/topic-radar");
1802 assert!(
1803 out_dir.join("SKILL.md").exists(),
1804 "rendered SKILL.md missing"
1805 );
1806 assert!(
1807 out_dir.join("bin/topic_radar.py").exists(),
1808 "bin/topic_radar.py not copied"
1809 );
1810 assert_eq!(
1811 fs::read_to_string(out_dir.join("bin/topic_radar.py")).unwrap(),
1812 "print('hello')\n",
1813 );
1814 assert_eq!(
1815 fs::read_to_string(out_dir.join("scripts/topic-radar.sh")).unwrap(),
1816 "#!/bin/sh\necho hi\n",
1817 );
1818 assert_eq!(
1819 fs::read_to_string(out_dir.join("references/source-strategy.md")).unwrap(),
1820 "# strategy\n",
1821 );
1822 #[cfg(unix)]
1823 {
1824 use std::os::unix::fs::PermissionsExt;
1825 let copied_mode = fs::metadata(out_dir.join("scripts/topic-radar.sh"))
1826 .unwrap()
1827 .permissions()
1828 .mode()
1829 & 0o777;
1830 assert_eq!(
1831 copied_mode, 0o755,
1832 "executable bit not preserved on rendered shell script",
1833 );
1834 }
1835 }
1836
1837 #[test]
1838 fn sibling_tera_file_is_rendered_through_helpers_and_drops_suffix() {
1839 let tmp = TempDir::new().unwrap();
1844 let root = tmp.path();
1845 write(
1846 &root.join("manifests/skills.yaml"),
1847 r#"
1848schema_version: 1
1849skills:
1850 - id: market.favorites
1851 domain: market
1852 source: core/skills/market/favorites
1853 products:
1854 codex:
1855 name: favorites
1856 render_to: skills/market/favorites/SKILL.md
1857 required_clis:
1858 agent-out: ">=0.5.0"
1859"#,
1860 );
1861 write(
1862 &root.join("manifests/plugins.yaml"),
1863 "schema_version: 1\nplugins: []\n",
1864 );
1865 write(
1866 &root.join("manifests/product-capabilities.yaml"),
1867 PRODUCT_CAPS,
1868 );
1869 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1870 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1871
1872 let skill_src = root.join("core/skills/market/favorites");
1873 write(&skill_src.join("SKILL.md.tera"), "# favorites\n");
1874 write(
1875 &skill_src.join("prompts/intro.md.tera"),
1876 r#"intro for {{ skill_ref(id="market.favorites") }}"#,
1877 );
1878
1879 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1880 let set = load_set(&source_root);
1881 write_product(&source_root, set, "codex").unwrap();
1882
1883 let out = root.join("build/codex/skills/market/favorites/prompts/intro.md");
1884 assert!(
1885 out.exists(),
1886 "rendered sibling tera should land without .tera suffix"
1887 );
1888 let body = fs::read_to_string(&out).unwrap();
1889 assert_eq!(body, "intro for favorites");
1890 }
1891
1892 #[test]
1893 fn stale_sibling_files_are_removed_on_re_render() {
1894 let tmp = TempDir::new().unwrap();
1899 let root = tmp.path();
1900 write(
1901 &root.join("manifests/skills.yaml"),
1902 r#"
1903schema_version: 1
1904skills:
1905 - id: tools.foo
1906 domain: tools
1907 source: core/skills/tools/foo
1908 products:
1909 codex:
1910 name: foo
1911 render_to: plugins/tools/skills/foo/SKILL.md
1912 required_clis: {}
1913"#,
1914 );
1915 write(
1916 &root.join("manifests/plugins.yaml"),
1917 "schema_version: 1\nplugins: []\n",
1918 );
1919 write(
1920 &root.join("manifests/product-capabilities.yaml"),
1921 PRODUCT_CAPS,
1922 );
1923 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1924 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1925
1926 let skill_src = root.join("core/skills/tools/foo");
1927 write(&skill_src.join("SKILL.md.tera"), "# foo\n");
1928 write(&skill_src.join("old-helper.sh"), "echo old\n");
1929
1930 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1931 let set = load_set(&source_root);
1932 write_product(&source_root, set.clone(), "codex").unwrap();
1933 let out_dir = root.join("build/codex/plugins/tools/skills/foo");
1934 assert!(out_dir.join("old-helper.sh").exists());
1935
1936 fs::remove_file(skill_src.join("old-helper.sh")).unwrap();
1938 let set = load_set(&source_root);
1939 write_product(&source_root, set, "codex").unwrap();
1940 assert!(
1941 !out_dir.join("old-helper.sh").exists(),
1942 "stale rendered sibling must be cleaned on re-render",
1943 );
1944 assert!(
1945 out_dir.join("SKILL.md").exists(),
1946 "SKILL.md should still render after sibling removal",
1947 );
1948 }
1949
1950 #[test]
1951 fn sibling_byte_change_invalidates_cache() {
1952 let tmp = TempDir::new().unwrap();
1957 let root = tmp.path();
1958 write(
1959 &root.join("manifests/skills.yaml"),
1960 r#"
1961schema_version: 1
1962skills:
1963 - id: tools.foo
1964 domain: tools
1965 source: core/skills/tools/foo
1966 products:
1967 codex:
1968 name: foo
1969 render_to: plugins/tools/skills/foo/SKILL.md
1970 required_clis: {}
1971"#,
1972 );
1973 write(
1974 &root.join("manifests/plugins.yaml"),
1975 "schema_version: 1\nplugins: []\n",
1976 );
1977 write(
1978 &root.join("manifests/product-capabilities.yaml"),
1979 PRODUCT_CAPS,
1980 );
1981 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1982 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1983
1984 let skill_src = root.join("core/skills/tools/foo");
1985 write(&skill_src.join("SKILL.md.tera"), "# foo\n");
1986 write(&skill_src.join("helper.sh"), "echo v1\n");
1987
1988 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1989 let set = load_set(&source_root);
1990 write_product(&source_root, set, "codex").unwrap();
1991
1992 write(&skill_src.join("helper.sh"), "echo v2\n");
1993 let set = load_set(&source_root);
1994 let second = write_product(&source_root, set, "codex").unwrap();
1995 assert_eq!(
1996 second.rendered,
1997 vec!["tools.foo".to_string()],
1998 "sibling byte change should trigger a re-render (cache miss)",
1999 );
2000 let body = fs::read_to_string(root.join("build/codex/plugins/tools/skills/foo/helper.sh"))
2001 .unwrap();
2002 assert_eq!(body, "echo v2\n");
2003 }
2004
2005 #[test]
2006 fn write_product_runs_against_empty_skills_manifest() {
2007 let tmp = TempDir::new().unwrap();
2010 let root = tmp.path();
2011 write(
2012 &root.join("manifests/skills.yaml"),
2013 "schema_version: 1\nskills: []\n",
2014 );
2015 write(
2016 &root.join("manifests/plugins.yaml"),
2017 "schema_version: 1\nplugins: []\n",
2018 );
2019 write(
2020 &root.join("manifests/product-capabilities.yaml"),
2021 PRODUCT_CAPS,
2022 );
2023 write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
2024 write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
2025 let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
2026 let set = load_set(&source_root);
2027 let report = write_product(&source_root, set, "codex").unwrap();
2028 assert!(report.rendered.is_empty());
2029 assert!(report.cached.is_empty());
2030 assert!(report.skipped.is_empty());
2031 assert!(report.output_root.exists());
2032 }
2033}