diff --git a/hooks/hermes/tests/test_rtk_rewrite_plugin.py b/hooks/hermes/tests/test_rtk_rewrite_plugin.py index 143426246..b940a243c 100644 --- a/hooks/hermes/tests/test_rtk_rewrite_plugin.py +++ b/hooks/hermes/tests/test_rtk_rewrite_plugin.py @@ -313,7 +313,7 @@ def test_cargo_init_installs_importable_plugin_that_rewrites_with_fake_rtk(self) env["RUSTUP_HOME"] = str(real_home / ".rustup") if "CARGO_HOME" not in env and (real_home / ".cargo").exists(): env["CARGO_HOME"] = str(real_home / ".cargo") - env.pop("RTK_CLAUDE_DIR", None) + env.pop("CLAUDE_CONFIG_DIR", None) result = subprocess.run( ["cargo", "run", "--quiet", "--", "init", "--agent", "hermes"], diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index 426c1e1fc..d9bee51f0 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -3,6 +3,8 @@ use super::constants::RTK_DATA_DIR; use crate::core::config; use crate::core::tracking; +use crate::hooks::constants::CLAUDE_DIR; +use crate::hooks::init::resolve_claude_dir; use sha2::{Digest, Sha256}; use std::fmt::Write as FmtWrite; use std::io::Write as IoWrite; @@ -353,10 +355,12 @@ fn detect_hook_type() -> String { None => return "unknown".to_string(), }; + let claude_dir = resolve_claude_dir().unwrap_or_else(|_| home.join(CLAUDE_DIR)); + // Check in order of popularity let checks = [ - (home.join(".claude/hooks/rtk-rewrite.sh"), "claude"), - (home.join(".claude/hooks/rtk-rewrite.json"), "claude"), + (claude_dir.join("hooks/rtk-rewrite.sh"), "claude"), + (claude_dir.join("hooks/rtk-rewrite.json"), "claude"), (home.join(".gemini/hooks/rtk-hook.sh"), "gemini"), (home.join(".codex/AGENTS.md"), "codex"), (home.join(".cursor/hooks/rtk-rewrite.json"), "cursor"), diff --git a/src/discover/provider.rs b/src/discover/provider.rs index a9b630a2b..d8f73c6fa 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -1,6 +1,6 @@ //! Reads Claude Code session logs from disk and streams their command history. -use crate::hooks::constants::CLAUDE_DIR; +use crate::hooks::init::resolve_claude_dir; use anyhow::{Context, Result}; use std::collections::HashMap; use std::fs; @@ -44,12 +44,8 @@ pub struct ClaudeProvider; impl ClaudeProvider { /// Get the base directory for Claude Code projects. fn projects_dir() -> Result { - let home = dirs::home_dir().context("could not determine home directory")?; - Ok(Self::projects_dir_for_home(&home)) - } - - fn projects_dir_for_home(home: &Path) -> PathBuf { - home.join(CLAUDE_DIR).join("projects") + let claude_dir = resolve_claude_dir().context("could not determine claude directory")?; + Ok(claude_dir.join("projects")) } fn discover_sessions_in_projects_dir( @@ -435,7 +431,10 @@ mod tests { #[test] fn test_discover_sessions_missing_projects_dir_returns_empty() { let temp_home = tempfile::tempdir().unwrap(); - let missing_projects_dir = temp_home.path().join(CLAUDE_DIR).join("projects"); + let missing_projects_dir = temp_home + .path() + .join(crate::hooks::constants::CLAUDE_DIR) + .join("projects"); let sessions = ClaudeProvider::discover_sessions_in_projects_dir( &missing_projects_dir, diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 4dd26d82b..ca6faf517 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -1,9 +1,9 @@ //! Detects whether RTK hooks are installed and warns if they are outdated. use super::constants::{ - CLAUDE_DIR, CLAUDE_HOOK_COMMAND, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, - SETTINGS_JSON, + CLAUDE_HOOK_COMMAND, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; +use super::init::resolve_claude_dir; use crate::core::constants::RTK_DATA_DIR; use std::path::PathBuf; @@ -25,11 +25,10 @@ pub enum HookStatus { /// Returns `Ok` if no Claude Code is detected (not applicable). pub fn status() -> HookStatus { // Don't warn users who don't have Claude Code installed - let home = match dirs::home_dir() { - Some(h) => h, - None => return HookStatus::Ok, + let claude_dir = match resolve_claude_dir() { + Ok(d) => d, + Err(_) => return HookStatus::Ok, }; - let claude_dir = home.join(CLAUDE_DIR); if !claude_dir.exists() { return HookStatus::Ok; } @@ -134,11 +133,8 @@ pub fn parse_hook_version(content: &str) -> u8 { } fn hook_installed_path() -> Option { - let home = dirs::home_dir()?; - let path = home - .join(CLAUDE_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE); + let claude_dir = resolve_claude_dir().ok()?; + let path = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if path.exists() { Some(path) } else { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 4c68ec49b..28363f4ce 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -507,7 +507,10 @@ fn prompt_telemetry_consent() -> Result<()> { } fn print_manual_instructions(hook_command: &str, include_opencode: bool) { - println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); + let settings_path = resolve_claude_dir() + .unwrap_or_else(|_| PathBuf::from(format!("~/{}", CLAUDE_DIR))) + .join(SETTINGS_JSON); + println!("\n MANUAL STEP: Add this to {}:", settings_path.display()); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); println!(" \"matcher\": \"Bash\","); @@ -2718,11 +2721,23 @@ fn resolve_home_subdir(subdir: &str) -> Result { }) } -fn resolve_claude_dir() -> Result { - if let Ok(dir) = std::env::var("RTK_CLAUDE_DIR") { - return Ok(PathBuf::from(dir)); +pub fn resolve_claude_dir() -> Result { + resolve_claude_dir_from( + std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from), + dirs::home_dir(), + ) +} + +fn resolve_claude_dir_from( + claude_dir: Option, + home_dir: Option, +) -> Result { + if let Some(path) = claude_dir.filter(|path| !path.as_os_str().is_empty()) { + return Ok(path); } - resolve_home_subdir(CLAUDE_DIR) + home_dir + .map(|h| h.join(CLAUDE_DIR)) + .context("Cannot determine Claude config directory. Set $CLAUDE_CONFIG_DIR or $HOME.") } fn resolve_codex_dir() -> Result { @@ -5007,6 +5022,46 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".codex")); } + #[test] + fn test_resolve_claude_dir_prefers_rtk_override() { + let result = resolve_claude_dir_from( + Some(PathBuf::from("/custom/rtk-claude")), + Some(PathBuf::from("/home/user")), + ) + .unwrap(); + assert_eq!(result, PathBuf::from("/custom/rtk-claude")); + } + + #[test] + fn test_resolve_claude_dir_uses_claude_config_dir() { + let result = resolve_claude_dir_from( + Some(PathBuf::from("/custom/claude-config")), + Some(PathBuf::from("/home/user")), + ) + .unwrap(); + assert_eq!(result, PathBuf::from("/custom/claude-config")); + } + + #[test] + fn test_resolve_claude_dir_falls_back_to_home() { + let result = resolve_claude_dir_from(None, Some(PathBuf::from("/home/user"))).unwrap(); + assert_eq!(result, PathBuf::from("/home/user/.claude")); + } + + #[test] + fn test_resolve_claude_dir_ignores_empty_overrides() { + let empty = + resolve_claude_dir_from(Some(PathBuf::new()), Some(PathBuf::from("/home/user"))) + .unwrap(); + assert_eq!(empty, PathBuf::from("/home/user/.claude")); + } + + #[test] + fn test_resolve_claude_dir_errors_without_home() { + let err = resolve_claude_dir_from(None, None).unwrap_err(); + assert!(err.to_string().contains("Cannot determine Claude config")); + } + #[test] fn test_resolve_hermes_home_prefers_hermes_home() { let hermes_home = OsString::from("~/custom hermes home"); @@ -5819,12 +5874,12 @@ mod tests { let claude_dir = tmp.path().join(CLAUDE_DIR); fs::create_dir_all(&claude_dir).unwrap(); - let orig = std::env::var_os("RTK_CLAUDE_DIR"); - std::env::set_var("RTK_CLAUDE_DIR", &claude_dir); + let orig = std::env::var_os("CLAUDE_CONFIG_DIR"); + std::env::set_var("CLAUDE_CONFIG_DIR", &claude_dir); f(&claude_dir); match orig { - Some(v) => std::env::set_var("RTK_CLAUDE_DIR", v), - None => std::env::remove_var("RTK_CLAUDE_DIR"), + Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), } } diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 1101fe26a..fc991dc06 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -12,7 +12,8 @@ //! //! Reference: SA-2025-RTK-001 (Finding F-01) -use super::constants::{CLAUDE_DIR, HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use super::constants::{HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use super::init::resolve_claude_dir; use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use std::fs; @@ -188,13 +189,7 @@ fn read_stored_hash(path: &Path) -> Result { /// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) pub fn resolve_hook_path() -> Result { - dirs::home_dir() - .map(|h| { - h.join(CLAUDE_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE) - }) - .context("Cannot determine home directory. Is $HOME set?") + resolve_claude_dir().map(|dir| dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE)) } /// Run integrity check and print results (for `rtk verify` subcommand) @@ -210,8 +205,8 @@ pub fn run_verify(verbose: u8) -> Result<()> { // If no legacy script exists, check for native binary command registration if !hook_path.exists() && !hash_file.exists() { // Check if the native binary command is registered in settings.json - let home = dirs::home_dir().context("Cannot determine home directory")?; - let settings_path = home.join(CLAUDE_DIR).join("settings.json"); + let claude_dir = resolve_claude_dir().context("Cannot determine claude directory")?; + let settings_path = claude_dir.join("settings.json"); if settings_path.exists() { let content = fs::read_to_string(&settings_path).unwrap_or_default(); if content.contains("rtk hook claude") {