From 143f503b346f5a8a8b0122fd93b54c0fe99e1415 Mon Sep 17 00:00:00 2001 From: elite133713 <26662443+elite133713@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:38:49 -0500 Subject: [PATCH 1/2] fix(init): create Claude config dir for global init --- src/hooks/init.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 28363f4ce6..32c2a4efd3 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -373,6 +373,7 @@ fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> 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 { @@ -383,6 +384,19 @@ fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> } } +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 { @@ -1013,6 +1027,7 @@ fn patch_settings_json_command( } // Atomic write + ensure_parent_dir(&settings_path)?; atomic_write(&settings_path, &serialized)?; println!("\n settings.json: hook added"); @@ -5872,15 +5887,46 @@ mod tests { fn with_claude_dir_override(tmp: &TempDir, f: F) { let _guard = CLAUDE_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let claude_dir = tmp.path().join(CLAUDE_DIR); + let home_dir = tmp.path().join("home"); fs::create_dir_all(&claude_dir).unwrap(); let orig = std::env::var_os("CLAUDE_CONFIG_DIR"); + let orig_home = std::env::var_os("HOME"); + std::env::set_var("CLAUDE_CONFIG_DIR", &claude_dir); + std::env::set_var("HOME", &home_dir); + f(&claude_dir); + match orig { + Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } + match orig_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + fn with_missing_claude_dir_override(tmp: &TempDir, f: F) { + let _guard = CLAUDE_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + 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" + ); + + let orig = std::env::var_os("CLAUDE_CONFIG_DIR"); + let orig_home = std::env::var_os("HOME"); std::env::set_var("CLAUDE_CONFIG_DIR", &claude_dir); + std::env::set_var("HOME", &home_dir); f(&claude_dir); match orig { Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v), None => std::env::remove_var("CLAUDE_CONFIG_DIR"), } + match orig_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } } fn with_pi_dir_override(tmp: &TempDir, f: F) { @@ -5919,6 +5965,28 @@ 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" + ); + }); + } + #[test] fn test_global_uninstall_removes_artifacts() { let tmp = TempDir::new().unwrap(); @@ -6010,6 +6078,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(); @@ -6035,6 +6125,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(); From 94a1f3dd3c1fc8a85d948f1845a40c556745f96a Mon Sep 17 00:00:00 2001 From: elite133713 <26662443+elite133713@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:21:34 -0500 Subject: [PATCH 2/2] test(init): keep OpenCode install when Claude dir is missing Co-authored-by: Cursor --- src/hooks/init.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 29880a218d..451ed32af6 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -11642,6 +11642,31 @@ mod tests { }); } + /// `--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() { + 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();