Skip to main content

atomr_agents_coding_cli_core/
projection.rs

1//! Flat, serde-friendly snapshots of atomr concepts a `CliVendor`
2//! adapter projects onto its on-disk CLI config (`CLAUDE.md`,
3//! `.cursor/rules/*`, `AGENTS.md`, `.mcp.json`, ...).
4//!
5//! Snapshots are *one-way*: the harness builds them from live
6//! `atomr-agents-skill::Skill`, `atomr-agents-persona::Persona`,
7//! `atomr-agents-strategy::Policy` values at run time and hands them
8//! to the vendor. The vendor never mutates atomr state from disk.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14/// Identity + behavior of the agent the CLI should impersonate.
15///
16/// Materialized into the vendor's "system instruction" surface:
17/// the top of `CLAUDE.md`, the system-instruction file for Antigravity,
18/// the top of `AGENTS.md` for Codex.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct PersonaSnapshot {
21    pub identity: String,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub salient_traits: Vec<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub style_tone: Option<String>,
26}
27
28/// One skill the CLI should be able to invoke.
29///
30/// For Claude Code this becomes `~/.claude/skills/<id>/SKILL.md` (or
31/// `<workdir>/.claude/skills/<id>/SKILL.md` for project scope).
32/// For Cursor this maps to `.cursor/rules/<id>.mdc`. For Antigravity and
33/// Codex (which lack a native concept) skills are concatenated into
34/// the system-instruction file.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillSnapshot {
37    pub id: String,
38    pub name: String,
39    pub instruction_fragment: String,
40    #[serde(default)]
41    pub keywords: Vec<String>,
42    #[serde(default = "default_priority")]
43    pub priority: u8,
44    /// Tool names this skill expects to be available. Up to the vendor
45    /// adapter how to surface this (Claude allows `--allowed-tools`;
46    /// others may just list them in the instruction fragment).
47    #[serde(default)]
48    pub tools: Vec<String>,
49}
50
51fn default_priority() -> u8 {
52    5
53}
54
55/// Narrowed permissions the harness wants enforced by the CLI itself.
56///
57/// Materialized as `--allowed-tools` / `--model` flags (Claude),
58/// `--full-access` gating (Codex), settings.json permissions, etc.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct PolicySnapshot {
61    /// Tool name allow-list. Empty means "no override — let the CLI
62    /// use its default policy".
63    #[serde(default)]
64    pub allowed_tools: Vec<String>,
65    /// Model id allow-list. The harness picks the first that the
66    /// `CliRequest::model` field intersects with.
67    #[serde(default)]
68    pub allowed_models: Vec<String>,
69    /// If `true`, the adapter is permitted to pass its
70    /// "auto-approve" flag (`--full-access`, `--yolo`, ...).
71    #[serde(default)]
72    pub auto_approve_unrestricted: bool,
73    /// Per-call token cap (used as a hint; not every CLI enforces it).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub max_tokens_per_call: Option<u32>,
76}
77
78/// One MCP server entry — vendor-agnostic.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct McpServerSnapshot {
81    pub name: String,
82    pub command: String,
83    #[serde(default)]
84    pub args: Vec<String>,
85    #[serde(default)]
86    pub env: BTreeMap<String, String>,
87}
88
89/// A bundled set of MCP servers + standalone tool names. Materializes
90/// to `.mcp.json` (Claude), `.cursor/mcp.json` (Cursor), Codex /
91/// Antigravity settings files.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct ToolSetSnapshot {
94    pub id: String,
95    #[serde(default)]
96    pub mcp_servers: Vec<McpServerSnapshot>,
97    #[serde(default)]
98    pub tool_names: Vec<String>,
99}
100
101/// Everything the harness hands a vendor adapter before a run so the
102/// adapter can materialize on-disk config.
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104pub struct ConceptProjection {
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub persona: Option<PersonaSnapshot>,
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub skills: Vec<SkillSnapshot>,
109    #[serde(default)]
110    pub policy: PolicySnapshot,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub toolsets: Vec<ToolSetSnapshot>,
113    /// Free-form project memory. Materializes to the `## Project Memory`
114    /// section of `CLAUDE.md` / `AGENTS.md`. Atomr long-term memory
115    /// summaries land here.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub project_memory: Option<String>,
118}
119
120impl ConceptProjection {
121    pub fn is_empty(&self) -> bool {
122        self.persona.is_none()
123            && self.skills.is_empty()
124            && self.toolsets.is_empty()
125            && self.project_memory.is_none()
126            && self.policy.allowed_tools.is_empty()
127            && self.policy.allowed_models.is_empty()
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn empty_projection_is_empty() {
137        assert!(ConceptProjection::default().is_empty());
138    }
139
140    #[test]
141    fn skill_round_trip() {
142        let s = SkillSnapshot {
143            id: "rag".into(),
144            name: "RAG".into(),
145            instruction_fragment: "use the index".into(),
146            keywords: vec!["search".into()],
147            priority: 7,
148            tools: vec!["WebSearch".into()],
149        };
150        let j = serde_json::to_string(&s).unwrap();
151        let back: SkillSnapshot = serde_json::from_str(&j).unwrap();
152        assert_eq!(back.id, "rag");
153        assert_eq!(back.priority, 7);
154    }
155}