whetstone-cli 3.2.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();

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

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

#[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,
        );
    }
}