From c11b018d003efaff3ee3bdb9ec36580943af91fb Mon Sep 17 00:00:00 2001 From: rNoz Date: Tue, 14 Jul 2026 22:05:29 +0200 Subject: [PATCH] fix(init): --agent cursor installs Cursor only and creates ~/.cursor rtk init -g --agent cursor fell through to the shared init path with install_claude derived only from !opencode, so it created RTK.md, CLAUDE.md, and patched settings.json under ~/.claude in addition to patching .cursor/hooks.json. On machines without ~/.claude it failed outright when creating the temp file. Two fixes: - Gate install_claude on the absence of a sibling agent target (Cursor, Windsurf, Cline) so only the default or explicit Claude target installs Claude Code files. - Create ~/.cursor before patching hooks.json; atomic_write places its temp file in the target's parent, which failed on a fresh machine where the directory did not yet exist. Closes #2097 --- src/hooks/init.rs | 8 ++++ src/main.rs | 7 ++- tests/init_agent_cursor_test.rs | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/init_agent_cursor_test.rs diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c02dc1e11..5af5dfc39 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3521,6 +3521,14 @@ fn install_cursor_hooks(ctx: InitContext) -> Result<()> { let InitContext { verbose, dry_run } = ctx; let cursor_dir = resolve_cursor_dir()?; + // Ensure ~/.cursor exists before any write: atomic_write creates its temp + // file in the target's parent, which fails on a fresh machine where the + // directory does not yet exist (part of #2097). + if !dry_run { + fs::create_dir_all(&cursor_dir) + .with_context(|| format!("Failed to create {}", cursor_dir.display()))?; + } + // Migrate old hook script if present let old_hook = cursor_dir.join("hooks").join(REWRITE_HOOK_FILE); if old_hook.exists() { diff --git a/src/main.rs b/src/main.rs index 1c85bb55f..124d62fb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2031,10 +2031,15 @@ fn run_cli() -> Result { hooks::init::run_droid_mode(global, ctx)?; } else { let install_opencode = opencode; - let install_claude = !opencode; let install_cursor = agent == Some(AgentTarget::Cursor); let install_windsurf = agent == Some(AgentTarget::Windsurf); let install_cline = agent == Some(AgentTarget::Cline); + // Sibling agents (Cursor, Windsurf, Cline) fall through to this + // shared init path; only the default (no --agent) or an explicit + // Claude target should install Claude Code files. Without this + // guard, `rtk init -g --agent cursor` writes into ~/.claude (#2097). + let install_claude = + !opencode && !install_cursor && !install_windsurf && !install_cline; let patch_mode = if auto_patch { hooks::init::PatchMode::Auto diff --git a/tests/init_agent_cursor_test.rs b/tests/init_agent_cursor_test.rs new file mode 100644 index 000000000..664dda6f9 --- /dev/null +++ b/tests/init_agent_cursor_test.rs @@ -0,0 +1,79 @@ +#![cfg(unix)] +//! Regression for #2097: `rtk init -g --agent cursor` must only touch `.cursor/` +//! and never create or patch Claude Code files under `~/.claude`. + +use std::process::{Command, Stdio}; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +#[test] +fn agent_cursor_dry_run_never_touches_claude() { + let home = tempfile::tempdir().unwrap(); + let out = rtk() + .args(["init", "-g", "--agent", "cursor", "--dry-run"]) + .env("HOME", home.path()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains("/.claude/"), + "cursor init must not touch .claude:\n{stdout}" + ); + assert!( + stdout.contains("/.cursor/"), + "cursor init must patch .cursor hooks.json:\n{stdout}" + ); + // Dry-run must not create anything on disk. + assert!( + !home.path().join(".claude").exists(), + "dry-run must not create ~/.claude" + ); + assert!( + !home.path().join(".cursor").exists(), + "dry-run must not create ~/.cursor" + ); +} + +#[test] +fn agent_cursor_real_write_creates_no_claude_dir() { + // The original #2097 failure was a hard error while creating a temp file + // under a non-existent ~/.claude. Prove a real (non-dry-run) install never + // creates .claude and does write .cursor/hooks.json. + let home = tempfile::tempdir().unwrap(); + let out = rtk() + .args(["init", "-g", "--agent", "cursor", "--auto-patch"]) + .env("HOME", home.path()) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!( + out.status.success(), + "cursor init failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !home.path().join(".claude").exists(), + "cursor init must not create ~/.claude" + ); + assert!( + home.path().join(".cursor").join("hooks.json").exists(), + "cursor init must write .cursor/hooks.json" + ); +} + +#[test] +fn default_dry_run_still_installs_claude() { + let home = tempfile::tempdir().unwrap(); + let out = rtk() + .args(["init", "-g", "--dry-run"]) + .env("HOME", home.path()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("/.claude/"), + "default init must still install Claude Code files:\n{stdout}" + ); +}