diff --git a/internal/setup/claude.go b/internal/setup/claude.go index d98c293..40f2439 100644 --- a/internal/setup/claude.go +++ b/internal/setup/claude.go @@ -79,10 +79,65 @@ func ClaudeWriteHook(configDir, filename string, content []byte) (string, error) return hookPath, nil } +// userClaudeConfigDir returns the directory Claude Code treats as user-global +// configuration: $CLAUDE_CONFIG_DIR when set, otherwise ~/.claude. +func userClaudeConfigDir() string { + if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".claude") +} + +// canonicalPath returns the symlink-resolved absolute form of p, falling back +// to the lexical absolute path when p does not (yet) exist. +func canonicalPath(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + +// collidesWithUserConfig reports whether a project-local config dir is in fact +// Claude Code's user-global config dir — the degenerate case of running a +// project-local setup with cwd == $HOME, where "./.claude" IS "~/.claude". +// Relative hook commands written into that file load for every session on the +// machine but only resolve when the session's working directory is $HOME; +// the user-global file's contract is absolute paths. Both sides are resolved +// through symlinks before comparison. +func collidesWithUserConfig(configDir string) bool { + userDir := userClaudeConfigDir() + if userDir == "" { + return false + } + return canonicalPath(configDir) == canonicalPath(userDir) +} + // ClaudeRegisterHooks registers selected hooks in settings.json. // Prime (SessionStart) is always registered. +// +// When the project-local config dir collides with the user-global one (setup +// run from $HOME), hook commands are written as absolute paths so they honor +// the global file's contract and resolve from any session directory. func ClaudeRegisterHooks(configDir string, sel HookSelection) (string, error) { hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if collidesWithUserConfig(configDir) { + abs, err := filepath.Abs(hooksDir) + if err != nil { + return "", err + } + hooksDir = abs + fmt.Printf(" Note: this project config dir is Claude Code's user-global config (%s);\n"+ + " writing absolute hook paths so hooks resolve from any directory.\n"+ + " Use --global to make a user-wide install explicit.\n", userClaudeConfigDir()) + } settingsPath := filepath.Join(configDir, "settings.json") data, err := ReadJSONFile(settingsPath) if err != nil { diff --git a/internal/setup/claude_test.go b/internal/setup/claude_test.go index 1021720..b3f2c0a 100644 --- a/internal/setup/claude_test.go +++ b/internal/setup/claude_test.go @@ -60,3 +60,102 @@ func TestWritePromptFilesWritesUnderMnemonDataDir(t *testing.T) { } } } + +func TestCollidesWithUserConfigHomeInstall(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + t.Chdir(home) + + if !collidesWithUserConfig(".claude") { + t.Fatal("project-local .claude with cwd == $HOME must collide with ~/.claude") + } + + proj := t.TempDir() + t.Chdir(proj) + if collidesWithUserConfig(".claude") { + t.Fatal("genuine project-local install must not collide") + } +} + +func TestCollidesWithUserConfigResolvesSymlinks(t *testing.T) { + base := t.TempDir() + real := filepath.Join(base, "realhome") + link := filepath.Join(base, "linkhome") + if err := os.MkdirAll(filepath.Join(real, ".claude"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", link) // $HOME reached via symlink + t.Setenv("CLAUDE_CONFIG_DIR", "") + t.Chdir(real) // cwd is the physical path + + if !collidesWithUserConfig(".claude") { + t.Fatal("symlinked $HOME vs physical cwd must still be detected as a collision") + } +} + +func TestCollidesWithUserConfigHonorsClaudeConfigDir(t *testing.T) { + home := t.TempDir() + relocated := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", relocated) + t.Chdir(home) + + // With the user config relocated, ~/.claude is NOT the global file. + if collidesWithUserConfig(".claude") { + t.Fatal("cwd == $HOME must not collide when CLAUDE_CONFIG_DIR points elsewhere") + } + + // But installing into the relocated dir itself is a collision. + t.Chdir(filepath.Dir(relocated)) + if !collidesWithUserConfig(filepath.Base(relocated)) { + t.Fatal("install targeting the CLAUDE_CONFIG_DIR dir must collide") + } +} + +func TestClaudeRegisterHooksCollisionWritesAbsoluteCommands(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + t.Chdir(home) + + if _, err := ClaudeRegisterHooks(".claude", HookSelection{Remind: true, Nudge: true}); err != nil { + t.Fatalf("register: %v", err) + } + data, err := ReadJSONFile(filepath.Join(home, ".claude", "settings.json")) + if err != nil { + t.Fatal(err) + } + hooks := data["hooks"].(map[string]any) + for _, ev := range []string{"SessionStart", "UserPromptSubmit", "Stop"} { + entry := hooks[ev].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + cmd := entry["command"].(string) + if !filepath.IsAbs(cmd) { + t.Fatalf("%s command must be absolute in the user-global file, got %q", ev, cmd) + } + } +} + +func TestClaudeRegisterHooksProjectLocalStaysRelative(t *testing.T) { + home := t.TempDir() + proj := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + t.Chdir(proj) + + if _, err := ClaudeRegisterHooks(".claude", HookSelection{}); err != nil { + t.Fatalf("register: %v", err) + } + data, err := ReadJSONFile(filepath.Join(proj, ".claude", "settings.json")) + if err != nil { + t.Fatal(err) + } + hooks := data["hooks"].(map[string]any) + entry := hooks["SessionStart"].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + if cmd := entry["command"].(string); filepath.IsAbs(cmd) { + t.Fatalf("genuine project-local install must keep existing relative form, got %q", cmd) + } +}