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

`42e4d95`→[main](/content/gh/entireio/cli/commits/main/index.html)·
?
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](/content/gh/entireio/cli/session/6c02d806-5e7a-4a05-a387-27e3243e75f9#timeline-01KXDH15JYQ4F8VJWSMH0YKA5J/index.html)

## 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)
		}
	})
}
