Skip to content

fix(paths): resolve the install root from CLAUDE_CONFIG_DIR / LIFEOS_DIR - #1918

Open
PigLardLord wants to merge 1 commit into
danielmiessler:mainfrom
PigLardLord:fix/parametrize-install-dir
Open

fix(paths): resolve the install root from CLAUDE_CONFIG_DIR / LIFEOS_DIR#1918
PigLardLord wants to merge 1 commit into
danielmiessler:mainfrom
PigLardLord:fix/parametrize-install-dir

Conversation

@PigLardLord

Copy link
Copy Markdown

Problem

LifeOS assumes it lives at $HOME/.claude. The harness does not — it exposes
CLAUDE_CONFIG_DIR precisely so the config dir can live elsewhere. Anyone who uses it
(a second install kept off a ~/.claude that an employer syncs, a multi-profile setup,
a non-default layout) gets ~165 call sites resolving join(homedir(), ".claude", …)
against a directory that is not their install.

Two failure classes, and the first is the nasty one:

1. Silent wrong-tree reads. settings.json, skills/, hooks/, agents/,
projects/ and CLAUDE.md exist in both trees. The lookup succeeds — against the
wrong file. Nothing throws. Observed: the Pulse wiki and freshness tabs inventory a
different install's skills and hooks; GetCounts, GenerateTelosSummary and
observability read a settings.json that is not the live one; AgentInvocation reads
agent frontmatter from an unrelated agents/; memory harvesting walks the wrong
projects/ transcripts.

2. Install-time baked paths. The Install*.ts tools materialize launchd plists with
literal $HOME/.claude/... program args and log paths, and never export LIFEOS_DIR or
CLAUDE_CONFIG_DIR in EnvironmentVariables. So even with the code fixed a daemon
re-derives the wrong root at boot, because the environment it actually runs under is
empty. Worth stressing: fixing the code alone was not enough downstream — the plists
had to carry the env too.

The codebase already knows the right pattern; it is just applied unevenly. These are
correct today:

// PULSE/Conduit/paths.ts
export const CLAUDE_ROOT = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
// PULSE/checks/notification-governor.ts, poller-meta-monitor.ts
const LIFEOS_DIR = process.env.LIFEOS_DIR || join(HOME, ".claude", "LIFEOS");

while the shared resolver every hook imports is not.

Fix

1. hooks/lib/paths.ts — the one that matters. getClaudeDir() honored
CLAUDE_PLUGIN_ROOT but not CLAUDE_CONFIG_DIR, so getSettingsPath(),
getSkillsDir(), getHooksDir() and getEnvPath() pointed at ~/.claude regardless.
One branch fixes every consumer that goes through the resolver.

 export function getClaudeDir(): string {
   const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;

   if (pluginRoot) {
     return expandPath(pluginRoot);
   }

+  const configDir = process.env.CLAUDE_CONFIG_DIR;
+
+  if (configDir) {
+    return expandPath(configDir);
+  }
+
   return join(homedir(), '.claude');
 }

and getLifeosDir()'s last resort follows it instead of re-hardcoding:

-  return join(homedir(), '.claude', 'LIFEOS');
+  return join(getClaudeDir(), 'LIFEOS');

2. 167 direct call sites across LIFEOS/** and hooks/** that bypass the resolver,
guarded mechanically:

join(HOME, ".claude", "LIFEOS", )  ->  join(process.env.LIFEOS_DIR || join(HOME, ".claude", "LIFEOS"), )
join(HOME, ".claude", "<seg>", )   ->  join(process.env.CLAUDE_CONFIG_DIR || join(HOME, ".claude"), "<seg>", )

Collapsing these into paiPath() / getClaudeDir() imports would be nicer; the inline
guard is the low-risk first step and keeps the diff reviewable line by line. Happy to
follow up with the import version if you prefer it.

3. Installers and templates. 16 plist templates parametrized ({{LIFEOS_DIR}} /
__LIFEOS_DIR__ and the config-dir equivalents), both roots exported in
EnvironmentVariables, and the 14 substitution sites plus the Pulse/MenuBar shell
materializers taught the new placeholders. Shell scripts (install.sh, StatusLine, the
Pulse manage scripts, Interceptor helpers) resolve through CLAUDE_CONFIG_DIR.

Compatibility

Both env vars unset → getClaudeDir() returns ~/.claude and every rewritten site
resolves exactly as before. Byte-identical on a default install, no migration.
CLAUDE_PLUGIN_ROOT still wins over CLAUDE_CONFIG_DIR, so plugin installs are
untouched.

Verified:

env getClaudeDir() getLifeosDir()
(nothing set) ~/.claude ~/.claude/LIFEOS
CLAUDE_CONFIG_DIR=/tmp/alt /tmp/alt /tmp/alt/LIFEOS
+ CLAUDE_PLUGIN_ROOT=/tmp/plug /tmp/plug /tmp/plug/LIFEOS

Checks run

  • 98 changed .ts files parse clean
  • every changed .sh passes bash -n
  • every changed plist passes plutil -lint
  • residual sweep: 0 unguarded sites in shell and plists, 2 left in pulse-old.ts
    (dead code, deliberately untouched)

Notes from running this downstream

Applied on a relocated install (config dir outside ~/.claude) before opening this PR:
Pulse healthy after reload, and every runtime path in the daemon logs now resolves to
the real install root instead of the unrelated tree that happened to sit at ~/.claude.

LifeOS assumes it lives at $HOME/.claude. The harness does not — it exposes
CLAUDE_CONFIG_DIR precisely so the config dir can live elsewhere. On any install
that uses it, ~165 call sites resolved against a directory that was not the
install, and because settings.json, skills/, hooks/, agents/, projects/ and
CLAUDE.md exist in both trees the lookups succeeded against the wrong file
instead of failing.

The codebase already had the right pattern (Conduit/paths.ts,
checks/notification-governor.ts); it was applied unevenly, and notably not in
the shared resolver every hook imports.

- hooks/lib/paths.ts: getClaudeDir() honors CLAUDE_CONFIG_DIR, after
  CLAUDE_PLUGIN_ROOT so plugin precedence is unchanged; getLifeosDir()'s
  fallback derives from it instead of re-hardcoding ~/.claude
- 167 direct call sites across LIFEOS/** and hooks/** guarded on
  process.env.LIFEOS_DIR / CLAUDE_CONFIG_DIR
- 16 plist templates parametrized ({{LIFEOS_DIR}} / __LIFEOS_DIR__ and the
  config-dir equivalents) and both roots exported in EnvironmentVariables —
  without that the daemons re-derive the wrong root at boot even with the code
  fixed
- 14 installer substitution sites and the PULSE/MenuBar shell materializers
  taught the new placeholders
- shell scripts (install.sh, StatusLine, Pulse manage scripts, Interceptor
  helpers) resolve through CLAUDE_CONFIG_DIR

Behavior is unchanged on a default install: with both env vars unset every path
resolves exactly as before. pulse-old.ts is left alone as dead code.
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