whetstone-cli 3.5.2

Installer and CLI for Claude Code token optimization (Headroom + RTK + Memory)
mod changelog;
mod claude_code;
mod cli;
mod config;
mod dashboard;
mod db;
mod doctor;
mod headroom;
mod integrations;
mod memory;
mod migrate;
mod preflight;
mod release;
mod rtk;
mod settings;
mod setup;
mod shell;
mod stats;
mod ui;
mod uninstall;
mod update;
mod version;
mod wizard;
mod wrapper;

use clap::Parser;
use cli::{Cli, Command};

fn show_upgrade_banner(cmd: &Option<Command>) {
    let skip = matches!(
        cmd,
        Some(
            Command::Update { .. }
                | Command::Version
                | Command::Dashboard
                | Command::Stats
                | Command::Migrate { .. }
        )
    );
    if skip {
        return;
    }
    let outdated = update::check_cached_upgrade();
    let v2_project = migrate::detect()
        .map(|d| d.needs_migration())
        .unwrap_or(false);
    ui::upgrade_banner(&outdated, v2_project);
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetupCheck {
    AlreadyConfigured,
    NotInteractive,
    NeedsSetup { offered: bool },
}

fn check_needs_setup(manifest_exists: bool, interactive: bool) -> SetupCheck {
    if manifest_exists {
        return SetupCheck::AlreadyConfigured;
    }
    if !interactive {
        return SetupCheck::NotInteractive;
    }
    SetupCheck::NeedsSetup { offered: false }
}

fn maybe_offer_setup() -> bool {
    let cwd = match std::env::current_dir() {
        Ok(d) => d,
        Err(_) => return false,
    };

    let manifest_exists = config::WhetstoneManifest::path_for(&cwd).exists();
    let interactive = ui::is_interactive();

    match check_needs_setup(manifest_exists, interactive) {
        SetupCheck::AlreadyConfigured | SetupCheck::NotInteractive => false,
        SetupCheck::NeedsSetup { .. } => {
            let project_name = cwd
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("this project");

            let prompt = format!("Set up whetstone for {project_name}?");
            if !ui::confirm(&prompt, true) {
                return true;
            }

            if let Err(e) = setup::run(false, "all") {
                ui::fail(&format!("{e:#}"));
            }
            false
        }
    }
}

fn main() {
    let cli = Cli::parse();

    show_upgrade_banner(&cli.command);

    match cli.command {
        None => {
            if maybe_offer_setup() {
                return;
            }
            wrapper::wrap_claude(&[]);
        }
        Some(cmd) => match cmd {
            Command::Setup {
                full,
                headroom_extras,
            } => {
                if let Err(e) = setup::run(full, &headroom_extras) {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Uninstall => {
                if let Err(e) = uninstall::run() {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Claude { args } | Command::Code { args } => {
                if maybe_offer_setup() {
                    return;
                }
                wrapper::wrap_claude(&args);
            }
            Command::Proxy { args } => {
                if maybe_offer_setup() {
                    return;
                }
                wrapper::wrap_proxy(&args);
            }
            Command::Rtk { args } => {
                if maybe_offer_setup() {
                    return;
                }
                wrapper::wrap_rtk(&args);
            }
            Command::Version => {
                let outdated = update::check_cached_upgrade();
                let is_outdated = |name: &str| outdated.iter().any(|c| c.name == name);

                let entries = vec![
                    ui::VersionEntry {
                        name: "whetstone",
                        version: Some(version::current().to_string()),
                        outdated: is_outdated("whetstone"),
                    },
                    ui::VersionEntry {
                        name: "claude code",
                        version: claude_code::installed_version(),
                        outdated: is_outdated("claude code"),
                    },
                    ui::VersionEntry {
                        name: "headroom",
                        version: headroom::installed_version(),
                        outdated: is_outdated("headroom"),
                    },
                    ui::VersionEntry {
                        name: "rtk",
                        version: rtk::installed_version(),
                        outdated: is_outdated("rtk"),
                    },
                ];
                ui::version_report(&entries);
            }
            Command::Update { full } => {
                if let Err(e) = update::run(full) {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Dashboard => {
                if let Err(e) = dashboard::run() {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Settings => {
                if let Err(e) = settings::run() {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Doctor => {
                if let Err(e) = doctor::run() {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Release { action } => {
                if let Err(e) = release::run(&action) {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::ReleasePublish { action } => {
                if let Err(e) = release::run_publish(&action) {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::ChangelogSync {
                input,
                output,
                limit,
            } => {
                if let Err(e) = run_changelog_sync(input, output, limit) {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Stats => {
                if let Err(e) = stats::run() {
                    ui::fail(&format!("{e:#}"));
                }
            }
            Command::Db { action } => {
                if let Err(e) = db::dispatch(action) {
                    ui::fail(&e.to_string());
                }
            }
            Command::Migrate {
                dry_run,
                yes,
                rollback,
            } => {
                let result = match rollback {
                    Some(id) => migrate::rollback(&id),
                    None => migrate::run(migrate::MigrateOptions { dry_run, yes }),
                };
                if let Err(e) = result {
                    ui::fail(&format!("{e:#}"));
                }
            }
        },
    }
}

fn run_changelog_sync(
    input: Option<String>,
    output: Option<String>,
    limit: usize,
) -> anyhow::Result<()> {
    let cwd = std::env::current_dir()?;
    let input_path = input
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| cwd.join("CHANGELOG.md"));
    let output_path = output
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| cwd.join("site/src/changelog.js"));

    let entries = changelog::parse_file(&input_path, limit)?;
    let js = changelog::render_js(&entries);

    if let Some(parent) = output_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&output_path, js)?;

    ui::ok(&format!(
        "changelog: {} -> {} ({} releases)",
        input_path.display(),
        output_path.display(),
        entries.len()
    ));
    Ok(())
}

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

    #[test]
    fn already_configured_skips_prompt() {
        assert_eq!(check_needs_setup(true, true), SetupCheck::AlreadyConfigured,);
    }

    #[test]
    fn already_configured_non_interactive_skips_prompt() {
        assert_eq!(
            check_needs_setup(true, false),
            SetupCheck::AlreadyConfigured,
        );
    }

    #[test]
    fn unconfigured_non_interactive_skips_prompt() {
        assert_eq!(check_needs_setup(false, false), SetupCheck::NotInteractive,);
    }

    #[test]
    fn unconfigured_interactive_needs_setup() {
        assert_eq!(
            check_needs_setup(false, true),
            SetupCheck::NeedsSetup { offered: false },
        );
    }
}