whetstone-cli 2.3.1

Installer and CLI for Claude Code token optimization (Headroom + RTK + Memory)
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Gauge, Paragraph},
    DefaultTerminal, Frame,
};
use std::io;
use std::time::Duration;

use crate::memory::MemoryProvider;
use crate::{headroom, hooks, preflight, rtk, setup, shell, ui, version};

const TOTAL_STEPS: usize = 7;

pub fn run(full: bool, headroom_extras: &str) -> Result<()> {
    welcome_screen()?;

    let assets = setup::resolve_assets_dir()?;
    ui::ok(&format!("assets at {}", assets.display()));

    step_progress(1, "Dependencies");
    preflight::check_all()?;

    step_progress(2, "Headroom");
    headroom::install(headroom_extras, full)?;

    step_progress(3, "RTK");
    rtk::install(full)?;

    step_progress(4, "Shell profile");
    shell::set_anthropic_base_url(setup::DEFAULT_PROXY)?;
    shell::ensure_path_contains_local_bin()?;

    step_progress(5, "Binary install");
    setup::self_install()?;

    step_progress(6, "Memory provider");
    let provider = setup::prompt_memory_provider(full)?;

    if provider != MemoryProvider::Skip {
        step_progress(7, "Assets & hooks");
        setup::install_general_assets(&assets, full, headroom_extras)?;
        setup::install_provider(provider)?;

        let claude_dir = dirs::home_dir()
            .context("could not determine home directory")?
            .join(".claude");
        let hooks_dir = claude_dir.join("hooks");
        let settings_path = claude_dir.join("settings.json");
        hooks::copy_hook_scripts(&assets.join("hooks"), &hooks_dir)?;
        hooks::merge_settings_json(&settings_path, &hooks_dir, provider)?;
        setup::generate_stack_setup(provider)?;
    } else {
        ui::info("skipped memory provider, skills, hooks, and STACK-SETUP.md");
    }

    completion_screen(provider)?;
    Ok(())
}

fn step_progress(step: usize, name: &str) {
    ui::section(&format!("Step {step}/{TOTAL_STEPS} — {name}"));
    render_gauge(step);
}

fn render_gauge(step: usize) {
    let ratio = (step.saturating_sub(1) as f64 / TOTAL_STEPS as f64).min(1.0);
    let label = format!(" {}/{TOTAL_STEPS} ", step.saturating_sub(1));

    let gauge = Gauge::default()
        .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
        .ratio(ratio)
        .label(Span::styled(
            label,
            Style::default().add_modifier(Modifier::BOLD),
        ));

    let backend = ratatui::backend::CrosstermBackend::new(io::stderr());
    if let Ok(mut terminal) = ratatui::Terminal::with_options(
        backend,
        ratatui::TerminalOptions {
            viewport: ratatui::Viewport::Inline(1),
        },
    ) {
        let _ = terminal.draw(|frame| {
            frame.render_widget(gauge, frame.area());
        });
    }
}

fn welcome_screen() -> Result<()> {
    let mut terminal = ratatui::init();
    let result = welcome_loop(&mut terminal);
    ratatui::restore();
    result
}

fn welcome_loop(terminal: &mut DefaultTerminal) -> Result<()> {
    loop {
        terminal.draw(draw_welcome)?;

        if event::poll(Duration::from_millis(250))? {
            if let Event::Key(key) = event::read()? {
                if key.kind != KeyEventKind::Press {
                    continue;
                }
                match key.code {
                    KeyCode::Enter | KeyCode::Char(' ') => return Ok(()),
                    KeyCode::Char('q') | KeyCode::Esc => {
                        anyhow::bail!("setup cancelled");
                    }
                    _ => {}
                }
            }
        }
    }
}

fn draw_welcome(frame: &mut Frame) {
    let area = centered_rect(52, 14, frame.area());

    let lines = vec![
        Line::from(""),
        Line::from(Span::styled(
            "  Whetstone Setup",
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from("  This wizard will install and configure:"),
        Line::from(""),
        Line::from(vec![
            Span::styled("    1. ", Style::default().add_modifier(Modifier::DIM)),
            Span::styled("Headroom", Style::default().add_modifier(Modifier::BOLD)),
            Span::raw(" — context compression proxy"),
        ]),
        Line::from(vec![
            Span::styled("    2. ", Style::default().add_modifier(Modifier::DIM)),
            Span::styled("RTK", Style::default().add_modifier(Modifier::BOLD)),
            Span::raw(" — CLI output compression"),
        ]),
        Line::from(vec![
            Span::styled("    3. ", Style::default().add_modifier(Modifier::DIM)),
            Span::styled("Memory", Style::default().add_modifier(Modifier::BOLD)),
            Span::raw(" — persistent session context"),
        ]),
        Line::from(""),
        Line::from(""),
        Line::from(Span::styled(
            "  Press Enter to begin, q to cancel",
            Style::default().add_modifier(Modifier::DIM),
        )),
        Line::from(""),
    ];

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan));
    let paragraph = Paragraph::new(lines).block(block);
    frame.render_widget(paragraph, area);
}

fn completion_screen(provider: MemoryProvider) -> Result<()> {
    let mut terminal = ratatui::init();
    let result = completion_loop(&mut terminal, provider);
    ratatui::restore();
    result
}

fn completion_loop(terminal: &mut DefaultTerminal, provider: MemoryProvider) -> Result<()> {
    loop {
        terminal.draw(|frame| draw_completion(frame, provider))?;

        if event::poll(Duration::from_millis(250))? {
            if let Event::Key(key) = event::read()? {
                if key.kind != KeyEventKind::Press {
                    continue;
                }
                return Ok(());
            }
        }
    }
}

fn draw_completion(frame: &mut Frame, provider: MemoryProvider) {
    let area = centered_rect(52, 16, frame.area());

    let headroom_ver = headroom::installed_version().unwrap_or_else(|| "—".into());
    let rtk_ver = rtk::installed_version().unwrap_or_else(|| "—".into());

    let mut lines = vec![
        Line::from(""),
        Line::from(vec![
            Span::styled(
                "  \u{2713} ",
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                "Setup Complete",
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD),
            ),
        ]),
        Line::from(""),
        Line::from(Span::styled(
            "  Installed:",
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::from(vec![
            Span::styled("    \u{25cf} ", Style::default().fg(Color::Green)),
            Span::raw(format!("Headroom {headroom_ver}")),
        ]),
        Line::from(vec![
            Span::styled("    \u{25cf} ", Style::default().fg(Color::Green)),
            Span::raw(format!("RTK {rtk_ver}")),
        ]),
    ];

    match provider {
        MemoryProvider::Icm => lines.push(Line::from(vec![
            Span::styled("    \u{25cf} ", Style::default().fg(Color::Green)),
            Span::raw("ICM (embedded SQLite)"),
        ])),
        MemoryProvider::AutoMem => lines.push(Line::from(vec![
            Span::styled("    \u{25cf} ", Style::default().fg(Color::Green)),
            Span::raw("AutoMem (graph memory)"),
        ])),
        MemoryProvider::Skip => lines.push(Line::from(vec![
            Span::styled(
                "    \u{25cb} ",
                Style::default().add_modifier(Modifier::DIM),
            ),
            Span::styled(
                "Memory provider skipped",
                Style::default().add_modifier(Modifier::DIM),
            ),
        ])),
    }

    lines.extend([
        Line::from(""),
        Line::from(Span::styled(
            format!("  whetstone v{}", version::current()),
            Style::default().add_modifier(Modifier::DIM),
        )),
        Line::from(""),
        Line::from(Span::styled(
            "  Run: whetstone",
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from(Span::styled(
            "  Press any key to exit",
            Style::default().add_modifier(Modifier::DIM),
        )),
        Line::from(""),
    ]);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(Span::styled(
            " whetstone ",
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ));
    let paragraph = Paragraph::new(lines).block(block);
    frame.render_widget(paragraph, area);
}

fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Fill(1),
            Constraint::Length(height),
            Constraint::Fill(1),
        ])
        .split(area);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Fill(1),
            Constraint::Length(width),
            Constraint::Fill(1),
        ])
        .split(vertical[1])[1]
}