use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::error::Result;
const CLAUDE_MD: &str = include_str!("../templates/claude/CLAUDE.md");
const CLAUDE_MCP_SEED: &str = include_str!("../templates/claude/mcp.json");
const CLAUDE_SKILL_MD: &str = include_str!("../templates/claude/skills/bears-planning/SKILL.md");
const CLAUDE_SKILL_CLI_FALLBACK: &str =
include_str!("../templates/claude/skills/bears-planning/references/cli-fallback.md");
const CLAUDE_AGENT_PLANNER: &str = include_str!("../templates/claude/agents/planner.md");
const COPILOT_MD: &str = include_str!("../templates/copilot/copilot-instructions.md");
const COPILOT_MCP_SEED: &str = include_str!("../templates/copilot/mcp.json");
const COPILOT_SKILL_MD: &str = include_str!("../templates/copilot/skills/bears-planning/SKILL.md");
const COPILOT_SKILL_CLI_FALLBACK: &str =
include_str!("../templates/copilot/skills/bears-planning/references/cli-fallback.md");
const COPILOT_AGENT_PLANNER: &str = include_str!("../templates/copilot/agents/planner.agent.md");
const CODEX_MD: &str = include_str!("../templates/codex/AGENTS.md");
#[derive(Clone)]
pub enum McpStrategy {
MergeJson {
target: &'static str,
server_key: &'static str,
seed_json: &'static str,
},
None,
}
pub struct ScaffoldFile {
pub target: &'static str,
pub content: &'static str,
}
pub struct Harness {
pub instruction: ScaffoldFile,
pub skills: &'static [ScaffoldFile],
pub mcp: McpStrategy,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Category {
Instructions,
Skills,
All,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WritePolicy {
Force,
SkipExisting,
Append,
}
const CLAUDE_INSTRUCTION: ScaffoldFile = ScaffoldFile {
target: "CLAUDE.md",
content: CLAUDE_MD,
};
static CLAUDE_SKILLS: &[ScaffoldFile] = &[
ScaffoldFile {
target: ".claude/skills/bears-planning/SKILL.md",
content: CLAUDE_SKILL_MD,
},
ScaffoldFile {
target: ".claude/skills/bears-planning/references/cli-fallback.md",
content: CLAUDE_SKILL_CLI_FALLBACK,
},
ScaffoldFile {
target: ".claude/agents/planner.md",
content: CLAUDE_AGENT_PLANNER,
},
];
const COPILOT_INSTRUCTION: ScaffoldFile = ScaffoldFile {
target: ".github/copilot-instructions.md",
content: COPILOT_MD,
};
static COPILOT_SKILLS: &[ScaffoldFile] = &[
ScaffoldFile {
target: ".github/skills/bears-planning/SKILL.md",
content: COPILOT_SKILL_MD,
},
ScaffoldFile {
target: ".github/skills/bears-planning/references/cli-fallback.md",
content: COPILOT_SKILL_CLI_FALLBACK,
},
ScaffoldFile {
target: ".github/agents/planner.agent.md",
content: COPILOT_AGENT_PLANNER,
},
];
const CODEX_INSTRUCTION: ScaffoldFile = ScaffoldFile {
target: "AGENTS.md",
content: CODEX_MD,
};
pub static REGISTRY: &[(&str, &Harness)] = &[
(
"claude",
&Harness {
instruction: CLAUDE_INSTRUCTION,
skills: CLAUDE_SKILLS,
mcp: McpStrategy::MergeJson {
target: ".mcp.json",
server_key: "mcpServers",
seed_json: CLAUDE_MCP_SEED,
},
},
),
(
"copilot",
&Harness {
instruction: COPILOT_INSTRUCTION,
skills: COPILOT_SKILLS,
mcp: McpStrategy::MergeJson {
target: ".github/mcp.json",
server_key: "servers",
seed_json: COPILOT_MCP_SEED,
},
},
),
(
"codex",
&Harness {
instruction: CODEX_INSTRUCTION,
skills: &[],
mcp: McpStrategy::None,
},
),
];
const APPEND_MARKER: &str = "<!-- bears:begin -->";
pub fn write_file(path: &Path, content: &str, policy: WritePolicy) -> Result<bool> {
if policy == WritePolicy::SkipExisting && path.exists() {
return Ok(false);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(true)
}
fn append_instruction(path: &Path, content: &str) -> Result<bool> {
if path.exists() {
let existing = fs::read_to_string(path)?;
if existing.contains(APPEND_MARKER) {
return Ok(false); }
let combined = format!("{existing}\n\n{APPEND_MARKER}\n{content}");
fs::write(path, combined)?;
return Ok(true);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(true)
}
pub fn merge_mcp_json(path: &Path, server_key: &str, seed_json: &str) -> Result<()> {
let seed: Value = serde_json::from_str(seed_json)?;
let bears_entry = seed
.get(server_key)
.and_then(|s| s.get("bears"))
.cloned()
.unwrap_or(Value::Object(Default::default()));
let mut doc: Value = if path.exists() {
let raw = fs::read_to_string(path)?;
serde_json::from_str(&raw).unwrap_or(Value::Object(Default::default()))
} else {
Value::Object(Default::default())
};
let servers = doc
.as_object_mut()
.expect("top-level JSON must be an object")
.entry(server_key)
.or_insert_with(|| Value::Object(Default::default()));
servers
.as_object_mut()
.expect("server key must be a JSON object")
.insert("bears".to_string(), bears_entry);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let pretty = serde_json::to_string_pretty(&doc)?;
fs::write(path, format!("{pretty}\n"))?;
Ok(())
}
fn category_has_instruction(category: Category) -> bool {
matches!(category, Category::Instructions | Category::All)
}
fn category_has_skills(category: Category) -> bool {
matches!(category, Category::Skills | Category::All)
}
pub fn category_targets(base: &Path, harness_labels: &[&str], category: Category) -> Vec<PathBuf> {
let mut targets = Vec::new();
for label in harness_labels {
let Some((_, harness)) = REGISTRY.iter().find(|(l, _)| l == label) else {
continue;
};
if category_has_instruction(category) {
targets.push(base.join(harness.instruction.target));
}
if category_has_skills(category) {
for f in harness.skills {
targets.push(base.join(f.target));
}
}
}
targets
}
pub fn scaffold_category(
base: &Path,
harness_labels: &[&str],
category: Category,
policy: WritePolicy,
) -> Result<Vec<PathBuf>> {
let mut written: Vec<PathBuf> = Vec::new();
for label in harness_labels {
let Some((_, harness)) = REGISTRY.iter().find(|(l, _)| l == label) else {
continue;
};
if category_has_instruction(category) {
let target = base.join(harness.instruction.target);
let changed = if policy == WritePolicy::Append {
append_instruction(&target, harness.instruction.content)?
} else {
write_file(&target, harness.instruction.content, policy)?
};
if changed {
written.push(target);
}
}
if category_has_skills(category) {
let skill_policy = if policy == WritePolicy::Append {
WritePolicy::Force
} else {
policy
};
for f in harness.skills {
let target = base.join(f.target);
if write_file(&target, f.content, skill_policy)? {
written.push(target);
}
}
if let McpStrategy::MergeJson {
target,
server_key,
seed_json,
} = &harness.mcp
{
let target_path = base.join(target);
merge_mcp_json(&target_path, server_key, seed_json)?;
written.push(target_path);
}
}
}
Ok(written)
}
#[allow(dead_code)]
pub fn scaffold(base: &Path, harness_labels: &[&str]) -> Result<Vec<PathBuf>> {
scaffold_category(base, harness_labels, Category::All, WritePolicy::Force)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_merge_mcp_json_preserves_other_servers() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".mcp.json");
let existing = serde_json::json!({
"mcpServers": {
"other-tool": {
"command": "other",
"args": ["serve"]
}
}
});
fs::write(&path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
let result: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = result["mcpServers"].as_object().unwrap();
assert!(servers.contains_key("bears"), "bears entry missing");
assert!(
servers.contains_key("other-tool"),
"other-tool entry must be preserved"
);
}
#[test]
fn test_merge_mcp_json_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".mcp.json");
merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
let after_first = fs::read_to_string(&path).unwrap();
merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
let after_second = fs::read_to_string(&path).unwrap();
assert_eq!(after_first, after_second, "merge must be idempotent");
}
#[test]
fn test_merge_mcp_json_fresh_create() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".mcp.json");
assert!(!path.exists());
merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
assert!(path.exists());
let result: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = result["mcpServers"].as_object().unwrap();
assert!(
servers.contains_key("bears"),
"fresh-created file must have bears entry"
);
let bears = &servers["bears"];
assert_eq!(bears["command"], "bea");
}
#[test]
fn test_scaffold_claude_idempotent() {
let tmp = TempDir::new().unwrap();
let written = scaffold(tmp.path(), &["claude"]).unwrap();
assert!(
written.iter().any(|p| p.ends_with("CLAUDE.md")),
"CLAUDE.md must be in written list"
);
assert!(
written.iter().any(|p| p.ends_with(".mcp.json")),
".mcp.json must be in written list"
);
assert!(tmp.path().join("CLAUDE.md").exists());
assert!(tmp.path().join(".mcp.json").exists());
let written2 = scaffold(tmp.path(), &["claude"]).unwrap();
assert_eq!(
written.len(),
written2.len(),
"same number of files on re-scaffold"
);
let md = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
assert!(md.contains("Bears"), "CLAUDE.md must reference Bears");
let mcp: Value =
serde_json::from_str(&fs::read_to_string(tmp.path().join(".mcp.json")).unwrap())
.unwrap();
assert!(mcp["mcpServers"]["bears"].is_object());
}
#[test]
fn test_scaffold_claude_skill_and_agent() {
let tmp = TempDir::new().unwrap();
let written = scaffold(tmp.path(), &["claude"]).unwrap();
let skill_path = tmp.path().join(".claude/skills/bears-planning/SKILL.md");
assert!(
written.iter().any(|p| p == &skill_path),
"SKILL.md must be in written list"
);
assert!(skill_path.exists(), "SKILL.md must be created");
let skill = fs::read_to_string(&skill_path).unwrap();
assert!(
skill.contains("bears-planning"),
"SKILL.md must contain skill name"
);
assert!(
skill.contains("mcp__bears__"),
"SKILL.md must reference MCP tools"
);
let cli_ref_path = tmp
.path()
.join(".claude/skills/bears-planning/references/cli-fallback.md");
assert!(
written.iter().any(|p| p == &cli_ref_path),
"cli-fallback.md must be in written list"
);
assert!(cli_ref_path.exists(), "cli-fallback.md must be created");
let cli_ref = fs::read_to_string(&cli_ref_path).unwrap();
assert!(
cli_ref.contains("bea create"),
"cli-fallback.md must contain bea create"
);
let agent_path = tmp.path().join(".claude/agents/planner.md");
assert!(
written.iter().any(|p| p == &agent_path),
"planner.md must be in written list"
);
assert!(agent_path.exists(), "planner.md must be created");
let agent = fs::read_to_string(&agent_path).unwrap();
assert!(agent.contains("planner"), "agent must have name");
assert!(
agent.contains("mcp__bears__"),
"agent must reference MCP tools"
);
let mcp: Value =
serde_json::from_str(&fs::read_to_string(tmp.path().join(".mcp.json")).unwrap())
.unwrap();
let bears = &mcp["mcpServers"]["bears"];
assert_eq!(bears["command"], "bea", "must use production binary form");
assert_eq!(bears["args"][0], "mcp", "must use 'mcp' subcommand");
}
#[test]
fn test_scaffold_copilot_skill_and_agent() {
let tmp = TempDir::new().unwrap();
let written = scaffold(tmp.path(), &["copilot"]).unwrap();
let instr_path = tmp.path().join(".github/copilot-instructions.md");
assert!(
instr_path.exists(),
"copilot-instructions.md must be created"
);
let skill_path = tmp.path().join(".github/skills/bears-planning/SKILL.md");
assert!(
written.iter().any(|p| p == &skill_path),
"SKILL.md must be in written list"
);
assert!(skill_path.exists(), "SKILL.md must be created");
let skill = fs::read_to_string(&skill_path).unwrap();
assert!(
skill.contains("bears-planning"),
"SKILL.md must contain skill name"
);
assert!(
skill.contains("bears/*"),
"SKILL.md must reference Copilot MCP tool prefix"
);
let cli_ref_path = tmp
.path()
.join(".github/skills/bears-planning/references/cli-fallback.md");
assert!(
written.iter().any(|p| p == &cli_ref_path),
"cli-fallback.md must be in written list"
);
assert!(cli_ref_path.exists(), "cli-fallback.md must be created");
let cli_ref = fs::read_to_string(&cli_ref_path).unwrap();
assert!(
cli_ref.contains("bea create"),
"cli-fallback.md must contain bea create"
);
let agent_path = tmp.path().join(".github/agents/planner.agent.md");
assert!(
written.iter().any(|p| p == &agent_path),
"planner.agent.md must be in written list"
);
assert!(agent_path.exists(), "planner.agent.md must be created");
let agent = fs::read_to_string(&agent_path).unwrap();
assert!(
agent.contains("bears/*"),
"agent must reference Copilot MCP tool prefix"
);
let mcp_path = tmp.path().join(".github/mcp.json");
assert!(
written.iter().any(|p| p == &mcp_path),
".github/mcp.json must be in written list"
);
assert!(mcp_path.exists(), ".github/mcp.json must be created");
let mcp: Value = serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
let bears = &mcp["servers"]["bears"];
assert_eq!(bears["command"], "bea", "must use production binary form");
assert_eq!(bears["args"][0], "mcp", "must use 'mcp' subcommand");
}
#[test]
fn test_scaffold_unknown_label_skipped() {
let tmp = TempDir::new().unwrap();
let result = scaffold(tmp.path(), &["nonexistent-harness"]);
assert!(result.is_ok());
let written = result.unwrap();
assert!(written.is_empty());
}
#[test]
fn test_write_file_force_overwrites() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("f.md");
fs::write(&path, "old").unwrap();
let changed = write_file(&path, "new", WritePolicy::Force).unwrap();
assert!(changed);
assert_eq!(fs::read_to_string(&path).unwrap(), "new");
}
#[test]
fn test_write_file_skip_existing() {
let tmp = TempDir::new().unwrap();
let existing = tmp.path().join("exists.md");
fs::write(&existing, "keep").unwrap();
let changed = write_file(&existing, "new", WritePolicy::SkipExisting).unwrap();
assert!(!changed, "existing file must be skipped");
assert_eq!(fs::read_to_string(&existing).unwrap(), "keep");
let missing = tmp.path().join("missing.md");
let created = write_file(&missing, "new", WritePolicy::SkipExisting).unwrap();
assert!(created, "missing file must be created under SkipExisting");
assert_eq!(fs::read_to_string(&missing).unwrap(), "new");
}
#[test]
fn test_append_instruction_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("CLAUDE.md");
fs::write(&path, "USER TEXT").unwrap();
let changed = append_instruction(&path, "TEMPLATE").unwrap();
assert!(changed);
let after_first = fs::read_to_string(&path).unwrap();
assert!(after_first.contains("USER TEXT"));
assert!(after_first.contains(APPEND_MARKER));
assert!(after_first.contains("TEMPLATE"));
let changed_again = append_instruction(&path, "TEMPLATE").unwrap();
assert!(!changed_again, "second append must be a no-op");
assert_eq!(
fs::read_to_string(&path).unwrap(),
after_first,
"file must be unchanged on re-append"
);
}
#[test]
fn test_append_instruction_creates_missing() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("CLAUDE.md");
let changed = append_instruction(&path, "TEMPLATE").unwrap();
assert!(changed);
assert_eq!(fs::read_to_string(&path).unwrap(), "TEMPLATE");
}
#[test]
fn test_scaffold_category_instructions_only() {
let tmp = TempDir::new().unwrap();
let written = scaffold_category(
tmp.path(),
&["claude"],
Category::Instructions,
WritePolicy::Force,
)
.unwrap();
assert!(tmp.path().join("CLAUDE.md").exists());
assert!(!tmp.path().join(".mcp.json").exists());
assert!(
!tmp.path()
.join(".claude/skills/bears-planning/SKILL.md")
.exists()
);
assert!(written.iter().all(|p| !p.ends_with(".mcp.json")));
}
#[test]
fn test_scaffold_category_skills_includes_mcp() {
let tmp = TempDir::new().unwrap();
scaffold_category(
tmp.path(),
&["claude"],
Category::Skills,
WritePolicy::Force,
)
.unwrap();
assert!(!tmp.path().join("CLAUDE.md").exists());
assert!(tmp.path().join(".mcp.json").exists());
assert!(
tmp.path()
.join(".claude/skills/bears-planning/SKILL.md")
.exists()
);
}
#[test]
fn test_scaffold_category_all_skip_existing() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("CLAUDE.md"), "MY EDITS").unwrap();
scaffold_category(
tmp.path(),
&["claude"],
Category::All,
WritePolicy::SkipExisting,
)
.unwrap();
assert_eq!(
fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap(),
"MY EDITS",
"existing instruction file must be preserved"
);
assert!(
tmp.path()
.join(".claude/skills/bears-planning/SKILL.md")
.exists()
);
assert!(tmp.path().join(".mcp.json").exists());
}
#[test]
fn test_scaffold_category_all_append() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("CLAUDE.md"), "MY EDITS").unwrap();
scaffold_category(tmp.path(), &["claude"], Category::All, WritePolicy::Append).unwrap();
let md = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
assert!(md.contains("MY EDITS"));
assert!(md.contains(APPEND_MARKER));
assert!(md.contains("Bears"), "template content must be appended");
assert!(
tmp.path()
.join(".claude/skills/bears-planning/SKILL.md")
.exists()
);
}
#[test]
fn test_category_targets_excludes_mcp_json() {
let tmp = TempDir::new().unwrap();
let targets = category_targets(tmp.path(), &["claude"], Category::All);
assert!(targets.iter().any(|p| p.ends_with("CLAUDE.md")));
assert!(
targets.iter().all(|p| !p.ends_with(".mcp.json")),
".mcp.json must be excluded from prompt-trigger targets"
);
}
}