Skip to content

Commit b1502bf

Browse files
committed
fix(install): Windows hook scripts get .cmd form; legacy files removed
Extensionless bash shims under %USERPROFILE%\.claude\hooks triggered the Windows 'How do you want to open this file?' dialog whenever an editor (Cursor) scanned the hooks dir, and could not execute without bash anyway. All three Claude hook scripts (discovery gate, session reminder, subagent reminder) now install as .cmd files with cmd syntax on Windows - @echo off bodies, caret-escaped pipes in the reminder text, 2>/dev/null redirects - and the install removes the pre-existing extensionless twin so upgrades stop triggering the dialog. The settings.json command paths follow automatically since they are built from the same script-name macros. POSIX behavior is byte-identical and guarded (no .cmd twin). Closes #929 Signed-off-by: Martin Vogel <[email protected]>
1 parent db330bf commit b1502bf

2 files changed

Lines changed: 143 additions & 0 deletions

File tree

‎src/cli/cli.c‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,7 +1798,16 @@ int cbm_remove_junie_mcp(const char *config_path) {
17981798
#define CMM_HOOK_MATCHER "Grep|Glob|Read"
17991799
/* Basename only; the full command path is resolved at install time via
18001800
* cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */
1801+
#ifdef _WIN32
1802+
/* #929: extensionless bash shims under %USERPROFILE%\\.claude\\hooks trigger
1803+
* the "How do you want to open this file?" dialog when editors (Cursor) scan
1804+
* the hooks dir, and cannot execute without bash anyway. Windows installs
1805+
* .cmd scripts; the extensionless legacy files are removed on upgrade. */
1806+
#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate.cmd"
1807+
#else
18011808
#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate"
1809+
#endif
1810+
#define CMM_HOOK_GATE_SCRIPT_LEGACY "cbm-code-discovery-gate"
18021811
/* Hard backstop in settings.json; the binary also self-bounds with an
18031812
* in-process deadline well under this. */
18041813
#define CMM_HOOK_TIMEOUT_SEC 5
@@ -2046,6 +2055,20 @@ int cbm_remove_claude_hooks(const char *settings_path) {
20462055
* a missing/old/hung binary results in a silent exit 0 (issue #362/#288).
20472056
* The legacy filename `cbm-code-discovery-gate` is retained so existing
20482057
* settings.json entries and uninstall keep working with zero migration. */
2058+
/* #929 (Windows): remove the pre-.cmd extensionless twin so upgrades stop
2059+
* triggering the Open-With dialog. POSIX keeps the extensionless name, where
2060+
* legacy == current — never unlink there. */
2061+
static void cbm_remove_legacy_hook_script(const char *hooks_dir, const char *legacy_name) {
2062+
#ifdef _WIN32
2063+
char legacy_path[CLI_BUF_1K];
2064+
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_name);
2065+
cbm_unlink(legacy_path);
2066+
#else
2067+
(void)hooks_dir;
2068+
(void)legacy_name;
2069+
#endif
2070+
}
2071+
20492072
void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20502073
if (!home || !binary_path) {
20512074
return;
@@ -2066,13 +2089,25 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20662089
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
20672090
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
20682091

2092+
cbm_remove_legacy_hook_script(hooks_dir, CMM_HOOK_GATE_SCRIPT_LEGACY);
20692093
char script_path[CLI_BUF_1K];
20702094
snprintf(script_path, sizeof(script_path), "%s/" CMM_HOOK_GATE_SCRIPT, hooks_dir);
20712095

20722096
FILE *f = fopen(script_path, "w");
20732097
if (!f) {
20742098
return;
20752099
}
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 Never blocks a tool call - it only adds graph context.\r\n"
2105+
"REM Any failure is silent (exit 0, no output).\r\n"
2106+
"if not exist \"%s\" exit /b 0\r\n"
2107+
"\"%s\" hook-augment 2>NUL\r\n"
2108+
"exit /b 0\r\n",
2109+
binary_path, binary_path);
2110+
#else
20762111
(void)fprintf(f,
20772112
"#!/usr/bin/env bash\n"
20782113
"# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n"
@@ -2084,6 +2119,7 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20842119
"\"$BIN\" hook-augment 2>/dev/null\n"
20852120
"exit 0\n",
20862121
binary_path);
2122+
#endif
20872123
/* fchmod before close to avoid TOCTOU race (CodeQL cpp/toctou-race-condition) */
20882124
#ifndef _WIN32
20892125
fchmod(fileno(f), CLI_OCTAL_PERM);
@@ -2095,7 +2131,12 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
20952131
}
20962132

20972133
/* SessionStart hook: remind agent to use MCP tools on every context reset. */
2134+
#ifdef _WIN32
2135+
#define CMM_SESSION_REMINDER_SCRIPT "cbm-session-reminder.cmd"
2136+
#else
20982137
#define CMM_SESSION_REMINDER_SCRIPT "cbm-session-reminder"
2138+
#endif
2139+
#define CMM_SESSION_REMINDER_SCRIPT_LEGACY "cbm-session-reminder"
20992140

21002141
static void cbm_install_session_reminder_script(const char *home) {
21012142
if (!home) {
@@ -2110,13 +2151,33 @@ static void cbm_install_session_reminder_script(const char *home) {
21102151
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
21112152
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
21122153

2154+
cbm_remove_legacy_hook_script(hooks_dir, CMM_SESSION_REMINDER_SCRIPT_LEGACY);
21132155
char script_path[CLI_BUF_1K];
21142156
snprintf(script_path, sizeof(script_path), "%s/" CMM_SESSION_REMINDER_SCRIPT, hooks_dir);
21152157

21162158
FILE *f = fopen(script_path, "w");
21172159
if (!f) {
21182160
return;
21192161
}
2162+
#ifdef _WIN32
2163+
/* cmd variant: echo per line; `|` must be caret-escaped in cmd. */
2164+
(void)fprintf(
2165+
f,
2166+
"@echo off\r\n"
2167+
"REM SessionStart hook: remind agent to use codebase-memory-mcp tools.\r\n"
2168+
"echo CRITICAL - Code Discovery Protocol:\r\n"
2169+
"echo 1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\r\n"
2170+
"echo - search_graph(name_pattern/label/qn_pattern) to find functions/classes/routes\r\n"
2171+
"echo - trace_path(function_name, mode=calls^|data_flow^|cross_service) for call "
2172+
"chains\r\n"
2173+
"echo - get_code_snippet(qualified_name) for exact symbol source (precise ranges)\r\n"
2174+
"echo - query_graph(query) for complex Cypher patterns\r\n"
2175+
"echo - get_architecture(aspects) for project structure\r\n"
2176+
"echo - search_code(pattern) for text search (graph-augmented grep)\r\n"
2177+
"echo 2. Use Grep/Glob/Read freely for text, configs, non-code files, and\r\n"
2178+
"echo always Read a file before editing it.\r\n"
2179+
"echo 3. If a project is not indexed yet, run index_repository FIRST.\r\n");
2180+
#else
21202181
(void)fprintf(
21212182
f, "#!/usr/bin/env bash\n"
21222183
"# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n"
@@ -2134,6 +2195,7 @@ static void cbm_install_session_reminder_script(const char *home) {
21342195
" always Read a file before editing it.\n"
21352196
"3. If a project is not indexed yet, run index_repository FIRST.\n"
21362197
"REMINDER\n");
2198+
#endif
21372199
#ifndef _WIN32
21382200
fchmod(fileno(f), CLI_OCTAL_PERM);
21392201
#endif
@@ -2180,7 +2242,12 @@ static int cbm_remove_session_hooks(const char *settings_path) {
21802242
* The text is a leaner variant of the SessionStart protocol: it omits the
21812243
* "run index_repository first" step, since the parent session has already
21822244
* indexed the project. Matcher "*" fires for every agent type. */
2245+
#ifdef _WIN32
2246+
#define CMM_SUBAGENT_REMINDER_SCRIPT "cbm-subagent-reminder.cmd"
2247+
#else
21832248
#define CMM_SUBAGENT_REMINDER_SCRIPT "cbm-subagent-reminder"
2249+
#endif
2250+
#define CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY "cbm-subagent-reminder"
21842251

21852252
static void cbm_install_subagent_reminder_script(const char *home) {
21862253
if (!home) {
@@ -2195,6 +2262,7 @@ static void cbm_install_subagent_reminder_script(const char *home) {
21952262
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
21962263
cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM);
21972264

2265+
cbm_remove_legacy_hook_script(hooks_dir, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY);
21982266
char script_path[CLI_BUF_1K];
21992267
snprintf(script_path, sizeof(script_path), "%s/" CMM_SUBAGENT_REMINDER_SCRIPT, hooks_dir);
22002268

@@ -2205,6 +2273,17 @@ static void cbm_install_subagent_reminder_script(const char *home) {
22052273
/* The additionalContext value is a single line with no embedded quotes,
22062274
* backslashes, or newlines, so the JSON below is valid as written — no
22072275
* runtime escaping (and no python3/jq dependency) is required. */
2276+
#ifdef _WIN32
2277+
/* cmd variant: one echo with the JSON verbatim (quotes are literal in
2278+
* cmd echo; no cmd metacharacters appear in the payload). */
2279+
(void)fprintf(f, "@echo off\r\n"
2280+
"REM SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\r\n"
2281+
"echo {\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\","
2282+
"\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools "
2283+
"(search_graph, trace_path, get_code_snippet, query_graph, get_architecture, "
2284+
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
2285+
"text, configs, and non-code files.\"}}\r\n");
2286+
#else
22082287
(void)fprintf(f,
22092288
"#!/usr/bin/env bash\n"
22102289
"# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n"
@@ -2217,6 +2296,7 @@ static void cbm_install_subagent_reminder_script(const char *home) {
22172296
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
22182297
"text, configs, and non-code files.\"}}\n"
22192298
"REMINDER\n");
2299+
#endif
22202300
#ifndef _WIN32
22212301
fchmod(fileno(f), CLI_OCTAL_PERM);
22222302
#endif

‎tests/test_cli.c‎

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

24492449
char script_path[512];
2450+
#ifdef _WIN32
2451+
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate.cmd",
2452+
tmpdir);
2453+
#else
24502454
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate", tmpdir);
2455+
#endif
24512456
const char *data = read_test_file(script_path);
24522457
ASSERT_NOT_NULL(data);
24532458
/* No predictable temp/state file and no PPID-derived path. */
@@ -2460,6 +2465,63 @@ TEST(cli_hook_gate_script_no_predictable_tmp_issue384) {
24602465
PASS();
24612466
}
24622467

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

32113273
/* Claude Code hooks (5 tests — group D) */
32123274
RUN_TEST(cli_hook_gate_script_no_predictable_tmp_issue384);
3275+
RUN_TEST(cli_hook_scripts_platform_shape_issue929);
32133276
RUN_TEST(cli_hook_augment_path_is_abs);
32143277
RUN_TEST(cli_upsert_claude_hook_fresh);
32153278
RUN_TEST(cli_upsert_claude_hook_existing);

0 commit comments

Comments
 (0)