use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde_json::Value;
const PENDING_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PendingApproval {
pub uuid: String,
pub session_id: String,
pub cwd: PathBuf,
pub tool_name: String,
pub tool_input_brief: String,
pub tool_input_full: String,
pub created_at: SystemTime,
pub pending_path: PathBuf,
}
impl PendingApproval {
pub fn tool_input_detail(&self) -> String {
let Ok(v) = serde_json::from_str::<Value>(&self.tool_input_full) else {
return self.tool_input_full.clone();
};
if let Some(cmd) = v.get("command").and_then(|s| s.as_str()) {
let desc = v
.get("description")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty());
return match desc {
Some(d) => format!("{cmd}\n# {d}"),
None => cmd.to_string(),
};
}
if let Some(path) = v.get("file_path").and_then(|s| s.as_str()) {
let detail = v
.get("old_string")
.or_else(|| v.get("content"))
.or_else(|| v.get("new_string"))
.and_then(|s| s.as_str());
return match detail {
Some(d) => format!("{path}\n---\n{d}"),
None => path.to_string(),
};
}
if let Some(url) = v.get("url").and_then(|s| s.as_str()) {
return url.to_string();
}
if let Some(s) = v.as_str() {
return s.to_string();
}
self.tool_input_full.clone()
}
}
pub fn triage_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join(".claude/triage")
}
pub fn pending_dir() -> PathBuf {
triage_dir().join("pending")
}
pub fn decisions_dir() -> PathBuf {
triage_dir().join("decisions")
}
pub fn claims_dir() -> PathBuf {
triage_dir().join("claims")
}
pub fn alive_file() -> PathBuf {
triage_dir().join(".alive")
}
pub fn read_default_model() -> Option<String> {
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home).join(".claude/settings.json");
let bytes = fs::read(&path).ok()?;
let v: Value = serde_json::from_slice(&bytes).ok()?;
v.get("model")?.as_str().map(|s| s.to_string())
}
pub fn write_claim(uuid: &str) {
let dir = claims_dir();
let _ = fs::create_dir_all(&dir);
let _ = fs::write(dir.join(format!("{uuid}.json")), b"{}");
}
pub fn remove_claim(uuid: &str) {
let _ = fs::remove_file(claims_dir().join(format!("{uuid}.json")));
}
#[derive(Debug)]
pub struct AliveRecord {
pub pid: u32,
pub pane_id: Option<String>,
}
pub struct AliveGuard;
impl AliveGuard {
pub fn install() -> Self {
let dir = triage_dir();
let _ = fs::create_dir_all(&dir);
let _ = fs::write(alive_file(), std::process::id().to_string());
if let Some(pane) = current_pane_id() {
crate::persist::save_last_pane_id(&pane);
}
AliveGuard
}
}
impl Drop for AliveGuard {
fn drop(&mut self) {
let _ = fs::remove_file(alive_file());
}
}
pub fn read_alive_record() -> Option<AliveRecord> {
let content = fs::read_to_string(alive_file()).ok()?;
let pid = content.trim().parse().ok()?;
Some(AliveRecord {
pid,
pane_id: crate::persist::read_last_pane_id(),
})
}
fn current_pane_id() -> Option<String> {
std::env::var("TMUX_PANE").ok()
}
pub fn read_pending() -> Vec<PendingApproval> {
let dir = pending_dir();
let Ok(entries) = fs::read_dir(&dir) else {
return Vec::new();
};
let now = SystemTime::now();
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(uuid) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let created_at = entry
.metadata()
.and_then(|m| m.created().or_else(|_| m.modified()))
.unwrap_or(now);
if now
.duration_since(created_at)
.is_ok_and(|age| age > PENDING_TTL)
{
let _ = fs::remove_file(&path);
let _ = fs::remove_file(decisions_dir().join(format!("{uuid}.json")));
continue;
}
let Ok(bytes) = fs::read(&path) else { continue };
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
let session_id = v
.get("session_id")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let cwd = v
.get("cwd")
.and_then(|s| s.as_str())
.map(PathBuf::from)
.unwrap_or_default();
let tool_name = v
.get("tool_name")
.and_then(|s| s.as_str())
.unwrap_or("?")
.to_string();
let tool_input_brief = brief_tool_input(v.get("tool_input"));
let tool_input_full = v
.get("tool_input")
.map(|val| {
if let Some(s) = val.as_str() {
s.to_string()
} else {
val.to_string()
}
})
.unwrap_or_default();
out.push(PendingApproval {
uuid: uuid.to_string(),
session_id,
cwd,
tool_name,
tool_input_brief,
tool_input_full,
created_at,
pending_path: path,
});
}
out.sort_by_key(|p| p.created_at);
out
}
pub fn approve(uuid: &str) {
write_decision(uuid, r#"{"decision":"approve"}"#);
}
pub fn deny(uuid: &str, reason: &str) {
let payload = serde_json::json!({
"decision": "block",
"reason": reason,
});
write_decision(uuid, &payload.to_string());
}
fn write_decision(uuid: &str, body: &str) {
let dir = decisions_dir();
let _ = fs::create_dir_all(&dir);
let path = dir.join(format!("{uuid}.json"));
let _ = fs::write(path, body);
}
pub fn brief_tool_input(input: Option<&Value>) -> String {
let Some(input) = input else {
return String::new();
};
if let Some(cmd) = input.get("command").and_then(|s| s.as_str()) {
let desc = input
.get("description")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty());
return match desc {
Some(d) => truncate(&format!("{cmd} · {d}"), 400),
None => truncate(cmd, 400),
};
}
if let Some(path) = input.get("file_path").and_then(|s| s.as_str()) {
let detail = input
.get("old_string")
.or_else(|| input.get("content"))
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty())
.map(|s| truncate(s, 120));
return match detail {
Some(d) => truncate(&format!("{path} · {d}"), 400),
None => truncate(path, 200),
};
}
if let Some(url) = input.get("url").and_then(|s| s.as_str()) {
return truncate(url, 200);
}
if let Some(s) = input.as_str() {
return truncate(s, 200);
}
truncate(&input.to_string(), 200)
}
pub fn truncate(s: &str, n: usize) -> String {
let s = s.replace('\n', " ");
if s.chars().count() <= n {
s
} else {
let mut out: String = s.chars().take(n).collect();
out.push('…');
out
}
}
pub fn attach_to_sessions(
approvals: Vec<PendingApproval>,
sessions: &mut [crate::models::Session],
) {
for a in approvals {
if let Some(idx) = sessions.iter().position(|s| {
s.provider == crate::models::Provider::Claude && s.session_id == a.session_id
}) {
sessions[idx].pending_approvals.push(a);
continue;
}
let cwd_matches: Vec<usize> = sessions
.iter()
.enumerate()
.filter_map(|(i, s)| {
(s.provider == crate::models::Provider::Claude && s.cwd == a.cwd).then_some(i)
})
.collect();
let chosen = cwd_matches
.iter()
.copied()
.find(|&i| sessions[i].pane.as_ref().is_some_and(|p| p.active))
.or_else(|| cwd_matches.first().copied());
if let Some(idx) = chosen {
sessions[idx].pending_approvals.push(a);
}
}
}
fn settings_json_path() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude/settings.json"))
}
const HOOK_SCRIPT: &str = include_str!("../scripts/hooks/triage-preuse.sh");
fn hook_install_path() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config/triage/hooks/triage-preuse.sh"))
}
fn is_triage_hook_command(cmd: &str) -> bool {
Path::new(cmd)
.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "triage-preuse.sh")
.unwrap_or(false)
}
fn write_hook_script(path: &Path, dry_run: bool) -> io::Result<bool> {
let need_write = match fs::read_to_string(path) {
Ok(existing) => existing != HOOK_SCRIPT,
Err(_) => true,
};
if !need_write && is_executable(path) {
return Ok(false);
}
if dry_run {
println!(
"DRY RUN — would write {} ({} bytes)",
path.display(),
HOOK_SCRIPT.len()
);
return Ok(true);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, HOOK_SCRIPT)?;
set_executable(path)?;
Ok(true)
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn is_executable(_path: &Path) -> bool {
true
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> io::Result<()> {
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOutcome {
Installed,
AlreadyInstalled,
Removed,
NotFound,
}
pub fn print_install_hint() {
let path = hook_install_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/triage/hooks/triage-preuse.sh".to_string());
println!("Run `triage --install-hooks` to install. Or, to merge by hand,");
println!(
"first write the bash hook to {} and then add the following to ~/.claude/settings.json:",
path
);
println!();
println!("{{");
println!(" \"hooks\": {{");
println!(" \"PreToolUse\": [");
println!(" {{");
println!(" \"matcher\": \".*\",");
println!(" \"hooks\": [");
println!(" {{ \"type\": \"command\", \"command\": \"{path}\" }}");
println!(" ]");
println!(" }}");
println!(" ]");
println!(" }}");
println!("}}");
println!();
println!("Or merge automatically: `triage --install-hooks` (add `--dry-run` to preview).");
}
pub fn install_hooks(dry_run: bool) -> io::Result<InstallOutcome> {
let script = hook_install_path()
.ok_or_else(|| io::Error::other("HOME is unset; cannot locate install path"))?;
let script_str = script.display().to_string();
let path = settings_json_path()
.ok_or_else(|| io::Error::other("HOME is unset; cannot locate settings.json"))?;
let original = read_settings_json(&path)?;
let (modified, outcome) = apply_install(&original, &script_str);
let script_changed = write_hook_script(&script, dry_run)?;
match outcome {
InstallOutcome::AlreadyInstalled if !script_changed => {
println!(
"triage hook already installed at {} (no changes)",
script_str
);
}
InstallOutcome::AlreadyInstalled => {
println!(
"Refreshed hook script at {} (settings.json unchanged)",
script_str
);
}
InstallOutcome::Installed if dry_run => {
println!("DRY RUN — would update {}:", path.display());
println!();
println!("{}", serde_json::to_string_pretty(&modified)?);
}
InstallOutcome::Installed => {
write_settings_json(&path, &modified)?;
println!("Installed triage hook in {}", path.display());
println!(" hook script: {}", script_str);
println!(" backup: {}.bak", path.display());
}
_ => {}
}
Ok(outcome)
}
pub fn uninstall_hooks(dry_run: bool) -> io::Result<InstallOutcome> {
let script = hook_install_path()
.ok_or_else(|| io::Error::other("HOME is unset; cannot locate install path"))?;
let path = settings_json_path()
.ok_or_else(|| io::Error::other("HOME is unset; cannot locate settings.json"))?;
let original = read_settings_json(&path)?;
let (modified, outcome) = apply_uninstall(&original);
let script_present = script.exists();
match outcome {
InstallOutcome::NotFound if !script_present => {
println!("triage hook not present in {} (no changes)", path.display());
}
InstallOutcome::NotFound if dry_run => {
println!("DRY RUN — would remove hook script {}", script.display());
}
InstallOutcome::NotFound => {
fs::remove_file(&script)?;
println!("Removed orphan hook script {}", script.display());
}
InstallOutcome::Removed if dry_run => {
println!("DRY RUN — would update {}:", path.display());
println!();
println!("{}", serde_json::to_string_pretty(&modified)?);
if script_present {
println!();
println!("DRY RUN — would remove hook script {}", script.display());
}
}
InstallOutcome::Removed => {
write_settings_json(&path, &modified)?;
println!("Removed triage hook from {}", path.display());
println!(" backup: {}.bak", path.display());
if script_present {
fs::remove_file(&script)?;
println!("Removed hook script {}", script.display());
}
}
_ => {}
}
Ok(outcome)
}
fn read_settings_json(path: &Path) -> io::Result<Value> {
if !path.exists() {
return Ok(Value::Object(serde_json::Map::new()));
}
let bytes = fs::read(path)?;
if bytes.iter().all(|b| b.is_ascii_whitespace()) {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_slice(&bytes)
.map_err(|e| io::Error::other(format!("failed to parse {}: {e}", path.display())))
}
fn write_settings_json(path: &Path, v: &Value) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if path.exists() {
let backup = path.with_extension("json.bak");
fs::copy(path, &backup)?;
}
let mut body = serde_json::to_string_pretty(v)?;
body.push('\n');
fs::write(path, body)?;
Ok(())
}
fn apply_install(input: &Value, canonical_path: &str) -> (Value, InstallOutcome) {
let stale_present = stale_triage_entries(input, canonical_path);
let canonical_present = canonical_hook_present(input, canonical_path);
if canonical_present && !stale_present {
return (input.clone(), InstallOutcome::AlreadyInstalled);
}
let mut root = if input.is_object() {
input.clone()
} else {
return (
make_fresh_settings(canonical_path),
InstallOutcome::Installed,
);
};
if stale_present || canonical_present {
let (purged, _) = apply_uninstall(&root);
root = purged;
}
let root_obj = root.as_object_mut().unwrap();
let hooks = root_obj
.entry("hooks".to_string())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !hooks.is_object() {
return (
make_fresh_settings(canonical_path),
InstallOutcome::Installed,
);
}
let hooks_obj = hooks.as_object_mut().unwrap();
let pre = hooks_obj
.entry("PreToolUse".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !pre.is_array() {
return (
make_fresh_settings(canonical_path),
InstallOutcome::Installed,
);
}
let pre_arr = pre.as_array_mut().unwrap();
pre_arr.push(serde_json::json!({
"matcher": ".*",
"hooks": [
{ "type": "command", "command": canonical_path }
]
}));
(root, InstallOutcome::Installed)
}
fn apply_uninstall(input: &Value) -> (Value, InstallOutcome) {
if !any_triage_hook_present(input) {
return (input.clone(), InstallOutcome::NotFound);
}
let mut root = input.clone();
let mut removed = false;
if let Some(root_obj) = root.as_object_mut()
&& let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut())
{
if let Some(pre) = hooks.get_mut("PreToolUse").and_then(|p| p.as_array_mut()) {
for group in pre.iter_mut() {
if let Some(group_hooks) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) {
let before = group_hooks.len();
group_hooks.retain(|h| {
h.get("command")
.and_then(|c| c.as_str())
.map(|c| !is_triage_hook_command(c))
.unwrap_or(true)
});
if group_hooks.len() != before {
removed = true;
}
}
}
pre.retain(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.map(|a| !a.is_empty())
.unwrap_or(true)
});
if pre.is_empty() {
hooks.remove("PreToolUse");
}
}
if hooks.is_empty() {
root_obj.remove("hooks");
}
}
if removed {
(root, InstallOutcome::Removed)
} else {
(input.clone(), InstallOutcome::NotFound)
}
}
fn any_triage_hook_present(v: &Value) -> bool {
triage_hook_commands(v).next().is_some()
}
fn canonical_hook_present(v: &Value, canonical: &str) -> bool {
triage_hook_commands(v).any(|c| paths_equivalent(c, canonical))
}
fn stale_triage_entries(v: &Value, canonical: &str) -> bool {
triage_hook_commands(v).any(|c| !paths_equivalent(c, canonical))
}
fn triage_hook_commands(v: &Value) -> impl Iterator<Item = &str> {
v.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(|p| p.as_array())
.into_iter()
.flat_map(|groups| groups.iter())
.filter_map(|g| g.get("hooks").and_then(|h| h.as_array()))
.flat_map(|hs| hs.iter())
.filter_map(|h| h.get("command").and_then(|c| c.as_str()))
.filter(|c| is_triage_hook_command(c))
}
fn paths_equivalent(a: &str, b: &str) -> bool {
let a_exp = expand_tilde(a);
let b_exp = expand_tilde(b);
if a_exp == b_exp {
return true;
}
if let (Ok(a_can), Ok(b_can)) = (
Path::new(&a_exp).canonicalize(),
Path::new(&b_exp).canonicalize(),
) {
return a_can == b_can;
}
false
}
fn expand_tilde(p: &str) -> String {
if let Some(rest) = p.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest).display().to_string();
}
p.to_string()
}
fn make_fresh_settings(script: &str) -> Value {
serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{ "type": "command", "command": script }
]
}
]
}
})
}