Skip to content

Commit befa2f8

Browse files
jseramncursoragent
andcommitted
fix(windows): install Claude Code hook shims as .cmd wrappers
Extensionless bash hook scripts trigger Windows Open with dialogs when Cursor/Claude Code spawn PreToolUse/SessionStart commands without a shell. Write .cmd wrappers on Windows, point settings.json at the absolute .cmd path, and add a regression test plus smoke-test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ce23ce7 commit befa2f8

4 files changed

Lines changed: 189 additions & 26 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,9 @@ injects a one-line code-discovery reminder as session context (Gemini CLI also
389389
keeps its `BeforeTool` reminder).
390390
The installed Claude shim file is named `cbm-code-discovery-gate` for
391391
backward compatibility with existing installs; despite the legacy name it
392-
never gates and never blocks.
392+
never gates and never blocks. On Windows the shim is written as
393+
`cbm-code-discovery-gate.cmd` so Cursor/Claude Code can execute it without
394+
the "Open with" dialog (extensionless bash scripts are not runnable on Windows).
393395

394396
## CLI Mode
395397

scripts/smoke-test.sh

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -946,22 +946,23 @@ fi
946946
echo "OK 8d: Claude Code PreToolUse hook (matcher=Grep|Glob, Read excluded)"
947947

948948
# 8e: Claude Code shim script — must be non-blocking augmenter, not a gate.
949-
if [ "$(uname -s)" != "MINGW64_NT" ] 2>/dev/null; then
950-
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate"
951-
if [ ! -x "$GATE_SCRIPT" ]; then
952-
echo "FAIL 8e: shim script not executable or missing"
953-
exit 1
954-
fi
955-
if grep -q 'exit 2' "$GATE_SCRIPT"; then
956-
echo "FAIL 8e: shim contains 'exit 2' — must never block"
957-
exit 1
958-
fi
959-
if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
960-
echo "FAIL 8e: shim missing 'hook-augment' delegation"
961-
exit 1
962-
fi
963-
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
949+
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate"
950+
if [ "$(uname -s)" = "MINGW64_NT" ] 2>/dev/null || [ "$(uname -s)" = "MSYS_NT" ] 2>/dev/null; then
951+
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate.cmd"
952+
fi
953+
if [ ! -f "$GATE_SCRIPT" ]; then
954+
echo "FAIL 8e: shim script missing ($GATE_SCRIPT)"
955+
exit 1
956+
fi
957+
if grep -q 'exit 2' "$GATE_SCRIPT"; then
958+
echo "FAIL 8e: shim contains 'exit 2' — must never block"
959+
exit 1
960+
fi
961+
if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
962+
echo "FAIL 8e: shim missing 'hook-augment' delegation"
963+
exit 1
964964
fi
965+
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
965966

966967
# 8f-8h: Codex TOML
967968
if ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$FAKE_HOME/.codex/config.toml"; then

src/cli/cli.c

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,20 +1148,46 @@ static void cbm_claude_user_root(const char *home_dir, char *out, size_t out_sz)
11481148
}
11491149
}
11501150

1151+
#ifdef _WIN32
1152+
#define CMM_HOOK_SCRIPT_EXT ".cmd"
1153+
#else
1154+
#define CMM_HOOK_SCRIPT_EXT ""
1155+
#endif
1156+
1157+
/* Append the platform hook script extension (e.g. ".cmd" on Windows). */
1158+
static void cbm_hook_script_filename(const char *script_name, char *out, size_t out_sz) {
1159+
if (out_sz == 0) {
1160+
return;
1161+
}
1162+
snprintf(out, out_sz, "%s%s", script_name, CMM_HOOK_SCRIPT_EXT);
1163+
}
1164+
11511165
/* Build the hook command string written into Claude Code's settings.json.
1152-
* Honors $CLAUDE_CONFIG_DIR. When CLAUDE_CONFIG_DIR is unset, preserves the
1153-
* legacy tilde-expanded form so settings.json stays portable across HOME values. */
1166+
* Honors $CLAUDE_CONFIG_DIR. On Unix, when CLAUDE_CONFIG_DIR is unset, preserves
1167+
* the legacy tilde-expanded form so settings.json stays portable across HOME values.
1168+
* On Windows, writes an absolute path to a .cmd wrapper — extensionless bash shims
1169+
* trigger the "Open with" dialog when Cursor/Claude Code spawn hook commands. */
11541170
static void cbm_resolve_hook_command(const char *script_name, char *out, size_t out_sz) {
11551171
if (out_sz == 0) {
11561172
return;
11571173
}
11581174
out[0] = '\0';
11591175
char env_buf[CLI_BUF_1K];
1176+
char filename[CLI_BUF_256];
1177+
cbm_hook_script_filename(script_name, filename, sizeof(filename));
11601178
const char *env = cbm_safe_getenv("CLAUDE_CONFIG_DIR", env_buf, sizeof(env_buf), NULL);
11611179
if (env && env[0]) {
1162-
snprintf(out, out_sz, "%s/hooks/%s", env, script_name);
1180+
snprintf(out, out_sz, "%s/hooks/%s", env, filename);
11631181
} else {
1164-
snprintf(out, out_sz, "~/.claude/hooks/%s", script_name);
1182+
#ifdef _WIN32
1183+
const char *home = cbm_get_home_dir();
1184+
if (!home || !home[0]) {
1185+
return;
1186+
}
1187+
snprintf(out, out_sz, "%s/.claude/hooks/%s", home, filename);
1188+
#else
1189+
snprintf(out, out_sz, "~/.claude/hooks/%s", filename);
1190+
#endif
11651191
}
11661192
}
11671193

@@ -2063,12 +2089,25 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20632089
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
20642090

20652091
char script_path[CLI_BUF_1K];
2066-
snprintf(script_path, sizeof(script_path), "%s/" CMM_HOOK_GATE_SCRIPT, hooks_dir);
2092+
char script_name[CLI_BUF_256];
2093+
cbm_hook_script_filename(CMM_HOOK_GATE_SCRIPT, script_name, sizeof(script_name));
2094+
snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, script_name);
20672095

20682096
FILE *f = fopen(script_path, "w");
20692097
if (!f) {
20702098
return;
20712099
}
2100+
#ifdef _WIN32
2101+
(void)fprintf(f,
2102+
"@echo off\r\n"
2103+
"REM codebase-memory-mcp search augmenter (Claude Code PreToolUse).\r\n"
2104+
"REM NOTE: the legacy basename is kept for zero-migration upgrades.\r\n"
2105+
"REM Despite the name this NEVER blocks a tool call - it only adds\r\n"
2106+
"REM graph context. Any failure is silent (exit 0, no output).\r\n"
2107+
"\"%s\" hook-augment 2>nul\r\n"
2108+
"exit /b 0\r\n",
2109+
binary_path);
2110+
#else
20722111
(void)fprintf(f,
20732112
"#!/usr/bin/env bash\n"
20742113
"# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n"
@@ -2080,6 +2119,7 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20802119
"\"$BIN\" hook-augment 2>/dev/null\n"
20812120
"exit 0\n",
20822121
binary_path);
2122+
#endif
20832123
/* fchmod before close to avoid TOCTOU race (CodeQL cpp/toctou-race-condition) */
20842124
#ifndef _WIN32
20852125
fchmod(fileno(f), CLI_OCTAL_PERM);
@@ -2107,12 +2147,34 @@ static void cbm_install_session_reminder_script(const char *home) {
21072147
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
21082148

21092149
char script_path[CLI_BUF_1K];
2110-
snprintf(script_path, sizeof(script_path), "%s/" CMM_SESSION_REMINDER_SCRIPT, hooks_dir);
2150+
char script_name[CLI_BUF_256];
2151+
cbm_hook_script_filename(CMM_SESSION_REMINDER_SCRIPT, script_name, sizeof(script_name));
2152+
snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, script_name);
21112153

21122154
FILE *f = fopen(script_path, "w");
21132155
if (!f) {
21142156
return;
21152157
}
2158+
#ifdef _WIN32
2159+
(void)fprintf(
2160+
f, "@echo off\r\n"
2161+
"REM SessionStart hook: remind agent to use codebase-memory-mcp tools.\r\n"
2162+
"echo CRITICAL - Code Discovery Protocol:\r\n"
2163+
"echo 1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\r\n"
2164+
"echo - search_graph(name_pattern/label/qn_pattern) to find "
2165+
"functions/classes/routes\r\n"
2166+
"echo - trace_path(function_name, mode=calls^|data_flow^|cross_service) for "
2167+
"call chains\r\n"
2168+
"echo - get_code_snippet(qualified_name) for exact symbol source (precise "
2169+
"ranges)\r\n"
2170+
"echo - query_graph(query) for complex Cypher patterns\r\n"
2171+
"echo - get_architecture(aspects) for project structure\r\n"
2172+
"echo - search_code(pattern) for text search (graph-augmented grep)\r\n"
2173+
"echo 2. Use Grep/Glob/Read freely for text, configs, non-code files, and\r\n"
2174+
"echo always Read a file before editing it.\r\n"
2175+
"echo 3. If a project is not indexed yet, run index_repository FIRST.\r\n"
2176+
"exit /b 0\r\n");
2177+
#else
21162178
(void)fprintf(
21172179
f, "#!/usr/bin/env bash\n"
21182180
"# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n"
@@ -2130,6 +2192,7 @@ static void cbm_install_session_reminder_script(const char *home) {
21302192
" always Read a file before editing it.\n"
21312193
"3. If a project is not indexed yet, run index_repository FIRST.\n"
21322194
"REMINDER\n");
2195+
#endif
21332196
#ifndef _WIN32
21342197
fchmod(fileno(f), CLI_OCTAL_PERM);
21352198
#endif
@@ -2192,15 +2255,25 @@ static void cbm_install_subagent_reminder_script(const char *home) {
21922255
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
21932256

21942257
char script_path[CLI_BUF_1K];
2195-
snprintf(script_path, sizeof(script_path), "%s/" CMM_SUBAGENT_REMINDER_SCRIPT, hooks_dir);
2258+
char script_name[CLI_BUF_256];
2259+
cbm_hook_script_filename(CMM_SUBAGENT_REMINDER_SCRIPT, script_name, sizeof(script_name));
2260+
snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, script_name);
21962261

21972262
FILE *f = fopen(script_path, "w");
21982263
if (!f) {
21992264
return;
22002265
}
2201-
/* The additionalContext value is a single line with no embedded quotes,
2202-
* backslashes, or newlines, so the JSON below is valid as written — no
2203-
* runtime escaping (and no python3/jq dependency) is required. */
2266+
#ifdef _WIN32
2267+
(void)fprintf(
2268+
f, "@echo off\r\n"
2269+
"REM SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\r\n"
2270+
"echo {\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\","
2271+
"\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools "
2272+
"(search_graph, trace_path, get_code_snippet, query_graph, get_architecture, "
2273+
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
2274+
"text, configs, and non-code files.\"}}\r\n"
2275+
"exit /b 0\r\n");
2276+
#else
22042277
(void)fprintf(f,
22052278
"#!/usr/bin/env bash\n"
22062279
"# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n"
@@ -2213,6 +2286,7 @@ static void cbm_install_subagent_reminder_script(const char *home) {
22132286
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
22142287
"text, configs, and non-code files.\"}}\n"
22152288
"REMINDER\n");
2289+
#endif
22162290
#ifndef _WIN32
22172291
fchmod(fileno(f), CLI_OCTAL_PERM);
22182292
#endif

tests/windows/test_hook_scripts.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
r"""GREEN regression guard — Claude Code hook shims are .cmd wrappers on Windows.
2+
3+
On Windows, Cursor and Claude Code spawn hook commands without a shell. The
4+
installer previously wrote extensionless bash scripts (`cbm-code-discovery-gate`)
5+
into `~/.claude/hooks/`, which triggered the "Open with" dialog and blocked
6+
workflows until the user picked an app.
7+
8+
The fix writes `.cmd` wrappers and points settings.json at the absolute `.cmd`
9+
path so CreateProcess can execute them directly.
10+
11+
Exit code: 0 == pass, 1 == regression, 2 == setup error.
12+
13+
Usage:
14+
python test_hook_scripts.py <path-to-codebase-memory-mcp.exe>
15+
"""
16+
import json
17+
import os
18+
import subprocess
19+
import sys
20+
import tempfile
21+
22+
23+
def run_install(binary, fake_home):
24+
env = dict(os.environ)
25+
env["USERPROFILE"] = fake_home
26+
env["HOME"] = fake_home
27+
env["APPDATA"] = os.path.join(fake_home, "AppData", "Roaming")
28+
env["LOCALAPPDATA"] = os.path.join(fake_home, "AppData", "Local")
29+
env["XDG_CONFIG_HOME"] = os.path.join(fake_home, ".config")
30+
env["PATH"] = os.path.dirname(binary) + os.pathsep + env.get("PATH", "")
31+
os.makedirs(os.path.join(fake_home, ".claude"), exist_ok=True)
32+
return subprocess.run([binary, "install", "-y"], capture_output=True, timeout=120, env=env)
33+
34+
35+
def main():
36+
if len(sys.argv) < 2:
37+
print("usage: python test_hook_scripts.py <binary>")
38+
return 2
39+
binary = os.path.abspath(sys.argv[1])
40+
if not os.path.exists(binary):
41+
print("FAIL: binary not found: %s" % binary)
42+
return 2
43+
44+
work = tempfile.mkdtemp(prefix="cbm_win_hooks_")
45+
try:
46+
result = run_install(binary, work)
47+
out = (result.stdout or b"").decode("utf-8", "replace")
48+
err = (result.stderr or b"").decode("utf-8", "replace")
49+
if result.returncode != 0:
50+
print("SETUP FAIL: install -y exit %d\n%s\n%s" % (result.returncode, out[:500], err[:500]))
51+
return 2
52+
53+
gate = os.path.join(work, ".claude", "hooks", "cbm-code-discovery-gate.cmd")
54+
reminder = os.path.join(work, ".claude", "hooks", "cbm-session-reminder.cmd")
55+
settings = os.path.join(work, ".claude", "settings.json")
56+
57+
for path in (gate, reminder, settings):
58+
if not os.path.isfile(path):
59+
print("FAIL: missing %s" % path)
60+
return 1
61+
62+
with open(gate, "r", encoding="utf-8", errors="replace") as f:
63+
gate_body = f.read()
64+
if "hook-augment" not in gate_body or "@echo off" not in gate_body:
65+
print("FAIL: gate .cmd missing expected content")
66+
return 1
67+
68+
with open(settings, "r", encoding="utf-8") as f:
69+
settings_doc = json.load(f)
70+
hooks = settings_doc.get("hooks", {}).get("PreToolUse", [])
71+
commands = []
72+
for entry in hooks:
73+
for h in entry.get("hooks", []):
74+
commands.append(h.get("command", ""))
75+
if not any("cbm-code-discovery-gate.cmd" in c for c in commands):
76+
print("FAIL: settings.json PreToolUse command missing .cmd wrapper: %s" % commands)
77+
return 1
78+
79+
print("PASS: Windows hook .cmd wrappers installed and wired in settings.json")
80+
return 0
81+
finally:
82+
pass
83+
84+
85+
if __name__ == "__main__":
86+
sys.exit(main())

0 commit comments

Comments
 (0)