1use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13use crate::error::Result;
14
15const CLAUDE_MD: &str = include_str!("../templates/claude/CLAUDE.md");
19
20const CLAUDE_MCP_SEED: &str = include_str!("../templates/claude/mcp.json");
23
24const CLAUDE_SKILL_MD: &str = include_str!("../templates/claude/skills/bears-planning/SKILL.md");
26
27const CLAUDE_SKILL_CLI_FALLBACK: &str =
29 include_str!("../templates/claude/skills/bears-planning/references/cli-fallback.md");
30
31const CLAUDE_AGENT_PLANNER: &str = include_str!("../templates/claude/agents/planner.md");
33
34const COPILOT_MD: &str = include_str!("../templates/copilot/copilot-instructions.md");
36
37const COPILOT_MCP_SEED: &str = include_str!("../templates/copilot/mcp.json");
39
40const COPILOT_SKILL_MD: &str = include_str!("../templates/copilot/skills/bears-planning/SKILL.md");
42
43const COPILOT_SKILL_CLI_FALLBACK: &str =
45 include_str!("../templates/copilot/skills/bears-planning/references/cli-fallback.md");
46
47const COPILOT_AGENT_PLANNER: &str = include_str!("../templates/copilot/agents/planner.agent.md");
49
50const CODEX_MD: &str = include_str!("../templates/codex/AGENTS.md");
52
53#[derive(Clone)]
57pub enum McpStrategy {
58 MergeJson {
64 target: &'static str,
66 server_key: &'static str,
69 seed_json: &'static str,
71 },
72 None,
74}
75
76pub struct ScaffoldFile {
81 pub target: &'static str,
83 pub content: &'static str,
85}
86
87pub struct Harness {
89 pub instruction: ScaffoldFile,
92 pub skills: &'static [ScaffoldFile],
94 pub mcp: McpStrategy,
96}
97
98#[derive(Clone, Copy, PartialEq, Eq, Debug)]
100pub enum Category {
101 Instructions,
103 Skills,
105 All,
107}
108
109#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114pub enum WritePolicy {
115 Force,
117 SkipExisting,
119 Append,
122}
123
124const CLAUDE_INSTRUCTION: ScaffoldFile = ScaffoldFile {
127 target: "CLAUDE.md",
128 content: CLAUDE_MD,
129};
130
131static CLAUDE_SKILLS: &[ScaffoldFile] = &[
132 ScaffoldFile {
133 target: ".claude/skills/bears-planning/SKILL.md",
134 content: CLAUDE_SKILL_MD,
135 },
136 ScaffoldFile {
137 target: ".claude/skills/bears-planning/references/cli-fallback.md",
138 content: CLAUDE_SKILL_CLI_FALLBACK,
139 },
140 ScaffoldFile {
141 target: ".claude/agents/planner.md",
142 content: CLAUDE_AGENT_PLANNER,
143 },
144];
145
146const COPILOT_INSTRUCTION: ScaffoldFile = ScaffoldFile {
147 target: ".github/copilot-instructions.md",
148 content: COPILOT_MD,
149};
150
151static COPILOT_SKILLS: &[ScaffoldFile] = &[
152 ScaffoldFile {
153 target: ".github/skills/bears-planning/SKILL.md",
154 content: COPILOT_SKILL_MD,
155 },
156 ScaffoldFile {
157 target: ".github/skills/bears-planning/references/cli-fallback.md",
158 content: COPILOT_SKILL_CLI_FALLBACK,
159 },
160 ScaffoldFile {
161 target: ".github/agents/planner.agent.md",
162 content: COPILOT_AGENT_PLANNER,
163 },
164];
165
166const CODEX_INSTRUCTION: ScaffoldFile = ScaffoldFile {
167 target: "AGENTS.md",
168 content: CODEX_MD,
169};
170
171pub static REGISTRY: &[(&str, &Harness)] = &[
175 (
176 "claude",
177 &Harness {
178 instruction: CLAUDE_INSTRUCTION,
179 skills: CLAUDE_SKILLS,
180 mcp: McpStrategy::MergeJson {
181 target: ".mcp.json",
182 server_key: "mcpServers",
183 seed_json: CLAUDE_MCP_SEED,
184 },
185 },
186 ),
187 (
188 "copilot",
189 &Harness {
190 instruction: COPILOT_INSTRUCTION,
191 skills: COPILOT_SKILLS,
192 mcp: McpStrategy::MergeJson {
193 target: ".github/mcp.json",
194 server_key: "servers",
195 seed_json: COPILOT_MCP_SEED,
196 },
197 },
198 ),
199 (
200 "codex",
201 &Harness {
202 instruction: CODEX_INSTRUCTION,
203 skills: &[],
204 mcp: McpStrategy::None,
205 },
206 ),
207];
208
209const APPEND_MARKER: &str = "<!-- bears:begin -->";
215
216pub fn write_file(path: &Path, content: &str, policy: WritePolicy) -> Result<bool> {
226 if policy == WritePolicy::SkipExisting && path.exists() {
227 return Ok(false);
228 }
229 if let Some(parent) = path.parent() {
230 fs::create_dir_all(parent)?;
231 }
232 fs::write(path, content)?;
233 Ok(true)
234}
235
236fn append_instruction(path: &Path, content: &str) -> Result<bool> {
244 if path.exists() {
245 let existing = fs::read_to_string(path)?;
246 if existing.contains(APPEND_MARKER) {
247 return Ok(false); }
249 let combined = format!("{existing}\n\n{APPEND_MARKER}\n{content}");
250 fs::write(path, combined)?;
251 return Ok(true);
252 }
253 if let Some(parent) = path.parent() {
254 fs::create_dir_all(parent)?;
255 }
256 fs::write(path, content)?;
257 Ok(true)
258}
259
260pub fn merge_mcp_json(path: &Path, server_key: &str, seed_json: &str) -> Result<()> {
268 let seed: Value = serde_json::from_str(seed_json)?;
270 let bears_entry = seed
271 .get(server_key)
272 .and_then(|s| s.get("bears"))
273 .cloned()
274 .unwrap_or(Value::Object(Default::default()));
275
276 let mut doc: Value = if path.exists() {
278 let raw = fs::read_to_string(path)?;
279 serde_json::from_str(&raw).unwrap_or(Value::Object(Default::default()))
280 } else {
281 Value::Object(Default::default())
282 };
283
284 let servers = doc
286 .as_object_mut()
287 .expect("top-level JSON must be an object")
288 .entry(server_key)
289 .or_insert_with(|| Value::Object(Default::default()));
290
291 servers
292 .as_object_mut()
293 .expect("server key must be a JSON object")
294 .insert("bears".to_string(), bears_entry);
295
296 if let Some(parent) = path.parent() {
298 fs::create_dir_all(parent)?;
299 }
300 let pretty = serde_json::to_string_pretty(&doc)?;
301 fs::write(path, format!("{pretty}\n"))?;
302 Ok(())
303}
304
305fn category_has_instruction(category: Category) -> bool {
309 matches!(category, Category::Instructions | Category::All)
310}
311
312fn category_has_skills(category: Category) -> bool {
314 matches!(category, Category::Skills | Category::All)
315}
316
317pub fn category_targets(base: &Path, harness_labels: &[&str], category: Category) -> Vec<PathBuf> {
324 let mut targets = Vec::new();
325 for label in harness_labels {
326 let Some((_, harness)) = REGISTRY.iter().find(|(l, _)| l == label) else {
327 continue;
328 };
329 if category_has_instruction(category) {
330 targets.push(base.join(harness.instruction.target));
331 }
332 if category_has_skills(category) {
333 for f in harness.skills {
334 targets.push(base.join(f.target));
335 }
336 }
337 }
338 targets
339}
340
341pub fn scaffold_category(
348 base: &Path,
349 harness_labels: &[&str],
350 category: Category,
351 policy: WritePolicy,
352) -> Result<Vec<PathBuf>> {
353 let mut written: Vec<PathBuf> = Vec::new();
354
355 for label in harness_labels {
356 let Some((_, harness)) = REGISTRY.iter().find(|(l, _)| l == label) else {
357 continue;
359 };
360
361 if category_has_instruction(category) {
362 let target = base.join(harness.instruction.target);
363 let changed = if policy == WritePolicy::Append {
364 append_instruction(&target, harness.instruction.content)?
365 } else {
366 write_file(&target, harness.instruction.content, policy)?
367 };
368 if changed {
369 written.push(target);
370 }
371 }
372
373 if category_has_skills(category) {
374 let skill_policy = if policy == WritePolicy::Append {
376 WritePolicy::Force
377 } else {
378 policy
379 };
380 for f in harness.skills {
381 let target = base.join(f.target);
382 if write_file(&target, f.content, skill_policy)? {
383 written.push(target);
384 }
385 }
386
387 if let McpStrategy::MergeJson {
390 target,
391 server_key,
392 seed_json,
393 } = &harness.mcp
394 {
395 let target_path = base.join(target);
396 merge_mcp_json(&target_path, server_key, seed_json)?;
397 written.push(target_path);
398 }
399 }
400 }
401
402 Ok(written)
403}
404
405#[allow(dead_code)]
411pub fn scaffold(base: &Path, harness_labels: &[&str]) -> Result<Vec<PathBuf>> {
412 scaffold_category(base, harness_labels, Category::All, WritePolicy::Force)
413}
414
415#[cfg(test)]
418mod tests {
419 use super::*;
420 use tempfile::TempDir;
421
422 #[test]
424 fn test_merge_mcp_json_preserves_other_servers() {
425 let tmp = TempDir::new().unwrap();
426 let path = tmp.path().join(".mcp.json");
427
428 let existing = serde_json::json!({
430 "mcpServers": {
431 "other-tool": {
432 "command": "other",
433 "args": ["serve"]
434 }
435 }
436 });
437 fs::write(&path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
438
439 merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
440
441 let result: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
442 let servers = result["mcpServers"].as_object().unwrap();
443
444 assert!(servers.contains_key("bears"), "bears entry missing");
446 assert!(
447 servers.contains_key("other-tool"),
448 "other-tool entry must be preserved"
449 );
450 }
451
452 #[test]
454 fn test_merge_mcp_json_idempotent() {
455 let tmp = TempDir::new().unwrap();
456 let path = tmp.path().join(".mcp.json");
457
458 merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
459 let after_first = fs::read_to_string(&path).unwrap();
460
461 merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
462 let after_second = fs::read_to_string(&path).unwrap();
463
464 assert_eq!(after_first, after_second, "merge must be idempotent");
465 }
466
467 #[test]
470 fn test_merge_mcp_json_fresh_create() {
471 let tmp = TempDir::new().unwrap();
472 let path = tmp.path().join(".mcp.json");
473
474 assert!(!path.exists());
475 merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
476
477 assert!(path.exists());
478 let result: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
479 let servers = result["mcpServers"].as_object().unwrap();
480 assert!(
481 servers.contains_key("bears"),
482 "fresh-created file must have bears entry"
483 );
484 let bears = &servers["bears"];
485 assert_eq!(bears["command"], "bea");
486 }
487
488 #[test]
491 fn test_scaffold_claude_idempotent() {
492 let tmp = TempDir::new().unwrap();
493
494 let written = scaffold(tmp.path(), &["claude"]).unwrap();
496 assert!(
497 written.iter().any(|p| p.ends_with("CLAUDE.md")),
498 "CLAUDE.md must be in written list"
499 );
500 assert!(
501 written.iter().any(|p| p.ends_with(".mcp.json")),
502 ".mcp.json must be in written list"
503 );
504 assert!(tmp.path().join("CLAUDE.md").exists());
505 assert!(tmp.path().join(".mcp.json").exists());
506
507 let written2 = scaffold(tmp.path(), &["claude"]).unwrap();
509 assert_eq!(
510 written.len(),
511 written2.len(),
512 "same number of files on re-scaffold"
513 );
514
515 let md = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
517 assert!(md.contains("Bears"), "CLAUDE.md must reference Bears");
518
519 let mcp: Value =
520 serde_json::from_str(&fs::read_to_string(tmp.path().join(".mcp.json")).unwrap())
521 .unwrap();
522 assert!(mcp["mcpServers"]["bears"].is_object());
523 }
524
525 #[test]
528 fn test_scaffold_claude_skill_and_agent() {
529 let tmp = TempDir::new().unwrap();
530 let written = scaffold(tmp.path(), &["claude"]).unwrap();
531
532 let skill_path = tmp.path().join(".claude/skills/bears-planning/SKILL.md");
534 assert!(
535 written.iter().any(|p| p == &skill_path),
536 "SKILL.md must be in written list"
537 );
538 assert!(skill_path.exists(), "SKILL.md must be created");
539 let skill = fs::read_to_string(&skill_path).unwrap();
540 assert!(
541 skill.contains("bears-planning"),
542 "SKILL.md must contain skill name"
543 );
544 assert!(
545 skill.contains("mcp__bears__"),
546 "SKILL.md must reference MCP tools"
547 );
548
549 let cli_ref_path = tmp
551 .path()
552 .join(".claude/skills/bears-planning/references/cli-fallback.md");
553 assert!(
554 written.iter().any(|p| p == &cli_ref_path),
555 "cli-fallback.md must be in written list"
556 );
557 assert!(cli_ref_path.exists(), "cli-fallback.md must be created");
558 let cli_ref = fs::read_to_string(&cli_ref_path).unwrap();
559 assert!(
560 cli_ref.contains("bea create"),
561 "cli-fallback.md must contain bea create"
562 );
563
564 let agent_path = tmp.path().join(".claude/agents/planner.md");
566 assert!(
567 written.iter().any(|p| p == &agent_path),
568 "planner.md must be in written list"
569 );
570 assert!(agent_path.exists(), "planner.md must be created");
571 let agent = fs::read_to_string(&agent_path).unwrap();
572 assert!(agent.contains("planner"), "agent must have name");
573 assert!(
574 agent.contains("mcp__bears__"),
575 "agent must reference MCP tools"
576 );
577
578 let mcp: Value =
580 serde_json::from_str(&fs::read_to_string(tmp.path().join(".mcp.json")).unwrap())
581 .unwrap();
582 let bears = &mcp["mcpServers"]["bears"];
583 assert_eq!(bears["command"], "bea", "must use production binary form");
584 assert_eq!(bears["args"][0], "mcp", "must use 'mcp' subcommand");
585 }
586
587 #[test]
590 fn test_scaffold_copilot_skill_and_agent() {
591 let tmp = TempDir::new().unwrap();
592 let written = scaffold(tmp.path(), &["copilot"]).unwrap();
593
594 let instr_path = tmp.path().join(".github/copilot-instructions.md");
596 assert!(
597 instr_path.exists(),
598 "copilot-instructions.md must be created"
599 );
600
601 let skill_path = tmp.path().join(".github/skills/bears-planning/SKILL.md");
603 assert!(
604 written.iter().any(|p| p == &skill_path),
605 "SKILL.md must be in written list"
606 );
607 assert!(skill_path.exists(), "SKILL.md must be created");
608 let skill = fs::read_to_string(&skill_path).unwrap();
609 assert!(
610 skill.contains("bears-planning"),
611 "SKILL.md must contain skill name"
612 );
613 assert!(
614 skill.contains("bears/*"),
615 "SKILL.md must reference Copilot MCP tool prefix"
616 );
617
618 let cli_ref_path = tmp
620 .path()
621 .join(".github/skills/bears-planning/references/cli-fallback.md");
622 assert!(
623 written.iter().any(|p| p == &cli_ref_path),
624 "cli-fallback.md must be in written list"
625 );
626 assert!(cli_ref_path.exists(), "cli-fallback.md must be created");
627 let cli_ref = fs::read_to_string(&cli_ref_path).unwrap();
628 assert!(
629 cli_ref.contains("bea create"),
630 "cli-fallback.md must contain bea create"
631 );
632
633 let agent_path = tmp.path().join(".github/agents/planner.agent.md");
635 assert!(
636 written.iter().any(|p| p == &agent_path),
637 "planner.agent.md must be in written list"
638 );
639 assert!(agent_path.exists(), "planner.agent.md must be created");
640 let agent = fs::read_to_string(&agent_path).unwrap();
641 assert!(
642 agent.contains("bears/*"),
643 "agent must reference Copilot MCP tool prefix"
644 );
645
646 let mcp_path = tmp.path().join(".github/mcp.json");
648 assert!(
649 written.iter().any(|p| p == &mcp_path),
650 ".github/mcp.json must be in written list"
651 );
652 assert!(mcp_path.exists(), ".github/mcp.json must be created");
653 let mcp: Value = serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
654 let bears = &mcp["servers"]["bears"];
655 assert_eq!(bears["command"], "bea", "must use production binary form");
656 assert_eq!(bears["args"][0], "mcp", "must use 'mcp' subcommand");
657 }
658
659 #[test]
661 fn test_scaffold_unknown_label_skipped() {
662 let tmp = TempDir::new().unwrap();
663 let result = scaffold(tmp.path(), &["nonexistent-harness"]);
664 assert!(result.is_ok());
665 let written = result.unwrap();
666 assert!(written.is_empty());
667 }
668
669 #[test]
673 fn test_write_file_force_overwrites() {
674 let tmp = TempDir::new().unwrap();
675 let path = tmp.path().join("f.md");
676 fs::write(&path, "old").unwrap();
677
678 let changed = write_file(&path, "new", WritePolicy::Force).unwrap();
679 assert!(changed);
680 assert_eq!(fs::read_to_string(&path).unwrap(), "new");
681 }
682
683 #[test]
686 fn test_write_file_skip_existing() {
687 let tmp = TempDir::new().unwrap();
688 let existing = tmp.path().join("exists.md");
689 fs::write(&existing, "keep").unwrap();
690
691 let changed = write_file(&existing, "new", WritePolicy::SkipExisting).unwrap();
692 assert!(!changed, "existing file must be skipped");
693 assert_eq!(fs::read_to_string(&existing).unwrap(), "keep");
694
695 let missing = tmp.path().join("missing.md");
696 let created = write_file(&missing, "new", WritePolicy::SkipExisting).unwrap();
697 assert!(created, "missing file must be created under SkipExisting");
698 assert_eq!(fs::read_to_string(&missing).unwrap(), "new");
699 }
700
701 #[test]
704 fn test_append_instruction_idempotent() {
705 let tmp = TempDir::new().unwrap();
706 let path = tmp.path().join("CLAUDE.md");
707 fs::write(&path, "USER TEXT").unwrap();
708
709 let changed = append_instruction(&path, "TEMPLATE").unwrap();
710 assert!(changed);
711 let after_first = fs::read_to_string(&path).unwrap();
712 assert!(after_first.contains("USER TEXT"));
713 assert!(after_first.contains(APPEND_MARKER));
714 assert!(after_first.contains("TEMPLATE"));
715
716 let changed_again = append_instruction(&path, "TEMPLATE").unwrap();
717 assert!(!changed_again, "second append must be a no-op");
718 assert_eq!(
719 fs::read_to_string(&path).unwrap(),
720 after_first,
721 "file must be unchanged on re-append"
722 );
723 }
724
725 #[test]
727 fn test_append_instruction_creates_missing() {
728 let tmp = TempDir::new().unwrap();
729 let path = tmp.path().join("CLAUDE.md");
730 let changed = append_instruction(&path, "TEMPLATE").unwrap();
731 assert!(changed);
732 assert_eq!(fs::read_to_string(&path).unwrap(), "TEMPLATE");
733 }
734
735 #[test]
738 fn test_scaffold_category_instructions_only() {
739 let tmp = TempDir::new().unwrap();
740 let written = scaffold_category(
741 tmp.path(),
742 &["claude"],
743 Category::Instructions,
744 WritePolicy::Force,
745 )
746 .unwrap();
747
748 assert!(tmp.path().join("CLAUDE.md").exists());
749 assert!(!tmp.path().join(".mcp.json").exists());
750 assert!(
751 !tmp.path()
752 .join(".claude/skills/bears-planning/SKILL.md")
753 .exists()
754 );
755 assert!(written.iter().all(|p| !p.ends_with(".mcp.json")));
756 }
757
758 #[test]
761 fn test_scaffold_category_skills_includes_mcp() {
762 let tmp = TempDir::new().unwrap();
763 scaffold_category(
764 tmp.path(),
765 &["claude"],
766 Category::Skills,
767 WritePolicy::Force,
768 )
769 .unwrap();
770
771 assert!(!tmp.path().join("CLAUDE.md").exists());
772 assert!(tmp.path().join(".mcp.json").exists());
773 assert!(
774 tmp.path()
775 .join(".claude/skills/bears-planning/SKILL.md")
776 .exists()
777 );
778 }
779
780 #[test]
783 fn test_scaffold_category_all_skip_existing() {
784 let tmp = TempDir::new().unwrap();
785 fs::write(tmp.path().join("CLAUDE.md"), "MY EDITS").unwrap();
786
787 scaffold_category(
788 tmp.path(),
789 &["claude"],
790 Category::All,
791 WritePolicy::SkipExisting,
792 )
793 .unwrap();
794
795 assert_eq!(
796 fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap(),
797 "MY EDITS",
798 "existing instruction file must be preserved"
799 );
800 assert!(
801 tmp.path()
802 .join(".claude/skills/bears-planning/SKILL.md")
803 .exists()
804 );
805 assert!(tmp.path().join(".mcp.json").exists());
806 }
807
808 #[test]
811 fn test_scaffold_category_all_append() {
812 let tmp = TempDir::new().unwrap();
813 fs::write(tmp.path().join("CLAUDE.md"), "MY EDITS").unwrap();
814
815 scaffold_category(tmp.path(), &["claude"], Category::All, WritePolicy::Append).unwrap();
816
817 let md = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
818 assert!(md.contains("MY EDITS"));
819 assert!(md.contains(APPEND_MARKER));
820 assert!(md.contains("Bears"), "template content must be appended");
821 assert!(
822 tmp.path()
823 .join(".claude/skills/bears-planning/SKILL.md")
824 .exists()
825 );
826 }
827
828 #[test]
830 fn test_category_targets_excludes_mcp_json() {
831 let tmp = TempDir::new().unwrap();
832 let targets = category_targets(tmp.path(), &["claude"], Category::All);
833 assert!(targets.iter().any(|p| p.ends_with("CLAUDE.md")));
834 assert!(
835 targets.iter().all(|p| !p.ends_with(".mcp.json")),
836 ".mcp.json must be excluded from prompt-trigger targets"
837 );
838 }
839}