use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use crate::git::GitRepo;
use crate::snapshot::{self, SnapOpts};
use crate::storage;
const EDIT_WRITE_COOLDOWN: Duration = Duration::from_secs(120);
pub fn settings_path() -> Result<PathBuf> {
if let Ok(p) = std::env::var("CLAUDE_OOPS_SETTINGS") {
return Ok(PathBuf::from(p));
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set"))?;
Ok(home.join(".claude").join("settings.json"))
}
pub fn commands_dir() -> Result<PathBuf> {
if let Ok(p) = std::env::var("CLAUDE_OOPS_COMMANDS_DIR") {
return Ok(PathBuf::from(p));
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set"))?;
Ok(home.join(".claude").join("commands"))
}
const OOPS_SLASH_COMMAND: &str = r#"---
description: List claude-oops snapshots and restore one
argument-hint: "[snapshot-id]"
allowed-tools: Bash(claude-oops *)
---
!`claude-oops list`
If the user supplied a snapshot id ($ARGUMENTS), run `claude-oops to $ARGUMENTS` and report what changed.
Otherwise, show the list above to the user and ask which snapshot they want
to restore. Once they pick one, run `claude-oops to <id>` and confirm.
"#;
fn load_settings(path: &std::path::Path) -> Result<Value> {
if !path.exists() {
return Ok(json!({}));
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
if raw.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&raw).with_context(|| format!("{} is not valid JSON", path.display()))
}
fn save_settings(path: &std::path::Path, v: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
let pretty = serde_json::to_string_pretty(v)?;
std::fs::write(&tmp, format!("{pretty}\n"))
.with_context(|| format!("failed to write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
fn ensure_array<'a>(parent: &'a mut Value, key: &str) -> &'a mut Vec<Value> {
let obj = parent.as_object_mut().expect("expected object");
let entry = obj.entry(key).or_insert_with(|| json!([]));
if !entry.is_array() {
*entry = json!([]);
}
entry.as_array_mut().expect("just ensured array")
}
fn our_hooks() -> Vec<(&'static str, &'static str, &'static str)> {
vec![
(
"SessionStart",
"*",
"claude-oops snap --trigger session-start --quiet",
),
(
"PreToolUse",
"Edit|Write|Bash",
"claude-oops _hook-pre-tool-use",
),
]
}
fn entry_is_ours(entry: &Value) -> bool {
entry
.get("hooks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter().any(|h| {
h.get("command")
.and_then(Value::as_str)
.map(|c| c.trim_start().starts_with("claude-oops"))
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn slash_command_path() -> Result<PathBuf> {
Ok(commands_dir()?.join("oops.md"))
}
fn install_slash_command() -> Result<PathBuf> {
let path = slash_command_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&path, OOPS_SLASH_COMMAND)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(path)
}
fn uninstall_slash_command() -> Result<Option<PathBuf>> {
let path = slash_command_path()?;
if !path.exists() {
return Ok(None);
}
let current = std::fs::read_to_string(&path).unwrap_or_default();
if current == OOPS_SLASH_COMMAND {
std::fs::remove_file(&path).ok();
Ok(Some(path))
} else {
Ok(None)
}
}
pub struct InstallReport {
pub settings: PathBuf,
pub slash_command: PathBuf,
}
pub fn install() -> Result<InstallReport> {
let settings = install_settings()?;
let slash_command = install_slash_command()?;
Ok(InstallReport {
settings,
slash_command,
})
}
fn install_settings() -> Result<PathBuf> {
let path = settings_path()?;
let mut settings = load_settings(&path)?;
if !settings.is_object() {
return Err(anyhow!(
"{} top-level is not a JSON object — refusing to overwrite",
path.display()
));
}
{
let obj = settings.as_object_mut().unwrap();
let h = obj.entry("hooks").or_insert_with(|| json!({}));
if !h.is_object() {
return Err(anyhow!(
"settings.hooks exists but is not an object — refusing to overwrite"
));
}
}
let hooks_obj = settings.get_mut("hooks").unwrap();
for (event, matcher, command) in our_hooks() {
let arr = ensure_array(hooks_obj, event);
arr.retain(|e| !entry_is_ours(e));
arr.push(json!({
"matcher": matcher,
"hooks": [{ "type": "command", "command": command }],
}));
}
save_settings(&path, &settings)?;
Ok(path)
}
pub struct UninstallReport {
pub settings: PathBuf,
pub removed_slash_command: Option<PathBuf>,
}
pub fn uninstall() -> Result<UninstallReport> {
let settings = settings_path()?;
if settings.exists() {
let mut s = load_settings(&settings)?;
if let Some(hooks_obj) = s.get_mut("hooks").and_then(|v| v.as_object_mut()) {
for arr in hooks_obj.values_mut() {
if let Some(items) = arr.as_array_mut() {
items.retain(|e| !entry_is_ours(e));
}
}
}
save_settings(&settings, &s)?;
}
let removed_slash_command = uninstall_slash_command()?;
Ok(UninstallReport {
settings,
removed_slash_command,
})
}
fn is_dangerous_bash(cmd: &str) -> bool {
let needles: &[&str] = &[
"rm -rf",
"rm -fr",
"rm -r ",
"rm -f ",
"rm -rf=",
"mv -f",
" dd ",
"git reset --hard",
"git clean",
"git checkout --",
"git checkout .",
"git restore .",
"> /dev/sd",
"mkfs",
"shred",
];
let normalized = format!(" {} ", cmd);
if needles.iter().any(|n| normalized.contains(n)) {
return true;
}
if normalized.contains(" find ") && normalized.contains(" -delete") {
return true;
}
if normalized.contains("xargs") && normalized.contains(" rm") {
return true;
}
false
}
fn classify_pre_tool_use(payload: &Value) -> Option<&'static str> {
let tool = payload.get("tool_name").and_then(Value::as_str)?;
match tool {
"Edit" | "Write" | "MultiEdit" | "NotebookEdit" => Some(snapshot::trigger::PRE_EDIT),
"Bash" => {
let cmd = payload
.get("tool_input")
.and_then(|t| t.get("command"))
.and_then(Value::as_str)
.unwrap_or("");
if is_dangerous_bash(cmd) {
Some(snapshot::trigger::PRE_BASH)
} else {
None
}
}
_ => None,
}
}
pub fn run_pre_tool_use_hook() -> Result<()> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf).ok();
let payload: Value = match serde_json::from_str(&buf) {
Ok(v) => v,
Err(_) => return Ok(()), };
let Some(trigger) = classify_pre_tool_use(&payload) else {
return Ok(());
};
let cwd = payload
.get("cwd")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let repo = match GitRepo::discover(&cwd) {
Ok(r) => r,
Err(_) => return Ok(()), };
if trigger == snapshot::trigger::PRE_EDIT {
if let Ok(recs) = storage::read_all(&repo) {
if let Some(last) = recs.last() {
let now = chrono::Utc::now().timestamp();
let age = now.saturating_sub(last.timestamp);
if age < EDIT_WRITE_COOLDOWN.as_secs() as i64 {
return Ok(());
}
}
}
}
let msg = match trigger {
t if t == snapshot::trigger::PRE_BASH => payload
.get("tool_input")
.and_then(|t| t.get("command"))
.and_then(Value::as_str)
.map(|c| truncate_one_line(c, 80)),
t if t == snapshot::trigger::PRE_EDIT => payload
.get("tool_input")
.and_then(|t| t.get("file_path"))
.and_then(Value::as_str)
.map(|p| p.to_string()),
_ => None,
};
let outcome = snapshot::snap(
&repo,
SnapOpts {
trigger,
message: msg,
force: false,
},
);
if let Ok(crate::snapshot::SnapOutcome::Created(rec)) = outcome {
let stats = if rec.clean {
"clean".to_string()
} else {
format!("+{}/-{}", rec.files_added, rec.files_deleted)
};
let suffix = rec
.message
.as_deref()
.map(|m| format!(" — {}", m))
.unwrap_or_default();
eprintln!(
"📸 claude-oops: {} ({}, {}){}",
rec.id, rec.trigger, stats, suffix
);
}
Ok(())
}
fn truncate_one_line(s: &str, max: usize) -> String {
let one_line: String = s.chars().take_while(|c| *c != '\n').collect();
if one_line.chars().count() <= max {
one_line
} else {
let mut out: String = one_line.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dangerous_bash_recognized() {
assert!(is_dangerous_bash("rm -rf node_modules"));
assert!(is_dangerous_bash("cd /tmp && rm -rf ./build"));
assert!(is_dangerous_bash("git reset --hard HEAD~5"));
assert!(is_dangerous_bash("git clean -fd"));
assert!(is_dangerous_bash("find . -name '*.log' -delete"));
assert!(is_dangerous_bash("ls | xargs rm"));
assert!(is_dangerous_bash("mkfs.ext4 /dev/sda1"));
}
#[test]
fn safe_bash_not_flagged() {
assert!(!is_dangerous_bash("ls -la"));
assert!(!is_dangerous_bash("cargo test"));
assert!(!is_dangerous_bash("git status"));
assert!(!is_dangerous_bash("npm install"));
assert!(!is_dangerous_bash("git reset --soft HEAD~1"));
}
#[test]
fn classify_edit_returns_pre_edit() {
let p = json!({"tool_name": "Edit", "tool_input": {}});
assert_eq!(classify_pre_tool_use(&p), Some("pre-edit"));
}
#[test]
fn classify_safe_bash_returns_none() {
let p = json!({"tool_name": "Bash", "tool_input": {"command": "ls"}});
assert_eq!(classify_pre_tool_use(&p), None);
}
#[test]
fn classify_dangerous_bash_returns_pre_bash() {
let p = json!({
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /tmp/foo"}
});
assert_eq!(classify_pre_tool_use(&p), Some("pre-bash"));
}
#[test]
fn entry_is_ours_detects_claude_oops_command() {
let ours = json!({
"matcher": "*",
"hooks": [{"type": "command", "command": "claude-oops snap"}]
});
let theirs = json!({
"matcher": "*",
"hooks": [{"type": "command", "command": "echo hello"}]
});
assert!(entry_is_ours(&ours));
assert!(!entry_is_ours(&theirs));
}
}