Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 16 additions & 15 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 84 additions & 10 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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"
Expand All @@ -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);
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions tests/windows/test_hook_scripts.py
Original file line number Diff line number Diff line change
@@ -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 <path-to-codebase-memory-mcp.exe>
"""
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 <binary>")
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())
Loading