diff --git a/README.md b/README.md index 678db3b9..238f424c 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,9 @@ injects a one-line code-discovery reminder as session context (Gemini CLI also keeps its `BeforeTool` reminder). The installed Claude shim file is named `cbm-code-discovery-gate` for backward compatibility with existing installs; despite the legacy name it -never gates and never blocks. +never gates and never blocks. On Windows the shim is written as +`cbm-code-discovery-gate.cmd` so Cursor/Claude Code can execute it without +the "Open with" dialog (extensionless bash scripts are not runnable on Windows). ## CLI Mode diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 34bdc3d6..34491581 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -946,22 +946,23 @@ fi echo "OK 8d: Claude Code PreToolUse hook (matcher=Grep|Glob, Read excluded)" # 8e: Claude Code shim script — must be non-blocking augmenter, not a gate. -if [ "$(uname -s)" != "MINGW64_NT" ] 2>/dev/null; then - GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate" - if [ ! -x "$GATE_SCRIPT" ]; then - echo "FAIL 8e: shim script not executable or missing" - exit 1 - fi - if grep -q 'exit 2' "$GATE_SCRIPT"; then - echo "FAIL 8e: shim contains 'exit 2' — must never block" - exit 1 - fi - if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then - echo "FAIL 8e: shim missing 'hook-augment' delegation" - exit 1 - fi - echo "OK 8e: shim installed, non-blocking, delegates to hook-augment" +GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate" +if [ "$(uname -s)" = "MINGW64_NT" ] 2>/dev/null || [ "$(uname -s)" = "MSYS_NT" ] 2>/dev/null; then + GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate.cmd" +fi +if [ ! -f "$GATE_SCRIPT" ]; then + echo "FAIL 8e: shim script missing ($GATE_SCRIPT)" + exit 1 +fi +if grep -q 'exit 2' "$GATE_SCRIPT"; then + echo "FAIL 8e: shim contains 'exit 2' — must never block" + exit 1 +fi +if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then + echo "FAIL 8e: shim missing 'hook-augment' delegation" + exit 1 fi +echo "OK 8e: shim installed, non-blocking, delegates to hook-augment" # 8f-8h: Codex TOML if ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$FAKE_HOME/.codex/config.toml"; then diff --git a/src/cli/cli.c b/src/cli/cli.c index 142d8a88..91023467 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1148,20 +1148,46 @@ static void cbm_claude_user_root(const char *home_dir, char *out, size_t out_sz) } } +#ifdef _WIN32 +#define CMM_HOOK_SCRIPT_EXT ".cmd" +#else +#define CMM_HOOK_SCRIPT_EXT "" +#endif + +/* Append the platform hook script extension (e.g. ".cmd" on Windows). */ +static void cbm_hook_script_filename(const char *script_name, char *out, size_t out_sz) { + if (out_sz == 0) { + return; + } + snprintf(out, out_sz, "%s%s", script_name, CMM_HOOK_SCRIPT_EXT); +} + /* Build the hook command string written into Claude Code's settings.json. - * Honors $CLAUDE_CONFIG_DIR. When CLAUDE_CONFIG_DIR is unset, preserves the - * legacy tilde-expanded form so settings.json stays portable across HOME values. */ + * Honors $CLAUDE_CONFIG_DIR. On Unix, when CLAUDE_CONFIG_DIR is unset, preserves + * the legacy tilde-expanded form so settings.json stays portable across HOME values. + * On Windows, writes an absolute path to a .cmd wrapper — extensionless bash shims + * trigger the "Open with" dialog when Cursor/Claude Code spawn hook commands. */ static void cbm_resolve_hook_command(const char *script_name, char *out, size_t out_sz) { if (out_sz == 0) { return; } out[0] = '\0'; char env_buf[CLI_BUF_1K]; + char filename[CLI_BUF_256]; + cbm_hook_script_filename(script_name, filename, sizeof(filename)); const char *env = cbm_safe_getenv("CLAUDE_CONFIG_DIR", env_buf, sizeof(env_buf), NULL); if (env && env[0]) { - snprintf(out, out_sz, "%s/hooks/%s", env, script_name); + snprintf(out, out_sz, "%s/hooks/%s", env, filename); } else { - snprintf(out, out_sz, "~/.claude/hooks/%s", script_name); +#ifdef _WIN32 + const char *home = cbm_get_home_dir(); + if (!home || !home[0]) { + return; + } + snprintf(out, out_sz, "%s/.claude/hooks/%s", home, filename); +#else + snprintf(out, out_sz, "~/.claude/hooks/%s", filename); +#endif } } @@ -2063,12 +2089,25 @@ void cbm_install_hook_gate_script(const char *home, const char *binary_path) { cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM); char script_path[CLI_BUF_1K]; - snprintf(script_path, sizeof(script_path), "%s/" CMM_HOOK_GATE_SCRIPT, hooks_dir); + char script_name[CLI_BUF_256]; + cbm_hook_script_filename(CMM_HOOK_GATE_SCRIPT, script_name, sizeof(script_name)); + snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, script_name); FILE *f = fopen(script_path, "w"); if (!f) { return; } +#ifdef _WIN32 + (void)fprintf(f, + "@echo off\r\n" + "REM codebase-memory-mcp search augmenter (Claude Code PreToolUse).\r\n" + "REM NOTE: the legacy basename is kept for zero-migration upgrades.\r\n" + "REM Despite the name this NEVER blocks a tool call - it only adds\r\n" + "REM graph context. Any failure is silent (exit 0, no output).\r\n" + "\"%s\" hook-augment 2>nul\r\n" + "exit /b 0\r\n", + binary_path); +#else (void)fprintf(f, "#!/usr/bin/env bash\n" "# 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) { "\"$BIN\" hook-augment 2>/dev/null\n" "exit 0\n", binary_path); +#endif /* fchmod before close to avoid TOCTOU race (CodeQL cpp/toctou-race-condition) */ #ifndef _WIN32 fchmod(fileno(f), CLI_OCTAL_PERM); @@ -2107,12 +2147,34 @@ static void cbm_install_session_reminder_script(const char *home) { cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM); char script_path[CLI_BUF_1K]; - snprintf(script_path, sizeof(script_path), "%s/" CMM_SESSION_REMINDER_SCRIPT, hooks_dir); + char script_name[CLI_BUF_256]; + cbm_hook_script_filename(CMM_SESSION_REMINDER_SCRIPT, script_name, sizeof(script_name)); + snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, script_name); FILE *f = fopen(script_path, "w"); if (!f) { return; } +#ifdef _WIN32 + (void)fprintf( + f, "@echo off\r\n" + "REM SessionStart hook: remind agent to use codebase-memory-mcp tools.\r\n" + "echo CRITICAL - Code Discovery Protocol:\r\n" + "echo 1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\r\n" + "echo - search_graph(name_pattern/label/qn_pattern) to find " + "functions/classes/routes\r\n" + "echo - trace_path(function_name, mode=calls^|data_flow^|cross_service) for " + "call chains\r\n" + "echo - get_code_snippet(qualified_name) for exact symbol source (precise " + "ranges)\r\n" + "echo - query_graph(query) for complex Cypher patterns\r\n" + "echo - get_architecture(aspects) for project structure\r\n" + "echo - search_code(pattern) for text search (graph-augmented grep)\r\n" + "echo 2. Use Grep/Glob/Read freely for text, configs, non-code files, and\r\n" + "echo always Read a file before editing it.\r\n" + "echo 3. If a project is not indexed yet, run index_repository FIRST.\r\n" + "exit /b 0\r\n"); +#else (void)fprintf( f, "#!/usr/bin/env bash\n" "# 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) { " always Read a file before editing it.\n" "3. If a project is not indexed yet, run index_repository FIRST.\n" "REMINDER\n"); +#endif #ifndef _WIN32 fchmod(fileno(f), CLI_OCTAL_PERM); #endif @@ -2192,15 +2255,25 @@ static void cbm_install_subagent_reminder_script(const char *home) { cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM); char script_path[CLI_BUF_1K]; - snprintf(script_path, sizeof(script_path), "%s/" CMM_SUBAGENT_REMINDER_SCRIPT, hooks_dir); + char script_name[CLI_BUF_256]; + cbm_hook_script_filename(CMM_SUBAGENT_REMINDER_SCRIPT, script_name, sizeof(script_name)); + snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, script_name); FILE *f = fopen(script_path, "w"); if (!f) { return; } - /* The additionalContext value is a single line with no embedded quotes, - * backslashes, or newlines, so the JSON below is valid as written — no - * runtime escaping (and no python3/jq dependency) is required. */ +#ifdef _WIN32 + (void)fprintf( + f, "@echo off\r\n" + "REM SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\r\n" + "echo {\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\"," + "\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools " + "(search_graph, trace_path, get_code_snippet, query_graph, get_architecture, " + "search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for " + "text, configs, and non-code files.\"}}\r\n" + "exit /b 0\r\n"); +#else (void)fprintf(f, "#!/usr/bin/env bash\n" "# 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) { "search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for " "text, configs, and non-code files.\"}}\n" "REMINDER\n"); +#endif #ifndef _WIN32 fchmod(fileno(f), CLI_OCTAL_PERM); #endif diff --git a/tests/windows/test_hook_scripts.py b/tests/windows/test_hook_scripts.py new file mode 100644 index 00000000..8268bd4a --- /dev/null +++ b/tests/windows/test_hook_scripts.py @@ -0,0 +1,86 @@ +r"""GREEN regression guard — Claude Code hook shims are .cmd wrappers on Windows. + +On Windows, Cursor and Claude Code spawn hook commands without a shell. The +installer previously wrote extensionless bash scripts (`cbm-code-discovery-gate`) +into `~/.claude/hooks/`, which triggered the "Open with" dialog and blocked +workflows until the user picked an app. + +The fix writes `.cmd` wrappers and points settings.json at the absolute `.cmd` +path so CreateProcess can execute them directly. + +Exit code: 0 == pass, 1 == regression, 2 == setup error. + +Usage: + python test_hook_scripts.py +""" +import json +import os +import subprocess +import sys +import tempfile + + +def run_install(binary, fake_home): + env = dict(os.environ) + env["USERPROFILE"] = fake_home + env["HOME"] = fake_home + env["APPDATA"] = os.path.join(fake_home, "AppData", "Roaming") + env["LOCALAPPDATA"] = os.path.join(fake_home, "AppData", "Local") + env["XDG_CONFIG_HOME"] = os.path.join(fake_home, ".config") + env["PATH"] = os.path.dirname(binary) + os.pathsep + env.get("PATH", "") + os.makedirs(os.path.join(fake_home, ".claude"), exist_ok=True) + return subprocess.run([binary, "install", "-y"], capture_output=True, timeout=120, env=env) + + +def main(): + if len(sys.argv) < 2: + print("usage: python test_hook_scripts.py ") + return 2 + binary = os.path.abspath(sys.argv[1]) + if not os.path.exists(binary): + print("FAIL: binary not found: %s" % binary) + return 2 + + work = tempfile.mkdtemp(prefix="cbm_win_hooks_") + try: + result = run_install(binary, work) + out = (result.stdout or b"").decode("utf-8", "replace") + err = (result.stderr or b"").decode("utf-8", "replace") + if result.returncode != 0: + print("SETUP FAIL: install -y exit %d\n%s\n%s" % (result.returncode, out[:500], err[:500])) + return 2 + + gate = os.path.join(work, ".claude", "hooks", "cbm-code-discovery-gate.cmd") + reminder = os.path.join(work, ".claude", "hooks", "cbm-session-reminder.cmd") + settings = os.path.join(work, ".claude", "settings.json") + + for path in (gate, reminder, settings): + if not os.path.isfile(path): + print("FAIL: missing %s" % path) + return 1 + + with open(gate, "r", encoding="utf-8", errors="replace") as f: + gate_body = f.read() + if "hook-augment" not in gate_body or "@echo off" not in gate_body: + print("FAIL: gate .cmd missing expected content") + return 1 + + with open(settings, "r", encoding="utf-8") as f: + settings_doc = json.load(f) + hooks = settings_doc.get("hooks", {}).get("PreToolUse", []) + commands = [] + for entry in hooks: + for h in entry.get("hooks", []): + commands.append(h.get("command", "")) + if not any("cbm-code-discovery-gate.cmd" in c for c in commands): + print("FAIL: settings.json PreToolUse command missing .cmd wrapper: %s" % commands) + return 1 + + print("PASS: Windows hook .cmd wrappers installed and wired in settings.json") + return 0 + finally: + pass + + +if __name__ == "__main__": + sys.exit(main())