Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/hooks/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2031,10 +2031,15 @@ fn run_cli() -> Result<i32> {
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
Expand Down
79 changes: 79 additions & 0 deletions tests/init_agent_cursor_test.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}