1use 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
18pub 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
29pub 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
38pub fn reinject_command(agent: Agent) -> String {
40 format!("truth-mirror reinject --agent {}", agent_slug(agent))
41}
42
43fn 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
173fn 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
230pub 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}