Skip to content

Commit 1982cff

Browse files
MoonCavesJay
andauthored
fix(setup): guard against project-local install colliding with the user-global Claude config (#53)
Running the default project-local setup with cwd == $HOME makes "./.claude" the same directory as ~/.claude — Claude Code's user-global config. Relative hook commands written there load for every session on the machine but only resolve when the session's working directory is $HOME; everywhere else the hooks fail (loudly in interactive sessions, silently in -p mode) and the memory integration stops working. ClaudeRegisterHooks now detects exactly that collision (symlink-resolved comparison, CLAUDE_CONFIG_DIR-aware) and writes absolute hook commands for it, honoring the user-global file's existing contract. Genuine project-local installs are untouched and keep their relative form. Co-authored-by: Jay <jay-m4@jay-m4.local>
1 parent 545f625 commit 1982cff

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

internal/setup/claude.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,65 @@ func ClaudeWriteHook(configDir, filename string, content []byte) (string, error)
7979
return hookPath, nil
8080
}
8181

82+
// userClaudeConfigDir returns the directory Claude Code treats as user-global
83+
// configuration: $CLAUDE_CONFIG_DIR when set, otherwise ~/.claude.
84+
func userClaudeConfigDir() string {
85+
if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" {
86+
return dir
87+
}
88+
home, err := os.UserHomeDir()
89+
if err != nil {
90+
return ""
91+
}
92+
return filepath.Join(home, ".claude")
93+
}
94+
95+
// canonicalPath returns the symlink-resolved absolute form of p, falling back
96+
// to the lexical absolute path when p does not (yet) exist.
97+
func canonicalPath(p string) string {
98+
abs, err := filepath.Abs(p)
99+
if err != nil {
100+
return p
101+
}
102+
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
103+
return resolved
104+
}
105+
return abs
106+
}
107+
108+
// collidesWithUserConfig reports whether a project-local config dir is in fact
109+
// Claude Code's user-global config dir — the degenerate case of running a
110+
// project-local setup with cwd == $HOME, where "./.claude" IS "~/.claude".
111+
// Relative hook commands written into that file load for every session on the
112+
// machine but only resolve when the session's working directory is $HOME;
113+
// the user-global file's contract is absolute paths. Both sides are resolved
114+
// through symlinks before comparison.
115+
func collidesWithUserConfig(configDir string) bool {
116+
userDir := userClaudeConfigDir()
117+
if userDir == "" {
118+
return false
119+
}
120+
return canonicalPath(configDir) == canonicalPath(userDir)
121+
}
122+
82123
// ClaudeRegisterHooks registers selected hooks in settings.json.
83124
// Prime (SessionStart) is always registered.
125+
//
126+
// When the project-local config dir collides with the user-global one (setup
127+
// run from $HOME), hook commands are written as absolute paths so they honor
128+
// the global file's contract and resolve from any session directory.
84129
func ClaudeRegisterHooks(configDir string, sel HookSelection) (string, error) {
85130
hooksDir := filepath.Join(configDir, "hooks", "mnemon")
131+
if collidesWithUserConfig(configDir) {
132+
abs, err := filepath.Abs(hooksDir)
133+
if err != nil {
134+
return "", err
135+
}
136+
hooksDir = abs
137+
fmt.Printf(" Note: this project config dir is Claude Code's user-global config (%s);\n"+
138+
" writing absolute hook paths so hooks resolve from any directory.\n"+
139+
" Use --global to make a user-wide install explicit.\n", userClaudeConfigDir())
140+
}
86141
settingsPath := filepath.Join(configDir, "settings.json")
87142
data, err := ReadJSONFile(settingsPath)
88143
if err != nil {

internal/setup/claude_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,102 @@ func TestWritePromptFilesWritesUnderMnemonDataDir(t *testing.T) {
6060
}
6161
}
6262
}
63+
64+
func TestCollidesWithUserConfigHomeInstall(t *testing.T) {
65+
home := t.TempDir()
66+
t.Setenv("HOME", home)
67+
t.Setenv("CLAUDE_CONFIG_DIR", "")
68+
t.Chdir(home)
69+
70+
if !collidesWithUserConfig(".claude") {
71+
t.Fatal("project-local .claude with cwd == $HOME must collide with ~/.claude")
72+
}
73+
74+
proj := t.TempDir()
75+
t.Chdir(proj)
76+
if collidesWithUserConfig(".claude") {
77+
t.Fatal("genuine project-local install must not collide")
78+
}
79+
}
80+
81+
func TestCollidesWithUserConfigResolvesSymlinks(t *testing.T) {
82+
base := t.TempDir()
83+
real := filepath.Join(base, "realhome")
84+
link := filepath.Join(base, "linkhome")
85+
if err := os.MkdirAll(filepath.Join(real, ".claude"), 0755); err != nil {
86+
t.Fatal(err)
87+
}
88+
if err := os.Symlink(real, link); err != nil {
89+
t.Fatal(err)
90+
}
91+
t.Setenv("HOME", link) // $HOME reached via symlink
92+
t.Setenv("CLAUDE_CONFIG_DIR", "")
93+
t.Chdir(real) // cwd is the physical path
94+
95+
if !collidesWithUserConfig(".claude") {
96+
t.Fatal("symlinked $HOME vs physical cwd must still be detected as a collision")
97+
}
98+
}
99+
100+
func TestCollidesWithUserConfigHonorsClaudeConfigDir(t *testing.T) {
101+
home := t.TempDir()
102+
relocated := t.TempDir()
103+
t.Setenv("HOME", home)
104+
t.Setenv("CLAUDE_CONFIG_DIR", relocated)
105+
t.Chdir(home)
106+
107+
// With the user config relocated, ~/.claude is NOT the global file.
108+
if collidesWithUserConfig(".claude") {
109+
t.Fatal("cwd == $HOME must not collide when CLAUDE_CONFIG_DIR points elsewhere")
110+
}
111+
112+
// But installing into the relocated dir itself is a collision.
113+
t.Chdir(filepath.Dir(relocated))
114+
if !collidesWithUserConfig(filepath.Base(relocated)) {
115+
t.Fatal("install targeting the CLAUDE_CONFIG_DIR dir must collide")
116+
}
117+
}
118+
119+
func TestClaudeRegisterHooksCollisionWritesAbsoluteCommands(t *testing.T) {
120+
home := t.TempDir()
121+
t.Setenv("HOME", home)
122+
t.Setenv("CLAUDE_CONFIG_DIR", "")
123+
t.Chdir(home)
124+
125+
if _, err := ClaudeRegisterHooks(".claude", HookSelection{Remind: true, Nudge: true}); err != nil {
126+
t.Fatalf("register: %v", err)
127+
}
128+
data, err := ReadJSONFile(filepath.Join(home, ".claude", "settings.json"))
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
hooks := data["hooks"].(map[string]any)
133+
for _, ev := range []string{"SessionStart", "UserPromptSubmit", "Stop"} {
134+
entry := hooks[ev].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
135+
cmd := entry["command"].(string)
136+
if !filepath.IsAbs(cmd) {
137+
t.Fatalf("%s command must be absolute in the user-global file, got %q", ev, cmd)
138+
}
139+
}
140+
}
141+
142+
func TestClaudeRegisterHooksProjectLocalStaysRelative(t *testing.T) {
143+
home := t.TempDir()
144+
proj := t.TempDir()
145+
t.Setenv("HOME", home)
146+
t.Setenv("CLAUDE_CONFIG_DIR", "")
147+
t.Chdir(proj)
148+
149+
if _, err := ClaudeRegisterHooks(".claude", HookSelection{}); err != nil {
150+
t.Fatalf("register: %v", err)
151+
}
152+
data, err := ReadJSONFile(filepath.Join(proj, ".claude", "settings.json"))
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
hooks := data["hooks"].(map[string]any)
157+
entry := hooks["SessionStart"].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
158+
if cmd := entry["command"].(string); filepath.IsAbs(cmd) {
159+
t.Fatalf("genuine project-local install must keep existing relative form, got %q", cmd)
160+
}
161+
}

0 commit comments

Comments
 (0)