use crate::error::Result;
use std::io::{self, Write};
use std::path::PathBuf;
const HOOK_COMMAND_LEGACY: &str = "claude-hindsight hook-index";
const HOOK_COMMAND_PREFIX: &str = "claude-hindsight hook ";
const HOOK_COMMAND_MARKER: &str = "hook ";
fn resolve_binary_path() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "claude-hindsight".to_string())
}
fn hook_entry(subcommand: &str) -> serde_json::Value {
let bin = resolve_binary_path();
serde_json::json!({ "type": "command", "command": format!("{} hook {}", bin, subcommand), "async": true })
}
fn hindsight_hooks() -> serde_json::Value {
serde_json::json!({
"hooks": {
"SessionStart": [{ "hooks": [hook_entry("session-start")] }],
"Stop": [{ "hooks": [hook_entry("stop")] }],
"SessionEnd": [{ "hooks": [hook_entry("session-end")] }],
"UserPromptSubmit": [{ "hooks": [hook_entry("user-prompt-submit")] }],
"PreToolUse": [{ "hooks": [hook_entry("pre-tool-use")] }],
"PostToolUse": [{ "hooks": [hook_entry("post-tool-use")] }],
"PostToolUseFailure": [{ "hooks": [hook_entry("post-tool-use-failure")] }],
"SubagentStart": [{ "hooks": [hook_entry("subagent-start")] }],
"SubagentStop": [{ "hooks": [hook_entry("subagent-stop")] }],
"PreCompact": [{ "hooks": [hook_entry("pre-compact")] }],
"PermissionRequest": [{ "hooks": [hook_entry("permission-request")] }],
"TaskCompleted": [{ "hooks": [hook_entry("task-completed")] }],
"WorktreeCreate": [{ "hooks": [hook_entry("worktree-create")] }],
"WorktreeRemove": [{ "hooks": [hook_entry("worktree-remove")] }],
"ConfigChange": [{ "hooks": [hook_entry("config-change")] }],
}
})
}
fn derive_settings_paths() -> Vec<(PathBuf, String)> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return vec![],
};
let config = crate::config::Config::load().unwrap_or_default();
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for entry in &config.paths.claude_dirs {
let expanded: PathBuf = if let Some(stripped) = entry.path.strip_prefix("~/") {
home.join(stripped)
} else {
PathBuf::from(&entry.path)
};
let parent = match expanded.parent() {
Some(p) => p.to_path_buf(),
None => continue,
};
let settings_path = parent.join("settings.json");
let key = settings_path.to_string_lossy().into_owned();
if seen.insert(key.clone()) {
let label = entry.name.clone().unwrap_or_else(|| {
parent
.to_str()
.map(|s| {
if let Some(h) = home.to_str() {
s.replacen(h, "~", 1)
} else {
s.to_string()
}
})
.unwrap_or_else(|| key.clone())
});
result.push((settings_path, label));
}
}
result
}
fn already_installed(value: &serde_json::Value) -> bool {
let text = serde_json::to_string(value).unwrap_or_default();
text.contains(HOOK_COMMAND_LEGACY)
|| text.contains(HOOK_COMMAND_PREFIX)
|| text.contains("claude-hindsight hook ")
|| (text.contains("hindsight") && text.contains(HOOK_COMMAND_MARKER))
}
fn merge_hooks(existing: &mut serde_json::Value) {
let desired = hindsight_hooks();
if existing.get("hooks").is_none() {
existing["hooks"] = serde_json::json!({});
}
let Some(hooks_obj) = existing["hooks"].as_object_mut() else {
eprintln!("Warning: hooks field is not an object, skipping merge");
return;
};
let Some(desired_hooks) = desired["hooks"].as_object() else {
return; };
for (event, desired_entries) in desired_hooks {
let arr = hooks_obj
.entry(event)
.or_insert_with(|| serde_json::json!([]));
if let Some(arr) = arr.as_array_mut() {
let text = serde_json::to_string(&arr).unwrap_or_default();
let has_hindsight = text.contains(HOOK_COMMAND_LEGACY)
|| text.contains(HOOK_COMMAND_PREFIX)
|| (text.contains("hindsight") && text.contains(HOOK_COMMAND_MARKER));
if !has_hindsight {
if let Some(entries) = desired_entries.as_array() {
arr.extend(entries.iter().cloned());
}
}
}
}
}
fn remove_hooks(existing: &mut serde_json::Value) {
if let Some(hooks_obj) = existing.get_mut("hooks").and_then(|h| h.as_object_mut()) {
for event_arr in hooks_obj.values_mut() {
if let Some(arr) = event_arr.as_array_mut() {
arr.retain(|entry| {
let text = serde_json::to_string(entry).unwrap_or_default();
!(text.contains(HOOK_COMMAND_LEGACY)
|| text.contains(HOOK_COMMAND_PREFIX)
|| (text.contains("hindsight") && text.contains(HOOK_COMMAND_MARKER)))
});
}
}
}
}
fn install_into(settings_path: &PathBuf, force: bool) -> Result<()> {
let mut value: serde_json::Value = if settings_path.exists() {
let raw = std::fs::read_to_string(settings_path)?;
serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
if already_installed(&value) {
if !force {
println!(" {} — already installed, skipping.", settings_path.display());
return Ok(());
}
remove_hooks(&mut value);
}
merge_hooks(&mut value);
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)?;
}
let pretty = serde_json::to_string_pretty(&value)?;
std::fs::write(settings_path, pretty)?;
println!(" {} — hooks installed.", settings_path.display());
Ok(())
}
fn remove_from(settings_path: &PathBuf) -> Result<()> {
if !settings_path.exists() {
println!(" {} — not found, skipping.", settings_path.display());
return Ok(());
}
let raw = std::fs::read_to_string(settings_path)?;
let mut value: serde_json::Value =
serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
if !already_installed(&value) {
println!(" {} — hooks not present, skipping.", settings_path.display());
return Ok(());
}
remove_hooks(&mut value);
let pretty = serde_json::to_string_pretty(&value)?;
std::fs::write(settings_path, pretty)?;
println!(" {} — hooks removed.", settings_path.display());
Ok(())
}
const OTEL_ENV_VARS: &[(&str, &str)] = &[
("CLAUDE_CODE_ENABLE_TELEMETRY", "1"),
("OTEL_METRICS_EXPORTER", "otlp"),
("OTEL_LOGS_EXPORTER", "otlp"),
("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"),
("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:7228"),
];
fn install_otel_env(settings_path: &PathBuf) -> Result<()> {
let mut value: serde_json::Value = if settings_path.exists() {
let raw = std::fs::read_to_string(settings_path)?;
serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let env_obj = value.get("env");
let already_set: Vec<&str> = OTEL_ENV_VARS
.iter()
.filter(|(k, _)| {
env_obj
.and_then(|e| e.get(k))
.is_some()
})
.map(|(k, _)| *k)
.collect();
if !already_set.is_empty() {
eprintln!(
" Warning: {} already has OTLP env vars set ({}).",
settings_path.display(),
already_set.join(", ")
);
eprintln!(" Skipping — configure manually to avoid overriding your settings.");
return Ok(());
}
let obj = value
.as_object_mut()
.ok_or_else(|| crate::error::HindsightError::Config("settings value is not an object".into()))?;
let env = obj
.entry("env")
.or_insert_with(|| serde_json::json!({}));
for (k, v) in OTEL_ENV_VARS {
env[k] = serde_json::Value::String(v.to_string());
}
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)?;
}
let pretty = serde_json::to_string_pretty(&value)?;
std::fs::write(settings_path, pretty)?;
println!(" {} — OTLP env vars written.", settings_path.display());
Ok(())
}
fn remove_otel_env(settings_path: &PathBuf) -> Result<()> {
if !settings_path.exists() {
println!(" {} — not found, skipping.", settings_path.display());
return Ok(());
}
let raw = std::fs::read_to_string(settings_path)?;
let mut value: serde_json::Value =
serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
let keys: Vec<&str> = OTEL_ENV_VARS.iter().map(|(k, _)| *k).collect();
let removed = if let Some(env) = value.get_mut("env").and_then(|e| e.as_object_mut()) {
let before = env.len();
for key in &keys {
env.remove(*key);
}
before != env.len()
} else {
false
};
if !removed {
println!(" {} — no OTLP env vars found, skipping.", settings_path.display());
return Ok(());
}
let pretty = serde_json::to_string_pretty(&value)?;
std::fs::write(settings_path, pretty)?;
println!(" {} — OTLP env vars removed.", settings_path.display());
Ok(())
}
pub fn run(remove: bool, status: bool, all_targets: bool, force: bool, otel: bool, remove_otel: bool) -> Result<()> {
let targets = derive_settings_paths();
if targets.is_empty() {
println!("No Claude Code installations found in configured paths.");
println!("Run 'hindsight paths list' to see configured directories.");
return Ok(());
}
if status {
println!("Hindsight hook status:\n");
for (path, label) in &targets {
let installed = if path.exists() {
let raw = std::fs::read_to_string(path).unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap_or_default();
already_installed(&v)
} else {
false
};
let icon = if installed { "" } else { "✗" };
println!(" {} {} ({})", icon, path.display(), label);
}
return Ok(());
}
if remove_otel {
println!("Removing OTLP telemetry env vars...\n");
for (path, _) in &targets {
remove_otel_env(path)?;
}
if let Ok(mut config) = crate::config::Config::load() {
if config.telemetry.otel_enabled {
config.telemetry.otel_enabled = false;
if config.save().is_ok() {
println!("\n OTEL disabled in Hindsight config.");
}
}
}
println!("\nRestart Claude Code for changes to take effect.");
println!("Costs will now be calculated from JSONL session data (model-aware pricing).");
return Ok(());
}
if remove {
println!("Removing Hindsight hooks...\n");
for (path, _) in &targets {
remove_from(path)?;
}
return Ok(());
}
let selected: Vec<&(PathBuf, String)> = if all_targets || targets.len() == 1 {
targets.iter().collect()
} else {
println!("Available Claude Code installations:\n");
for (i, (path, label)) in targets.iter().enumerate() {
println!(" [{}] {} ({})", i + 1, path.display(), label);
}
println!(" [a] All of the above");
print!("\nSelect: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.eq_ignore_ascii_case("a") {
targets.iter().collect()
} else {
match input.parse::<usize>() {
Ok(n) if n >= 1 && n <= targets.len() => vec![&targets[n - 1]],
_ => {
println!("Invalid selection. Aborted.");
return Ok(());
}
}
}
};
println!("\nInstalling Hindsight hooks...\n");
for (path, _) in &selected {
install_into(path, force)?;
}
if otel {
println!("\nWriting OTLP telemetry env vars...\n");
for (path, _) in &selected {
install_otel_env(path)?;
}
println!("\nRestart Claude Code for the env vars to take effect.");
}
println!("\nDone! Claude Code will now auto-index sessions via hook.");
println!("Tip: run 'hindsight integrate --status' to verify.");
Ok(())
}