atomr-agents-coding-cli-vendor-claude 0.17.0

Claude Code adapter for the coding-cli harness: stream-json parser + concept projection (CLAUDE.md, skills, MCP, settings).
Documentation
//! One-way projection: atomr concepts → Claude Code on-disk config.
//!
//! Writes files under `<workdir>/`:
//! - `CLAUDE.md`
//! - `.claude/skills/<id>/SKILL.md`
//! - `.mcp.json`
//! - `.claude/settings.local.json`
//!
//! All writes are idempotent — re-running with a different projection
//! overwrites the previous output.

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());
    }
}