Skip to content

Commit da4c006

Browse files
authored
Merge pull request #1057 from DeusData/fix/929-windows-cmd-hooks
fix(install): Windows hook scripts get .cmd form; legacy files removed
2 parents 5c9c554 + 0fa9f42 commit da4c006

3 files changed

Lines changed: 175 additions & 15 deletions

File tree

scripts/smoke-test.sh

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -977,22 +977,39 @@ fi
977977
echo "OK 8d: Claude Code PreToolUse hook (matcher=Grep|Glob|Read)"
978978

979979
# 8e: Claude Code shim script — must be non-blocking augmenter, not a gate.
980-
if [ "$(uname -s)" != "MINGW64_NT" ] 2>/dev/null; then
981-
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate"
982-
if [ ! -x "$GATE_SCRIPT" ]; then
983-
echo "FAIL 8e: shim script not executable or missing"
984-
exit 1
985-
fi
986-
if grep -q 'exit 2' "$GATE_SCRIPT"; then
987-
echo "FAIL 8e: shim contains 'exit 2' — must never block"
988-
exit 1
989-
fi
990-
if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
991-
echo "FAIL 8e: shim missing 'hook-augment' delegation"
992-
exit 1
993-
fi
994-
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
980+
# #929: Windows installs a .cmd script (extensionless bash shims triggered the
981+
# Open-With dialog); the old `!= "MINGW64_NT"` gate never matched the real
982+
# uname (MINGW64_NT-10.0-...), so this check silently ran the POSIX branch on
983+
# Windows. Branch by platform prefix instead.
984+
case "$(uname -s)" in
985+
MINGW*|MSYS*|CYGWIN*)
986+
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate.cmd"
987+
if [ ! -f "$GATE_SCRIPT" ]; then
988+
echo "FAIL 8e: .cmd shim missing on Windows"
989+
exit 1
990+
fi
991+
if [ -f "$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate" ]; then
992+
echo "FAIL 8e: legacy extensionless shim still installed on Windows"
993+
exit 1
994+
fi
995+
;;
996+
*)
997+
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate"
998+
if [ ! -x "$GATE_SCRIPT" ]; then
999+
echo "FAIL 8e: shim script not executable or missing"
1000+
exit 1
1001+
fi
1002+
;;
1003+
esac
1004+
if grep -q 'exit 2' "$GATE_SCRIPT"; then
1005+
echo "FAIL 8e: shim contains 'exit 2' — must never block"
1006+
exit 1
1007+
fi
1008+
if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
1009+
echo "FAIL 8e: shim missing 'hook-augment' delegation"
1010+
exit 1
9951011
fi
1012+
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
9961013

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

src/cli/cli.c

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,7 +1838,16 @@ int cbm_remove_junie_mcp(const char *config_path) {
18381838
#define CMM_HOOK_MATCHER "Grep|Glob|Read"
18391839
/* Basename only; the full command path is resolved at install time via
18401840
* cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */
1841+
#ifdef _WIN32
1842+
/* #929: extensionless bash shims under %USERPROFILE%\\.claude\\hooks trigger
1843+
* the "How do you want to open this file?" dialog when editors (Cursor) scan
1844+
* the hooks dir, and cannot execute without bash anyway. Windows installs
1845+
* .cmd scripts; the extensionless legacy files are removed on upgrade. */
1846+
#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate.cmd"
1847+
#else
18411848
#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate"
1849+
#endif
1850+
#define CMM_HOOK_GATE_SCRIPT_LEGACY "cbm-code-discovery-gate"
18421851
/* Hard backstop in settings.json; the binary also self-bounds with an
18431852
* in-process deadline well under this. */
18441853
#define CMM_HOOK_TIMEOUT_SEC 5
@@ -2086,6 +2095,20 @@ int cbm_remove_claude_hooks(const char *settings_path) {
20862095
* a missing/old/hung binary results in a silent exit 0 (issue #362/#288).
20872096
* The legacy filename `cbm-code-discovery-gate` is retained so existing
20882097
* settings.json entries and uninstall keep working with zero migration. */
2098+
/* #929 (Windows): remove the pre-.cmd extensionless twin so upgrades stop
2099+
* triggering the Open-With dialog. POSIX keeps the extensionless name, where
2100+
* legacy == current — never unlink there. */
2101+
static void cbm_remove_legacy_hook_script(const char *hooks_dir, const char *legacy_name) {
2102+
#ifdef _WIN32
2103+
char legacy_path[CLI_BUF_1K];
2104+
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_name);
2105+
cbm_unlink(legacy_path);
2106+
#else
2107+
(void)hooks_dir;
2108+
(void)legacy_name;
2109+
#endif
2110+
}
2111+
20892112
void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20902113
if (!home || !binary_path) {
20912114
return;
@@ -2106,13 +2129,25 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
21062129
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
21072130
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
21082131

2132+
cbm_remove_legacy_hook_script(hooks_dir, CMM_HOOK_GATE_SCRIPT_LEGACY);
21092133
char script_path[CLI_BUF_1K];
21102134
snprintf(script_path, sizeof(script_path), "%s/" CMM_HOOK_GATE_SCRIPT, hooks_dir);
21112135

21122136
FILE *f = fopen(script_path, "w");
21132137
if (!f) {
21142138
return;
21152139
}
2140+
#ifdef _WIN32
2141+
(void)fprintf(f,
2142+
"@echo off\r\n"
2143+
"REM codebase-memory-mcp search augmenter (Claude Code PreToolUse).\r\n"
2144+
"REM Never blocks a tool call - it only adds graph context.\r\n"
2145+
"REM Any failure is silent (exit 0, no output).\r\n"
2146+
"if not exist \"%s\" exit /b 0\r\n"
2147+
"\"%s\" hook-augment 2>NUL\r\n"
2148+
"exit /b 0\r\n",
2149+
binary_path, binary_path);
2150+
#else
21162151
(void)fprintf(f,
21172152
"#!/usr/bin/env bash\n"
21182153
"# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n"
@@ -2124,6 +2159,7 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
21242159
"\"$BIN\" hook-augment 2>/dev/null\n"
21252160
"exit 0\n",
21262161
binary_path);
2162+
#endif
21272163
/* fchmod before close to avoid TOCTOU race (CodeQL cpp/toctou-race-condition) */
21282164
#ifndef _WIN32
21292165
fchmod(fileno(f), CLI_OCTAL_PERM);
@@ -2135,7 +2171,12 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
21352171
}
21362172

21372173
/* SessionStart hook: remind agent to use MCP tools on every context reset. */
2174+
#ifdef _WIN32
2175+
#define CMM_SESSION_REMINDER_SCRIPT "cbm-session-reminder.cmd"
2176+
#else
21382177
#define CMM_SESSION_REMINDER_SCRIPT "cbm-session-reminder"
2178+
#endif
2179+
#define CMM_SESSION_REMINDER_SCRIPT_LEGACY "cbm-session-reminder"
21392180

21402181
static void cbm_install_session_reminder_script(const char *home) {
21412182
if (!home) {
@@ -2150,13 +2191,33 @@ static void cbm_install_session_reminder_script(const char *home) {
21502191
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
21512192
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
21522193

2194+
cbm_remove_legacy_hook_script(hooks_dir, CMM_SESSION_REMINDER_SCRIPT_LEGACY);
21532195
char script_path[CLI_BUF_1K];
21542196
snprintf(script_path, sizeof(script_path), "%s/" CMM_SESSION_REMINDER_SCRIPT, hooks_dir);
21552197

21562198
FILE *f = fopen(script_path, "w");
21572199
if (!f) {
21582200
return;
21592201
}
2202+
#ifdef _WIN32
2203+
/* cmd variant: echo per line; `|` must be caret-escaped in cmd. */
2204+
(void)fprintf(
2205+
f,
2206+
"@echo off\r\n"
2207+
"REM SessionStart hook: remind agent to use codebase-memory-mcp tools.\r\n"
2208+
"echo CRITICAL - Code Discovery Protocol:\r\n"
2209+
"echo 1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\r\n"
2210+
"echo - search_graph(name_pattern/label/qn_pattern) to find functions/classes/routes\r\n"
2211+
"echo - trace_path(function_name, mode=calls^|data_flow^|cross_service) for call "
2212+
"chains\r\n"
2213+
"echo - get_code_snippet(qualified_name) for exact symbol source (precise ranges)\r\n"
2214+
"echo - query_graph(query) for complex Cypher patterns\r\n"
2215+
"echo - get_architecture(aspects) for project structure\r\n"
2216+
"echo - search_code(pattern) for text search (graph-augmented grep)\r\n"
2217+
"echo 2. Use Grep/Glob/Read freely for text, configs, non-code files, and\r\n"
2218+
"echo always Read a file before editing it.\r\n"
2219+
"echo 3. If a project is not indexed yet, run index_repository FIRST.\r\n");
2220+
#else
21602221
(void)fprintf(
21612222
f, "#!/usr/bin/env bash\n"
21622223
"# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n"
@@ -2174,6 +2235,7 @@ static void cbm_install_session_reminder_script(const char *home) {
21742235
" always Read a file before editing it.\n"
21752236
"3. If a project is not indexed yet, run index_repository FIRST.\n"
21762237
"REMINDER\n");
2238+
#endif
21772239
#ifndef _WIN32
21782240
fchmod(fileno(f), CLI_OCTAL_PERM);
21792241
#endif
@@ -2220,7 +2282,12 @@ static int cbm_remove_session_hooks(const char *settings_path) {
22202282
* The text is a leaner variant of the SessionStart protocol: it omits the
22212283
* "run index_repository first" step, since the parent session has already
22222284
* indexed the project. Matcher "*" fires for every agent type. */
2285+
#ifdef _WIN32
2286+
#define CMM_SUBAGENT_REMINDER_SCRIPT "cbm-subagent-reminder.cmd"
2287+
#else
22232288
#define CMM_SUBAGENT_REMINDER_SCRIPT "cbm-subagent-reminder"
2289+
#endif
2290+
#define CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY "cbm-subagent-reminder"
22242291

22252292
static void cbm_install_subagent_reminder_script(const char *home) {
22262293
if (!home) {
@@ -2235,6 +2302,7 @@ static void cbm_install_subagent_reminder_script(const char *home) {
22352302
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
22362303
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
22372304

2305+
cbm_remove_legacy_hook_script(hooks_dir, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY);
22382306
char script_path[CLI_BUF_1K];
22392307
snprintf(script_path, sizeof(script_path), "%s/" CMM_SUBAGENT_REMINDER_SCRIPT, hooks_dir);
22402308

@@ -2245,6 +2313,17 @@ static void cbm_install_subagent_reminder_script(const char *home) {
22452313
/* The additionalContext value is a single line with no embedded quotes,
22462314
* backslashes, or newlines, so the JSON below is valid as written — no
22472315
* runtime escaping (and no python3/jq dependency) is required. */
2316+
#ifdef _WIN32
2317+
/* cmd variant: one echo with the JSON verbatim (quotes are literal in
2318+
* cmd echo; no cmd metacharacters appear in the payload). */
2319+
(void)fprintf(f, "@echo off\r\n"
2320+
"REM SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\r\n"
2321+
"echo {\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\","
2322+
"\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools "
2323+
"(search_graph, trace_path, get_code_snippet, query_graph, get_architecture, "
2324+
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
2325+
"text, configs, and non-code files.\"}}\r\n");
2326+
#else
22482327
(void)fprintf(f,
22492328
"#!/usr/bin/env bash\n"
22502329
"# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n"
@@ -2257,6 +2336,7 @@ static void cbm_install_subagent_reminder_script(const char *home) {
22572336
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
22582337
"text, configs, and non-code files.\"}}\n"
22592338
"REMINDER\n");
2339+
#endif
22602340
#ifndef _WIN32
22612341
fchmod(fileno(f), CLI_OCTAL_PERM);
22622342
#endif

tests/test_cli.c

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2467,7 +2467,12 @@ TEST(cli_hook_gate_script_no_predictable_tmp_issue384) {
24672467
cbm_install_hook_gate_script(tmpdir, "/usr/local/bin/codebase-memory-mcp");
24682468

24692469
char script_path[512];
2470+
#ifdef _WIN32
2471+
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate.cmd",
2472+
tmpdir);
2473+
#else
24702474
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate", tmpdir);
2475+
#endif
24712476
const char *data = read_test_file(script_path);
24722477
ASSERT_NOT_NULL(data);
24732478
/* No predictable temp/state file and no PPID-derived path. */
@@ -2480,6 +2485,63 @@ TEST(cli_hook_gate_script_no_predictable_tmp_issue384) {
24802485
PASS();
24812486
}
24822487

2488+
/* #929: on Windows, extensionless bash shims under .claude/hooks trigger the
2489+
* "How do you want to open this file?" dialog when editors (Cursor) scan the
2490+
* dir, and cannot execute without bash. Windows must install .cmd scripts
2491+
* (and remove the extensionless legacy twin on upgrade); POSIX keeps the
2492+
* extensionless bash form with no .cmd twin. */
2493+
TEST(cli_hook_scripts_platform_shape_issue929) {
2494+
char tmpdir[256];
2495+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook929-XXXXXX");
2496+
if (!cbm_mkdtemp(tmpdir))
2497+
FAIL("cbm_mkdtemp failed");
2498+
2499+
char hooks_dir[512];
2500+
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
2501+
2502+
#ifdef _WIN32
2503+
/* Upgrade path: pre-create the pre-#929 extensionless file; the install
2504+
* must remove it so the Open-With trigger disappears. */
2505+
cbm_mkdir_p(hooks_dir, 0755);
2506+
char legacy_path[512];
2507+
snprintf(legacy_path, sizeof(legacy_path), "%s/cbm-code-discovery-gate", hooks_dir);
2508+
write_test_file(legacy_path, "#!/bin/bash\nexit 0\n");
2509+
#endif
2510+
2511+
cbm_install_hook_gate_script(tmpdir, "/usr/local/bin/codebase-memory-mcp");
2512+
2513+
char script_path[512];
2514+
#ifdef _WIN32
2515+
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
2516+
const char *data = read_test_file(script_path);
2517+
ASSERT_NOT_NULL(data);
2518+
ASSERT(strncmp(data, "@echo off", 9) == 0); /* cmd, not bash */
2519+
ASSERT(strstr(data, "hook-augment") != NULL);
2520+
/* Legacy extensionless twin removed on upgrade. */
2521+
FILE *lf = fopen(legacy_path, "r");
2522+
if (lf) {
2523+
fclose(lf);
2524+
FAIL("legacy extensionless hook file still present after install");
2525+
}
2526+
#else
2527+
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate", hooks_dir);
2528+
const char *data = read_test_file(script_path);
2529+
ASSERT_NOT_NULL(data);
2530+
ASSERT(strncmp(data, "#!/usr/bin/env bash", 19) == 0);
2531+
/* No .cmd twin on POSIX. */
2532+
char cmd_path[512];
2533+
snprintf(cmd_path, sizeof(cmd_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
2534+
FILE *cf = fopen(cmd_path, "r");
2535+
if (cf) {
2536+
fclose(cf);
2537+
FAIL(".cmd twin must not exist on POSIX");
2538+
}
2539+
#endif
2540+
2541+
test_rmdir_r(tmpdir);
2542+
PASS();
2543+
}
2544+
24832545
/* issue #618: hook-augment was a structural no-op on Windows because its path
24842546
* guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter
24852547
* cwd (C:/repo) was rejected before any search_graph query. The predicate must
@@ -3302,6 +3364,7 @@ SUITE(cli) {
33023364

33033365
/* Claude Code hooks (5 tests — group D) */
33043366
RUN_TEST(cli_hook_gate_script_no_predictable_tmp_issue384);
3367+
RUN_TEST(cli_hook_scripts_platform_shape_issue929);
33053368
RUN_TEST(cli_hook_augment_path_is_abs);
33063369
RUN_TEST(cli_hook_augment_deadline_breadcrumb_issue858);
33073370
RUN_TEST(cli_upsert_claude_hook_fresh);

0 commit comments

Comments
 (0)