Skip to content

feat(config): honor CLAUDE_CONFIG_DIR for all .claude path lookups - #142

Merged
edouard-claude merged 6 commits into
edouard-claude:masterfrom
phaedrus1992:feat/claude-config-dir
Jul 30, 2026
Merged

feat(config): honor CLAUDE_CONFIG_DIR for all .claude path lookups#142
edouard-claude merged 6 commits into
edouard-claude:masterfrom
phaedrus1992:feat/claude-config-dir

Conversation

@phaedrus1992

@phaedrus1992 phaedrus1992 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • snip learn, snip discover, and the Claude Code hook installer (snip init/snip uninstall) hardcoded ~/.claude, so they silently found nothing for users running Claude Code with a non-default CLAUDE_CONFIG_DIR.
  • Adds config.ClaudeBaseDir() as the single source of truth for .claude path resolution: checks CLAUDE_CONFIG_DIR (the same env var Claude Code itself respects) before falling back to ~/.claude.
  • Adds config.ClaudeProjectsDir() (base dir + projects) used by both discover and learn — previously each package had its own byte-identical copy of this helper.
  • discover.go, learn.go, and initcmd/init.go now route through these helpers instead of constructing .claude paths directly.
  • SNIP_CONFIG and snip's own config resolution (configPath()) are untouched — CLAUDE_CONFIG_DIR only affects .claude lookups, not snip's own config directory.

Fixed during review

A pre-PR review pass (code-reviewer, semgrep, silent-failure-hunter, variant-bug-hunter, code-simplifier) surfaced two issues, both fixed:

  • P1 — lost error context: ClaudeBaseDir() originally swallowed the underlying os.UserHomeDir() error and returned "", so initClaudeCode/uninstallClaudeCode had to hand-roll a generic error message instead of wrapping the real cause (a regression vs. the pre-PR code, which did fmt.Errorf("get home dir: %w", err)). Changed ClaudeBaseDir() to return (string, error) so callers can wrap the actual failure; discover/learn keep their existing silent-on-failure behavior by discarding the error, matching their pre-existing UX.
  • P2 — duplicated helper: discover.go and learn.go each had a byte-identical claudeProjectsDir() wrapper. Consolidated into config.ClaudeProjectsDir(), removing the duplicate implementation and duplicate test pairs.

Reviewed and dismissed

  • Silent-failure-hunter flagged that discover/learn return "no session directories found" instead of surfacing a HOME-resolution error — confirmed via git show master:<file> that this exact behavior predates this PR (not a regression), so left as-is.
  • The asymmetry between discover/learn (silent) and initcmd (loud error) on a resolution failure also predates this branch.
  • semgrep and variant-bug-hunter: no findings.

Test plan

  • go build ./...
  • go vet ./...
  • gofmt -l clean
  • go test ./... (all 18 packages pass)
  • Tests: ClaudeBaseDir/ClaudeProjectsDir env-var/fallback/isolation-from-SNIP_CONFIG (config), initClaudeCode/uninstallClaudeCode env-var behavior (initcmd)

snip hardcoded ~/.claude in discover, learn, and the Claude Code hook
installer, so snip learn/discover/init silently found nothing for users
running Claude Code with a non-default CLAUDE_CONFIG_DIR. Route every
.claude path through a shared config.ClaudeBaseDir() helper that checks
CLAUDE_CONFIG_DIR (matching Claude Code's own behavior) before falling
back to ~/.claude. Leaves SNIP_CONFIG and snip's own config resolution
untouched.
ClaudeBaseDir() previously swallowed the underlying os.UserHomeDir error and
returned a bare empty string, forcing initcmd's two callers to hand-roll a
generic error message with no information about why resolution failed
(a regression versus the prior direct os.UserHomeDir() + %w wrapping).
Change it to return (string, error) so callers can wrap the real cause;
discover/learn keep their existing silent-on-failure behavior by discarding
the error.

Also consolidate the two byte-identical claudeProjectsDir() copies in
discover.go and learn.go into config.ClaudeProjectsDir(), removing the
duplicated implementation and duplicated test pairs.

Found by pre-pr-review's silent-failure-hunter and code-simplifier passes.

@edouard-claude edouard-claude left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — genuinely. This is one of the best-prepared contributions this project has had.

You found a real bug that nobody reported: three separate code paths silently returning nothing for anyone running Claude Code with a relocated config directory, and you noticed it was the same hardcoded assumption in all three rather than fixing one and moving on. The pre-PR review pass is good practice on its own, but the "Reviewed and dismissed" section is the part I want to single out: you checked a flagged item against git show master:<file>, established it predated your branch, and said so instead of quietly widening the diff. That is the check most reviews skip, and it is exactly how I would want a finding handled. Consolidating the duplicated helper rather than editing it twice is the same instinct.

So none of what follows is a complaint about the work. Two fixes, one question, and some notes.

Verdict: good change, two things to fix before merge, one question I would like answered.

Verified

go build ./..., go vet ./..., go test -count=1 ./..., -race, -tags lite, golangci-lint run (0 issues), snip verify 45/45. Mutation check: replacing the os.Getenv("CLAUDE_CONFIG_DIR") read with "" fails TestClaudeBaseDirRespectsEnvVar, TestClaudeProjectsDirRespectsEnvVar and TestUninstallClaudeCodeRespectsClaudeConfigDir, so the change is genuinely pinned.

Consolidating the duplicated claudeProjectsDir() was the right call, and I want to flag something you got right that is easy to get wrong: learn.go:592 builds .claude/rules/ relative to the project, and you left it alone. That is correct, CLAUDE_CONFIG_DIR is about user config rather than project rules, and nothing in the repo says so. Worth a one-line comment there so the next person does not "fix" it.

Please fix: the tests sandbox themselves with the mechanism under test

TestInitClaudeCodeRespectsClaudeConfigDir, TestInitClaudeCodeMigratesLegacyHookUnderClaudeConfigDir and TestUninstallClaudeCodeRespectsClaudeConfigDir set only CLAUDE_CONFIG_DIR. If ClaudeBaseDir() ever regresses, the fallback is ~/.claude and the test suite starts operating on the developer's real Claude Code config.

Not theoretical. Running the mutation above:

snip uninstalled (claude-code)
--- FAIL: TestUninstallClaudeCodeRespectsClaudeConfigDir (0.00s)
    init_test.go:679: expected snip hook removed from CLAUDE_CONFIG_DIR settings

The test correctly fails, but uninstallClaudeCode() ran against my real ~/.claude/settings.json first. It only came out unchanged because my snip hook does not happen to live in that file. On a machine where it does, go test ./... would silently uninstall it.

One line each:

t.Setenv("HOME", t.TempDir())

so the fallback path is sandboxed too. Same for the two config tests that call os.UserHomeDir().

Please fix: the gofmt hunks are out of scope

The struct realignments in discover.go (sessionLine) and learn.go (commandEntry, ErrorPattern) are unrelated to CLAUDE_CONFIG_DIR. They are correct gofmt output on code that was already unformatted before your branch, so nothing is wrong with them, but house rule here is one PR per change.

They also surface something worth its own issue rather than a drive-by: master is not gofmt-clean (gofmt -l internal/ lists six files) and make lint does not check it, which is why it drifted. I will file that separately. Please drop the two hunks here.

Question: is the premise verified?

The description says CLAUDE_CONFIG_DIR is "the same env var Claude Code itself respects". I could not confirm that from a primary source:

Both issues are closed, neither with an authoritative answer.

This does not block the PR, because the change fails safe: with the variable unset, which is the common case, behaviour is byte-identical to master. But the failure mode if snip's reading diverges from Claude Code's is bad and quiet: snip init writes the hook into a directory Claude Code never reads, and the user gets a successful-looking install that does nothing.

Could you confirm empirically? Set CLAUDE_CONFIG_DIR to a fresh directory, run snip init, start Claude Code, and check that the hook actually fires. If it does, please put that in the commit message, since the docs will not back it up. If Claude Code turns out to read ~/.claude regardless, the honest shape is probably to check CLAUDE_CONFIG_DIR and fall back to ~/.claude when the former holds no settings.json.

Minor

  • TestClaudeBaseDirDoesNotAffectSnipConfig passes with or without this PR: configPath() never read CLAUDE_CONFIG_DIR. It is a reasonable guard against a future mistake, but its name suggests it covers this change and it does not. Worth a comment saying which it is.
  • ClaudeProjectsDir() swallowing the error and returning "" matches the pre-existing silent behaviour of discover/learn, and you called that out, which I appreciate. Now that it is a shared helper, a // Returns "" when the home directory cannot be resolved; callers treat that as "no sessions found". on the declaration would stop the next caller assuming it cannot fail.

Logistics

CI has not run on this PR at all: it comes from a fork and has zero check runs. I will approve the workflow so we get a real signal on ubuntu.

The branch is also behind master, which has moved a fair amount today (#132, #135, #137, #139, #140, #141). Rebasing will show snip verify at 55 tests rather than 45, and picks up a new CI step that actually runs those filter tests.


To be clear about the shape of this review: the two fixes are small and mechanical, and the question about CLAUDE_CONFIG_DIR is one I could not answer myself from the documentation, so I am asking rather than objecting. Happy to take this as soon as the HOME sandboxing is in and the gofmt hunks are out. Thanks again for the care you put into it.

Without HOME sandboxed, a regression in ClaudeBaseDir() falls back to the
developer's real ~/.claude and go test operates on it directly (confirmed:
mutating the CLAUDE_CONFIG_DIR read makes uninstallClaudeCode() touch the
real settings.json before the assertion catches it).
sessionLine, commandEntry and ErrorPattern struct tags were reformatted
incidentally while editing nearby code. Revert to master's current
(unformatted) alignment -- master isn't gofmt-clean on these files and
that's a separate issue, not part of the CLAUDE_CONFIG_DIR change.
Makes the "no sessions found" meaning of a "" return explicit on the
declaration so the next caller doesn't assume the function can't fail.
@phaedrus1992

phaedrus1992 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressing this review body (id 4793247528).

Fixed: tests sandboxed themselves with the mechanism under test

Added t.Setenv("HOME", t.TempDir()) to TestInitClaudeCodeRespectsClaudeConfigDir, TestInitClaudeCodeMigratesLegacyHookUnderClaudeConfigDir, TestUninstallClaudeCodeRespectsClaudeConfigDir, and the two config tests that call os.UserHomeDir() (TestClaudeBaseDirFallsBackToHomeClaude, TestClaudeProjectsDirFallsBackToHome). Fixed in 0e43616.

Re-ran your mutation (CLAUDE_CONFIG_DIR read forced to "") against the sandboxed tests: they still fail correctly, and the settings path in the failure output now points at a t.TempDir(), not ~/.claude — confirmed nothing touches the real home directory anymore.

Fixed: gofmt hunks dropped

Reverted the sessionLine (discover.go) and commandEntry/ErrorPattern (learn.go) struct tag alignment back to master's current formatting. Fixed in 492d3ee. Confirmed both files are gofmt-dirty on master itself (gofmt -l on git show master:<path> flags both), so this correctly scopes the diff to just the CLAUDE_CONFIG_DIR change.

Answering: is the premise verified?

Yes, on both counts:

  1. Primary source, now current: https://code.claude.com/docs/en/env-vars documents CLAUDE_CONFIG_DIR directly: "Override the configuration directory (default: ~/.claude). All settings, session history, and plugins are stored under this path, as are credentials on Linux and Windows; on macOS, credentials are in the system Keychain." That's an exact match for what this PR relocates.
  2. Empirical, from the session that wrote this fix: I'm running under llmenv (the author's own tool) with CLAUDE_CONFIG_DIR=/Users/ranger/.cache/llmenv/claude-code/3.6/54da26dd254d set for this session. That directory holds CLAUDE.md, settings-derived hook config, and .claude.json — and Claude Code is demonstrably reading all of it (the hooks that ran during this very session, e.g. the commit-msg guard, loaded from $CLAUDE_CONFIG_DIR/hooks/, and the global CLAUDE.md content came from $CLAUDE_CONFIG_DIR/CLAUDE.md). llmenv's whole approach is materializing per-project .claude config into a directory and pointing CLAUDE_CONFIG_DIR at it rather than writing into the shared ~/.claude, which only works because Claude Code honors the variable for exactly the paths this PR touches (settings.json, hooks, session history under projects/).

Given both, no fallback-and-check-for-settings.json hedge is needed — the direct read is correct as written.

Minor notes

  • Added a comment on dir := filepath.Join(".claude", "rules") in learn.go noting it's intentionally project-relative, not CLAUDE_CONFIG_DIR.
  • Added the suggested doc comment on ClaudeProjectsDir()'s "" return contract. Fixed in c141381.
  • Added a note on TestClaudeBaseDirDoesNotAffectSnipConfig clarifying it's a guard against a future regression, not a test of this PR's change (configPath() never read CLAUDE_CONFIG_DIR, before or after). Fixed in 0e43616.

Logistics

Left the rebase onto current master (8 commits behind, per #139/#140/#141/#143) for you to trigger, since it wasn't listed as a blocker and touches CI history rather than this diff — happy to do it if you'd rather I did.

Both packages routed through config.ClaudeProjectsDir(), but nothing
covered it: reverting either findProjectDirs to a hardcoded
~/.claude/projects kept the suite green, so the two commands this branch
set out to fix were unguarded.

Each test sandboxes HOME and cwd, points CLAUDE_CONFIG_DIR at a temp
dir, and asserts the project directory is resolved under it.
@edouard-claude
edouard-claude merged commit 76d3543 into edouard-claude:master Jul 30, 2026
3 checks passed
@edouard-claude

Copy link
Copy Markdown
Owner

Pushed one commit to your branch: 69cefbf, adding a test in discover and one in learn that pin the CLAUDE_CONFIG_DIR wiring in findProjectDirs. Reverting either back to a hardcoded ~/.claude/projects kept the suite green, so the two commands this PR set out to fix were the last unguarded part. Each test sandboxes HOME and cwd, so nothing touches a real config dir. CI is green on the new head.

Everything else was already settled: the doc entry you quoted is in env-vars.md verbatim, and the HOME sandboxing and gofmt scoping are exactly what I asked for. Thanks for the care on this one.

Good1Cheese pushed a commit to Good1Cheese/snip that referenced this pull request Aug 1, 2026
Six files were unformatted, which is how edouard-claude#142 picked up three struct
realignments that had nothing to do with its change. Isolated here so
the reformatting is reviewable on its own; the check that keeps it from
drifting again is the next commit.

Refs edouard-claude#144
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants