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.
This commit is contained in:
Shane McCarron
2026-05-30 15:22:37 +02:00
committed by Martin Vogel
parent afd98bfc7f
commit dedd33d975
4 changed files with 164 additions and 22 deletions
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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": {'
+107 -18
View File
@@ -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);
+53 -1
View File
@@ -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);