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:
committed by
Martin Vogel
parent
afd98bfc7f
commit
dedd33d975
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user