Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions internal/setup/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions internal/setup/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading