use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
pub fn config_dir() -> Option<std::path::PathBuf> {
if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
return Some(std::path::PathBuf::from(d));
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(std::path::PathBuf::from(home).join(".claude"))
}
pub const BIN: &str = "amont-agent";
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Scope {
User,
Project,
ProjectLocal,
}
impl Scope {
pub fn path(self, project: &Path) -> Option<PathBuf> {
match self {
Scope::User => Some(config_dir()?.join("settings.json")),
Scope::Project => Some(project.join(".claude").join("settings.json")),
Scope::ProjectLocal => Some(project.join(".claude").join("settings.local.json")),
}
}
}
pub enum Change {
Add,
Update,
AlreadyCurrent,
Remove,
NothingToRemove,
WouldReformat,
}
impl Change {
pub fn describe(&self, path: &Path) -> String {
let p = path.display();
match self {
Change::Add => format!("added our hooks to {p}"),
Change::Update => format!("updated our hooks in {p}"),
Change::AlreadyCurrent => format!("{p} is already current — nothing written"),
Change::Remove => format!("removed our hooks from {p}"),
Change::NothingToRemove => format!("no amont-agent hook in {p} — nothing written"),
Change::WouldReformat => format!(
"{p} uses formatting this program cannot reproduce — writing it back would \
reformat parts of the file nobody asked to change.\n\
Paste the block below in by hand, or re-run with --reformat to accept a \
normalised file."
),
}
}
}
fn would_reformat(raw: &str, doc: &Value, indent: &str, nl: bool) -> bool {
if raw.trim().is_empty() {
return false;
}
match serde_json::from_str::<Value>(raw) {
Ok(original) => {
let _ = doc;
render(&original, indent, nl) != raw
}
Err(_) => false,
}
}
pub struct Plan {
pub path: PathBuf,
pub after: String,
pub change: Change,
}
pub enum MergeError {
Unparseable { path: PathBuf, why: String },
WrongShape { path: PathBuf, key: &'static str },
NotAFile(PathBuf),
}
impl MergeError {
pub fn explain(&self) -> String {
match self {
MergeError::Unparseable { path, why } => format!(
"{} is not valid JSON ({why}).\n\
Refusing to edit it — fix the file, or paste the block below in by hand.",
path.display()
),
MergeError::WrongShape { path, key } => format!(
"{} has a `{key}` that is not the shape Claude Code expects; refusing to edit it",
path.display()
),
MergeError::NotAFile(p) => format!(
"{} is not a regular file; refusing to write through it",
p.display()
),
}
}
}
fn handler(bin: &Path) -> Value {
json!({
"type": "command",
"command": bin.display().to_string(),
"args": ["hook"],
"timeout": 10
})
}
pub fn is_ours(h: &Value) -> bool {
h.get("command")
.and_then(|c| c.as_str())
.map(|c| {
Path::new(c)
.file_name()
.is_some_and(|n| n.to_string_lossy().trim_end_matches(".exe") == BIN)
})
.unwrap_or(false)
}
fn shape(raw: &str) -> (String, bool) {
let indent = raw
.lines()
.find_map(|l| {
let ws: String = l.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
if !ws.is_empty() && l.trim_start().starts_with('"') {
Some(ws)
} else {
None
}
})
.unwrap_or_else(|| " ".to_string());
(indent, raw.ends_with('\n'))
}
fn render(doc: &Value, indent: &str, trailing_newline: bool) -> String {
let mut buf = Vec::new();
let fmt = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes());
let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt);
use serde::Serialize;
doc.serialize(&mut ser).expect("a Value always serialises");
let mut out = String::from_utf8(buf).expect("serde_json emits UTF-8");
if trailing_newline {
out.push('\n');
}
out
}
fn read(path: &Path) -> Result<(Value, String), MergeError> {
if path.exists() {
let meta =
std::fs::symlink_metadata(path).map_err(|_| MergeError::NotAFile(path.into()))?;
if !meta.is_file() && !meta.file_type().is_symlink() {
return Err(MergeError::NotAFile(path.into()));
}
let raw = std::fs::read_to_string(path).map_err(|_| MergeError::NotAFile(path.into()))?;
if raw.trim().is_empty() {
return Ok((Value::Object(Map::new()), raw));
}
let doc = serde_json::from_str(&raw).map_err(|e| MergeError::Unparseable {
path: path.into(),
why: e.to_string(),
})?;
Ok((doc, raw))
} else {
Ok((Value::Object(Map::new()), String::new()))
}
}
pub const TARGETS: &[(&str, Option<&str>)] = &[
("PreToolUse", Some("Bash")),
("PostToolUse", Some("Bash")),
("SessionStart", None),
];
fn ensure(
hooks: &mut Map<String, Value>,
path: &Path,
event: &str,
matcher: Option<&str>,
want: &Value,
) -> Result<(), MergeError> {
let list = hooks
.entry(event.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
let list = list.as_array_mut().ok_or(MergeError::WrongShape {
path: path.into(),
key: "an event",
})?;
for block in list.iter_mut() {
if block.get("matcher").and_then(|m| m.as_str()) != matcher {
continue;
}
let Some(handlers) = block.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
continue;
};
if let Some(mine) = handlers.iter_mut().find(|h| is_ours(h)) {
*mine = want.clone();
} else {
handlers.push(want.clone());
}
return Ok(());
}
let block = match matcher {
Some(m) => json!({ "matcher": m, "hooks": [want] }),
None => json!({ "hooks": [want] }),
};
list.push(block);
Ok(())
}
pub fn plan_install(path: &Path, bin: &Path, reformat: bool) -> Result<Plan, MergeError> {
let (mut doc, raw) = read(path)?;
let (indent, nl) = shape(&raw);
if !reformat && would_reformat(&raw, &doc, &indent, nl) {
return Ok(Plan {
path: path.into(),
after: raw,
change: Change::WouldReformat,
});
}
let before = raw.clone();
let root = doc.as_object_mut().ok_or(MergeError::WrongShape {
path: path.into(),
key: "(root)",
})?;
let existed = root.contains_key("hooks");
let hooks = root
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
let hooks = hooks.as_object_mut().ok_or(MergeError::WrongShape {
path: path.into(),
key: "hooks",
})?;
let want = handler(bin);
for (event, matcher) in TARGETS {
ensure(hooks, path, event, *matcher, &want)?;
}
let after = render(&doc, &indent, nl || before.is_empty());
let change = if after == before {
Change::AlreadyCurrent
} else if existed {
Change::Update
} else {
Change::Add
};
Ok(Plan {
path: path.into(),
after,
change,
})
}
pub fn plan_uninstall(path: &Path, reformat: bool) -> Result<Plan, MergeError> {
let (mut doc, raw) = read(path)?;
let (indent, nl) = shape(&raw);
if raw.is_empty() {
return Ok(Plan {
path: path.into(),
after: raw,
change: Change::NothingToRemove,
});
}
if !reformat && would_reformat(&raw, &doc, &indent, nl) {
return Ok(Plan {
path: path.into(),
after: raw,
change: Change::WouldReformat,
});
}
let mut removed = false;
for (event, _) in TARGETS {
let Some(list) = doc
.get_mut("hooks")
.and_then(|h| h.get_mut(*event))
.and_then(|p| p.as_array_mut())
else {
continue;
};
let mut emptied: Vec<usize> = Vec::new();
for (i, block) in list.iter_mut().enumerate() {
if let Some(handlers) = block.get_mut("hooks").and_then(|h| h.as_array_mut()) {
let before = handlers.len();
handlers.retain(|h| !is_ours(h));
if handlers.len() != before {
removed = true;
if handlers.is_empty() {
emptied.push(i);
}
}
}
}
let mut at = 0;
list.retain(|_| {
let keep = !emptied.contains(&at);
at += 1;
keep
});
if list.is_empty() {
if let Some(hooks) = doc.get_mut("hooks").and_then(|h| h.as_object_mut()) {
hooks.remove(*event);
}
}
}
if let Some(hooks) = doc.get("hooks").and_then(|h| h.as_object()) {
if hooks.is_empty() {
if let Some(root) = doc.as_object_mut() {
root.remove("hooks");
}
}
}
if !removed {
return Ok(Plan {
path: path.into(),
after: raw,
change: Change::NothingToRemove,
});
}
Ok(Plan {
path: path.into(),
after: render(&doc, &indent, nl),
change: Change::Remove,
})
}
pub fn apply(plan: &Plan) -> std::io::Result<()> {
if matches!(
plan.change,
Change::AlreadyCurrent | Change::NothingToRemove | Change::WouldReformat
) {
return Ok(());
}
if let Some(parent) = plan.path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::atomic::write_atomic(&plan.path, &plan.after)
}
pub fn snippet(bin: &Path) -> String {
let want = handler(bin);
let mut hooks = Map::new();
for (event, matcher) in TARGETS {
let block = match matcher {
Some(m) => json!({ "matcher": m, "hooks": [want.clone()] }),
None => json!({ "hooks": [want.clone()] }),
};
hooks.insert((*event).to_string(), Value::Array(vec![block]));
}
render(&json!({ "hooks": hooks }), " ", true)
}