diff --git a/scripts/security-install.sh b/scripts/security-install.sh index ea5fdcbf..b9e2d4e3 100755 --- a/scripts/security-install.sh +++ b/scripts/security-install.sh @@ -121,7 +121,7 @@ fi echo "" echo "--- Auditing skill file content ---" -SKILLS_DIR="$HOME/.claude/skills" +SKILLS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills" if [[ -d "$SKILLS_DIR" ]]; then SKILL_ISSUES=0 diff --git a/scripts/setup.sh b/scripts/setup.sh index 2c1873f7..81a0508d 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -209,13 +209,14 @@ build_from_source() { configure_claude() { echo "" local binary_path="${INSTALL_DIR}/${BINARY_NAME}" - local settings_file="$HOME/.claude/settings.json" + local claude_config_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + local settings_file="${claude_config_dir}/settings.json" printf "%s" "${BOLD}Configure Claude Code to use codebase-memory-mcp? [y/N] ${RESET}" read -r answer if [[ ! "$answer" =~ ^[Yy]$ ]]; then echo "" - info "Add this to your .mcp.json or ~/.claude/settings.json:" + info "Add this to your .mcp.json or ${claude_config_dir}/settings.json:" echo "" echo ' {' echo ' "mcpServers": {' diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 8444f535..95851fb9 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -941,6 +941,154 @@ fi rm -rf "$FAKE_HOME" "$EMPTY_HOME" +echo "" +echo "=== Phase 9c: CLAUDE_CONFIG_DIR honored by install/uninstall ===" + +# Verify install/uninstall write to $CLAUDE_CONFIG_DIR (not $HOME/.claude) +# when the env var is set. Mirrors Phase 8/9 but with CLAUDE_CONFIG_DIR +# pointing at a non-default location. +CCD_HOME=$(mktemp -d) +CCD_DIR="$CCD_HOME/custom-claude" +mkdir -p "$CCD_HOME/.local/bin" +mkdir -p "$CCD_DIR" + +# A pre-existing $HOME/.claude must NOT receive new writes from this install, +# but it must still be detected so the migration nudge can fire. +mkdir -p "$CCD_HOME/.claude" + +if [[ "$BINARY" == *.exe ]]; then + cp "$BINARY" "$CCD_HOME/.local/bin/codebase-memory-mcp.exe" + CCD_SELF="$CCD_HOME/.local/bin/codebase-memory-mcp.exe" +else + cp "$BINARY" "$CCD_HOME/.local/bin/codebase-memory-mcp" + CCD_SELF="$CCD_HOME/.local/bin/codebase-memory-mcp" +fi + +CCD_INSTALL_OUT=$(HOME="$CCD_HOME" \ + CLAUDE_CONFIG_DIR="$CCD_DIR" \ + XDG_CONFIG_HOME="$CCD_HOME/.config" \ + PATH="$CCD_HOME/.local/bin:$PATH" \ + "$BINARY" install -y 2>&1 || true) + +# 9c-1: skills landed in $CLAUDE_CONFIG_DIR/skills, not $HOME/.claude/skills +if [ ! -s "$CCD_DIR/skills/codebase-memory/SKILL.md" ]; then + echo "FAIL 9c-1: SKILL.md missing from \$CLAUDE_CONFIG_DIR/skills" + echo "$CCD_INSTALL_OUT" | head -30 + exit 1 +fi +if [ -e "$CCD_HOME/.claude/skills/codebase-memory/SKILL.md" ]; then + echo "FAIL 9c-1b: install wrote SKILL.md to \$HOME/.claude/skills (should have been \$CLAUDE_CONFIG_DIR)" + exit 1 +fi +echo "OK 9c-1: skills under \$CLAUDE_CONFIG_DIR (not \$HOME/.claude)" + +# 9c-2: settings.json landed in $CLAUDE_CONFIG_DIR +if [ ! -f "$CCD_DIR/settings.json" ]; then + echo "FAIL 9c-2: settings.json missing from \$CLAUDE_CONFIG_DIR" + exit 1 +fi +if [ -f "$CCD_HOME/.claude/settings.json" ]; then + echo "FAIL 9c-2b: install wrote settings.json to \$HOME/.claude (should have been \$CLAUDE_CONFIG_DIR)" + exit 1 +fi +echo "OK 9c-2: settings.json under \$CLAUDE_CONFIG_DIR" + +# 9c-3: .mcp.json landed in $CLAUDE_CONFIG_DIR +CMD=$(json_get "$CCD_DIR/.mcp.json" "d['mcpServers']['codebase-memory-mcp']['command']") +if ! path_match "$CMD" "$CCD_SELF"; then + echo "FAIL 9c-3: \$CLAUDE_CONFIG_DIR/.mcp.json command='$CMD'" + exit 1 +fi +echo "OK 9c-3: .mcp.json under \$CLAUDE_CONFIG_DIR" + +# 9c-4: .claude.json landed alongside $CLAUDE_CONFIG_DIR (Claude Code's user config) +CMD=$(json_get "$CCD_DIR/.claude.json" "d.get('mcpServers',{}).get('codebase-memory-mcp',{}).get('command','')") +if [ -z "$CMD" ] || ! path_match "$CMD" "$CCD_SELF"; then + echo "FAIL 9c-4: \$CLAUDE_CONFIG_DIR/.claude.json command='$CMD'" + exit 1 +fi +if [ -f "$CCD_HOME/.claude.json" ]; then + echo "FAIL 9c-4b: install wrote .claude.json to \$HOME (should have been \$CLAUDE_CONFIG_DIR)" + exit 1 +fi +echo "OK 9c-4: .claude.json under \$CLAUDE_CONFIG_DIR" + +# 9c-5: hook gate script landed in $CLAUDE_CONFIG_DIR/hooks +if [ "$(uname -s)" != "MINGW64_NT" ] 2>/dev/null; then + if [ ! -x "$CCD_DIR/hooks/cbm-code-discovery-gate" ]; then + echo "FAIL 9c-5: gate script missing or not executable in \$CLAUDE_CONFIG_DIR/hooks" + exit 1 + fi + if [ -e "$CCD_HOME/.claude/hooks/cbm-code-discovery-gate" ]; then + echo "FAIL 9c-5b: install wrote gate script to \$HOME/.claude/hooks" + exit 1 + fi + echo "OK 9c-5: gate script under \$CLAUDE_CONFIG_DIR/hooks" +fi + +# 9c-6: hook command in settings.json points at $CLAUDE_CONFIG_DIR (not ~) +HOOK_CMD=$(json_get "$CCD_DIR/settings.json" \ + "d.get('hooks',{}).get('PreToolUse',[{}])[0].get('hooks',[{}])[0].get('command','')") +case "$HOOK_CMD" in + "$CCD_DIR/hooks/cbm-code-discovery-gate") echo "OK 9c-6: settings.json hook command points at \$CLAUDE_CONFIG_DIR" ;; + "~/.claude/hooks/"*) + echo "FAIL 9c-6: settings.json still has tilde-form command='$HOOK_CMD' under \$CLAUDE_CONFIG_DIR" + exit 1 + ;; + *) + echo "FAIL 9c-6: unexpected hook command='$HOOK_CMD'" + exit 1 + ;; +esac + +# 9c-7: migration nudge fires (legacy ~/.claude exists, $CLAUDE_CONFIG_DIR differs) +if ! echo "$CCD_INSTALL_OUT" | grep -q 'CLAUDE_CONFIG_DIR'; then + echo "FAIL 9c-7: migration nudge missing from install output" + exit 1 +fi +echo "OK 9c-7: migration nudge surfaced legacy \$HOME/.claude" + +# 9c-8: uninstall removes from $CLAUDE_CONFIG_DIR +HOME="$CCD_HOME" \ + CLAUDE_CONFIG_DIR="$CCD_DIR" \ + XDG_CONFIG_HOME="$CCD_HOME/.config" \ + PATH="$CCD_HOME/.local/bin:$PATH" \ + "$BINARY" uninstall -y -n 2>&1 >/dev/null || true + +if [ -d "$CCD_DIR/skills/codebase-memory" ]; then + echo "FAIL 9c-8: uninstall left skill behind in \$CLAUDE_CONFIG_DIR" + exit 1 +fi +echo "OK 9c-8: uninstall removed skill from \$CLAUDE_CONFIG_DIR" + +# 9c-9: uninstall does NOT touch the legacy $HOME/.claude (it stays as we found it) +if [ ! -d "$CCD_HOME/.claude" ]; then + echo "FAIL 9c-9: uninstall removed pre-existing \$HOME/.claude (must leave it alone)" + exit 1 +fi +echo "OK 9c-9: uninstall left legacy \$HOME/.claude alone" + +# 9c-10: with CLAUDE_CONFIG_DIR unset, the legacy tilde-form is preserved in settings.json +LEGACY_HOME=$(mktemp -d) +mkdir -p "$LEGACY_HOME/.local/bin" "$LEGACY_HOME/.claude" +cp "$BINARY" "$LEGACY_HOME/.local/bin/codebase-memory-mcp" +HOME="$LEGACY_HOME" \ + XDG_CONFIG_HOME="$LEGACY_HOME/.config" \ + PATH="$LEGACY_HOME/.local/bin:$PATH" \ + "$BINARY" install -y 2>&1 >/dev/null || true +LEGACY_HOOK_CMD=$(json_get "$LEGACY_HOME/.claude/settings.json" \ + "d.get('hooks',{}).get('PreToolUse',[{}])[0].get('hooks',[{}])[0].get('command','')") +case "$LEGACY_HOOK_CMD" in + "~/.claude/hooks/cbm-code-discovery-gate") echo "OK 9c-10: legacy default preserves ~/.claude/hooks/... in settings.json" ;; + *) + echo "FAIL 9c-10: legacy default lost tilde-form, command='$LEGACY_HOOK_CMD'" + exit 1 + ;; +esac +rm -rf "$LEGACY_HOME" + +rm -rf "$CCD_HOME" + echo "" echo "=== Phase 10: binary security E2E ===" diff --git a/src/cli/cli.c b/src/cli/cli.c index dd3e6d30..226384f5 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -969,6 +969,55 @@ static bool dir_exists(const char *path) { return stat(path, &st) == 0 && S_ISDIR(st.st_mode); } +/* Resolve the Claude Code config dir. + * Honors $CLAUDE_CONFIG_DIR; falls back to "$home_dir/.claude". */ +static void cbm_claude_config_dir(const char *home_dir, char *out, size_t out_sz) { + if (out_sz == 0) { + return; + } + out[0] = '\0'; + char env_buf[CLI_BUF_1K]; + const char *env = cbm_safe_getenv("CLAUDE_CONFIG_DIR", env_buf, sizeof(env_buf), NULL); + if (env && env[0]) { + snprintf(out, out_sz, "%s", env); + } else if (home_dir && home_dir[0]) { + snprintf(out, out_sz, "%s/.claude", home_dir); + } +} + +/* Resolve the parent dir containing `.claude.json` (Claude Code's user config file). + * Honors $CLAUDE_CONFIG_DIR; falls back to "$home_dir". */ +static void cbm_claude_user_root(const char *home_dir, char *out, size_t out_sz) { + if (out_sz == 0) { + return; + } + out[0] = '\0'; + char env_buf[CLI_BUF_1K]; + const char *env = cbm_safe_getenv("CLAUDE_CONFIG_DIR", env_buf, sizeof(env_buf), NULL); + if (env && env[0]) { + snprintf(out, out_sz, "%s", env); + } else if (home_dir && home_dir[0]) { + snprintf(out, out_sz, "%s", home_dir); + } +} + +/* 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. */ +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]; + 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); + } else { + snprintf(out, out_sz, "~/.claude/hooks/%s", script_name); + } +} + cbm_detected_agents_t cbm_detect_agents(const char *home_dir) { cbm_detected_agents_t agents; memset(&agents, 0, sizeof(agents)); @@ -978,8 +1027,8 @@ cbm_detected_agents_t cbm_detect_agents(const char *home_dir) { char path[CLI_BUF_1K]; - snprintf(path, sizeof(path), "%s/.claude", home_dir); - agents.claude_code = dir_exists(path); + cbm_claude_config_dir(home_dir, path, sizeof(path)); + agents.claude_code = path[0] != '\0' && dir_exists(path); snprintf(path, sizeof(path), "%s/.codex", home_dir); agents.codex = dir_exists(path); @@ -1463,7 +1512,7 @@ int cbm_remove_antigravity_mcp(const char *config_path) { /* ── Claude Code pre-tool hooks ───────────────────────────────── */ #define CMM_HOOK_MATCHER "Grep|Glob|Read|Search" -#define CMM_HOOK_COMMAND "~/.claude/hooks/cbm-code-discovery-gate" +#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate" /* Old matcher values from previous versions — recognized during upgrade so * upsert_hooks_json can remove them before inserting the current matcher. */ @@ -1630,8 +1679,10 @@ static int remove_hooks_json(hooks_remove_args_t args) { } int cbm_upsert_claude_hooks(const char *settings_path) { + char command[CLI_BUF_1K]; + cbm_resolve_hook_command(CMM_HOOK_GATE_SCRIPT, command, sizeof(command)); return upsert_hooks_json( - (hooks_upsert_args_t){settings_path, "PreToolUse", CMM_HOOK_MATCHER, CMM_HOOK_COMMAND}); + (hooks_upsert_args_t){settings_path, "PreToolUse", CMM_HOOK_MATCHER, command}); } int cbm_remove_claude_hooks(const char *settings_path) { @@ -1646,12 +1697,17 @@ static void cbm_install_hook_gate_script(const char *home) { if (!home) { return; } + char config_dir[CLI_BUF_1K]; + cbm_claude_config_dir(home, config_dir, sizeof(config_dir)); + if (!config_dir[0]) { + return; + } char hooks_dir[CLI_BUF_1K]; - snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", home); + snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM); char script_path[CLI_BUF_1K]; - snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate", hooks_dir); + snprintf(script_path, sizeof(script_path), "%s/" CMM_HOOK_GATE_SCRIPT, hooks_dir); FILE *f = fopen(script_path, "w"); if (!f) { @@ -1684,18 +1740,23 @@ static void cbm_install_hook_gate_script(const char *home) { } /* SessionStart hook: remind agent to use MCP tools on every context reset. */ -#define CMM_SESSION_COMMAND "~/.claude/hooks/cbm-session-reminder" +#define CMM_SESSION_REMINDER_SCRIPT "cbm-session-reminder" static void cbm_install_session_reminder_script(const char *home) { if (!home) { return; } + char config_dir[CLI_BUF_1K]; + cbm_claude_config_dir(home, config_dir, sizeof(config_dir)); + if (!config_dir[0]) { + return; + } char hooks_dir[CLI_BUF_1K]; - snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", home); + snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); cbm_mkdir_p(hooks_dir, CLI_OCTAL_PERM); char script_path[CLI_BUF_1K]; - snprintf(script_path, sizeof(script_path), "%s/cbm-session-reminder", hooks_dir); + snprintf(script_path, sizeof(script_path), "%s/" CMM_SESSION_REMINDER_SCRIPT, hooks_dir); FILE *f = fopen(script_path, "w"); if (!f) { @@ -1728,10 +1789,12 @@ static void cbm_install_session_reminder_script(const char *home) { static int cbm_upsert_session_hooks(const char *settings_path) { static const char *matchers[] = {"startup", "resume", "clear", "compact"}; + char command[CLI_BUF_1K]; + cbm_resolve_hook_command(CMM_SESSION_REMINDER_SCRIPT, command, sizeof(command)); int rc = 0; for (int i = 0; i < NUM_DIRS; i++) { - if (upsert_hooks_json((hooks_upsert_args_t){settings_path, "SessionStart", matchers[i], - CMM_SESSION_COMMAND}) != 0) { + if (upsert_hooks_json( + (hooks_upsert_args_t){settings_path, "SessionStart", matchers[i], command}) != 0) { rc = CLI_ERR; } } @@ -2625,8 +2688,13 @@ static void print_detected_agents(const cbm_detected_agents_t *a) { /* Install Claude Code-specific configs (skills, MCP, hooks). */ static void install_claude_code_config(const char *home, const char *binary_path, bool force, bool dry_run) { + char config_dir[CLI_BUF_1K]; + cbm_claude_config_dir(home, config_dir, sizeof(config_dir)); + char user_root[CLI_BUF_1K]; + cbm_claude_user_root(home, user_root, sizeof(user_root)); + char skills_dir[CLI_BUF_1K]; - snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); printf("Claude Code:\n"); int skill_count = cbm_install_skills(skills_dir, force, dry_run); @@ -2637,21 +2705,21 @@ static void install_claude_code_config(const char *home, const char *binary_path } char mcp_path[CLI_BUF_1K]; - snprintf(mcp_path, sizeof(mcp_path), "%s/.claude/.mcp.json", home); + snprintf(mcp_path, sizeof(mcp_path), "%s/.mcp.json", config_dir); if (!dry_run) { cbm_install_editor_mcp(binary_path, mcp_path); } printf(" mcp: %s\n", mcp_path); char mcp_path2[CLI_BUF_1K]; - snprintf(mcp_path2, sizeof(mcp_path2), "%s/.claude.json", home); + snprintf(mcp_path2, sizeof(mcp_path2), "%s/.claude.json", user_root); if (!dry_run) { cbm_install_editor_mcp(binary_path, mcp_path2); } printf(" mcp: %s\n", mcp_path2); char settings_path[CLI_BUF_1K]; - snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir); if (!dry_run) { cbm_upsert_claude_hooks(settings_path); cbm_install_hook_gate_script(home); @@ -2660,6 +2728,20 @@ static void install_claude_code_config(const char *home, const char *binary_path } printf(" hooks: PreToolUse (code discovery gate)\n"); printf(" hooks: SessionStart (MCP usage reminder on startup/resume/clear/compact)\n"); + + /* Migration nudge: when CLAUDE_CONFIG_DIR is set and a legacy ~/.claude tree + * still exists, mention it so users can clean up stale artifacts. */ + if (home && home[0]) { + char legacy_dir[CLI_BUF_1K]; + snprintf(legacy_dir, sizeof(legacy_dir), "%s/.claude", home); + if (strcmp(legacy_dir, config_dir) != 0 && dir_exists(legacy_dir)) { + (void)fprintf(stderr, + " note: $CLAUDE_CONFIG_DIR=%s used; legacy %s still exists.\n" + " Remove stale {skills,hooks,settings.json,.mcp.json} there if " + "no longer needed.\n", + config_dir, legacy_dir); + } + } } /* Install MCP config + optional instructions for a generic agent. */ @@ -2945,26 +3027,31 @@ int cbm_cmd_install(int argc, char **argv) { /* Remove Claude Code agent configs. */ static void uninstall_claude_code(const char *home, bool dry_run) { + char config_dir[CLI_BUF_1K]; + cbm_claude_config_dir(home, config_dir, sizeof(config_dir)); + char user_root[CLI_BUF_1K]; + cbm_claude_user_root(home, user_root, sizeof(user_root)); + char skills_dir[CLI_BUF_1K]; - snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); int removed = cbm_remove_skills(skills_dir, dry_run); printf("Claude Code: removed %d skill(s)\n", removed); char mcp_path[CLI_BUF_1K]; - snprintf(mcp_path, sizeof(mcp_path), "%s/.claude/.mcp.json", home); + snprintf(mcp_path, sizeof(mcp_path), "%s/.mcp.json", config_dir); if (!dry_run) { cbm_remove_editor_mcp(mcp_path); } printf(" removed MCP config entry\n"); char mcp_path2[CLI_BUF_1K]; - snprintf(mcp_path2, sizeof(mcp_path2), "%s/.claude.json", home); + snprintf(mcp_path2, sizeof(mcp_path2), "%s/.claude.json", user_root); if (!dry_run) { cbm_remove_editor_mcp(mcp_path2); } char settings_path[CLI_BUF_1K]; - snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir); if (!dry_run) { cbm_remove_claude_hooks(settings_path); cbm_remove_session_hooks(settings_path); diff --git a/tests/test_cli.c b/tests/test_cli.c index f8e5b654..d618b8df 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1388,9 +1388,48 @@ TEST(cli_detect_agents_finds_claude) { snprintf(dir, sizeof(dir), "%s/.claude", tmpdir); test_mkdirp(dir); + /* Unset CLAUDE_CONFIG_DIR so detection is exercised against home_dir/.claude. */ + const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR"); + char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL; + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + cbm_detected_agents_t agents = cbm_detect_agents(tmpdir); ASSERT_TRUE(agents.claude_code); + if (saved_ccd_copy) { + cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1); + free(saved_ccd_copy); + } + test_rmdir_r(tmpdir); + PASS(); +} + +TEST(cli_detect_agents_finds_claude_via_env) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-env-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + /* Set CLAUDE_CONFIG_DIR to a path outside tmpdir/.claude. + * cbm_detect_agents must report claude_code based on the env-pointed dir, + * even when tmpdir/.claude does not exist. */ + char ccd[512]; + snprintf(ccd, sizeof(ccd), "%s/custom-claude", tmpdir); + test_mkdirp(ccd); + + const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR"); + char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL; + cbm_setenv("CLAUDE_CONFIG_DIR", ccd, 1); + + cbm_detected_agents_t agents = cbm_detect_agents(tmpdir); + ASSERT_TRUE(agents.claude_code); + + if (saved_ccd_copy) { + cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1); + free(saved_ccd_copy); + } else { + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + } test_rmdir_r(tmpdir); PASS(); } @@ -1481,8 +1520,8 @@ TEST(cli_detect_agents_finds_kilocode) { snprintf(dir, sizeof(dir), "%s/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code", tmpdir); #elif defined(_WIN32) - snprintf(dir, sizeof(dir), - "%s/AppData/Roaming/Code/User/globalStorage/kilocode.kilo-code", tmpdir); + snprintf(dir, sizeof(dir), "%s/AppData/Roaming/Code/User/globalStorage/kilocode.kilo-code", + tmpdir); #else snprintf(dir, sizeof(dir), "%s/.config/Code/User/globalStorage/kilocode.kilo-code", tmpdir); #endif @@ -1520,7 +1559,13 @@ TEST(cli_detect_agents_none_found) { /* Empty home dir → no config dirs → no directory-based agents detected. * Note: opencode/aider may still be detected via system fallback paths - * (e.g. /usr/local/bin) so we only assert on directory-based agents. */ + * (e.g. /usr/local/bin) so we only assert on directory-based agents. + * Also unset CLAUDE_CONFIG_DIR so the test runner's real env doesn't + * leak into agent detection. */ + const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR"); + char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL; + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + cbm_detected_agents_t agents = cbm_detect_agents(tmpdir); ASSERT_FALSE(agents.claude_code); ASSERT_FALSE(agents.codex); @@ -1530,6 +1575,10 @@ TEST(cli_detect_agents_none_found) { ASSERT_FALSE(agents.kilocode); ASSERT_FALSE(agents.kiro); + if (saved_ccd_copy) { + cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1); + free(saved_ccd_copy); + } rmdir(tmpdir); PASS(); } @@ -2438,6 +2487,7 @@ SUITE(cli) { /* Agent detection (6 tests — group A) */ RUN_TEST(cli_detect_agents_finds_claude); + RUN_TEST(cli_detect_agents_finds_claude_via_env); RUN_TEST(cli_detect_agents_finds_codex); RUN_TEST(cli_detect_agents_finds_gemini); RUN_TEST(cli_detect_agents_finds_zed);