whetstone-cli 2.1.1

Installer and CLI for Claude Code token optimization (Headroom + RTK + Memory)
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::memory::MemoryProvider;
use crate::ui;

pub fn copy_hook_scripts(assets_hooks: &Path, dest_hooks: &Path) -> Result<()> {
    fs::create_dir_all(dest_hooks).with_context(|| format!("creating {}", dest_hooks.display()))?;

    let scripts = [
        "pre-tool-notify.sh",
        "pre-push.sh",
        "post-commit.sh",
        "session-start.sh",
        "session-end.sh",
    ];

    for script in &scripts {
        let src = assets_hooks.join(script);
        if !src.exists() {
            continue;
        }
        let dst = dest_hooks.join(script);
        fs::copy(&src, &dst)
            .with_context(|| format!("copying {script} to {}", dest_hooks.display()))?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&dst, fs::Permissions::from_mode(0o755))?;
        }
    }

    ui::ok(&format!("copied hook scripts to {}", dest_hooks.display()));
    Ok(())
}

pub fn merge_settings_json(
    settings_path: &Path,
    hooks_dir: &Path,
    provider: MemoryProvider,
) -> Result<()> {
    if settings_path.exists() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let backup = settings_path.with_file_name(format!("settings.json.bak.{ts}"));
        fs::copy(settings_path, &backup)
            .with_context(|| format!("backing up {}", settings_path.display()))?;
        ui::ok("backed up existing settings.json");
    }

    let existing: Value = if settings_path.exists() {
        let content = fs::read_to_string(settings_path)
            .with_context(|| format!("reading {}", settings_path.display()))?;
        serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
    } else {
        json!({})
    };

    let hd = hooks_dir.display().to_string();
    let merged = build_hooks_value(&existing, &hd, provider);

    let json_str = serde_json::to_string_pretty(&merged).context("serializing settings.json")?;

    if let Some(parent) = settings_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(settings_path, json_str)
        .with_context(|| format!("writing {}", settings_path.display()))?;

    ui::ok("all hooks registered in settings.json");
    Ok(())
}

fn build_hooks_value(existing: &Value, hd: &str, provider: MemoryProvider) -> Value {
    let mut result = existing.clone();

    let whetstone_hooks: Vec<(&str, Vec<Value>)> = vec![
        (
            "PreToolUse",
            vec![
                json!({
                    "matcher": "Bash",
                    "hooks": [{"type": "command", "command": format!("{hd}/rtk-rewrite.sh")}]
                }),
                json!({
                    "matcher": "Write|Edit|MultiEdit|Bash",
                    "hooks": [{"type": "command", "command": format!("{hd}/pre-tool-notify.sh"), "timeout": 10000}]
                }),
                json!({
                    "matcher": "Bash",
                    "hooks": [{"type": "command",
                        "command": format!("bash -c 'echo \"$CLAUDE_TOOL_INPUT\" | grep -q \"git push\" && {hd}/pre-push.sh || exit 0'"),
                        "timeout": 60000}]
                }),
            ],
        ),
        (
            "PostToolUse",
            vec![json!({
                "matcher": "Bash",
                "hooks": [{"type": "command",
                    "command": format!("bash -c 'echo \"$CLAUDE_TOOL_INPUT\" | grep -q \"git commit\" && {hd}/post-commit.sh || exit 0'"),
                    "timeout": 10000}]
            })],
        ),
        (
            "SessionStart",
            vec![json!({
                "hooks": [{"type": "command", "command": format!("{hd}/session-start.sh"), "timeout": 10000}]
            })],
        ),
        (
            "Stop",
            vec![json!({
                "hooks": [{"type": "command", "command": format!("{hd}/session-end.sh"), "timeout": 10000}]
            })],
        ),
    ];

    let old_hooks = result.get("hooks").cloned().unwrap_or_else(|| json!({}));
    let mut new_hooks = serde_json::Map::new();

    for (event, whetstone_entries) in &whetstone_hooks {
        let mut merged: Vec<Value> = old_hooks
            .get(*event)
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter(|entry| !entry_references_dir(entry, hd))
                    .cloned()
                    .collect()
            })
            .unwrap_or_default();
        merged.extend(whetstone_entries.iter().cloned());
        new_hooks.insert((*event).to_string(), Value::Array(merged));
    }

    if let Some(obj) = old_hooks.as_object() {
        for (key, val) in obj {
            if !new_hooks.contains_key(key) {
                new_hooks.insert(key.clone(), val.clone());
            }
        }
    }

    result["hooks"] = Value::Object(new_hooks);

    if provider == MemoryProvider::AutoMem {
        if result.get("mcpServers").is_none() {
            result["mcpServers"] = json!({});
        }
        result["mcpServers"]["memory"] = json!({
            "command": "npx",
            "args": ["-y", "@verygoodplugins/mcp-automem"]
        });
    }

    result
}

fn entry_references_dir(entry: &Value, dir: &str) -> bool {
    let s = entry.to_string();
    s.contains(dir)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn merge_into_empty_settings() {
        let existing = json!({});
        let result = build_hooks_value(&existing, "/home/user/.claude/hooks", MemoryProvider::Skip);

        let hooks = &result["hooks"];
        assert!(hooks["PreToolUse"].is_array());
        assert_eq!(hooks["PreToolUse"].as_array().unwrap().len(), 3);
        assert_eq!(hooks["PostToolUse"].as_array().unwrap().len(), 1);
        assert_eq!(hooks["SessionStart"].as_array().unwrap().len(), 1);
        assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn merge_preserves_existing_keys() {
        let existing = json!({
            "apiKey": "sk-test",
            "model": "claude-opus-4-6"
        });
        let result = build_hooks_value(&existing, "/tmp/hooks", MemoryProvider::Icm);

        assert_eq!(result["apiKey"], "sk-test");
        assert_eq!(result["model"], "claude-opus-4-6");
        assert!(result["hooks"].is_object());
    }

    #[test]
    fn hooks_use_absolute_paths() {
        let result =
            build_hooks_value(&json!({}), "/home/user/.claude/hooks", MemoryProvider::Skip);

        let rtk_cmd = result["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
            .as_str()
            .unwrap();
        assert!(rtk_cmd.starts_with("/home/user/.claude/hooks/"));
        assert!(rtk_cmd.ends_with("rtk-rewrite.sh"));
    }

    #[test]
    fn merge_preserves_non_whetstone_hooks() {
        let existing = json!({
            "hooks": {
                "PreToolUse": [{
                    "matcher": "Bash",
                    "hooks": [{"type": "command", "command": "/usr/local/bin/icm hook pre"}]
                }],
                "PreCompact": [{
                    "hooks": [{"type": "command", "command": "/usr/local/bin/icm hook compact"}]
                }]
            }
        });
        let result = build_hooks_value(&existing, "/home/user/.claude/hooks", MemoryProvider::Icm);

        let pre = result["hooks"]["PreToolUse"].as_array().unwrap();
        assert!(pre.iter().any(|e| e.to_string().contains("icm hook pre")));
        assert!(pre.iter().any(|e| e.to_string().contains("rtk-rewrite")));

        assert!(result["hooks"]["PreCompact"].is_array());
    }

    #[test]
    fn automem_adds_mcp_server() {
        let result = build_hooks_value(
            &json!({}),
            "/home/user/.claude/hooks",
            MemoryProvider::AutoMem,
        );

        assert!(result["mcpServers"]["memory"].is_object());
        assert_eq!(result["mcpServers"]["memory"]["command"], "npx");
    }

    #[test]
    fn skip_provider_no_mcp_servers() {
        let result =
            build_hooks_value(&json!({}), "/home/user/.claude/hooks", MemoryProvider::Skip);

        assert!(result.get("mcpServers").is_none());
    }
}