From dedd33d975bd6d307fed7427ba2cf3ac66f442f4 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sat, 30 May 2026 15:22:37 +0200 Subject: [PATCH] fix(install): respect $CLAUDE_CONFIG_DIR in install/uninstall/update Route Claude Code config paths (skills, .mcp.json, .claude.json, settings.json, hook scripts) and agent detection through CLAUDE_CONFIG_DIR-aware helpers, falling back to ~/.claude. Hook command strings written to settings.json keep the legacy tilde form when the env var is unset, so existing configs stay portable across HOME values. Prints a one-line migration nudge when CLAUDE_CONFIG_DIR is set and a legacy ~/.claude tree still exists. Adds cli_detect_agents_finds_claude_via_env and isolates CLAUDE_CONFIG_DIR in the existing detection tests so the runner env can't leak in. Distilled from #321 onto current main (adapts to the v0.7.0 non-blocking augmenter hook signature, which the original branch predated). Closes #320. --- scripts/security-install.sh | 2 +- scripts/setup.sh | 5 +- src/cli/cli.c | 125 ++++++++++++++++++++++++++++++------ tests/test_cli.c | 54 +++++++++++++++- 4 files changed, 164 insertions(+), 22 deletions(-) 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/src/cli/cli.c b/src/cli/cli.c index 1b206e60..79ba5a44 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); @@ -1466,7 +1515,9 @@ int cbm_remove_antigravity_mcp(const char *config_path) { * read-before-edit invariant (issue #362). The hook is a non-blocking * augmenter, never a gate. */ #define CMM_HOOK_MATCHER "Grep|Glob" -#define CMM_HOOK_COMMAND "~/.claude/hooks/cbm-code-discovery-gate" +/* Basename only; the full command path is resolved at install time via + * cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */ +#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate" /* Hard backstop in settings.json; the binary also self-bounds with an * in-process deadline well under this. */ #define CMM_HOOK_TIMEOUT_SEC 5 @@ -1651,11 +1702,13 @@ 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 = settings_path, .hook_event = "PreToolUse", .matcher_str = CMM_HOOK_MATCHER, - .command_str = CMM_HOOK_COMMAND, + .command_str = command, .old_matchers = cmm_claude_old_matchers, .timeout_sec = CMM_HOOK_TIMEOUT_SEC, }); @@ -1687,12 +1740,17 @@ static void cbm_install_hook_gate_script(const char *home, const char *binary_pa if (strchr(binary_path, '"') != NULL) { 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) { @@ -1720,18 +1778,23 @@ static void cbm_install_hook_gate_script(const char *home, const char *binary_pa } /* 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) { @@ -1765,12 +1828,14 @@ 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 = settings_path, .hook_event = "SessionStart", .matcher_str = matchers[i], - .command_str = CMM_SESSION_COMMAND}) != 0) { + .command_str = command}) != 0) { rc = CLI_ERR; } } @@ -2676,8 +2741,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); @@ -2688,21 +2758,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, binary_path); @@ -2711,6 +2781,20 @@ static void install_claude_code_config(const char *home, const char *binary_path } printf(" hooks: PreToolUse (Grep/Glob search-graph augmenter, non-blocking)\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. */ @@ -2996,26 +3080,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 4150073e..1c378c51 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1388,9 +1388,50 @@ 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 + * and the runner's real env (which may set it) does not leak in. */ + 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-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + /* Config dir lives OUTSIDE home_dir/.claude, pointed at by CLAUDE_CONFIG_DIR. */ + 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); + + /* home_dir has no .claude, but detection must still find Claude via the env var. */ + 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(); } @@ -1520,7 +1561,12 @@ 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. + * Unset CLAUDE_CONFIG_DIR so the runner's real env does not leak in. */ + 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 +1576,11 @@ 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(); } @@ -2443,6 +2494,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);