Skip to content

fix: honour CLAUDE_CONFIG_DIR — hooks and always-on tools work on non-default config roots#1429

Open
borfast wants to merge 6 commits into
danielmiessler:mainfrom
borfast:fix/respect-claude-config-dir
Open

fix: honour CLAUDE_CONFIG_DIR — hooks and always-on tools work on non-default config roots#1429
borfast wants to merge 6 commits into
danielmiessler:mainfrom
borfast:fix/respect-claude-config-dir

Conversation

@borfast

@borfast borfast commented Jul 6, 2026

Copy link
Copy Markdown

Problem

Claude Code supports multiple profiles via CLAUDE_CONFIG_DIR, and the agentic installer already detects it correctly (DetectEnv.ts reports the right configRoot) — but the rest of the pipeline ignores what it found. Installing v6.0.5 into a profile at ~/.claude-gc produced:

  • All 60 hook commands merged into settings.json pointing at $HOME/.claude/hooks/... — a location where this installer never deployed anything, so every hook event errored on the next session.
  • Hooks and always-on tools that did run resolved their own paths back to ~/.claude, reading the wrong profile's settings and writing MEMORY state/observability files into a different profile than the one that loaded them.

This is the same class of issue as #830/#284, but scoped to the new agentic-installer ship model rather than the old full-clone one — per the direction stated there: "The installer hands integration to your own AI, which reads your actual machine (your OS, your paths, your harness) and wires the hooks." This PR makes the shipped tooling deliver that for non-default config roots.

Changes

  1. Installer substitutionInstallEngine.rewriteHooksConfigRoot(): at merge time, InstallHooks.ts retargets the default-root spellings ($HOME/${HOME}/~ + /.claude) in payload hook commands at the detected configRoot. No-op when the config root is ~/.claude, so default installs stay byte-identical.
  2. Path resolutionhooks/lib/paths.ts getClaudeDir() now falls back CLAUDE_PLUGIN_ROOTCLAUDE_CONFIG_DIR~/.claude (hooks inherit the env var from the harness that loaded them). New LIFEOS/TOOLS/Paths.ts mirrors this for runtime tools, and LifeosConfig.ts defaults derive from it.
  3. Hardcode sweep — ~30 hooks/handlers/libs with inline join(homedir(), '.claude', ...)-style fallbacks now route through the shared helpers. Five LIFEOS/TOOLS files (including the lifeos.ts launcher) imported hooks libs via ../../../.claude/hooks/lib/... — a relative path that climbs out of the config root and back in through a literal .claude segment; now the structure-relative ../../hooks/lib/..., which resolves in any profile.
  4. User-facing command hints — new displayPath() in hooks/lib/paths.ts; the always-on hooks' guidance messages (memory-health "Run: bun …", writing-gate PangramScore instructions, Art workflow listing, etc.) are built from the resolved path constants instead of a hardcoded ~/.claude, so users are never told to run a command against the wrong profile. Default-root installs render byte-identical messages.
  5. install.sh — skills-dir detection probes CLAUDE_CONFIG_DIR before the ~/.claude / ~/.config/claude defaults.
  6. Nested skill-payload mirrors (install/skills/LifeOS/...) kept in sync.

Verification

Performed a real install into a live CLAUDE_CONFIG_DIR=~/.claude-gc profile (Linux, Claude Code):

  • InstallHooks.ts --apply: all 60 commands rewritten to the profile root (0 left on the default), pre-existing foreign hook entries preserved, backup created.
  • All 46 wired command hooks executed with representative event payloads: zero module-resolution or path failures; state/observability files written only under the active profile; ~/.claude left untouched.
  • Default-root behaviour: the command rewrite is a no-op and every helper resolves to the same paths as before (bun build clean on all touched files).

Out of scope

borfast added 6 commits July 5, 2026 23:20
Claude Code supports multiple profiles via CLAUDE_CONFIG_DIR, but the
hooks payload assumed the config root is always ~/.claude, at three
levels:

- hooks.json ships every command as $HOME/.claude/hooks/... and
  InstallHooks.ts merged them verbatim, so on a non-default profile all
  60 hook entries pointed at files the installer never deployed there —
  every hook event errored.
- hooks/lib/paths.ts getClaudeDir() only honored CLAUDE_PLUGIN_ROOT,
  never CLAUDE_CONFIG_DIR.
- ~20 hook files bypassed lib/paths.ts with their own inline
  join(homedir(), '.claude', ...) spellings.

Fixes, in the spirit of the agentic installer (DetectEnv.ts already
detects CLAUDE_CONFIG_DIR correctly — the rest of the pipeline just
ignored what it found):

- InstallEngine.rewriteHooksConfigRoot(): retargets the default-root
  spellings ($HOME/${HOME}/~ + /.claude) in payload hook commands at
  the detected configRoot at merge time. No-op when configRoot IS
  ~/.claude, so default installs stay byte-identical.
- getClaudeDir() falls back CLAUDE_PLUGIN_ROOT → CLAUDE_CONFIG_DIR →
  ~/.claude; getLifeosDir() default now derives from getClaudeDir().
- All hook files with inline .claude paths now route through the
  lib/paths.ts helpers (getClaudeDir/getSkillsDir/getSettingsPath/
  paiPath).
- install.sh skills-dir detection probes CLAUDE_CONFIG_DIR before the
  ~/.claude and ~/.config/claude defaults.
- Nested skill-payload mirrors (install/skills/LifeOS/...) synced.

Verified: InstallHooks.ts --apply against a sandbox config root rewrites
all 60 commands (0 left on the default root), preserves pre-existing
foreign hook entries, and the rewrite is a no-op for a default-root
install.
Companion to the hooks fix: the tools that hooks invoke on every turn
(MemoryHealthCheck, FreshnessCache, SettingsBackport, MemoryReviewer,
ISARender) and the shared LifeosConfig loader still hardcoded ~/.claude,
so on a non-default CLAUDE_CONFIG_DIR profile they read from — and wrote
observability/state files into — the wrong profile.

Adds LIFEOS/TOOLS/Paths.ts mirroring hooks/lib/paths.ts resolution
(CLAUDE_PLUGIN_ROOT → CLAUDE_CONFIG_DIR → ~/.claude; LIFEOS_DIR override
for the data dir) and routes those tools' root constants through it.
Default installs resolve to the same paths as before.
…orts

Second sweep after runtime verification against a non-default
CLAUDE_CONFIG_DIR profile caught stragglers the first pass missed:

- 13 hooks + handlers/UpdateCounts.ts still resolved LIFEOS_DIR or the
  Claude root with inline 'process.env.LIFEOS_DIR || join(HOME,
  ".claude", ...)' / 'resolve(homedir(), ".claude")' fallbacks —
  they wrote MEMORY state/observability files into ~/.claude from a
  ~/.claude-gc profile. All now route through lib/paths.ts.
- lib/safety-classifier.ts TRUSTED_PREFIXES trusted the literal
  ~/.claude instead of the active config root.
- Five LIFEOS/TOOLS files (lifeos.ts launcher, algorithm.ts,
  TranscriptParser.ts, IntegrityMaintenance.ts, SessionHarvester.ts)
  imported hooks/lib via '../../../.claude/hooks/lib/...' — a relative
  path that climbs OUT of the config root and back in through a literal
  '.claude' segment. Now the structure-relative '../../hooks/lib/...',
  which resolves in any profile.

Verified: all 46 wired hooks execute from a ~/.claude-gc profile with
zero module-resolution or path failures, write state/observability only
under the active profile, and leave ~/.claude untouched.

Display-only strings that mention ~/.claude in user-facing messages are
left as-is.
The launcher hardcoded CLAUDE_DIR/BANNER_SCRIPT at ~/.claude, so on a
non-default CLAUDE_CONFIG_DIR profile it would cd into and read MCP
state from the wrong profile even though the spawned claude process
inherits the right env. WALLPAPER_DIR stays home-relative by design.
The always-on hooks' guidance messages (memory-health 'Run: bun
~/.claude/...', writing-gate PangramScore instructions, Art workflow
listing, system-file-guard import suggestion, work-repo setup hint)
hardcoded ~/.claude, so on a non-default CLAUDE_CONFIG_DIR profile they
told the user to run commands against the wrong profile — commands that
either fail or, worse, act on another install.

Adds displayPath() to hooks/lib/paths.ts (abbreviates the home prefix
to ~ for display) and builds those messages from the already-resolved
path constants. Default-root installs render byte-identical messages.
The message-correctness fix is building hints from resolved path
constants; displayPath() only abbreviates the home prefix so default
installs render byte-identical strings.
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.

1 participant