Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hooks/hermes/tests/test_rtk_rewrite_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
8 changes: 6 additions & 2 deletions src/core/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand Down
15 changes: 7 additions & 8 deletions src/discover/provider.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,12 +44,8 @@ pub struct ClaudeProvider;
impl ClaudeProvider {
/// Get the base directory for Claude Code projects.
fn projects_dir() -> Result<PathBuf> {
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(
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 7 additions & 11 deletions src/hooks/hook_check.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -134,11 +133,8 @@ pub fn parse_hook_version(content: &str) -> u8 {
}

fn hook_installed_path() -> Option<PathBuf> {
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 {
Expand Down
73 changes: 64 additions & 9 deletions src/hooks/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\",");
Expand Down Expand Up @@ -2718,11 +2721,23 @@ fn resolve_home_subdir(subdir: &str) -> Result<PathBuf> {
})
}

fn resolve_claude_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("RTK_CLAUDE_DIR") {
return Ok(PathBuf::from(dir));
pub fn resolve_claude_dir() -> Result<PathBuf> {
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<PathBuf>,
home_dir: Option<PathBuf>,
) -> Result<PathBuf> {
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<PathBuf> {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"),
}
}

Expand Down
15 changes: 5 additions & 10 deletions src/hooks/integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -188,13 +189,7 @@ fn read_stored_hash(path: &Path) -> Result<String> {

/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh)
pub fn resolve_hook_path() -> Result<PathBuf> {
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)
Expand All @@ -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") {
Expand Down
Loading