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:
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. Setscoops[].name: entireso it installs asentire.entire configure --absolute-git-hook-pathfailed with "failed to resolve symlinks … The system cannot find the path specified" because filepath.EvalSymlinks can't resolve Scoop'scurrent\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.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
M.goreleaser.yaml+8/-1
cmd/entire/cli
agent/cursor
Mhooks.go+15/-7
Mhooks_test.go+43
strategy
Mhooks.go+24/-2
Mhooks_test.go+53
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) } }) }