Skip to content

rtk init -g deletes the legacy hook script and either unregisters rtk entirely or leaves a dangling reference — both reported as "RTK hook registered (global)" #3693

Description

@mattbuot

Related issues (searched before filing)

Summary

rtk init -g deletes ~/.claude/hooks/rtk-rewrite.sh before it has written any replacement registration, and prints RTK hook registered (global). at a point in the flow where settings.json has not been touched yet. Depending on what else lives in the Bash matcher group, the user is left in one of two broken states — both with exit 0 and a success banner:

Precondition Result
A. rtk's legacy entry is the only hook in the Bash matcher group Script deleted and the entry removed. settings.json ends as "PreToolUse": []. rtk is fully unregistered.
B. another hook shares the Bash matcher group Script deleted, entry kept. settings.json still points at a file that no longer exists — the state reported in #1962.

Variant A is the quieter of the two: Claude Code raises no hook error, because there is no hook. Every subsequent agent command runs unfiltered with nothing to signal it.

Environment

  • rtk 0.45.0 (Homebrew), macOS arm64 (Darwin 25.6.0)
  • Command: rtk init -g, stdin redirected from /dev/null (non-TTY)
  • Both variants reproduced in an isolated HOME; targeting verified with --dry-run first
  • Root-caused against develop, so this is not specific to the 0.45.0 build

Reproduce — variant A (rtk alone in the matcher group)

Before:

$ ls -l $SB/.claude/hooks/
-rwxr-xr-x  53  rtk-rewrite.sh

$ cat $SB/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "$SB/.claude/hooks/rtk-rewrite.sh" } ] }
    ]
  }
}

Run:

$ HOME=$SB rtk init -g < /dev/null

RTK hook registered (global).

  Command:   rtk hook claude
  RTK.md:    $SB/.claude/RTK.md (10 lines)
  CLAUDE.md: @RTK.md reference added

Patch existing $SB/.claude/settings.json? [y/N]
(non-interactive mode, defaulting to N)

  MANUAL STEP: Add this to $SB/.claude/settings.json:
  ...

$ echo $?
0

After:

$ ls -la $SB/.claude/hooks/
(empty)

$ cat $SB/.claude/settings.json
{
  "hooks": {
    "PreToolUse": []
  }
}

$ grep -rl "rtk" $SB/.claude --include="*.json"
(no match)

Reproduce — variant B (a second hook shares the group)

Same starting point, except the Bash group also contains /opt/acme/audit-hook.sh, and a Write group contains /opt/acme/write-guard.sh. After the same command:

  • settings.json is byte-identical — the legacy entry, the co-resident hook and the Write group all survive;
  • ~/.claude/hooks/rtk-rewrite.sh is gone;
  • so settings.json now registers a PreToolUse command whose script does not exist. This is rtk-rewrite.sh: No such file or directory #1962.

The foreign hooks surviving is correct behaviour and should be preserved by any fix.

Root cause (line numbers verified against develop, src/hooks/init.rs)

  1. init.rs:1160migrate_old_hook_script(ctx) runs as step 1.
  2. init.rs:1249 — deletes ~/.claude/hooks/rtk-rewrite.sh. On success, init.rs:1257 calls remove_legacy_settings_entries. The deletion is unconditional; nothing has been written in its place yet.
  3. init.rs:1344remove_legacy_hook_entries_from_json drops a matcher entry only when dominated_by_legacy holds, i.e. when every command in the group is the legacy script. That single predicate is what splits variant A from variant B.
  4. init.rs:1179println!("\nRTK hook registered (global).\n") fires at step 4, before settings.json is touched at all at step 5. The banner is unconditional and cannot reflect an outcome that has not happened.
  5. init.rs:1198 → 1001 → 445 — under the default PatchMode::Ask, prompt_user_consent returns Ok(false) on non-TTY stdin ((non-interactive mode, defaulting to N)) → PatchResult::Declined.
  6. init.rs:1211 — the Declined | Skipped arm prints nothing further, so the success banner stands as the run's last status line.

To be fair to the current behaviour: the MANUAL STEP block is printed, and a human watching a terminal can act on it. The defect is that the banner above it says the opposite, the exit code is 0, and the previously working install has already been destroyed by the time either is printed — so nothing recoverable is left if the output is not read.

--dry-run does not disclose either effect

rtk init -g --dry-run on the variant-A fixture prints only:

[dry-run] would migrate legacy hook script: …/rtk-rewrite.sh
[dry-run] would create RTK.md: …
[dry-run] would add @RTK.md reference to CLAUDE.md: …
[dry-run] would create global filters template: …

[dry-run] Nothing written.

There is no would remove legacy rtk-rewrite.sh entry from settings.json line (that branch at init.rs:1320 is only reached from the non-dry-run delete path) and no would prompt before patching. So the one command a cautious user would run to preview this cannot show them the destructive part.

Interaction with #2588

PR #2588 ("Fixes #1962") hoists remove_legacy_settings_entries out of the deletion-success branch, so the entry is stripped even when the script removal failed and when no legacy script exists. It does not touch the banner at init.rs:1179 or the Declined path.

That converts variant B into variant A for more users rather than fixing either: it removes the dangling reference by removing the registration, while the replacement write can still be declined. Worth resolving the ordering here first, or folding it in.

Suggested fix

  • Move the RTK hook registered (global). banner after step 5 and derive its wording from PatchResult. Declined and Skipped must not print a registration banner, and arguably should not exit 0.
  • Write before deleting: patch settings.json with rtk hook claude first, and remove the legacy script and legacy entry only after that write succeeds — the ordering PR fix(init): migrate Cursor hooks.json before deleting the legacy hook script #3570 adopted for Cursor. This fixes both variants at once and preserves co-resident foreign hooks.
  • Make --dry-run report the legacy-entry removal and the consent prompt, so the preview matches the real run.

I have the fixture scripts for both variants and am happy to send a PR with regression coverage if that is useful — flagging first since src/hooks/init.rs is a Tier-1 file under SECURITY.md and #2588 already claims part of this code.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions