whetstone-cli 3.4.0

Installer and CLI for Claude Code token optimization (Headroom + RTK + Memory)
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::Command;

use crate::ui;
use crate::version;

const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";

#[derive(Debug, PartialEq, Eq)]
pub enum InstallMethod {
    NativeBinary,
    Npm,
    Unknown,
}

#[derive(Deserialize)]
struct NpmLatest {
    version: String,
}

pub fn installed_version() -> Option<String> {
    let output = Command::new("claude").arg("--version").output().ok()?;
    if !output.status.success() {
        return None;
    }
    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
    parse_claude_version(&raw)
}

fn parse_claude_version(raw: &str) -> Option<String> {
    let first_token = raw.split_whitespace().next()?;
    version::extract_semver(first_token)
}

pub fn latest_npm_version() -> Option<String> {
    let resp = ureq::get(NPM_REGISTRY_URL)
        .set("Accept", "application/json")
        .call()
        .ok()?;
    let body = resp.into_string().ok()?;
    parse_npm_response(&body)
}

fn parse_npm_response(body: &str) -> Option<String> {
    let parsed: NpmLatest = serde_json::from_str(body).ok()?;
    version::extract_semver(&parsed.version)
}

pub fn install_method() -> InstallMethod {
    detect_install_method_from_path(resolve_claude_binary())
}

fn resolve_claude_binary() -> Option<PathBuf> {
    let output = Command::new("which").arg("claude").output().ok()?;
    if !output.status.success() {
        return None;
    }
    let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let path = PathBuf::from(&path_str);
    fs::canonicalize(&path).ok().or(Some(path))
}

fn detect_install_method_from_path(binary_path: Option<PathBuf>) -> InstallMethod {
    let Some(path) = binary_path else {
        return InstallMethod::Unknown;
    };
    let path_str = path.to_string_lossy();

    if path_str.contains(".local/share/claude/versions/") {
        return InstallMethod::NativeBinary;
    }

    if path_str.contains("node_modules")
        || path_str.contains("/node/")
        || path_str.contains("/nodejs/")
        || path_str.contains("/mise/installs/node/")
        || path_str.contains("/nvm/versions/node/")
        || path_str.contains("/fnm/node-versions/")
        || path_str.contains("/.volta/")
    {
        return InstallMethod::Npm;
    }

    InstallMethod::Unknown
}

pub fn update() -> Result<ui::ComponentStatus> {
    let Some(old_ver) = installed_version() else {
        return Ok(ui::ComponentStatus::NotInstalled);
    };

    let method = install_method();

    let result = match method {
        InstallMethod::NativeBinary => update_from_native(&old_ver),
        InstallMethod::Npm | InstallMethod::Unknown => update_via_npm(&old_ver),
    };

    ensure_config_install_method_is_npm();

    result
}

fn update_from_native(old_ver: &str) -> Result<ui::ComponentStatus> {
    ui::info("native binary detected — installing via npm to fix stale update channel");

    run_npm_install()?;
    cleanup_native_install();

    let new_ver = installed_version().unwrap_or_else(|| old_ver.to_string());
    if new_ver != old_ver {
        Ok(ui::ComponentStatus::Updated(old_ver.to_string(), new_ver))
    } else {
        Ok(ui::ComponentStatus::UpToDate(old_ver.to_string()))
    }
}

fn update_via_npm(old_ver: &str) -> Result<ui::ComponentStatus> {
    run_npm_install()?;

    let new_ver = installed_version().unwrap_or_else(|| old_ver.to_string());
    if new_ver != old_ver {
        Ok(ui::ComponentStatus::Updated(old_ver.to_string(), new_ver))
    } else {
        Ok(ui::ComponentStatus::UpToDate(old_ver.to_string()))
    }
}

fn run_npm_install() -> Result<()> {
    let output = Command::new("npm")
        .args(["install", "-g", "@anthropic-ai/claude-code"])
        .output()
        .context("failed to run npm install")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("npm install -g @anthropic-ai/claude-code failed: {stderr}");
    }

    Ok(())
}

fn cleanup_native_install() {
    let Some(home) = dirs::home_dir() else {
        return;
    };

    let symlink = home.join(".local/bin/claude");
    let versions_dir = home.join(".local/share/claude");

    if symlink.is_symlink() || symlink.exists() {
        if let Ok(target) = fs::read_link(&symlink) {
            if target
                .to_string_lossy()
                .contains(".local/share/claude/versions/")
            {
                ui::info("removing stale native binary symlink (~/.local/bin/claude)");
                let _ = fs::remove_file(&symlink);
            }
        }
    }

    if versions_dir.is_dir() {
        ui::info("removing stale native versions directory (~/.local/share/claude/)");
        let _ = fs::remove_dir_all(&versions_dir);
    }
}

fn ensure_config_install_method_is_npm() {
    let Some(home) = dirs::home_dir() else {
        return;
    };
    fix_install_method_in_config(&home);
}

fn fix_install_method_in_config(home: &std::path::Path) {
    let config_path = home.join(".claude.json");

    let content = match fs::read_to_string(&config_path) {
        Ok(c) => c,
        Err(_) => return,
    };

    let mut config: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return,
    };

    let obj = match config.as_object_mut() {
        Some(o) => o,
        None => return,
    };

    if obj.get("installMethod").and_then(|v| v.as_str()) == Some("npm") {
        return;
    }

    obj.insert(
        "installMethod".to_string(),
        serde_json::Value::String("npm".to_string()),
    );

    ui::info("fixing installMethod in ~/.claude.json (native → npm)");
    let _ = fs::write(
        &config_path,
        serde_json::to_string_pretty(&config).unwrap_or_default(),
    );
}

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

    #[test]
    fn parse_claude_version_standard_format() {
        assert_eq!(
            parse_claude_version("2.1.153 (Claude Code)"),
            Some("2.1.153".into()),
        );
    }

    #[test]
    fn parse_claude_version_bare() {
        assert_eq!(parse_claude_version("2.1.172"), Some("2.1.172".into()));
    }

    #[test]
    fn parse_claude_version_empty() {
        assert_eq!(parse_claude_version(""), None);
    }

    #[test]
    fn parse_claude_version_garbage() {
        assert_eq!(parse_claude_version("not a version"), None);
    }

    #[test]
    fn parse_npm_response_valid() {
        let body = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.172"}"#;
        assert_eq!(parse_npm_response(body), Some("2.1.172".into()));
    }

    #[test]
    fn parse_npm_response_invalid_json() {
        assert_eq!(parse_npm_response("{broken"), None);
    }

    #[test]
    fn parse_npm_response_missing_version() {
        assert_eq!(parse_npm_response(r#"{"name":"foo"}"#), None);
    }

    #[test]
    fn detect_native_binary_path() {
        let path = Some(PathBuf::from(
            "/home/user/.local/share/claude/versions/2.1.153",
        ));
        assert_eq!(
            detect_install_method_from_path(path),
            InstallMethod::NativeBinary,
        );
    }

    #[test]
    fn detect_npm_via_mise() {
        let path = Some(PathBuf::from(
            "/home/user/.local/share/mise/installs/node/24.11.1/bin/claude",
        ));
        assert_eq!(detect_install_method_from_path(path), InstallMethod::Npm);
    }

    #[test]
    fn detect_npm_via_nvm() {
        let path = Some(PathBuf::from(
            "/home/user/.nvm/versions/node/v22.0.0/bin/claude",
        ));
        assert_eq!(detect_install_method_from_path(path), InstallMethod::Npm);
    }

    #[test]
    fn detect_npm_via_node_modules() {
        let path = Some(PathBuf::from(
            "/usr/lib/node_modules/@anthropic-ai/claude-code/bin/claude",
        ));
        assert_eq!(detect_install_method_from_path(path), InstallMethod::Npm);
    }

    #[test]
    fn detect_npm_via_volta() {
        let path = Some(PathBuf::from("/home/user/.volta/bin/claude"));
        assert_eq!(detect_install_method_from_path(path), InstallMethod::Npm);
    }

    #[test]
    fn detect_unknown_when_no_binary() {
        assert_eq!(
            detect_install_method_from_path(None),
            InstallMethod::Unknown
        );
    }

    #[test]
    fn detect_unknown_for_unrecognized_path() {
        let path = Some(PathBuf::from("/opt/custom/bin/claude"));
        assert_eq!(
            detect_install_method_from_path(path),
            InstallMethod::Unknown,
        );
    }

    #[test]
    fn fix_install_method_rewrites_native_to_npm() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join(".claude.json");
        fs::write(&config, r#"{"installMethod":"native","numStartups":10}"#).unwrap();

        fix_install_method_in_config(dir.path());

        let result: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&config).unwrap()).unwrap();
        assert_eq!(result["installMethod"], "npm");
        assert_eq!(result["numStartups"], 10);
    }

    #[test]
    fn fix_install_method_noop_when_already_npm() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join(".claude.json");
        let original = r#"{"installMethod":"npm","numStartups":5}"#;
        fs::write(&config, original).unwrap();

        fix_install_method_in_config(dir.path());

        let after = fs::read_to_string(&config).unwrap();
        assert_eq!(after, original, "file should not be rewritten");
    }

    #[test]
    fn fix_install_method_adds_field_when_missing() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join(".claude.json");
        fs::write(&config, r#"{"numStartups":1}"#).unwrap();

        fix_install_method_in_config(dir.path());

        let result: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&config).unwrap()).unwrap();
        assert_eq!(result["installMethod"], "npm");
    }

    #[test]
    fn fix_install_method_noop_when_no_config_file() {
        let dir = tempfile::tempdir().unwrap();
        fix_install_method_in_config(dir.path());
        assert!(!dir.path().join(".claude.json").exists());
    }

    #[test]
    fn fix_install_method_noop_for_invalid_json() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join(".claude.json");
        let garbage = "not json {{{";
        fs::write(&config, garbage).unwrap();

        fix_install_method_in_config(dir.path());

        assert_eq!(fs::read_to_string(&config).unwrap(), garbage);
    }
}