use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde_json::{Map, Value, json};
use crate::cli::Agent;
pub const ALL_AGENTS: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Pi];
pub fn agent_slug(agent: Agent) -> &'static str {
match agent {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Pi => "pi",
}
}
pub fn surface_relative_path(agent: Agent) -> &'static str {
match agent {
Agent::Claude => ".claude/settings.json",
Agent::Codex => ".codex/hooks.json",
Agent::Pi => ".pi/hooks.json",
}
}
pub fn reinject_command(agent: Agent) -> String {
format!("truth-mirror reinject --agent {}", agent_slug(agent))
}
fn is_nested(agent: Agent) -> bool {
matches!(agent, Agent::Claude)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SurfacePlan {
pub agent: Agent,
pub path: PathBuf,
}
impl SurfacePlan {
pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
Self {
agent,
path: repo_root.join(surface_relative_path(agent)),
}
}
pub fn install(&self) -> Result<()> {
let mut root = read_object(&self.path)?;
install_command(self.agent, &mut root, &reinject_command(self.agent));
write_object(&self.path, &root)
}
pub fn uninstall(&self) -> Result<()> {
if !self.path.exists() {
return Ok(());
}
let mut root = read_object(&self.path)?;
remove_command(self.agent, &mut root, &reinject_command(self.agent));
if root.is_empty() {
fs::remove_file(&self.path)
.with_context(|| format!("removing empty surface {}", self.path.display()))?;
} else {
write_object(&self.path, &root)?;
}
Ok(())
}
pub fn contains_reinject(&self) -> Result<bool> {
if !self.path.exists() {
return Ok(false);
}
let root = read_object(&self.path)?;
Ok(surface_contains(
self.agent,
&root,
&reinject_command(self.agent),
))
}
}
fn read_object(path: &Path) -> Result<Map<String, Value>> {
match fs::read_to_string(path) {
Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
Ok(contents) => {
let value: Value = serde_json::from_str(&contents)
.with_context(|| format!("parsing existing surface {}", path.display()))?;
match value {
Value::Object(map) => Ok(map),
_ => anyhow::bail!(
"surface {} is not a JSON object; refusing to clobber",
path.display()
),
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
}
}
fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating surface dir {}", parent.display()))?;
}
let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
serialized.push('\n');
fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
Ok(())
}
fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
let entries = user_prompt_submit_mut(agent, root);
if array_contains_command(agent, entries, command) {
return;
}
entries.push(surface_entry(agent, command));
}
fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
if is_nested(agent) {
let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
return;
};
if let Some(groups) = hooks
.get_mut("UserPromptSubmit")
.and_then(Value::as_array_mut)
{
for group in groups.iter_mut() {
if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
inner.retain(|entry| !entry_matches_command(entry, command));
}
}
groups.retain(|group| {
group
.get("hooks")
.and_then(Value::as_array)
.is_none_or(|inner| !inner.is_empty())
});
if groups.is_empty() {
hooks.remove("UserPromptSubmit");
}
}
if hooks.is_empty() {
root.remove("hooks");
}
} else if let Some(entries) = root
.get_mut("UserPromptSubmit")
.and_then(Value::as_array_mut)
{
entries.retain(|entry| !entry_matches_command(entry, command));
if entries.is_empty() {
root.remove("UserPromptSubmit");
}
}
}
fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
if is_nested(agent) {
let hooks = root
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks = hooks.as_object_mut().expect("hooks is object");
let entries = hooks
.entry("UserPromptSubmit")
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
entries.as_array_mut().expect("UserPromptSubmit is array")
} else {
let entries = root
.entry("UserPromptSubmit")
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
entries.as_array_mut().expect("UserPromptSubmit is array")
}
}
fn surface_entry(agent: Agent, command: &str) -> Value {
if is_nested(agent) {
json!({ "hooks": [ { "type": "command", "command": command } ] })
} else {
json!({ "command": command })
}
}
fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
if is_nested(agent) {
entries.iter().any(|group| {
group
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
})
} else {
entries.iter().any(|e| entry_matches_command(e, command))
}
}
fn entry_matches_command(entry: &Value, command: &str) -> bool {
entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|value| value == command)
}
pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
if is_nested(agent) {
root.get("hooks")
.and_then(Value::as_object)
.and_then(|hooks| hooks.get("UserPromptSubmit"))
.and_then(Value::as_array)
.is_some_and(|entries| array_contains_command(agent, entries, command))
} else {
root.get("UserPromptSubmit")
.and_then(Value::as_array)
.is_some_and(|entries| array_contains_command(agent, entries, command))
}
}
#[cfg(test)]
mod tests {
use super::{
Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
};
use proptest::prelude::*;
use serde_json::{Map, Value, json};
fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
install_command(agent, &mut root, &reinject_command(agent));
root
}
#[test]
fn claude_surface_uses_nested_user_prompt_submit() {
let root = install_into(Agent::Claude, Map::new());
let value = Value::Object(root.clone());
let command = value
.pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
.and_then(Value::as_str)
.unwrap();
assert_eq!(command, "truth-mirror reinject --agent claude");
assert!(surface_contains(
Agent::Claude,
&root,
&reinject_command(Agent::Claude)
));
}
#[test]
fn codex_and_pi_use_flat_user_prompt_submit() {
for agent in [Agent::Codex, Agent::Pi] {
let root = install_into(agent, Map::new());
let value = Value::Object(root.clone());
let command = value
.pointer("/UserPromptSubmit/0/command")
.and_then(Value::as_str)
.unwrap();
assert_eq!(command, reinject_command(agent));
}
}
#[test]
fn install_is_idempotent() {
let mut root = install_into(Agent::Claude, Map::new());
install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
let count = Value::Object(root)
.pointer("/hooks/UserPromptSubmit")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn install_preserves_foreign_config() {
let existing: Map<String, Value> = json!({
"model": "sonnet",
"hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
})
.as_object()
.cloned()
.unwrap();
let root = install_into(Agent::Claude, existing);
let value = Value::Object(root);
assert_eq!(
value.pointer("/model").and_then(Value::as_str),
Some("sonnet")
);
assert!(value.pointer("/hooks/PreToolUse").is_some());
assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
}
#[test]
fn uninstall_removes_only_truth_mirror_entries() {
let existing: Map<String, Value> = json!({
"model": "sonnet",
"hooks": {
"UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
}
})
.as_object()
.cloned()
.unwrap();
let mut root = install_into(Agent::Claude, existing);
remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
let value = Value::Object(root);
assert_eq!(
value.pointer("/model").and_then(Value::as_str),
Some("sonnet")
);
let commands: Vec<&str> = value
.pointer("/hooks/UserPromptSubmit")
.and_then(Value::as_array)
.unwrap()
.iter()
.filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
.collect();
assert_eq!(commands, ["other-tool"]);
}
#[test]
fn install_then_uninstall_on_disk_round_trips() {
let temp = tempfile::tempdir().unwrap();
for agent in [Agent::Claude, Agent::Codex, Agent::Pi] {
let plan = SurfacePlan::for_agent(temp.path(), agent);
plan.install().unwrap();
assert!(plan.contains_reinject().unwrap());
plan.uninstall().unwrap();
assert!(!plan.contains_reinject().unwrap());
assert!(!plan.path.exists());
}
}
proptest! {
#[test]
fn foreign_keys_survive_install_uninstall(
key in "[a-z]{1,8}",
val in "[a-z0-9]{1,8}",
) {
prop_assume!(key != "hooks" && key != "UserPromptSubmit");
let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
.as_object()
.cloned()
.unwrap();
let mut root = existing.clone();
install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
}
}
}