tmux-tango 2.7.3

A CLI tool for managing tmux sessions - dance between your sessions!
Documentation
//! Claude Code hook installation for reliable pane status detection.
//!
//! Installs a shell script that Claude Code invokes on state transitions
//! (UserPromptSubmit, PreToolUse, Stop, Notification). The script writes
//! the current status to `/tmp/tango-status/{pane_id}`, which
//! `status::read_hook_status()` reads during each monitoring cycle.

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)]
    })
}

/// Read settings.json, creating it with `{}` if missing.
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(())
}

/// Remove all TmuxTango-managed rules from a hooks object.
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)
            });
        }
    }
    // Remove empty event arrays
    hooks.retain(|_k, v| {
        v.as_array().map_or(true, |a| !a.is_empty())
    });
}

/// Install the hook script and register it in Claude's settings.json.
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(())
}

/// Check if hooks are installed; if not, install silently.
/// Errors are suppressed — the TUI still works via heuristic fallback.
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)
                    })
                })
            })
        }))
}

/// Same as `install()` but without printing to stdout.
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(())
}

/// Remove TmuxTango hooks from Claude's settings and delete the hook script.
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);
            // Remove empty hooks object
            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());
    }

    // Clean up config dir if empty
    if let Some(parent) = script_path.parent() {
        let _ = fs::remove_dir(parent); // ignore error if not empty
    }

    println!("\nTmuxTango hooks have been uninstalled.");
    Ok(())
}

/// Show whether hooks are currently installed.
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);
    }
}