use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::{json, Map, Value};
const HOOK_SCRIPT: &str = r#"#!/usr/bin/env bash
# TmuxTango status hook for Claude Code
# Writes pane status to /tmp/tango-status/ for TmuxTango to read.
set -e
PANE_ID="${TMUX_PANE:-}"
[ -z "$PANE_ID" ] && exit 0
STATUS_DIR="/tmp/tango-status"
mkdir -p "$STATUS_DIR"
PANE_FILE="${STATUS_DIR}/${PANE_ID//%/_}"
INPUT=$(cat)
EVENT=$(printf '%s' "$INPUT" | sed -n 's/.*"hook_event_name":"\([^"]*\)".*/\1/p')
case "$EVENT" in
UserPromptSubmit|PreToolUse)
printf 'active' > "$PANE_FILE" ;;
Stop)
printf 'idle' > "$PANE_FILE" ;;
Notification)
TYPE=$(printf '%s' "$INPUT" | sed -n 's/.*"notification_type":"\([^"]*\)".*/\1/p')
case "$TYPE" in
permission_prompt|elicitation_dialog) printf 'waiting' > "$PANE_FILE" ;;
idle_prompt) printf 'idle' > "$PANE_FILE" ;;
esac ;;
esac
"#;
const TANGO_MARKER: &str = "_tango_managed";
fn hook_script_path() -> Result<PathBuf> {
let config_dir = dirs_or_home(".config/tango")?;
Ok(config_dir.join("status-hook.sh"))
}
fn claude_settings_path() -> Result<PathBuf> {
let home = home_dir()?;
Ok(home.join(".claude").join("settings.json"))
}
fn home_dir() -> Result<PathBuf> {
std::env::var("HOME")
.map(PathBuf::from)
.context("HOME environment variable not set")
}
fn dirs_or_home(relative: &str) -> Result<PathBuf> {
let home = home_dir()?;
Ok(home.join(relative))
}
fn tango_hook_entry(script_path: &Path) -> Value {
let command = script_path.to_string_lossy().to_string();
json!({
"type": "command",
"command": command
})
}
fn tango_rule(script_path: &Path, matcher: &str) -> Value {
json!({
TANGO_MARKER: true,
"matcher": matcher,
"hooks": [tango_hook_entry(script_path)]
})
}
fn read_settings(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(json!({}));
}
let content = fs::read_to_string(path).context("Failed to read Claude settings")?;
if content.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&content).context("Failed to parse Claude settings.json")
}
fn write_settings(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).context("Failed to create .claude directory")?;
}
let content = serde_json::to_string_pretty(value).context("Failed to serialize settings")?;
fs::write(path, content + "\n").context("Failed to write Claude settings")?;
Ok(())
}
fn strip_tango_rules(hooks: &mut Map<String, Value>) {
for (_event, rules) in hooks.iter_mut() {
if let Some(arr) = rules.as_array_mut() {
arr.retain(|rule| {
!rule.get(TANGO_MARKER).and_then(|v| v.as_bool()).unwrap_or(false)
});
}
}
hooks.retain(|_k, v| {
v.as_array().map_or(true, |a| !a.is_empty())
});
}
pub fn install() -> Result<()> {
install_quiet()?;
let script_path = hook_script_path()?;
let settings_path = claude_settings_path()?;
println!("Hook script installed to {}", script_path.display());
println!("Claude Code settings updated at {}", settings_path.display());
println!("\nStatus monitoring hooks are now active for new Claude Code sessions.");
println!("Existing sessions need to be restarted to pick up the hooks.");
Ok(())
}
pub fn ensure_installed() {
if is_installed().unwrap_or(false) {
return;
}
let _ = install_quiet();
}
fn is_installed() -> Result<bool> {
let script_path = hook_script_path()?;
if !script_path.exists() {
return Ok(false);
}
let settings_path = claude_settings_path()?;
if !settings_path.exists() {
return Ok(false);
}
let settings = read_settings(&settings_path)?;
Ok(settings
.get("hooks")
.and_then(|h| h.as_object())
.map_or(false, |hooks| {
hooks.values().any(|rules| {
rules.as_array().map_or(false, |arr| {
arr.iter().any(|rule| {
rule.get(TANGO_MARKER)
.and_then(|v| v.as_bool())
.unwrap_or(false)
})
})
})
}))
}
fn install_quiet() -> Result<()> {
let script_path = hook_script_path()?;
if let Some(parent) = script_path.parent() {
fs::create_dir_all(parent).context("Failed to create config directory")?;
}
fs::write(&script_path, HOOK_SCRIPT).context("Failed to write hook script")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
.context("Failed to set hook script permissions")?;
}
let settings_path = claude_settings_path()?;
let mut settings = read_settings(&settings_path)?;
let hooks = settings
.as_object_mut()
.context("Settings is not a JSON object")?
.entry("hooks")
.or_insert_with(|| json!({}))
.as_object_mut()
.context("hooks is not a JSON object")?;
strip_tango_rules(hooks);
let events: &[(&str, &str)] = &[
("UserPromptSubmit", ""),
("PreToolUse", ""),
("Stop", ""),
("Notification", "permission_prompt|idle_prompt|elicitation_dialog"),
];
for (event, matcher) in events {
let rule = tango_rule(&script_path, matcher);
let arr = hooks
.entry(*event)
.or_insert_with(|| json!([]))
.as_array_mut()
.context("Hook event entry is not an array")?;
arr.push(rule);
}
write_settings(&settings_path, &settings)?;
Ok(())
}
pub fn uninstall() -> Result<()> {
let settings_path = claude_settings_path()?;
if settings_path.exists() {
let mut settings = read_settings(&settings_path)?;
if let Some(hooks) = settings
.as_object_mut()
.and_then(|obj| obj.get_mut("hooks"))
.and_then(|h| h.as_object_mut())
{
strip_tango_rules(hooks);
if hooks.is_empty() {
settings.as_object_mut().unwrap().remove("hooks");
}
}
write_settings(&settings_path, &settings)?;
println!("Removed TmuxTango hooks from {}", settings_path.display());
}
let script_path = hook_script_path()?;
if script_path.exists() {
fs::remove_file(&script_path).context("Failed to remove hook script")?;
println!("Removed hook script at {}", script_path.display());
}
if let Some(parent) = script_path.parent() {
let _ = fs::remove_dir(parent); }
println!("\nTmuxTango hooks have been uninstalled.");
Ok(())
}
pub fn status() -> Result<()> {
let script_path = hook_script_path()?;
let script_installed = script_path.exists();
let settings_path = claude_settings_path()?;
let hooks_registered = if settings_path.exists() {
let settings = read_settings(&settings_path)?;
settings
.get("hooks")
.and_then(|h| h.as_object())
.map_or(false, |hooks| {
hooks.values().any(|rules| {
rules.as_array().map_or(false, |arr| {
arr.iter().any(|rule| {
rule.get(TANGO_MARKER)
.and_then(|v| v.as_bool())
.unwrap_or(false)
})
})
})
})
} else {
false
};
if script_installed && hooks_registered {
println!("TmuxTango hooks: installed");
println!(" Script: {}", script_path.display());
println!(" Settings: {}", settings_path.display());
} else if script_installed {
println!("TmuxTango hooks: partially installed (script exists but not registered)");
println!(" Run `tango hooks install` to complete installation.");
} else if hooks_registered {
println!("TmuxTango hooks: partially installed (registered but script missing)");
println!(" Run `tango hooks install` to complete installation.");
} else {
println!("TmuxTango hooks: not installed");
println!(" Run `tango hooks install` to enable hook-based status detection.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_tango_rule_has_marker() {
let rule = tango_rule(Path::new("/tmp/test.sh"), "");
assert_eq!(rule[TANGO_MARKER], true);
assert_eq!(rule["matcher"], "");
}
#[test]
fn test_strip_tango_rules_removes_only_tango() {
let mut hooks = Map::new();
hooks.insert(
"Stop".to_string(),
json!([
{ TANGO_MARKER: true, "matcher": "", "hooks": [] },
{ "matcher": "", "hooks": [{ "type": "command", "command": "other.sh" }] }
]),
);
strip_tango_rules(&mut hooks);
let stop = hooks.get("Stop").unwrap().as_array().unwrap();
assert_eq!(stop.len(), 1);
assert!(stop[0].get(TANGO_MARKER).is_none());
}
#[test]
fn test_strip_tango_rules_removes_empty_events() {
let mut hooks = Map::new();
hooks.insert(
"Stop".to_string(),
json!([{ TANGO_MARKER: true, "matcher": "", "hooks": [] }]),
);
strip_tango_rules(&mut hooks);
assert!(!hooks.contains_key("Stop"));
}
#[test]
fn test_read_settings_missing_file() {
let result = read_settings(Path::new("/tmp/nonexistent_tango_test_settings.json"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), json!({}));
}
#[test]
fn test_read_settings_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "").unwrap();
let result = read_settings(&path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), json!({}));
}
#[test]
fn test_read_write_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let value = json!({"hooks": {"Stop": []}});
write_settings(&path, &value).unwrap();
let read_back = read_settings(&path).unwrap();
assert_eq!(read_back, value);
}
}