Skip to main content

truth_mirror/
surface.rs

1//! Per-agent reinjection surface installation.
2//!
3//! `install-hooks --claude|--codex|--pi` installs `truth-mirror reinject --agent <agent>`
4//! into each selected agent's repo-local configuration surface. Installs are
5//! non-clobbering (existing config is merged, not overwritten), idempotent, and
6//! reversible: uninstall removes only truth-mirror's own entries.
7
8use std::{
9    fs,
10    path::{Path, PathBuf},
11};
12
13use anyhow::{Context, Result};
14use serde_json::{Map, Value, json};
15
16use crate::cli::Agent;
17
18/// All agents that expose a reinjection surface.
19pub const ALL_AGENTS: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Pi];
20
21pub fn agent_slug(agent: Agent) -> &'static str {
22    match agent {
23        Agent::Claude => "claude",
24        Agent::Codex => "codex",
25        Agent::Pi => "pi",
26    }
27}
28
29/// Repo-relative path of an agent's reinjection surface file.
30pub fn surface_relative_path(agent: Agent) -> &'static str {
31    match agent {
32        Agent::Claude => ".claude/settings.json",
33        Agent::Codex => ".codex/hooks.json",
34        Agent::Pi => ".pi/hooks.json",
35    }
36}
37
38/// The exact command truth-mirror installs into each surface.
39pub fn reinject_command(agent: Agent) -> String {
40    format!("truth-mirror reinject --agent {}", agent_slug(agent))
41}
42
43/// Claude nests hooks under `hooks.UserPromptSubmit`; Codex and Pi use a flat
44/// top-level `UserPromptSubmit` array.
45fn is_nested(agent: Agent) -> bool {
46    matches!(agent, Agent::Claude)
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct SurfacePlan {
51    pub agent: Agent,
52    pub path: PathBuf,
53}
54
55impl SurfacePlan {
56    pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
57        Self {
58            agent,
59            path: repo_root.join(surface_relative_path(agent)),
60        }
61    }
62
63    pub fn install(&self) -> Result<()> {
64        let mut root = read_object(&self.path)?;
65        install_command(self.agent, &mut root, &reinject_command(self.agent));
66        write_object(&self.path, &root)
67    }
68
69    pub fn uninstall(&self) -> Result<()> {
70        if !self.path.exists() {
71            return Ok(());
72        }
73        let mut root = read_object(&self.path)?;
74        remove_command(self.agent, &mut root, &reinject_command(self.agent));
75        if root.is_empty() {
76            fs::remove_file(&self.path)
77                .with_context(|| format!("removing empty surface {}", self.path.display()))?;
78        } else {
79            write_object(&self.path, &root)?;
80        }
81        Ok(())
82    }
83
84    pub fn contains_reinject(&self) -> Result<bool> {
85        if !self.path.exists() {
86            return Ok(false);
87        }
88        let root = read_object(&self.path)?;
89        Ok(surface_contains(
90            self.agent,
91            &root,
92            &reinject_command(self.agent),
93        ))
94    }
95}
96
97fn read_object(path: &Path) -> Result<Map<String, Value>> {
98    match fs::read_to_string(path) {
99        Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
100        Ok(contents) => {
101            let value: Value = serde_json::from_str(&contents)
102                .with_context(|| format!("parsing existing surface {}", path.display()))?;
103            match value {
104                Value::Object(map) => Ok(map),
105                _ => anyhow::bail!(
106                    "surface {} is not a JSON object; refusing to clobber",
107                    path.display()
108                ),
109            }
110        }
111        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
112        Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
113    }
114}
115
116fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
117    if let Some(parent) = path.parent() {
118        fs::create_dir_all(parent)
119            .with_context(|| format!("creating surface dir {}", parent.display()))?;
120    }
121    let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
122    serialized.push('\n');
123    fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
124    Ok(())
125}
126
127fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
128    let entries = user_prompt_submit_mut(agent, root);
129    if array_contains_command(agent, entries, command) {
130        return;
131    }
132    entries.push(surface_entry(agent, command));
133}
134
135fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
136    if is_nested(agent) {
137        let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
138            return;
139        };
140        if let Some(groups) = hooks
141            .get_mut("UserPromptSubmit")
142            .and_then(Value::as_array_mut)
143        {
144            for group in groups.iter_mut() {
145                if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
146                    inner.retain(|entry| !entry_matches_command(entry, command));
147                }
148            }
149            groups.retain(|group| {
150                group
151                    .get("hooks")
152                    .and_then(Value::as_array)
153                    .is_none_or(|inner| !inner.is_empty())
154            });
155            if groups.is_empty() {
156                hooks.remove("UserPromptSubmit");
157            }
158        }
159        if hooks.is_empty() {
160            root.remove("hooks");
161        }
162    } else if let Some(entries) = root
163        .get_mut("UserPromptSubmit")
164        .and_then(Value::as_array_mut)
165    {
166        entries.retain(|entry| !entry_matches_command(entry, command));
167        if entries.is_empty() {
168            root.remove("UserPromptSubmit");
169        }
170    }
171}
172
173/// Return a mutable handle to the array we append entries to, creating the
174/// nested containers if they do not exist.
175fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
176    if is_nested(agent) {
177        let hooks = root
178            .entry("hooks")
179            .or_insert_with(|| Value::Object(Map::new()));
180        if !hooks.is_object() {
181            *hooks = Value::Object(Map::new());
182        }
183        let hooks = hooks.as_object_mut().expect("hooks is object");
184        let entries = hooks
185            .entry("UserPromptSubmit")
186            .or_insert_with(|| Value::Array(Vec::new()));
187        if !entries.is_array() {
188            *entries = Value::Array(Vec::new());
189        }
190        entries.as_array_mut().expect("UserPromptSubmit is array")
191    } else {
192        let entries = root
193            .entry("UserPromptSubmit")
194            .or_insert_with(|| Value::Array(Vec::new()));
195        if !entries.is_array() {
196            *entries = Value::Array(Vec::new());
197        }
198        entries.as_array_mut().expect("UserPromptSubmit is array")
199    }
200}
201
202fn surface_entry(agent: Agent, command: &str) -> Value {
203    if is_nested(agent) {
204        json!({ "hooks": [ { "type": "command", "command": command } ] })
205    } else {
206        json!({ "command": command })
207    }
208}
209
210fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
211    if is_nested(agent) {
212        entries.iter().any(|group| {
213            group
214                .get("hooks")
215                .and_then(Value::as_array)
216                .is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
217        })
218    } else {
219        entries.iter().any(|e| entry_matches_command(e, command))
220    }
221}
222
223fn entry_matches_command(entry: &Value, command: &str) -> bool {
224    entry
225        .get("command")
226        .and_then(Value::as_str)
227        .is_some_and(|value| value == command)
228}
229
230/// Whether the surface JSON already carries the reinject command for an agent.
231pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
232    if is_nested(agent) {
233        root.get("hooks")
234            .and_then(Value::as_object)
235            .and_then(|hooks| hooks.get("UserPromptSubmit"))
236            .and_then(Value::as_array)
237            .is_some_and(|entries| array_contains_command(agent, entries, command))
238    } else {
239        root.get("UserPromptSubmit")
240            .and_then(Value::as_array)
241            .is_some_and(|entries| array_contains_command(agent, entries, command))
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::{
248        Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
249    };
250    use proptest::prelude::*;
251    use serde_json::{Map, Value, json};
252
253    fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
254        install_command(agent, &mut root, &reinject_command(agent));
255        root
256    }
257
258    #[test]
259    fn claude_surface_uses_nested_user_prompt_submit() {
260        let root = install_into(Agent::Claude, Map::new());
261        let value = Value::Object(root.clone());
262
263        let command = value
264            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
265            .and_then(Value::as_str)
266            .unwrap();
267        assert_eq!(command, "truth-mirror reinject --agent claude");
268        assert!(surface_contains(
269            Agent::Claude,
270            &root,
271            &reinject_command(Agent::Claude)
272        ));
273    }
274
275    #[test]
276    fn codex_and_pi_use_flat_user_prompt_submit() {
277        for agent in [Agent::Codex, Agent::Pi] {
278            let root = install_into(agent, Map::new());
279            let value = Value::Object(root.clone());
280
281            let command = value
282                .pointer("/UserPromptSubmit/0/command")
283                .and_then(Value::as_str)
284                .unwrap();
285            assert_eq!(command, reinject_command(agent));
286        }
287    }
288
289    #[test]
290    fn install_is_idempotent() {
291        let mut root = install_into(Agent::Claude, Map::new());
292        install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
293
294        let count = Value::Object(root)
295            .pointer("/hooks/UserPromptSubmit")
296            .and_then(Value::as_array)
297            .map(Vec::len)
298            .unwrap();
299        assert_eq!(count, 1);
300    }
301
302    #[test]
303    fn install_preserves_foreign_config() {
304        let existing: Map<String, Value> = json!({
305            "model": "sonnet",
306            "hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
307        })
308        .as_object()
309        .cloned()
310        .unwrap();
311
312        let root = install_into(Agent::Claude, existing);
313        let value = Value::Object(root);
314
315        assert_eq!(
316            value.pointer("/model").and_then(Value::as_str),
317            Some("sonnet")
318        );
319        assert!(value.pointer("/hooks/PreToolUse").is_some());
320        assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
321    }
322
323    #[test]
324    fn uninstall_removes_only_truth_mirror_entries() {
325        let existing: Map<String, Value> = json!({
326            "model": "sonnet",
327            "hooks": {
328                "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
329            }
330        })
331        .as_object()
332        .cloned()
333        .unwrap();
334
335        let mut root = install_into(Agent::Claude, existing);
336        remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
337        let value = Value::Object(root);
338
339        assert_eq!(
340            value.pointer("/model").and_then(Value::as_str),
341            Some("sonnet")
342        );
343        let commands: Vec<&str> = value
344            .pointer("/hooks/UserPromptSubmit")
345            .and_then(Value::as_array)
346            .unwrap()
347            .iter()
348            .filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
349            .collect();
350        assert_eq!(commands, ["other-tool"]);
351    }
352
353    #[test]
354    fn install_then_uninstall_on_disk_round_trips() {
355        let temp = tempfile::tempdir().unwrap();
356        for agent in [Agent::Claude, Agent::Codex, Agent::Pi] {
357            let plan = SurfacePlan::for_agent(temp.path(), agent);
358            plan.install().unwrap();
359            assert!(plan.contains_reinject().unwrap());
360            plan.uninstall().unwrap();
361            assert!(!plan.contains_reinject().unwrap());
362            assert!(!plan.path.exists());
363        }
364    }
365
366    proptest! {
367        #[test]
368        fn foreign_keys_survive_install_uninstall(
369            key in "[a-z]{1,8}",
370            val in "[a-z0-9]{1,8}",
371        ) {
372            prop_assume!(key != "hooks" && key != "UserPromptSubmit");
373            let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
374                .as_object()
375                .cloned()
376                .unwrap();
377
378            let mut root = existing.clone();
379            install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
380            prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
381
382            remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
383            prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
384            prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
385        }
386    }
387}