1use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use serde_json;
31use std::path::{Path, PathBuf};
32
33const COORDINATION_DEFAULT: &str = include_str!("../assets/agent-skills/coordination.md");
38
39const SUPERVISOR_DEFAULT: &str = include_str!("../assets/agent-skills/supervisor.md");
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum Source {
45 Embedded,
47 AgentsStandard,
49 User,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55pub enum SkillFormat {
56 Standardized,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62pub struct StandardizedSkillMetadata {
63 pub name: String,
65 pub description: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub license: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub compatibility: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub metadata: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SkillTemplate {
81 pub name: String,
83 pub content: String,
85 pub source: Source,
87 pub format: SkillFormat,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub metadata: Option<StandardizedSkillMetadata>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub resource_paths: Option<Vec<PathBuf>>,
95}
96
97#[derive(Debug, thiserror::Error)]
99pub enum SkillError {
100 #[error("unknown skill '{name}' — no embedded default or user override exists")]
102 UnknownSkill {
103 name: String,
105 },
106
107 #[error("skill '{name}' validation failed: {reason}")]
109 ValidationError {
110 name: String,
112 reason: String,
114 },
115
116 #[error("cannot read skill directory at '{}' — check directory permissions", path.display())]
118 DirectoryReadError {
119 path: PathBuf,
121 source: std::io::Error,
123 },
124
125 #[error("cannot read user override skill file at '{}' — check file permissions", path.display())]
127 UserOverrideRead {
128 path: PathBuf,
130 source: std::io::Error,
132 },
133}
134
135fn embedded_default(skill_name: &str) -> Option<&'static str> {
141 match skill_name {
142 "coordination" => Some(COORDINATION_DEFAULT),
143 "supervisor" => Some(SUPERVISOR_DEFAULT),
144 _ => None,
145 }
146}
147
148pub fn resolve(skill_name: &str) -> Result<SkillTemplate, SkillError> {
153 resolve_with_config_dir(skill_name, None)
154}
155
156fn try_load_standardized_skill(
161 skill_name: &str,
162 config_dir_override: Option<&Path>,
163) -> Result<Option<SkillTemplate>, SkillError> {
164 if let Some(config_dir) = config_dir_override
166 && let Some(skill) = try_load_user_override(skill_name, config_dir)?
167 {
168 return Ok(Some(skill));
169 }
170
171 try_load_from_agents_dir(skill_name)
173}
174
175fn try_load_user_override(
177 skill_name: &str,
178 config_dir: &Path,
179) -> Result<Option<SkillTemplate>, SkillError> {
180 let skill_dir = config_dir
181 .join("git-paw")
182 .join("agent-skills")
183 .join(skill_name);
184
185 if skill_dir.is_dir() {
186 let skill_md_path = skill_dir.join("SKILL.md");
187 if skill_md_path.exists() {
188 return load_skill_from_directory(&skill_dir, skill_name, Source::User);
189 }
190 }
191
192 Ok(None)
193}
194
195fn try_load_from_agents_dir(skill_name: &str) -> Result<Option<SkillTemplate>, SkillError> {
197 let Ok(mut current_dir) = std::env::current_dir() else {
198 return Ok(None);
199 };
200
201 for _ in 0..5 {
202 let agents_dir = current_dir.join(".agents").join("skills").join(skill_name);
204
205 if agents_dir.is_dir() {
206 let skill_md_path = agents_dir.join("SKILL.md");
207 if skill_md_path.exists() {
208 return load_skill_from_directory(&agents_dir, skill_name, Source::AgentsStandard);
209 }
210 }
211
212 if !current_dir.pop() {
213 break;
214 }
215 }
216
217 Ok(None)
218}
219
220fn load_skill_from_directory(
222 skill_dir: &Path,
223 skill_name: &str,
224 source: Source,
225) -> Result<Option<SkillTemplate>, SkillError> {
226 let skill_md_path = skill_dir.join("SKILL.md");
227
228 let content = match std::fs::read_to_string(&skill_md_path) {
229 Ok(content) => content,
230 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
231 Err(source_err) => {
232 let error = match source {
233 Source::User => SkillError::UserOverrideRead {
234 path: skill_md_path.clone(),
235 source: source_err,
236 },
237 _ => SkillError::DirectoryReadError {
238 path: skill_dir.to_path_buf(),
239 source: source_err,
240 },
241 };
242 return Err(error);
243 }
244 };
245
246 let (metadata, content_without_frontmatter) = parse_standardized_metadata(&content)?;
248
249 let mut resource_paths = Vec::new();
251 for subdir in ["scripts", "references", "assets"] {
252 let subdir_path = skill_dir.join(subdir);
253 if subdir_path.exists() && subdir_path.is_dir() {
254 resource_paths.push(subdir_path);
255 }
256 }
257
258 Ok(Some(SkillTemplate {
259 name: skill_name.to_string(),
260 content: content_without_frontmatter,
261 source,
262 format: SkillFormat::Standardized,
263 metadata,
264 resource_paths: if resource_paths.is_empty() {
265 None
266 } else {
267 Some(resource_paths)
268 },
269 }))
270}
271
272fn parse_standardized_metadata(
276 content: &str,
277) -> Result<(Option<StandardizedSkillMetadata>, String), SkillError> {
278 let lines: Vec<&str> = content.lines().collect();
280 if lines.len() < 2 || !lines[0].trim().starts_with("---") {
281 return Ok((None, content.to_string()));
283 }
284
285 let mut frontmatter_end = None;
287 for (i, line) in lines.iter().enumerate().skip(1) {
288 if line.trim().starts_with("---") {
289 frontmatter_end = Some(i);
290 break;
291 }
292 }
293
294 let Some(frontmatter_end) = frontmatter_end else {
295 return Ok((None, content.to_string())); };
297
298 let frontmatter_lines = &lines[1..frontmatter_end];
300 let frontmatter_yaml = frontmatter_lines.join("\n");
301
302 let metadata: StandardizedSkillMetadata = match serde_yaml::from_str(&frontmatter_yaml) {
304 Ok(meta) => meta,
305 Err(e) => {
306 return Err(SkillError::ValidationError {
307 name: "unknown".to_string(),
308 reason: format!("invalid YAML frontmatter: {e}"),
309 });
310 }
311 };
312
313 if metadata.name.is_empty() {
315 return Err(SkillError::ValidationError {
316 name: "unknown".to_string(),
317 reason: "missing required 'name' field in frontmatter".to_string(),
318 });
319 }
320
321 if metadata.description.is_empty() {
322 return Err(SkillError::ValidationError {
323 name: metadata.name.clone(),
324 reason: "missing required 'description' field in frontmatter".to_string(),
325 });
326 }
327
328 let content_without_frontmatter = lines[frontmatter_end + 1..].join("\n");
330
331 Ok((Some(metadata), content_without_frontmatter))
332}
333
334fn resolve_with_config_dir(
336 skill_name: &str,
337 config_dir: Option<&Path>,
338) -> Result<SkillTemplate, SkillError> {
339 if let Some(skill) = try_load_standardized_skill(skill_name, config_dir)? {
341 return Ok(skill);
342 }
343
344 if let Some(content) = embedded_default(skill_name) {
346 let (metadata, content_without_frontmatter) = parse_standardized_metadata(content)?;
348
349 return Ok(SkillTemplate {
350 name: skill_name.to_string(),
351 content: content_without_frontmatter,
352 source: Source::Embedded,
353 format: SkillFormat::Standardized,
354 metadata,
355 resource_paths: None,
356 });
357 }
358
359 Err(SkillError::UnknownSkill {
360 name: skill_name.to_string(),
361 })
362}
363
364fn slugify_branch(branch: &str) -> String {
367 crate::broker::messages::slugify_branch(branch)
368}
369
370pub fn build_boot_block(branch_id: &str, broker_url: &str) -> String {
388 let template = include_str!("../assets/boot-block-template.md");
389 let slugified_branch = slugify_branch(branch_id);
390
391 template
392 .replace("{{BRANCH_ID}}", &slugified_branch)
393 .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
394}
395
396pub fn render(
421 template: &SkillTemplate,
422 branch: &str,
423 broker_url: &str,
424 project: &str,
425 test_command: Option<&str>,
426) -> String {
427 let branch_id = slugify_branch(branch);
428 let test_command_value = test_command.unwrap_or("(not configured)");
429
430 let mut output = template
432 .content
433 .replace("{{BRANCH_ID}}", &branch_id)
434 .replace("{{PROJECT_NAME}}", project)
435 .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
436 .replace("{{TEST_COMMAND}}", test_command_value);
437
438 if let Some(metadata) = &template.metadata {
440 output = output
441 .replace("{{SKILL_NAME}}", &metadata.name)
442 .replace("{{SKILL_DESCRIPTION}}", &metadata.description);
443 }
444
445 let mut start = 0;
447 while let Some(open) = output[start..].find("{{") {
448 let abs_open = start + open;
449 if let Some(close) = output[abs_open..].find("}}") {
450 let placeholder = &output[abs_open..abs_open + close + 2];
451 eprintln!(
452 "warning: unsubstituted placeholder {placeholder} in skill '{}'",
453 template.name
454 );
455 start = abs_open + close + 2;
456 } else {
457 break;
458 }
459 }
460
461 output
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use serial_test::serial;
468
469 #[test]
471 fn embedded_coordination_is_reachable() {
472 let tmpl = resolve("coordination").expect("should resolve coordination");
473 assert_eq!(tmpl.source, Source::Embedded);
474 assert!(!tmpl.content.is_empty());
475 }
476
477 #[test]
479 fn embedded_coordination_contains_all_operations() {
480 let tmpl = resolve("coordination").unwrap();
481 assert!(tmpl.content.contains("agent.status"));
482 assert!(tmpl.content.contains("agent.artifact"));
483 assert!(tmpl.content.contains("agent.blocked"));
484 assert!(
485 tmpl.content
486 .contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}")
487 );
488 }
489
490 #[test]
491 fn embedded_coordination_documents_supervisor_messages() {
492 let tmpl = resolve("coordination").unwrap();
493 assert!(tmpl.content.contains("agent.verified"));
494 assert!(tmpl.content.contains("agent.feedback"));
495 assert!(tmpl.content.contains("re-publish"));
496 }
497
498 #[test]
500 #[serial(directory_changes)]
501 fn standard_location_skill_loading() {
502 let dir = tempfile::tempdir().unwrap();
503 let project_dir = dir.path().join("my-project");
504 std::fs::create_dir_all(&project_dir).unwrap();
505
506 let skill_dir = project_dir
508 .join(".agents")
509 .join("skills")
510 .join("coordination");
511 std::fs::create_dir_all(&skill_dir).unwrap();
512
513 let skill_md_content = "---\nname: coordination\ndescription: Custom coordination skill\n---\n\ncustom skill content";
514 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
515
516 let original_dir = std::env::current_dir().unwrap();
518 std::env::set_current_dir(&project_dir).unwrap();
519
520 let tmpl = resolve("coordination").expect("should resolve");
521 assert_eq!(tmpl.source, Source::AgentsStandard);
522 assert!(tmpl.content.contains("custom skill content"));
523
524 std::env::set_current_dir(original_dir).unwrap();
526 }
527
528 #[test]
530 fn unknown_skill_returns_error() {
531 let result = resolve("nonexistent");
532 assert!(
533 matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
534 "expected UnknownSkill error, got {result:?}"
535 );
536 }
537
538 #[test]
540 fn branch_id_is_substituted() {
541 let tmpl = SkillTemplate {
542 name: "test".into(),
543 content: "agent_id:\"{{BRANCH_ID}}\"".into(),
544 source: Source::Embedded,
545 format: SkillFormat::Standardized,
546 metadata: None,
547 resource_paths: None,
548 };
549 let output = render(
550 &tmpl,
551 "feat/http-broker",
552 "http://127.0.0.1:9119",
553 "git-paw",
554 None,
555 );
556 assert!(output.contains("feat-http-broker"));
557 assert!(!output.contains("{{BRANCH_ID}}"));
558 }
559
560 #[test]
562 fn broker_url_placeholder_substituted() {
563 let tmpl = SkillTemplate {
564 name: "test".into(),
565 content: "curl {{GIT_PAW_BROKER_URL}}/status".into(),
566 source: Source::Embedded,
567 format: SkillFormat::Standardized,
568 metadata: None,
569 resource_paths: None,
570 };
571 let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
572 assert!(output.contains("http://127.0.0.1:9119/status"));
573 assert!(!output.contains("{{GIT_PAW_BROKER_URL}}"));
574 }
575
576 #[test]
578 fn slug_substitution_matches_slugify_branch() {
579 let tmpl = SkillTemplate {
580 name: "test".into(),
581 content: "id={{BRANCH_ID}}".into(),
582 source: Source::Embedded,
583 format: SkillFormat::Standardized,
584 metadata: None,
585 resource_paths: None,
586 };
587 let output = render(
588 &tmpl,
589 "Feature/HTTP_Broker",
590 "http://127.0.0.1:9119",
591 "git-paw",
592 None,
593 );
594 let expected = slugify_branch("Feature/HTTP_Broker");
595 assert_eq!(output, format!("id={expected}"));
596 }
597
598 #[test]
600 fn render_is_deterministic() {
601 let tmpl = resolve("coordination").unwrap();
602 let a = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
603 let b = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
604 assert_eq!(a, b);
605 }
606
607 #[test]
609 #[serial(directory_changes)]
610 fn render_performs_no_io() {
611 let dir = tempfile::tempdir().unwrap();
612 let project_dir = dir.path().join("my-project");
613 std::fs::create_dir_all(&project_dir).unwrap();
614
615 let skill_dir = project_dir
616 .join(".agents")
617 .join("skills")
618 .join("coordination");
619 std::fs::create_dir_all(&skill_dir).unwrap();
620
621 let skill_md_content = "---\nname: coordination\ndescription: Test coordination skill\n---\n\nuser {{BRANCH_ID}}";
622 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
623
624 let original_dir = std::env::current_dir().unwrap();
626 std::env::set_current_dir(&project_dir).unwrap();
627
628 let tmpl = resolve("coordination").unwrap();
629 assert_eq!(tmpl.source, Source::AgentsStandard);
630
631 std::fs::remove_dir_all(skill_dir).unwrap();
633 let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
634 assert!(output.contains("feat-x"));
635
636 std::env::set_current_dir(original_dir).unwrap();
638 }
639
640 #[test]
642 fn unknown_placeholder_survives() {
643 let tmpl = SkillTemplate {
644 name: "test".into(),
645 content: "url={{UNKNOWN_THING}}".into(),
646 source: Source::Embedded,
647 format: SkillFormat::Standardized,
648 metadata: None,
649 resource_paths: None,
650 };
651 let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
652 assert!(
653 output.contains("{{UNKNOWN_THING}}"),
654 "unknown placeholder should survive in output"
655 );
656 }
657
658 #[test]
660 fn no_unknown_placeholders_after_render() {
661 let tmpl = resolve("coordination").unwrap();
662 let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
663 assert!(
664 !output.contains("{{"),
665 "no double-curly placeholders should remain: {output}"
666 );
667 }
668
669 #[test]
671 fn embedded_supervisor_is_reachable() {
672 let tmpl = resolve("supervisor").expect("should resolve supervisor");
673 assert_eq!(tmpl.source, Source::Embedded);
674 assert!(!tmpl.content.is_empty());
675 }
676
677 #[test]
679 fn supervisor_skill_contains_role_definition() {
680 let tmpl = resolve("supervisor").unwrap();
681 assert!(tmpl.content.contains("do NOT write code"));
682 }
683
684 #[test]
686 fn supervisor_skill_contains_broker_status() {
687 let tmpl = resolve("supervisor").unwrap();
688 assert!(tmpl.content.contains("{{GIT_PAW_BROKER_URL}}/status"));
689 }
690
691 #[test]
693 fn supervisor_skill_contains_verified_and_feedback() {
694 let tmpl = resolve("supervisor").unwrap();
695 assert!(tmpl.content.contains("agent.verified"));
696 assert!(tmpl.content.contains("agent.feedback"));
697 }
698
699 #[test]
701 fn supervisor_skill_contains_tmux_commands() {
702 let tmpl = resolve("supervisor").unwrap();
703 assert!(tmpl.content.contains("tmux capture-pane"));
704 assert!(tmpl.content.contains("tmux send-keys"));
705 assert!(tmpl.content.contains("paw-{{PROJECT_NAME}}"));
706 }
707
708 #[test]
709 fn supervisor_skill_contains_spec_audit_procedure() {
710 let tmpl = resolve("supervisor").unwrap();
711 assert!(
712 tmpl.content.contains("Spec Audit"),
713 "supervisor skill should contain Spec Audit section"
714 );
715 assert!(
716 tmpl.content.contains("openspec/changes/"),
717 "should reference openspec/changes/ for spec file discovery"
718 );
719 assert!(
720 tmpl.content.contains("grep"),
721 "should instruct to grep for matching tests"
722 );
723 }
724
725 #[test]
726 fn supervisor_skill_spec_audit_after_test_before_verified() {
727 let tmpl = resolve("supervisor").unwrap();
728 let test_pos = tmpl.content.find("Regression check").unwrap_or(0);
729 let audit_pos = tmpl.content.find("Spec Audit").unwrap_or(0);
730 let verify_pos = tmpl.content.find("Verify or feedback").unwrap_or(0);
731 assert!(
732 audit_pos > test_pos,
733 "spec audit should appear after test/regression check"
734 );
735 assert!(
736 audit_pos < verify_pos,
737 "spec audit should appear before verify/feedback"
738 );
739 }
740
741 #[test]
743 fn project_name_is_substituted() {
744 let tmpl = SkillTemplate {
745 name: "test".into(),
746 content: "session=paw-{{PROJECT_NAME}}".into(),
747 source: Source::Embedded,
748 format: SkillFormat::Standardized,
749 metadata: None,
750 resource_paths: None,
751 };
752 let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "my-app", None);
753 assert!(output.contains("paw-my-app"));
754 assert!(!output.contains("{{PROJECT_NAME}}"));
755 }
756
757 #[test]
759 fn branch_id_and_project_name_both_substituted() {
760 let tmpl = SkillTemplate {
761 name: "test".into(),
762 content: "agent={{BRANCH_ID}} session=paw-{{PROJECT_NAME}}".into(),
763 source: Source::Embedded,
764 format: SkillFormat::Standardized,
765 metadata: None,
766 resource_paths: None,
767 };
768 let output = render(&tmpl, "feat/http-broker", "url", "git-paw", None);
769 assert!(output.contains("feat-http-broker"));
770 assert!(output.contains("paw-git-paw"));
771 assert!(!output.contains("{{BRANCH_ID}}"));
772 assert!(!output.contains("{{PROJECT_NAME}}"));
773 }
774
775 #[test]
777 #[serial(directory_changes)]
778 fn standardized_skill_format_is_detected() {
779 let dir = tempfile::tempdir().unwrap();
780 let project_dir = dir.path().join("my-project");
781 std::fs::create_dir_all(&project_dir).unwrap();
782
783 let skill_dir = project_dir
784 .join(".agents")
785 .join("skills")
786 .join("test-standardized");
787 std::fs::create_dir_all(&skill_dir).unwrap();
788
789 let skill_md_content = "---\nname: test-standardized\ndescription: A test standardized skill\n---\n\nThis is the skill content with {{BRANCH_ID}} placeholder.";
790 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
791
792 let original_dir = std::env::current_dir().unwrap();
794 std::env::set_current_dir(&project_dir).unwrap();
795
796 let tmpl = resolve("test-standardized").expect("should resolve");
797 assert_eq!(tmpl.format, SkillFormat::Standardized);
798 assert!(tmpl.content.contains("This is the skill content"));
799 assert!(tmpl.content.contains("{{BRANCH_ID}}"));
800 assert!(tmpl.metadata.is_some());
801 let metadata = tmpl.metadata.as_ref().unwrap();
802 assert_eq!(metadata.name, "test-standardized");
803 assert_eq!(metadata.description, "A test standardized skill");
804
805 std::env::set_current_dir(original_dir).unwrap();
807 }
808
809 #[test]
811 fn standardized_skill_with_resources_loads_paths() {
812 let dir = tempfile::tempdir().unwrap();
813 let skills_parent_dir = dir.path().join("git-paw").join("agent-skills");
814 let specific_skill_dir = skills_parent_dir.join("test-with-resources");
815 std::fs::create_dir_all(&specific_skill_dir).unwrap();
816
817 std::fs::create_dir_all(specific_skill_dir.join("scripts")).unwrap();
819 std::fs::create_dir_all(specific_skill_dir.join("references")).unwrap();
820 std::fs::create_dir_all(specific_skill_dir.join("assets")).unwrap();
821
822 let skill_md_content = "---\nname: test-with-resources\ndescription: Skill with resources\n---\n\nMain content here.";
823 std::fs::write(specific_skill_dir.join("SKILL.md"), skill_md_content).unwrap();
824
825 let tmpl = resolve_with_config_dir("test-with-resources", Some(dir.path()))
826 .expect("should resolve");
827 assert_eq!(tmpl.format, SkillFormat::Standardized);
828 assert!(tmpl.resource_paths.is_some());
829 let resource_paths = tmpl.resource_paths.as_ref().unwrap();
830 assert_eq!(resource_paths.len(), 3);
831 assert!(resource_paths.iter().any(|p| p.ends_with("scripts")));
832 assert!(resource_paths.iter().any(|p| p.ends_with("references")));
833 assert!(resource_paths.iter().any(|p| p.ends_with("assets")));
834 }
835
836 #[test]
838 #[serial(directory_changes)]
839 fn standard_location_loading() {
840 let temp_dir = tempfile::tempdir().unwrap();
841 let project_dir = temp_dir.path().join("my-project");
842 std::fs::create_dir_all(&project_dir).unwrap();
843
844 let standard_skill_dir = project_dir
846 .join(".agents")
847 .join("skills")
848 .join("test-skill");
849 std::fs::create_dir_all(&standard_skill_dir).unwrap();
850 let standard_content = "---\nname: test-skill\ndescription: Standard location skill\n---\n\nContent from .agents/skills/";
851 std::fs::write(standard_skill_dir.join("SKILL.md"), standard_content).unwrap();
852
853 let original_dir = std::env::current_dir().unwrap();
855 std::env::set_current_dir(&project_dir).unwrap();
856
857 let tmpl = resolve("test-skill").expect("should resolve");
858
859 assert_eq!(tmpl.source, Source::AgentsStandard);
861 assert!(tmpl.content.contains("Content from .agents/skills/"));
862
863 std::env::set_current_dir(original_dir).unwrap();
865 }
866
867 #[test]
869 fn standardized_skill_metadata_placeholders_are_substituted() {
870 let metadata = StandardizedSkillMetadata {
871 name: "test-skill".to_string(),
872 description: "Test description".to_string(),
873 license: None,
874 compatibility: None,
875 metadata: None,
876 };
877
878 let tmpl = SkillTemplate {
879 name: "test".into(),
880 content: "Name: {{SKILL_NAME}}, Desc: {{SKILL_DESCRIPTION}}".into(),
881 source: Source::Embedded,
882 format: SkillFormat::Standardized,
883 metadata: Some(metadata),
884 resource_paths: None,
885 };
886
887 let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
888 assert!(output.contains("Name: test-skill, Desc: Test description"));
889 assert!(!output.contains("{{SKILL_NAME}}"));
890 assert!(!output.contains("{{SKILL_DESCRIPTION}}"));
891 }
892
893 #[test]
894 fn test_command_placeholder_substitutes_when_set() {
895 let tmpl = SkillTemplate {
896 name: "supervisor".into(),
897 content: "Run `{{TEST_COMMAND}}` after each merge.".into(),
898 source: Source::Embedded,
899 format: SkillFormat::Standardized,
900 metadata: None,
901 resource_paths: None,
902 };
903 let output = render(
904 &tmpl,
905 "supervisor",
906 "http://127.0.0.1:9119",
907 "git-paw",
908 Some("just check"),
909 );
910 assert_eq!(output, "Run `just check` after each merge.");
911 assert!(!output.contains("{{TEST_COMMAND}}"));
912 }
913
914 #[test]
915 fn test_command_placeholder_falls_back_when_unset() {
916 let tmpl = SkillTemplate {
917 name: "supervisor".into(),
918 content: "Baseline: {{TEST_COMMAND}}".into(),
919 source: Source::Embedded,
920 format: SkillFormat::Standardized,
921 metadata: None,
922 resource_paths: None,
923 };
924 let output = render(
925 &tmpl,
926 "supervisor",
927 "http://127.0.0.1:9119",
928 "git-paw",
929 None,
930 );
931 assert_eq!(output, "Baseline: (not configured)");
932 assert!(!output.contains("{{TEST_COMMAND}}"));
933 }
934
935 #[test]
936 fn supervisor_template_no_unsubstituted_placeholders_when_test_command_set() {
937 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
942 let output = render(
943 &tmpl,
944 "supervisor",
945 "http://127.0.0.1:9119",
946 "git-paw",
947 Some("just check"),
948 );
949 assert!(
950 !output.contains("{{TEST_COMMAND}}"),
951 "supervisor template still contains a literal {{TEST_COMMAND}} after render"
952 );
953 assert!(
954 !output.contains("{{"),
955 "supervisor template has unsubstituted {{...}} placeholder after render"
956 );
957 }
958
959 #[test]
961 fn invalid_standardized_skill_frontmatter_returns_error() {
962 let dir = tempfile::tempdir().unwrap();
963 let project_dir = dir.path().join("my-project");
964 std::fs::create_dir_all(&project_dir).unwrap();
965
966 let skill_dir = project_dir
967 .join(".agents")
968 .join("skills")
969 .join("invalid-skill");
970 std::fs::create_dir_all(&skill_dir).unwrap();
971
972 let skill_md_content = "---\nname: invalid-skill\n---\n\nContent here.";
974 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
975
976 let original_dir = std::env::current_dir().unwrap();
978 std::env::set_current_dir(&project_dir).unwrap();
979
980 let result = resolve("invalid-skill");
981 assert!(matches!(result, Err(SkillError::ValidationError { .. })));
982
983 std::env::set_current_dir(original_dir).unwrap();
985 }
986
987 #[test]
989 fn skill_template_is_cloneable() {
990 let tmpl = resolve("coordination").unwrap();
991 let cloned = tmpl.clone();
992 assert_eq!(tmpl.name, cloned.name);
993 assert_eq!(tmpl.content, cloned.content);
994 assert_eq!(tmpl.source, cloned.source);
995 }
996
997 #[test]
999 fn boot_block_contains_all_four_essential_events() {
1000 let block = build_boot_block("feat/errors", "http://localhost:9119");
1001 assert!(
1002 block.contains("### 1. REGISTER"),
1003 "Missing REGISTER section"
1004 );
1005 assert!(block.contains("### 2. DONE"), "Missing DONE section");
1006 assert!(block.contains("### 3. BLOCKED"), "Missing BLOCKED section");
1007 assert!(
1008 block.contains("### 4. QUESTION"),
1009 "Missing QUESTION section"
1010 );
1011 }
1012
1013 #[test]
1014 fn boot_block_substitutes_branch_id_placeholder() {
1015 let block = build_boot_block("Feature/HTTP_Broker", "http://localhost:9119");
1016 assert!(
1017 block.contains("feature-http_broker"),
1018 "Branch ID not properly slugified"
1019 );
1020 assert!(
1021 !block.contains("{{BRANCH_ID}}"),
1022 "BRANCH_ID placeholder not substituted"
1023 );
1024 }
1025
1026 #[test]
1027 fn boot_block_substitutes_broker_url_placeholder() {
1028 let block = build_boot_block("feat/x", "http://127.0.0.1:9119");
1029 assert!(
1030 block.contains("http://127.0.0.1:9119/publish"),
1031 "Broker URL not substituted"
1032 );
1033 assert!(
1034 !block.contains("{{GIT_PAW_BROKER_URL}}"),
1035 "GIT_PAW_BROKER_URL placeholder not substituted"
1036 );
1037 }
1038
1039 #[test]
1040 fn boot_block_contains_paste_handling_instructions() {
1041 let block = build_boot_block("feat/x", "http://localhost:9119");
1042 assert!(
1043 block.contains("PASTE HANDLING"),
1044 "Missing paste handling section"
1045 );
1046 assert!(
1047 block.contains("additional Enter key"),
1048 "Missing Enter key instruction"
1049 );
1050 assert!(
1051 block.contains("[Pasted text #N]"),
1052 "Missing paste text reference"
1053 );
1054 }
1055
1056 #[test]
1057 fn boot_block_question_section_emphasizes_waiting() {
1058 let block = build_boot_block("feat/x", "http://localhost:9119");
1059 assert!(
1060 block.contains("DO NOT CONTINUE UNTIL YOU RECEIVE AN ANSWER!"),
1061 "Missing wait emphasis"
1062 );
1063 assert!(
1064 block.contains("WAIT for the answer before continuing"),
1065 "Missing wait instruction"
1066 );
1067 }
1068
1069 #[test]
1070 fn boot_block_is_deterministic() {
1071 let a = build_boot_block("feat/x", "http://localhost:9119");
1072 let b = build_boot_block("feat/x", "http://localhost:9119");
1073 assert_eq!(a, b, "Boot block generation should be deterministic");
1074 }
1075
1076 #[test]
1077 fn boot_block_handles_complex_branch_names() {
1078 let block = build_boot_block("fix/topological-cycle-fallback", "http://localhost:9119");
1079 assert!(
1080 block.contains("fix-topological-cycle-fallback"),
1081 "Complex branch name not properly slugified"
1082 );
1083 }
1084
1085 #[test]
1086 fn boot_block_contains_pre_expanded_curl_commands() {
1087 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
1088
1089 assert!(
1091 block.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
1092 "Curl commands not pre-expanded"
1093 );
1094
1095 assert!(
1097 block.contains("\"agent_id\":\"feat-test\""),
1098 "Agent ID not substituted in curl commands"
1099 );
1100 }
1101}