use std::collections::BTreeMap;
use std::path::Path;
use serde_json::json;
use tokio::fs;
use atomr_agents_coding_cli_core::{ConceptProjection, MapperError, PersonaSnapshot, SkillSnapshot};
pub async fn materialize(projection: &ConceptProjection, workdir: &Path) -> Result<(), MapperError> {
write_claude_md(projection, workdir).await?;
write_skills(&projection.skills, workdir).await?;
write_mcp(&projection.toolsets, workdir).await?;
write_settings(projection, workdir).await?;
Ok(())
}
async fn write_claude_md(projection: &ConceptProjection, workdir: &Path) -> Result<(), MapperError> {
let path = workdir.join("CLAUDE.md");
let body = compose_claude_md(
projection.persona.as_ref(),
projection.project_memory.as_deref(),
&projection.skills,
);
write_file(&path, body.as_bytes()).await
}
fn compose_claude_md(
persona: Option<&PersonaSnapshot>,
project_memory: Option<&str>,
skills: &[SkillSnapshot],
) -> String {
let mut out = String::new();
out.push_str("# CLAUDE.md\n\n");
out.push_str("<!-- Generated by atomr-agents-coding-cli-harness. Do not edit by hand. -->\n\n");
if let Some(p) = persona {
out.push_str("## Identity\n\n");
out.push_str(&p.identity);
if !p.identity.ends_with('\n') {
out.push('\n');
}
if !p.salient_traits.is_empty() {
out.push_str("\n### Salient traits\n\n");
for t in &p.salient_traits {
out.push_str("- ");
out.push_str(t);
out.push('\n');
}
}
if let Some(tone) = &p.style_tone {
out.push_str("\n### Style / tone\n\n");
out.push_str(tone);
out.push('\n');
}
out.push('\n');
}
if let Some(mem) = project_memory {
out.push_str("## Project memory\n\n");
out.push_str(mem);
if !mem.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
if !skills.is_empty() {
out.push_str("## Available skills\n\n");
out.push_str(
"The following skills are projected from atomr into `.claude/skills/<id>/SKILL.md`.\n\n",
);
for s in skills {
out.push_str(&format!("- **{}** — {}\n", s.name, summary_of(&s.instruction_fragment)));
}
out.push('\n');
}
out
}
fn summary_of(text: &str) -> String {
let first_line = text.lines().next().unwrap_or("");
let trimmed = first_line.trim();
if trimmed.len() > 120 {
format!("{}…", &trimmed[..120])
} else {
trimmed.to_string()
}
}
async fn write_skills(skills: &[SkillSnapshot], workdir: &Path) -> Result<(), MapperError> {
if skills.is_empty() {
return Ok(());
}
let dir = workdir.join(".claude").join("skills");
fs::create_dir_all(&dir)
.await
.map_err(|e| MapperError::io(dir.display().to_string(), e))?;
for skill in skills {
let skill_dir = dir.join(&skill.id);
fs::create_dir_all(&skill_dir)
.await
.map_err(|e| MapperError::io(skill_dir.display().to_string(), e))?;
let path = skill_dir.join("SKILL.md");
let body = render_skill(skill);
write_file(&path, body.as_bytes()).await?;
}
Ok(())
}
fn render_skill(s: &SkillSnapshot) -> String {
let mut out = String::new();
out.push_str("---\n");
out.push_str(&format!("name: {}\n", s.name));
out.push_str(&format!("id: {}\n", s.id));
out.push_str(&format!("priority: {}\n", s.priority));
if !s.keywords.is_empty() {
out.push_str("keywords:\n");
for k in &s.keywords {
out.push_str(&format!(" - {k}\n"));
}
}
if !s.tools.is_empty() {
out.push_str("tools:\n");
for t in &s.tools {
out.push_str(&format!(" - {t}\n"));
}
}
out.push_str("---\n\n");
out.push_str(&s.instruction_fragment);
if !out.ends_with('\n') {
out.push('\n');
}
out
}
async fn write_mcp(
toolsets: &[atomr_agents_coding_cli_core::ToolSetSnapshot],
workdir: &Path,
) -> Result<(), MapperError> {
if toolsets.iter().all(|t| t.mcp_servers.is_empty()) {
return Ok(());
}
let mut servers = BTreeMap::new();
for ts in toolsets {
for s in &ts.mcp_servers {
servers.insert(
s.name.clone(),
json!({
"command": s.command,
"args": s.args,
"env": s.env,
}),
);
}
}
let body = json!({ "mcpServers": servers });
let pretty = serde_json::to_vec_pretty(&body)?;
let path = workdir.join(".mcp.json");
write_file(&path, &pretty).await
}
async fn write_settings(projection: &ConceptProjection, workdir: &Path) -> Result<(), MapperError> {
let policy = &projection.policy;
if policy.allowed_tools.is_empty()
&& policy.allowed_models.is_empty()
&& !policy.auto_approve_unrestricted
{
return Ok(());
}
let dir = workdir.join(".claude");
fs::create_dir_all(&dir)
.await
.map_err(|e| MapperError::io(dir.display().to_string(), e))?;
let path = dir.join("settings.local.json");
let body = json!({
"permissions": {
"allow": policy.allowed_tools,
"models": policy.allowed_models,
},
"autoApproveUnrestricted": policy.auto_approve_unrestricted,
});
let pretty = serde_json::to_vec_pretty(&body)?;
write_file(&path, &pretty).await
}
async fn write_file(path: &Path, body: &[u8]) -> Result<(), MapperError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| MapperError::io(parent.display().to_string(), e))?;
}
fs::write(path, body)
.await
.map_err(|e| MapperError::io(path.display().to_string(), e))
}
#[cfg(test)]
mod tests {
use super::*;
use atomr_agents_coding_cli_core::{
McpServerSnapshot, PolicySnapshot, SkillSnapshot, ToolSetSnapshot,
};
use tempfile::TempDir;
fn projection_fixture() -> ConceptProjection {
ConceptProjection {
persona: Some(PersonaSnapshot {
identity: "Senior Rust engineer specializing in distributed systems.".into(),
salient_traits: vec!["pragmatic".into(), "type-safety-focused".into()],
style_tone: Some("Terse, no fluff.".into()),
}),
skills: vec![SkillSnapshot {
id: "rag".into(),
name: "RAG".into(),
instruction_fragment: "Use the codebase index before answering.\n".into(),
keywords: vec!["search".into()],
priority: 7,
tools: vec!["Read".into()],
}],
policy: PolicySnapshot {
allowed_tools: vec!["Bash".into(), "Read".into()],
allowed_models: vec!["claude-sonnet-4-6".into()],
auto_approve_unrestricted: false,
max_tokens_per_call: None,
},
toolsets: vec![ToolSetSnapshot {
id: "linear".into(),
mcp_servers: vec![McpServerSnapshot {
name: "linear".into(),
command: "npx".into(),
args: vec!["-y".into(), "@linear/mcp".into()],
env: Default::default(),
}],
tool_names: vec![],
}],
project_memory: Some("Service is sharded by tenant id.".into()),
}
}
#[tokio::test]
async fn materializes_full_layout() {
let dir = TempDir::new().unwrap();
let p = projection_fixture();
materialize(&p, dir.path()).await.unwrap();
let md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(md.contains("# CLAUDE.md"));
assert!(md.contains("Senior Rust engineer"));
assert!(md.contains("pragmatic"));
assert!(md.contains("Service is sharded"));
assert!(md.contains("- **RAG**"));
let skill = std::fs::read_to_string(
dir.path().join(".claude/skills/rag/SKILL.md"),
)
.unwrap();
assert!(skill.contains("name: RAG"));
assert!(skill.contains("Use the codebase index"));
let mcp = std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
assert!(mcp.contains("\"linear\""));
assert!(mcp.contains("\"command\": \"npx\""));
let settings = std::fs::read_to_string(
dir.path().join(".claude/settings.local.json"),
)
.unwrap();
assert!(settings.contains("\"Bash\""));
assert!(settings.contains("claude-sonnet-4-6"));
}
#[tokio::test]
async fn empty_projection_writes_only_claude_md_skeleton() {
let dir = TempDir::new().unwrap();
let p = ConceptProjection::default();
materialize(&p, dir.path()).await.unwrap();
let md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(md.contains("# CLAUDE.md"));
assert!(!dir.path().join(".mcp.json").exists());
assert!(!dir.path().join(".claude/settings.local.json").exists());
}
}