use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use crate::{config, db, headroom, hooks, preflight, rtk, shell, ui};
const DEFAULT_PROXY: &str = "http://127.0.0.1:8787";
pub fn resolve_assets_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("WHETSTONE_ASSETS") {
let p = PathBuf::from(dir);
if p.is_dir() {
return Ok(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for candidate in ["../assets", "../../assets"] {
let relative = bin_dir.join(candidate);
if relative.is_dir() {
return Ok(relative.canonicalize()?);
}
}
}
}
let home = dirs::home_dir().context("could not determine home directory")?;
let fallback = home.join(".whetstone").join("assets");
if fallback.is_dir() {
return Ok(fallback);
}
bail!(
"could not locate whetstone assets — set WHETSTONE_ASSETS or install to ~/.whetstone/assets/"
);
}
pub fn run(full: bool, headroom_extras: &str) -> Result<()> {
ui::info("whetstone setup");
let assets = resolve_assets_dir()?;
ui::ok(&format!("assets at {}", assets.display()));
ui::info("checking dependencies");
preflight::check_all()?;
ui::info("step 1/7 — headroom");
headroom::install(headroom_extras, full)?;
ui::info("step 2/7 — rtk");
rtk::install(full)?;
ui::info("step 3/7 — rtk hook");
rtk::configure()?;
ui::info("step 4/7 — shell profile");
shell::set_anthropic_base_url(DEFAULT_PROXY)?;
shell::ensure_path_contains_local_bin()?;
ui::info("step 5/7 — install whetstone binary");
self_install()?;
let install_memstack = prompt_memstack(full)?;
if install_memstack {
ui::info("step 6/7 — memstack");
install_memstack_assets(&assets, full, headroom_extras)?;
ui::info("step 7/7 — hooks + settings.json");
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)?;
generate_stack_setup()?;
} else {
ui::info("skipped memstack skills, hooks, and STACK-SETUP.md");
}
ui::ok("whetstone setup complete");
Ok(())
}
fn prompt_memstack(full: bool) -> Result<bool> {
let project_dir = std::env::current_dir()?;
let has_existing = project_dir.join(".claude/skills").is_dir()
|| project_dir.join(".claude/MEMSTACK.md").exists();
if full {
if has_existing {
ui::info("full update: refreshing existing memstack install");
return Ok(true);
}
ui::info("full update: no memstack install found — skipping");
return Ok(false);
}
Ok(ui::confirm(
"Install MemStack skills and hooks for this project?",
true,
))
}
fn install_memstack_assets(assets: &Path, full: bool, headroom_extras: &str) -> Result<()> {
let project_dir = std::env::current_dir()?;
let claude_dir = project_dir.join(".claude");
copy_skills(assets, &claude_dir, full)?;
copy_subdirs(assets, &claude_dir, full)?;
copy_memstack_md(assets, &claude_dir, full)?;
let config_path = claude_dir.join("config.local.json");
let cfg = config::WhetstoneConfig::new_for_project(&project_dir, headroom_extras);
cfg.write_to(&config_path)?;
ui::ok("created config.local.json");
let db_dir = claude_dir.join("db");
fs::create_dir_all(&db_dir)?;
let db_path = db_dir.join("memstack.db");
if !db_path.exists() {
db::dispatch(crate::cli::DbCommand::Init)?;
ui::ok("database initialized");
} else {
ui::ok("database already exists");
}
Ok(())
}
fn copy_skills(assets: &Path, claude_dir: &Path, force: bool) -> Result<()> {
let src = assets.join("skills");
if !src.is_dir() {
ui::warn("no bundled skills found — skipping");
return Ok(());
}
let dest = claude_dir.join("skills");
if force {
ui::info("refreshing skills...");
copy_dir_recursive(&src, &dest)?;
ui::ok("skills refreshed");
} else if dest.is_dir() && has_subdirs(&dest) {
ui::ok("skills already installed");
} else {
ui::info("copying skills...");
copy_dir_recursive(&src, &dest)?;
ui::ok("skills copied");
}
Ok(())
}
fn copy_subdirs(assets: &Path, claude_dir: &Path, force: bool) -> Result<()> {
for subdir in ["rules", "commands"] {
let src = assets.join(subdir);
if !src.is_dir() {
continue;
}
let dest = claude_dir.join(subdir);
if force || !dest.is_dir() {
copy_dir_recursive(&src, &dest)?;
}
}
Ok(())
}
fn copy_memstack_md(assets: &Path, claude_dir: &Path, force: bool) -> Result<()> {
let src = assets.join("MEMSTACK.md");
if !src.exists() {
return Ok(());
}
let dest = claude_dir.join("MEMSTACK.md");
if force || !dest.exists() {
fs::copy(&src, &dest)?;
}
Ok(())
}
fn generate_stack_setup() -> Result<()> {
let project_dir = std::env::current_dir()?;
let dest = project_dir.join("STACK-SETUP.md");
fs::write(&dest, STACK_SETUP_CONTENT)?;
ui::ok("generated STACK-SETUP.md");
Ok(())
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else {
fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
fn has_subdirs(dir: &Path) -> bool {
fs::read_dir(dir)
.map(|entries| entries.filter_map(|e| e.ok()).any(|e| e.path().is_dir()))
.unwrap_or(false)
}
const STACK_SETUP_CONTENT: &str = r#"# Whetstone (Claude Code stack)
This project was set up with Whetstone: Headroom, RTK, and MemStack for
token-efficient Claude Code sessions.
## Quick Start
```bash
whetstone # Start Claude with Headroom proxy
whetstone claude # Same as above
```
## Tools
| Tool | Purpose | Savings |
|------|---------|---------|
| Headroom | HTTP proxy compresses context before API | 50-90% |
| RTK | Hook rewrites CLI output before entering context | 60-90% |
| MemStack | Skills, SQLite memory, session hooks | efficiency |
## Hooks
| Event | Hook | Tool |
|-------|------|------|
| Before Bash | RTK rewrites command | RTK |
| Before Write/Edit/Bash | TTS notification | MemStack |
| Before `git push` | Build check + secrets scan | MemStack |
| After `git commit` | Debug artifact scan | MemStack |
| Session start | Headroom auto-start + indexing | MemStack |
| Session end | Session reporting | MemStack |
## Configuration
| File | Purpose |
|------|---------|
| `~/.claude/settings.json` | Hook registrations (global) |
| `.claude/config.local.json` | Project config |
| `.claude/db/memstack.db` | SQLite database |
## Database CLI
```bash
whetstone db stats
whetstone db search "query"
whetstone db get-sessions
whetstone db export-md
```
## Uninstall
Per-project: `whetstone uninstall`
"#;
fn self_install() -> Result<()> {
let current_exe =
std::env::current_exe().context("could not determine current executable path")?;
let home = dirs::home_dir().context("could not determine home directory")?;
let bin_dir = home.join(".local").join("bin");
fs::create_dir_all(&bin_dir)?;
let dest = bin_dir.join("whetstone");
if dest.exists() && same_file(¤t_exe, &dest) {
ui::ok("whetstone binary already in place");
return Ok(());
}
fs::copy(¤t_exe, &dest)
.with_context(|| format!("copying binary to {}", dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755))?;
}
ui::ok(&format!("installed to {}", dest.display()));
Ok(())
}
fn same_file(a: &PathBuf, b: &PathBuf) -> bool {
let Ok(a_canon) = fs::canonicalize(a) else {
return false;
};
let Ok(b_canon) = fs::canonicalize(b) else {
return false;
};
a_canon == b_canon
}