fn user_profile_dir() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
std::env::var("USERPROFILE")
.ok()
.map(std::path::PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var("HOME").ok().map(std::path::PathBuf::from)
}
}
fn settings_path() -> Option<std::path::PathBuf> {
user_profile_dir().map(|h| h.join(".claude").join("settings.json"))
}
fn codex_hooks_path() -> Option<std::path::PathBuf> {
user_profile_dir().map(|h| h.join(".codex").join("hooks.json"))
}
#[cfg(windows)]
fn schedule_binary_delete_on_reboot(path: &std::path::Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
let old_path = path.with_extension("exe.old");
std::fs::rename(path, &old_path)?;
#[link(name = "kernel32")]
extern "system" {
fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
}
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
let wide: Vec<u16> = old_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let ok =
unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 };
if ok {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn binary_path() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
std::env::var("LOCALAPPDATA").ok().map(|h| {
std::path::PathBuf::from(h)
.join("Programs")
.join("vibestats")
.join("vibestats.exe")
})
}
#[cfg(not(windows))]
{
std::env::var("HOME").ok().map(|h| {
std::path::PathBuf::from(h)
.join(".local")
.join("bin")
.join("vibestats")
})
}
}
fn first_command_token(command: &str) -> Option<&str> {
let trimmed = command.trim_start();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix('"') {
let end_quote = rest.find('"')?;
return Some(&rest[..end_quote]);
}
trimmed.split_whitespace().next()
}
fn command_executable_basename(command: &str) -> Option<&str> {
let token = first_command_token(command)?;
token.rsplit(['/', '\\']).next()
}
fn is_vibestats_hook(hook_obj: &serde_json::Value) -> bool {
hook_obj
.get("command")
.and_then(|c| c.as_str())
.and_then(command_executable_basename)
.map(|name| {
name.eq_ignore_ascii_case("vibestats") || name.eq_ignore_ascii_case("vibestats.exe")
})
.unwrap_or(false)
}
fn remove_vibestats_hooks(settings: &mut serde_json::Value) -> bool {
let mut changed = false;
for hook_type in &["Stop", "SessionStart"] {
let Some(groups_arr) = settings
.get_mut("hooks")
.and_then(|h| h.get_mut(*hook_type))
.and_then(|h| h.as_array_mut())
else {
continue;
};
for group in groups_arr.iter_mut() {
let Some(inner_hooks) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
continue;
};
let before = inner_hooks.len();
inner_hooks.retain(|hook| !is_vibestats_hook(hook));
if inner_hooks.len() != before {
changed = true;
}
}
let before = groups_arr.len();
groups_arr.retain(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(true)
});
if groups_arr.len() != before {
changed = true;
}
}
if !changed {
return false;
}
if let Some(hooks_obj) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
for hook_type in &["Stop", "SessionStart"] {
let is_empty = hooks_obj
.get(*hook_type)
.and_then(|v| v.as_array())
.map(|a| a.is_empty())
.unwrap_or(false);
if is_empty {
hooks_obj.remove(*hook_type);
}
}
}
let hooks_empty = settings
.get("hooks")
.and_then(|h| h.as_object())
.map(|o| o.is_empty())
.unwrap_or(false);
if hooks_empty {
if let Some(root) = settings.as_object_mut() {
root.remove("hooks");
}
}
true
}
pub fn run() {
match settings_path() {
None => {
println!(
"vibestats: {} not set — skipping hook removal",
if cfg!(windows) { "USERPROFILE" } else { "HOME" }
);
}
Some(path) => {
if !path.exists() {
println!("vibestats: no vibestats hooks found in settings.json (already clean)");
} else {
match std::fs::read_to_string(&path) {
Err(e) => {
println!("vibestats: could not read ~/.claude/settings.json: {e}");
println!("Hook removal skipped.");
}
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Err(e) => {
println!("vibestats: could not parse ~/.claude/settings.json: {e}");
println!("Hook removal skipped.");
}
Ok(mut settings) => {
if remove_vibestats_hooks(&mut settings) {
match serde_json::to_string_pretty(&settings) {
Err(e) => {
println!(
"vibestats: could not serialize ~/.claude/settings.json: {e}"
);
}
Ok(updated) => {
let mut tmp_path = path.clone();
let mut tmp_name =
path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(".tmp");
tmp_path.set_file_name(tmp_name);
let write_result =
std::fs::write(&tmp_path, updated + "\n")
.and_then(|()| std::fs::rename(&tmp_path, &path));
match write_result {
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
println!(
"vibestats: could not write ~/.claude/settings.json: {e}"
);
}
Ok(()) => {
println!(
"vibestats: removed Stop and SessionStart hooks from ~/.claude/settings.json"
);
}
}
}
}
} else {
println!(
"vibestats: no vibestats hooks found in settings.json (already clean)"
);
}
}
},
}
}
}
}
if let Some(path) = codex_hooks_path() {
if path.exists() {
match std::fs::read_to_string(&path) {
Err(e) => {
println!("vibestats: could not read ~/.codex/hooks.json: {e}");
println!("Codex hook removal skipped.");
}
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Err(e) => {
println!("vibestats: could not parse ~/.codex/hooks.json: {e}");
println!("Codex hook removal skipped.");
}
Ok(mut settings) => {
if remove_vibestats_hooks(&mut settings) {
match serde_json::to_string_pretty(&settings) {
Err(e) => {
println!(
"vibestats: could not serialize ~/.codex/hooks.json: {e}"
);
}
Ok(updated) => {
let mut tmp_path = path.clone();
let mut tmp_name =
path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(".tmp");
tmp_path.set_file_name(tmp_name);
let write_result = std::fs::write(&tmp_path, updated + "\n")
.and_then(|()| std::fs::rename(&tmp_path, &path));
match write_result {
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
println!(
"vibestats: could not write ~/.codex/hooks.json: {e}"
);
}
Ok(()) => {
println!(
"vibestats: removed Stop and SessionStart hooks from ~/.codex/hooks.json"
);
}
}
}
}
}
}
},
}
}
}
match binary_path() {
None => {
println!(
"vibestats: {} not set — skipping binary deletion",
if cfg!(windows) {
"LOCALAPPDATA"
} else {
"HOME"
}
);
}
Some(path) => match std::fs::remove_file(&path) {
Ok(()) => println!("vibestats: deleted binary at {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!(
"vibestats: binary not found at {} (already removed?)",
path.display()
);
}
Err(e) => {
#[cfg(windows)]
{
match schedule_binary_delete_on_reboot(&path) {
Ok(()) => {
println!(
"vibestats: binary at {} is in use and will be removed on next reboot",
path.display()
);
}
Err(_) => {
println!(
"vibestats: could not delete binary at {}: {e}",
path.display()
);
println!(
"Delete it manually: Remove-Item -Force \"{}\"",
path.display()
);
}
}
}
#[cfg(not(windows))]
{
println!(
"vibestats: could not delete binary at {}: {e}",
path.display()
);
println!("Delete it manually: rm \"{}\"", path.display());
}
}
},
}
println!();
println!("vibestats: uninstall complete.");
println!();
println!("Optional manual cleanup (not done automatically):");
println!(" 1. Delete your vibestats-data repo if you no longer want the data:");
println!(" gh repo delete <username>/vibestats-data --yes");
println!(" 2. Remove the heatmap markers from your profile README:");
println!(" Delete the lines between <!-- vibestats-start --> and <!-- vibestats-end -->");
println!(" 3. Remove vibestats config and logs:");
println!(
" {}",
if cfg!(windows) {
r#"Remove-Item -Recurse -Force "$env:LOCALAPPDATA\vibestats""#
} else {
"rm -rf ~/.config/vibestats"
}
);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn settings_path_reflects_home_state() {
#[cfg(windows)]
const ENV_NAME: &str = "USERPROFILE";
#[cfg(not(windows))]
const ENV_NAME: &str = "HOME";
let saved = std::env::var(ENV_NAME).ok();
#[cfg(windows)]
unsafe {
std::env::set_var(ENV_NAME, r"C:\Users\Test");
}
#[cfg(not(windows))]
unsafe {
std::env::set_var(ENV_NAME, "/tmp/vibestats-test-home");
}
let path = settings_path().expect("must return Some when profile env var is set");
assert_eq!(
path.file_name(),
Some(std::ffi::OsStr::new("settings.json"))
);
assert_eq!(
path.parent().and_then(|p| p.file_name()),
Some(std::ffi::OsStr::new(".claude"))
);
unsafe {
std::env::remove_var(ENV_NAME);
}
assert!(
settings_path().is_none(),
"settings_path must return None when {ENV_NAME} is unset"
);
if let Some(value) = saved {
unsafe {
std::env::set_var(ENV_NAME, value);
}
}
}
#[test]
fn binary_path_builds_local_bin_path() {
#[cfg(windows)]
{
let saved = std::env::var("LOCALAPPDATA").ok();
unsafe {
std::env::set_var("LOCALAPPDATA", r"C:\Users\Test\AppData\Local");
}
let path = binary_path().expect("must return Some when LOCALAPPDATA is set");
assert_eq!(
path.file_name(),
Some(std::ffi::OsStr::new("vibestats.exe"))
);
assert_eq!(
path.parent().and_then(|p| p.file_name()),
Some(std::ffi::OsStr::new("vibestats"))
);
assert_eq!(
path.parent()
.and_then(|p| p.parent())
.and_then(|p| p.file_name()),
Some(std::ffi::OsStr::new("Programs"))
);
if let Some(value) = saved {
unsafe {
std::env::set_var("LOCALAPPDATA", value);
}
} else {
unsafe {
std::env::remove_var("LOCALAPPDATA");
}
}
}
#[cfg(not(windows))]
{
let saved = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", "/tmp/vibestats-test-home");
}
let path = binary_path().expect("must return Some when HOME is set");
assert_eq!(path.file_name(), Some(std::ffi::OsStr::new("vibestats")));
assert_eq!(
path.parent().and_then(|p| p.file_name()),
Some(std::ffi::OsStr::new("bin"))
);
assert_eq!(
path.parent()
.and_then(|p| p.parent())
.and_then(|p| p.file_name()),
Some(std::ffi::OsStr::new(".local"))
);
if let Some(value) = saved {
unsafe {
std::env::set_var("HOME", value);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
}
}
#[test]
fn is_vibestats_hook_matches_legacy_and_full_path_commands() {
assert!(is_vibestats_hook(&json!({ "command": "vibestats" })));
assert!(is_vibestats_hook(&json!({ "command": "vibestats sync" })));
assert!(is_vibestats_hook(
&json!({ "command": "vibestats session-start" })
));
assert!(is_vibestats_hook(
&json!({ "command": r#""C:\Users\Test\AppData\Local\Programs\vibestats\vibestats.exe" sync"# })
));
assert!(is_vibestats_hook(
&json!({ "command": r#""C:\Users\Test\AppData\Local\Programs\vibestats\VIBESTATS.EXE" sync --quiet"# })
));
assert!(is_vibestats_hook(
&json!({ "command": "/home/test/.local/bin/vibestats sync" })
));
assert!(!is_vibestats_hook(
&json!({ "command": "my-tool --arg vibestats-fake" })
));
assert!(!is_vibestats_hook(
&json!({ "command": "vibestats-killer" })
));
assert!(!is_vibestats_hook(
&json!({ "command": r#""C:\Tools\vibestats-helper.exe" sync"# })
));
assert!(!is_vibestats_hook(
&json!({ "command": r#"python "C:\Tools\vibestats.exe" sync"# })
));
assert!(!is_vibestats_hook(&json!({ "command": "not-vibestats" })));
assert!(!is_vibestats_hook(&json!({})));
assert!(!is_vibestats_hook(&json!({ "command": 42 })));
}
#[test]
fn hook_filtering_removes_vibestats_commands() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [
{ "type": "command", "command": "vibestats sync", "async": true },
{ "type": "command", "command": "other-tool run" }
]
}]
}
});
let changed = remove_vibestats_hooks(&mut settings);
assert!(changed, "must report changed=true when hooks removed");
let stop_hooks = &settings["hooks"]["Stop"][0]["hooks"];
let arr = stop_hooks.as_array().expect("must be array");
assert_eq!(arr.len(), 1, "only non-vibestats hook should remain");
assert_eq!(arr[0]["command"], "other-tool run");
}
#[test]
fn hook_filtering_preserves_non_vibestats_hooks() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [
{ "type": "command", "command": "my-tool --do-thing" }
]
}],
"SessionStart": [{
"hooks": [
{ "type": "command", "command": "another-tool start" }
]
}]
},
"other_setting": "should remain"
});
let changed = remove_vibestats_hooks(&mut settings);
assert!(
!changed,
"must report changed=false when nothing vibestats-related is found"
);
let stop_hooks = &settings["hooks"]["Stop"][0]["hooks"];
assert_eq!(
stop_hooks.as_array().unwrap().len(),
1,
"non-vibestats Stop hook must remain"
);
let session_hooks = &settings["hooks"]["SessionStart"][0]["hooks"];
assert_eq!(
session_hooks.as_array().unwrap().len(),
1,
"non-vibestats SessionStart hook must remain"
);
assert_eq!(settings["other_setting"], "should remain");
}
#[test]
fn hook_filtering_handles_missing_hook_keys_gracefully() {
let mut settings = json!({ "some_other_key": 42 });
let changed = remove_vibestats_hooks(&mut settings);
assert!(!changed);
assert_eq!(settings["some_other_key"], 42);
}
#[test]
fn hook_filtering_removes_group_when_all_hooks_are_vibestats() {
let mut settings = json!({
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "vibestats sync", "async": true }
]
},
{
"hooks": [
{ "type": "command", "command": "other-tool" }
]
}
]
}
});
let changed = remove_vibestats_hooks(&mut settings);
assert!(changed);
let stop_groups = settings["hooks"]["Stop"].as_array().expect("must be array");
assert_eq!(
stop_groups.len(),
1,
"empty vibestats group should be removed; only other-tool group remains"
);
assert_eq!(stop_groups[0]["hooks"][0]["command"], "other-tool");
}
#[test]
fn hook_filtering_preserves_unknown_hook_types() {
let mut settings = json!({
"hooks": {
"PreToolUse": [{
"hooks": [{ "type": "command", "command": "some-tool" }]
}]
}
});
let changed = remove_vibestats_hooks(&mut settings);
assert!(!changed);
let pre_hooks = &settings["hooks"]["PreToolUse"][0]["hooks"];
assert_eq!(
pre_hooks.as_array().unwrap().len(),
1,
"unknown hook type must be preserved"
);
}
#[test]
fn hook_filtering_strips_empty_hook_type_and_hooks_key_when_only_vibestats_present() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [
{ "type": "command", "command": "vibestats sync", "async": true }
]
}],
"SessionStart": [{
"hooks": [
{ "type": "command", "command": "vibestats session-start" }
]
}]
},
"model": "sonnet"
});
let changed = remove_vibestats_hooks(&mut settings);
assert!(changed, "must report changed=true");
assert!(
settings.get("hooks").is_none(),
"top-level hooks key must be removed when empty"
);
assert_eq!(settings["model"], "sonnet");
}
#[test]
fn hook_filtering_does_not_touch_non_stop_sessionstart_types_even_if_vibestats_command() {
let mut settings = json!({
"hooks": {
"PreToolUse": [{
"hooks": [
{ "type": "command", "command": "vibestats weird-hook" }
]
}]
}
});
let changed = remove_vibestats_hooks(&mut settings);
assert!(
!changed,
"must not touch hook types outside Stop/SessionStart"
);
assert_eq!(
settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"vibestats weird-hook"
);
}
#[test]
fn first_command_token_extracts_plain_command() {
assert_eq!(first_command_token("vibestats sync"), Some("vibestats"));
assert_eq!(first_command_token("vibestats"), Some("vibestats"));
assert_eq!(first_command_token("my-tool --flag arg"), Some("my-tool"));
}
#[test]
fn first_command_token_handles_quoted_path() {
assert_eq!(
first_command_token(r#""C:\Users\x\vibestats.exe" sync"#),
Some(r"C:\Users\x\vibestats.exe")
);
assert_eq!(
first_command_token(r#""C:\Program Files\app\tool.exe" --flag"#),
Some(r"C:\Program Files\app\tool.exe")
);
}
#[test]
fn first_command_token_returns_none_for_unterminated_quote() {
assert_eq!(first_command_token(r#""C:\foo"#), None);
assert_eq!(first_command_token(r#""unterminated"#), None);
}
#[test]
fn first_command_token_handles_leading_whitespace() {
assert_eq!(first_command_token(" vibestats"), Some("vibestats"));
assert_eq!(first_command_token(" \t other-cmd"), Some("other-cmd"));
}
#[test]
fn first_command_token_returns_none_for_empty_or_whitespace() {
assert_eq!(first_command_token(""), None);
assert_eq!(first_command_token(" "), None);
assert_eq!(first_command_token("\t\n"), None);
}
#[test]
fn command_executable_basename_extracts_from_plain_command() {
assert_eq!(
command_executable_basename("vibestats sync"),
Some("vibestats")
);
assert_eq!(
command_executable_basename("my-tool --arg"),
Some("my-tool")
);
}
#[test]
fn command_executable_basename_extracts_from_windows_path() {
assert_eq!(
command_executable_basename(
r#""C:\Users\Test\AppData\Local\Programs\vibestats\vibestats.exe" sync"#
),
Some("vibestats.exe")
);
assert_eq!(
command_executable_basename(r#""C:\Program Files\App\tool.exe" --flag"#),
Some("tool.exe")
);
}
#[test]
fn command_executable_basename_extracts_from_unix_path() {
assert_eq!(
command_executable_basename("/home/test/.local/bin/vibestats sync"),
Some("vibestats")
);
assert_eq!(
command_executable_basename("/usr/local/bin/my-tool --arg"),
Some("my-tool")
);
}
#[test]
fn command_executable_basename_handles_mixed_separators() {
assert_eq!(
command_executable_basename(r"C:\tools\vibestats.exe"),
Some("vibestats.exe")
);
assert_eq!(command_executable_basename("/path/to/app"), Some("app"));
}
#[test]
fn command_executable_basename_returns_none_for_unterminated_quote() {
assert_eq!(command_executable_basename(r#""C:\foo"#), None);
}
#[test]
fn command_executable_basename_returns_none_for_empty_or_whitespace() {
assert_eq!(command_executable_basename(""), None);
assert_eq!(command_executable_basename(" "), None);
}
}