Fix Windows Cursor hooks + Scoop app-dir naming (#1424) · Entire

Fix Windows Cursor hooks + Scoop app-dir naming (#1424)

42e4d95→main· ? Karthik Rameshkumar·4d ago·5 files·+143 added/-10 removed

Three independent Windows breakages reported in #1424:

  1. Scoop installed the CLI under the app dir cli (…\scoop\apps\cli) because goreleaser defaults the scoop manifest name to the project name, which resolves to the repo name. Set scoops[].name: entire so it installs as entire.

  2. entire configure --absolute-git-hook-path failed with "failed to resolve symlinks … The system cannot find the path specified" because filepath.EvalSymlinks can't resolve Scoop's current\ NTFS directory junction. Fall back to the unresolved os.Executable() path on Windows (extracted into resolveHookExePath for cross-platform testing) — that path is valid and, on Scoop, more stable across updates.

  3. Cursor agent hooks never fired on Windows: InstallHooks hardcoded the POSIX sh -c '…' wrapper, which silently fails to launch when Cursor spawns the command via the native shell with no runnable POSIX sh, and the silent wrapper hides the failure. Switch Cursor to the shared UseWindowsProductionHooks + WrapProductionSilentHookCommandForOS path (already used by codex) so it installs the native cmd.exe wrapper when no sh is present. Probe-gated: a no-op on non-Windows and on Windows hosts where sh works.

Other sh-based agents (gemini, claude-code, factory-droid) still hardcode the sh wrapper; copilot-cli deliberately stays sh-based because it runs hook commands via a bash field, not the native shell. Auditing/migrating those is left to a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

01KXDH15JYQ4F8VJWSMH0YKA5JView transcript

?\ Fix Windows Hooks and Cursor Installation IssuesClaude Code·Opus 4.8[1m]·5 steps

Changes

5

99 unmodified lines

100
101
102
103
103
104
105
106
107
108
109
110
111
112
113

99 unmodified lines

prerelease: auto

scoops:
  - repository:
  # Name the manifest (and therefore the Scoop app directory) "entire". Without
  # this, goreleaser defaults the manifest name to the project name, which
  # resolves to the repo name ("cli"), so `scoop install` lands the binary in
  # …\scoop\apps\cli\current\entire.exe. That mismatched app-dir name is
  # surprising ("scoop install cli"?) and fed the Windows hook-path bug in
  # https://github.com/entireio/cli/issues/1424.
  - name: entire
    repository:
      owner: entireio
      name: scoop-bucket
      token: "{{ .Env.TAP_GITHUB_TOKEN }}"

M.goreleaser.yaml+8/-1

129 unmodified lines

130
131
132
133
134
135
136
137
138
139
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150

129 unmodified lines

subagentStartCmd := cmdPrefix + HookNameSubagentStart
    subagentEndCmd := cmdPrefix + HookNameSubagentStop
    if !localDev {
        sessionStartCmd = agent.WrapProductionSilentHookCommand(sessionStartCmd)
        sessionEndCmd = agent.WrapProductionSilentHookCommand(sessionEndCmd)
        beforeSubmitPromptCmd = agent.WrapProductionSilentHookCommand(beforeSubmitPromptCmd)
        stopCmd = agent.WrapProductionSilentHookCommand(stopCmd)
        preCompactCmd = agent.WrapProductionSilentHookCommand(preCompactCmd)
        subagentStartCmd = agent.WrapProductionSilentHookCommand(subagentStartCmd)
        subagentEndCmd = agent.WrapProductionSilentHookCommand(subagentEndCmd)
        // Cursor spawns hook commands through the native OS shell (cmd.exe on
        // Windows), so a `sh -c '…'` wrapper silently fails to launch on a
        // Windows host without a working POSIX sh — no hook fires and, because
        // this is the *silent* wrapper, no error surfaces (issue #1424).
        // UseWindowsProductionHooks probes for a runnable sh and only swaps in
        // the native cmd.exe wrapper when one is absent, so this is a no-op on
        // hosts (incl. all non-Windows) where the sh wrapper already works.
        useWindowsHooks := agent.UseWindowsProductionHooks(ctx, localDev)
        sessionStartCmd = agent.WrapProductionSilentHookCommandForOS(sessionStartCmd, useWindowsHooks)
        sessionEndCmd = agent.WrapProductionSilentHookCommandForOS(sessionEndCmd, useWindowsHooks)
        beforeSubmitPromptCmd = agent.WrapProductionSilentHookCommandForOS(beforeSubmitPromptCmd, useWindowsHooks)
        stopCmd = agent.WrapProductionSilentHookCommandForOS(stopCmd, useWindowsHooks)
        preCompactCmd = agent.WrapProductionSilentHookCommandForOS(preCompactCmd, useWindowsHooks)
        subagentStartCmd = agent.WrapProductionSilentHookCommandForOS(subagentStartCmd, useWindowsHooks)
        subagentEndCmd = agent.WrapProductionSilentHookCommandForOS(subagentEndCmd, useWindowsHooks)
    }

count := 0

Mcmd/entire/cli/agent/cursor/hooks.go+15/-7

62 unmodified lines

63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111

62 unmodified lines

assertEntryCommand(t, hooksFile.Hooks.SubagentStop, agent.WrapProductionSilentHookCommand("entire hooks cursor subagent-stop"))
}

// TestInstallHooks_WindowsProbeSuccessKeepsShWrappers verifies that on a
// Windows host where a POSIX sh is runnable, Cursor keeps the sh-based wrappers
// (parity with non-Windows). Mutates the shared probe, so no t.Parallel().
func TestInstallHooks_WindowsProbeSuccessKeepsShWrappers(t *testing.T) {
    t.Cleanup(agent.SetWindowsHookProbeForTesting("windows", func(context.Context, string) bool {
        return true // sh works
    }))

tempDir := t.TempDir()
    t.Chdir(tempDir)

ag := &CursorAgent{}
    if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
        t.Fatalf("InstallHooks() error = %v", err)
    }

hooksFile := readHooksFile(t, tempDir)
    assertEntryCommand(t, hooksFile.Hooks.SessionStart, agent.WrapProductionSilentHookCommand("entire hooks cursor session-start"))
    assertEntryCommand(t, hooksFile.Hooks.Stop, agent.WrapProductionSilentHookCommand("entire hooks cursor stop"))
}

// TestInstallHooks_WindowsProbeFailureUsesCmdWrappers verifies that on a Windows
// host with no runnable POSIX sh, Cursor installs the native cmd.exe wrappers so
// hooks actually fire (issue #1424). Mutates the shared probe, so no t.Parallel().
func TestInstallHooks_WindowsProbeFailureUsesCmdWrappers(t *testing.T) {
    t.Cleanup(agent.SetWindowsHookProbeForTesting("windows", func(context.Context, string) bool {
        return false // no working sh
    }))

tempDir := t.TempDir()
    t.Chdir(tempDir)

ag := &CursorAgent{}
    if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
        t.Fatalf("InstallHooks() error = %v", err)
    }

hooksFile := readHooksFile(t, tempDir)
    assertEntryCommand(t, hooksFile.Hooks.SessionStart, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor session-start"))
    assertEntryCommand(t, hooksFile.Hooks.Stop, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor stop"))
    assertEntryCommand(t, hooksFile.Hooks.SubagentStop, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor subagent-stop"))
}

func TestInstallHooks_Idempotent(t *testing.T) {
    tempDir := t.TempDir()
    t.Chdir(tempDir)
}

Mcmd/entire/cli/strategy/hooks.go+24/-2

1 unmodified line

2 3 4 5 6 7 8 1691 unmodified lines

1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754

1 unmodified line

import ( "context" "errors" "os" "os/exec" "path/filepath" 1691 unmodified lines

t.Errorf("error should mention 'failed to remove hooks', got: %v", err) } }

// TestResolveHookExePath covers the absolute-git-hook-path symlink resolution, // including the Windows fallback for NTFS junctions that EvalSymlinks cannot // resolve (e.g. Scoop's …\current\ junction — issue #1424). GOOS and the // symlink resolver are injected so every branch runs on any host. func TestResolveHookExePath(t *testing.T) { t.Parallel()

const exe = C:\Users\admin\scoop\apps\cli\current\entire.exe // Stand-in for the Windows junction error ("The system cannot find the path // specified") that filepath.EvalSymlinks returns on Scoop's current\. junctionErr := errors.New("cannot find the path specified")

t.Run("resolves normally when EvalSymlinks succeeds", func(t *testing.T) { t.Parallel() got, err := resolveHookExePath("/tmp/linkto", func(string) (string, error) { return "/opt/entire/entire", nil }, "linux") if err != nil { t.Fatalf("unexpected error: %v", err) } if got != "/opt/entire/entire" { t.Errorf("got %q, want resolved target", got) } })

t.Run("windows falls back to unresolved path on EvalSymlinks failure", func(t *testing.T) { t.Parallel() got, err := resolveHookExePath(exe, func(string) (string, error) { return "", junctionErr }, "windows") if err != nil { t.Fatalf("windows should fall back, got error: %v", err) } if got != exe { t.Errorf("got %q, want unresolved exe %q", got, exe) } })

t.Run("non-windows surfaces EvalSymlinks failure", func(t *testing.T) { t.Parallel() _, err := resolveHookExePath("/usr/local/bin/entire", func(string) (string, error) { return "", junctionErr }, "linux") if err == nil { t.Fatal("expected error on non-windows EvalSymlinks failure") } if !strings.Contains(err.Error(), "failed to resolve symlinks") { t.Errorf("error should mention symlink resolution, got: %v", err) } }) }