diff --git a/src/hooks/init/claude.rs b/src/hooks/init/claude.rs index 2f83eafe81..9c7f429f24 100644 --- a/src/hooks/init/claude.rs +++ b/src/hooks/init/claude.rs @@ -235,6 +235,9 @@ fn patch_settings_json_command( insert_hook_entry(&mut root, hook_command)?; + if !dry_run { + ensure_parent_dir(&settings_path)?; + } update_json_file( &settings_path, &root, @@ -1016,6 +1019,57 @@ mod tests { }); } + #[test] + fn test_global_default_mode_creates_missing_claude_dir() { + let tmp = TempDir::new().unwrap(); + with_missing_claude_dir_override(&tmp, |claude_dir| { + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + + assert!( + claude_dir.exists(), + "missing Claude config dir must be created" + ); + assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); + assert!( + claude_dir.join(CLAUDE_MD).exists(), + "CLAUDE.md must be created" + ); + assert!( + claude_dir.join(SETTINGS_JSON).exists(), + "settings.json must be created" + ); + }); + } + + /// `--opencode` is installed after RTK.md. A missing Claude dir must not + /// abort before that install. Unix only: OpenCode resolves through + /// `dirs::home_dir()`, which follows `$HOME` on Unix and ignores it on Windows. + #[cfg(unix)] + #[test] + fn test_global_opencode_installs_when_claude_dir_missing() { + use crate::hooks::constants::{ + CONFIG_DIR, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, + }; + + let tmp = TempDir::new().unwrap(); + with_missing_claude_dir_override(&tmp, |claude_dir| { + run_default_mode(true, PatchMode::Auto, true, InitContext::default()).unwrap(); + + assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); + let plugin = tmp + .path() + .join("home") + .join(CONFIG_DIR) + .join(OPENCODE_SUBDIR) + .join(PLUGIN_SUBDIR) + .join(OPENCODE_PLUGIN_FILE); + assert!( + plugin.exists(), + "OpenCode plugin must be installed when ~/.claude was missing" + ); + }); + } + #[test] fn test_patch_settings_json_tolerates_utf8_bom() { let tmp = TempDir::new().unwrap(); @@ -1171,6 +1225,28 @@ mod tests { }); } + #[test] + fn test_global_hook_only_mode_creates_missing_claude_dir() { + let tmp = TempDir::new().unwrap(); + with_missing_claude_dir_override(&tmp, |claude_dir| { + run_hook_only_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + + assert!( + claude_dir.exists(), + "missing Claude config dir must be created" + ); + assert!( + !claude_dir.join(RTK_MD).exists(), + "RTK.md must NOT be created in hook-only mode" + ); + let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); + assert!( + settings.contains(CLAUDE_HOOK_COMMAND), + "settings.json must contain hook command" + ); + }); + } + #[test] fn test_run_default_mode_dry_run_writes_nothing() { let tmp = TempDir::new().unwrap(); @@ -1196,6 +1272,23 @@ mod tests { }); } + #[test] + fn test_run_default_mode_dry_run_does_not_create_missing_claude_dir() { + let tmp = TempDir::new().unwrap(); + with_missing_claude_dir_override(&tmp, |claude_dir| { + let dry = InitContext { + dry_run: true, + ..Default::default() + }; + run_default_mode(true, PatchMode::Auto, false, dry).unwrap(); + + assert!( + !claude_dir.exists(), + "dry-run must not create missing Claude config dir" + ); + }); + } + #[test] fn test_uninstall_dry_run_preserves_artifacts() { let tmp = TempDir::new().unwrap(); diff --git a/src/hooks/init/mod.rs b/src/hooks/init/mod.rs index aacc246a45..ce18146626 100644 --- a/src/hooks/init/mod.rs +++ b/src/hooks/init/mod.rs @@ -515,6 +515,7 @@ fn write_if_changed_internal( println!("[dry-run] content:\n{}", content); } } else { + ensure_parent_dir(path)?; atomic_write(path, content) .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; if verbose > 0 { @@ -525,6 +526,19 @@ fn write_if_changed_internal( } } +pub(super) fn ensure_parent_dir(path: &Path) -> Result<()> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + + if parent.as_os_str().is_empty() { + return Ok(()); + } + + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display())) +} + /// Resolve the final write target: if `path` is a symlink, follow it so /// the atomic rename lands on the real file and the symlink is preserved. fn resolve_atomic_target(path: &Path) -> PathBuf { @@ -1686,6 +1700,24 @@ pub(super) fn with_claude_dir_override(tmp: &TempDir, f: F) { temp_env::with_var("CLAUDE_CONFIG_DIR", Some(&claude_dir), || f(&claude_dir)); } +#[cfg(test)] +pub(super) fn with_missing_claude_dir_override(tmp: &TempDir, f: F) { + let claude_dir = tmp.path().join(CLAUDE_DIR); + let home_dir = tmp.path().join("home"); + assert!( + !claude_dir.exists(), + "test precondition: Claude config dir must be missing" + ); + + temp_env::with_vars( + [ + ("CLAUDE_CONFIG_DIR", Some(claude_dir.as_os_str())), + ("HOME", Some(home_dir.as_os_str())), + ], + || f(&claude_dir), + ); +} + #[cfg(test)] mod tests { use super::claude::{remove_hook_from_json, remove_legacy_hook_entries_from_json};