feat: configurable install locations via CLAUDE_CONFIG_DIR and PAI_DIR#1160
feat: configurable install locations via CLAUDE_CONFIG_DIR and PAI_DIR#1160neilinger wants to merge 22 commits into
Conversation
…DIR + PAI_DIR) Locks the v5.0.0 architecture contract from PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34: two independent root directories (CLAUDE_CONFIG_DIR, PAI_DIR) with cross-domain absolute access. - hooks/lib/paths.test.ts: 13 cases covering Claude lookup, expansion, empty/whitespace handling, relative-path rejection. PAI section conditionally enabled until PAI/lib/paths.ts lands. - PAI/lib/paths.bats: 12 cases hermetic via env -u; helper not yet created — most rows fail with status 99 (expected RED). - Evals: paths-resolution-regression suite + 2 tasks under Suites/Regression/ + UseCases/Regression/. Tagged architecture-L18-34 for auto-deprecation traceability. RED state confirmed: 2 TS tests fail for whitespace/assertAbsolute (real bugs), 9 bats tests fail for missing helper. Both fail for the right reason. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When CLAUDE_CONFIG_DIR=/opt/claude is set, getPaiDir() previously returned /opt/claude/.claude/PAI because the join hardcoded '.claude' as a second segment. Drop the bogus segment so the result respects the user-set CLAUDE_CONFIG_DIR exactly: /opt/claude/PAI. Standalone single-line fix per the TDD plan; broader rewrite follows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add readEnv() helper: trim, treat empty/whitespace as unset
- Add assertAbsolute(): reject relative paths at lib boundary with
source-named error (e.g., CLAUDE_CONFIG_DIR=relative/path → throws)
- Add Claude-domain helpers: claudePath(), getAgentsDir(),
getCommandsDir(), getPluginsDir()
- Function-style throughout (no module-level path constants — env
changes between calls are honored, important for tests)
- expandPath unchanged (leading $HOME/${HOME}/~ only — not scope creep)
- getPaiDir / paiPath / getMemoryDir kept as @deprecated re-exports;
authoritative implementations move to PAI/lib/paths.ts in next commit;
these stay until commit 8 sweeps hooks/ consumers
Tests: 12 Claude-domain rows GREEN. PAI section conditionally skipped
until PAI/lib/paths.ts lands in commit 3.
Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical sweep replacing ad-hoc env-var path resolution with centralized lib helpers. Every PAI-data path now flows through getPaiDir() / paiPath() / getMemoryDir() / etc. Every Claude-domain access flows through getClaudeDir() / getSettingsPath() / getEnvPath(). Key fixes incident to migration (legacy v4 PAI_DIR-as-claude-home pattern was actively miswiring paths): - GetCounts.ts: \`join(PAI_DIR, "PAI/USER")\` → \`USER\` (was double-PAI prefix when CLAUDE_CONFIG_DIR set) - pai.ts:401: \`join(CLAUDE_DIR, "PAI", "PAI_SYSTEM_PROMPT.md")\` → \`join(PAI_DIR, ...)\` - OpinionTracker.ts, RelationshipReflect.ts: \`PAI/USER/\` double prefixes corrected - algorithm.ts, FailureCapture.ts, Wisdom*Synthesizer/Classifier/Updater: legacy v4 var resolved correctly to PAI domain homedir() retained where the path is genuinely non-Claude (codex bin ~/.bun/, ~/.local/bin/, ~/.config/arbol, iCloud paths, ~/Projects/*), or as part of generic tilde-expansion helpers (Checkpoint.ts allowlist parser, ArchitectureSummaryGenerator.ts). DAInterview.ts left untouched (intentional SCRIPT_DIR self-relative bootstrap — Rule 6 of migration spec). Tests: 23/23 TS GREEN, 12/12 bats GREEN. Smoke-loaded 8 representative tools — all parse and produce expected CLI startup output. Verification (final state of grep set scoped to PAI/TOOLS/): - process.env.HOME: empty - process.env.PAI_DIR: empty - process.env.CLAUDE_CONFIG_DIR: empty - homedir(): only legitimate non-Claude / non-PAI paths Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Targeted, judgment-call sweep — installer mixes runtime config (state.detection.paiDir) with default-fallback resolution. Migrated only the v5 default-fallback branches; left state.detection precedence intact. Out of scope: ~/.config/PAI (PAI_CONFIG_DIR — third var), ~/.env (~ ELEVENLABS voice/Telegram backup), ~/.zshrc, ~/Library, ~/.bun, ~/Projects/*, .replace(homedir(),"~") display formatting. Files modified: - generate-welcome.ts: hardcoded settings path → getSettingsPath() - engine/actions.ts: 6 sites of \`state.detection?.paiDir || join(homedir(),".claude")\` → \`|| getClaudeDir()\` - engine/validate.ts: same pattern, 1 site Behavior preserved: when state.detection.paiDir is set the installer still wins over env. The fallback now respects CLAUDE_CONFIG_DIR instead of hardcoding $HOME/.claude. Tests: 23/23 TS GREEN. Smoke-loaded actions.ts (10 exports OK). Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Mechanical sweep replacing ad-hoc env-var path resolution with
centralized lib helpers. Modules, checks, observability, performance,
voice server, lib utilities — all PAI-data paths now flow through
getPaiDir() / paiPath() / getMemoryDir() / getStateDir(); cross-domain
Claude-home access via getClaudeDir().
Rule 1 ambiguity resolutions (all confirmed PAI-data, not Claude-home):
- pulse.ts / pulse-unified.ts: PULSE_DIR = paiPath("PULSE")
- modules/wiki.ts: bare HOME+.claude+PAI → getPaiDir()
- Performance/cost-aggregator.ts, Performance/module.ts,
Observability/observability.ts (L54, L1649): all PAI MEMORY/Pulse data
homedir() retained where the path is genuinely non-Claude/non-PAI:
- claude binary fallback ~/.local/bin/claude (lib.ts, github-work.ts)
- macOS Messages DB ~/Library/Messages/chat.db (lib/messages-db.ts)
- macOS LaunchAgents ~/Library/LaunchAgents (setup.ts)
- subprocess HOME passthrough (lib.ts, observability.ts)
- user-configured key-path tilde expansion (setup.ts, github-work.ts)
Verification (PAI/PULSE/ scoped):
- process.env.HOME: empty
- process.env.PAI_DIR: empty
- process.env.CLAUDE_CONFIG_DIR: empty
- getClaudeDir()/PAI concatenations: empty
Tests: 23/23 TS GREEN, 12/12 bats GREEN.
Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Phase A — hooks/**/*.ts mechanical sweep (27 files): - 22 files: redirect PAI-helper imports from ./lib/paths or ../lib/paths to ../PAI/lib/paths or ../../PAI/lib/paths - Inline migrations: ContainmentGuard, InstructionsLoadedHandler, LastResponseCache, PreCompact, RepeatDetection, SatisfactionCapture, SessionCleanup, SmartApprover, WorkCompletionLearning, CheckpointPerISC, handlers/UpdateCounts, lib/identity, lib/observability-transport — all switch from ad-hoc env-var resolution to centralized lib helpers - homedir() retained where genuinely non-Claude (~/Projects, ~/LocalProjects, ~/Downloads, user-supplied path tilde-expansion helpers in CheckpointPerISC, SmartApprover, security inspectors) Phase B — drop @deprecated PAI helpers from hooks/lib/paths.ts: - Remove getPaiDir(), paiPath(), getMemoryDir() and their docblocks - Update top-of-file docblock: hooks/lib/paths.ts is now purely Claude-domain. PAI helpers live exclusively in PAI/lib/paths.ts. - One-way dependency PAI → Claude fully realized. Verification (hooks/ scoped, excluding paths.test.ts): - process.env.HOME: empty - process.env.PAI_DIR: empty - process.env.CLAUDE_CONFIG_DIR: empty - PAI helpers imported from Claude lib: empty Tests: 23/23 TS GREEN, 12/12 bats GREEN — through Phase A and Phase B. Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Mechanical sweep across Art, Agents, Telos, Daemon, AudioEditor,
PAIUpgrade, USMetrics. Skill code imports the PAI lib (which re-exports
Claude-domain helpers) so cross-domain access lands through a single
boundary.
Notable:
- Art/Generate.ts, GenerateMidjourneyImage.ts: \`PAI_DIR || ~/.claude\`
used only to find .env file → switched to getEnvPath()
- Telos DashboardTemplate routes (Next.js App Router): import depth
3-6 levels deep through DashboardTemplate/App/api/{dir}/route.ts
homedir() retained for user-domain paths: ~/Downloads (Art),
~/.local/bin/rembg (REMBG_BIN override default), ~/Projects/Substrate
(USMetrics — explicitly outside Claude/PAI).
Verification: all 4 forbidden grep patterns clean in skills/.
Tests: 23/23 TS GREEN, 12/12 bats GREEN.
Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
SuiteManager.loadSuite() looks up Suites/{type}/{name}.yaml by the
suite's name (matches filename, not the YAML 'name' field). Rename
paths-resolution.yaml → paths-resolution-regression.yaml so
\`AlgorithmBridge.ts -s paths-resolution-regression\` can resolve it.
Final acceptance:
Tests:
- bun test hooks/lib/paths.test.ts → 23/23 GREEN
- bats PAI/lib/paths.bats → 12/12 GREEN
Manual smoke matrix (representative rows):
| CLAUDE_CONFIG_DIR | PAI_DIR | Resolves to |
| unset | unset | ~/.claude, ~/.claude/PAI |
| /opt/claude | unset | /opt/claude, /opt/claude/PAI|
| unset | /data/pai | ~/.claude, /data/pai |
| /b/.claude | /a/PAI | /b/.claude, /a/PAI |
| /opt/claude | relative/path | throws PAI_DIR must be |
| | | absolute |
Verification grep set (all clean):
- process.env.HOME in TS (excl test): empty
- process.env.PAI_DIR in TS (excl test): empty
- process.env.CLAUDE_CONFIG_DIR in TS (excl test): empty
- homedir() with .claude: only inside hooks/lib/paths.ts (lib fallback)
- .claude in *.sh: only paths.sh, install.sh fallback bootstrap,
ContextReduction bootstrap, statusline recovery block — all expected
Known limitation (out of scope): the Evals BinaryTests grader uses
'timeout' which is unavailable on macOS by default (would need
gtimeout from coreutils). Tests run correctly via direct invocation;
the registered suite is discoverable via SuiteManager.list. Framework
fix is independent of this refactor.
This completes the path configurability refactor across 12 commits.
~149 files migrated; ~30 incidental bugs fixed (double-PAI prefix,
broken truthy fallbacks, malformed parameter expansion in statusline).
Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
The earlier commits inadvertently used relative filesystem paths to
cross between the Claude domain and the PAI domain (e.g.,
PAI/lib/paths.ts importing from ../../hooks/lib/paths). This works
only in the default nested layout where PAI lives under
CLAUDE_CONFIG_DIR. In cross-domain split mode (PAI_DIR=/a/PAI,
CLAUDE_CONFIG_DIR=/b/.claude) the relative paths resolve to
nonexistent locations and the imports fail at module load.
Per architecture C1: "Cross-domain access uses absolute paths via env
vars; never relative paths." Fix: each domain's lib is now fully
self-contained, carrying the full API (Claude helpers + PAI helpers).
Consumers import from their own domain's lib only. No filesystem
traversal between domains.
## Lib changes
- hooks/lib/paths.ts: add full PAI helper set (getPaiDir, paiPath,
getMemoryDir, getStateDir, getLearningDir, getKnowledgeDir,
getWorkDir, getObservabilityDir, getUserDir, getAlgorithmDir,
getToolsDir, getDocumentationDir). Now self-contained.
- PAI/lib/paths.ts: drop import from ../../hooks/lib/paths. Inline
full Claude helper set (getClaudeDir, claudePath, getSettingsPath,
getEnvPath, getHooksDir, getSkillsDir, getAgentsDir, getCommandsDir,
getPluginsDir) plus expandPath/assertAbsolute primitives. Now
self-contained.
The two libs are mirrors of each other implementing the same env-var
contract. Bug fixes apply to both. ~30 LOC of duplication is the
cost of architectural correctness — alternative would be dynamic
imports via env-resolved paths in every consumer, which is heavier.
## Consumer repointing (~46 files)
PAI domain (now imports from PAI/lib/paths):
- PAI/PAI-Install/generate-welcome.ts: ../../hooks/lib/paths → ../lib/paths
- PAI/PAI-Install/engine/{actions,validate}.ts: ../../../hooks/... → ../../lib/paths
Claude domain (now imports from hooks/lib/paths):
- 23 hooks/*.hook.ts: ../PAI/lib/paths → ./lib/paths
- 5 hooks/handlers/*.ts: ../../PAI/lib/paths → ../lib/paths
- hooks/security/logger.ts: ../../PAI/lib/paths → ../lib/paths
- hooks/lib/observability-transport.ts: ../../PAI/lib/paths → ./paths
- 13 skills/*/Tools/*.ts (and Telos DashboardTemplate routes):
../../../...PAI/lib/paths → ../../../...hooks/lib/paths
(depth-aware, 3-6 levels)
## Verification
- bun test hooks/lib/paths.test.ts: 23/23 GREEN
- rg "from .*hooks/lib/paths" -t ts PAI/ → empty
- rg "from .*PAI/lib/paths" -t ts hooks/ skills/ → empty
- Smoke split-mode: PAI_DIR=/tmp/pai CLAUDE_CONFIG_DIR=/tmp/claude
produces (claude=/tmp/claude pai=/tmp/pai) from BOTH libs
Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Constraint: C1 (cross-domain absolute access)
…edence Locks the contract for making the PAI installer honor CLAUDE_CONFIG_DIR and PAI_DIR. Today the installer hardcodes \`~/.claude\` at detect.ts:340 regardless of env or flags. Tests cover: - Defaults (no env, no flags) → ~/.claude + ~/.claude/PAI - Env vars (CLAUDE_CONFIG_DIR, PAI_DIR, both, split, empty, whitespace, relative-rejection) - CLI flags (--claude-config-dir, --pai-dir) override env - Contract symmetry: installer's resolved paths match runtime lib (PAI/lib/paths.ts: getClaudeDir, getPaiDir) - detectSystem() integration: DetectionResult exposes both claudeConfigDir AND paiDir as separate fields RED state: tests fail at module load because PAI/PAI-Install/engine/paths.ts does not yet exist (will be created in GREEN commits 1-3) and DetectionResult lacks the claudeConfigDir field. Existing path-lib tests stay green (21 TS + 12 bats). Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
- computeBackupPath(claudeConfigDir): returns sibling \${dir}.backup-<ts>
instead of hardcoded \$HOME/.claude.backup-<ts>. Default config still
produces ~/.claude.backup-<ts>; CLAUDE_CONFIG_DIR=/opt/claude now
produces /opt/claude.backup-<ts>.
- inventoryExistingConfig() in actions.ts: walks dirname(claudeConfigDir)
for siblings whose name extends basename(claudeConfigDir). Default
config walks \$HOME for .claude*; custom config walks /opt for claude*.
- detectExisting() in detect.ts: backup-pattern globs derived from
dirname/basename of claudeConfigDir.
Default config behavior preserved. Verification: 17/17 paths.test.ts
GREEN; actions.ts + detect.ts smoke-load.
The previous Plan-agent flagged Pulse plist path-baking as the highest- risk landmine for cross-domain split mode. Both plist templates hardcoded __HOME__/.claude/PAI/PULSE for WorkingDirectory and log paths, so a daemon launched after install with custom env vars would silently point at the wrong location on next boot. Fix: - com.pai.pulse.plist: WorkingDirectory + StandardOutPath + StandardErrorPath now use __PULSE_DIR__ placeholder. Added CLAUDE_CONFIG_DIR + PAI_DIR to EnvironmentVariables so the daemon inherits the resolved paths under launchd. - com.pai.pulse-menubar.plist: same treatment. - manage.sh: sed adds __CLAUDE_CONFIG_DIR__, __PAI_DIR__, __PULSE_DIR__ substitutions (sourced from PAI/lib/paths.sh exports). - MenuBar/install.sh: same. Default config ($HOME/.claude) produces identical plist output as before. Cross-domain split (CLAUDE_CONFIG_DIR=/tmp/c PAI_DIR=/tmp/p) verified via sed simulation: WorkingDirectory=/tmp/p/PULSE, EnvironmentVariables include CLAUDE_CONFIG_DIR=/tmp/c PAI_DIR=/tmp/p. bash -n: both shell scripts clean. Pulse runtime tests not affected.
paths-resolution-regression now covers three tasks: 1. task_paths_resolution_ts (runtime lib, 21 cases) 2. task_paths_resolution_sh (shell helper, 12 cases) 3. task_paths_installer_precedence (installer, 17 cases) — NEW Final acceptance state: - 38/38 TS tests GREEN - 12/12 bats GREEN - rg "join(home, '.claude')" -t ts PAI/PAI-Install/ → empty - rg "join(paiDir, 'PAI'" -t ts PAI/PAI-Install/ → empty - rg "state.detection?.paiDir" → only new " || getPaiDir()" forms - All hardcoded ~/.claude.bak / .previous etc. patterns are docstrings/UI labels, not runtime paths - Pulse plist substitution verified under cross-domain split (CLAUDE_CONFIG_DIR=/tmp/c PAI_DIR=/tmp/p produces correct WorkingDirectory + EnvironmentVariables) This completes the installer env-var/CLI-flag refactor (9 commits). The PAI installer now honors --claude-config-dir / --pai-dir flags and CLAUDE_CONFIG_DIR / PAI_DIR env vars end-to-end: - detect.ts resolves both from env+flags - types.ts has explicit claudeConfigDir + paiDir fields - main.ts validates and plumbs CLI flags into env - actions.ts + validate.ts consume the right field per intent - Backup paths land sibling-to-claudeConfigDir - Backup-scanner globs derive from dirname/basename - Pulse plist templates honor the resolved env Architecture: PAI/DOCUMENTATION/PAISystemArchitecture.md L18-34
Addresses code-review findings post-installer-refactor: danielmiessler#3 Mirror-lib drift guard: hooks/lib/paths.test.ts adds a unit test that diffs hooks/lib/paths.ts against PAI/lib/paths.ts (post-docblock). The two are deliberate mirrors per C1; this test fails the suite if they drift. No CI plumbing needed — runs in the regular bun test pass. danielmiessler#4 engine/paths.ts:53 clean() consistency: replaced direct process.env.PAI_DIR.trim() check with a clean() helper invocation matching the rest of the function. Same semantics, explicit intent, no future drift if clean() ever changes. danielmiessler#5 paths.sh exit-vs-return: documented the "return 1 2>/dev/null || exit 1" pattern with a four-line comment. danielmiessler#7 Test gap closed: new rows in PAI-Install/engine/paths.test.ts cover cliClaude + PAI_DIR=whitespace and cliClaude + PAI_DIR=empty. (Was implicit before; now explicit.) 41 tests total (was 38). danielmiessler#6 Untracked dirs: added .claude/worktrees/ and .omc/ to superproject gitignore; added .omc/ to release-tree gitignore. Architecture references: switched from "L18-34" to '"## Directory Structure"' (section name, stable across edits) in hooks/lib/paths.ts, PAI/lib/paths.ts, PAI/lib/paths.sh, PAI/lib/paths.bats, hooks/lib/paths.test.ts, PAI/PAI-Install/engine/paths.ts, PAI/PAI-Install/engine/paths.test.ts, all 3 task YAMLs and the suite YAML. Old eval Results/ JSONs left as historical run artifacts. Verification: 41/41 TS GREEN (17 installer + 24 path-lib including new drift guard); 12/12 bats GREEN; shell helper smoke clean. Not addressed in this commit (per review): - #1 rebase onto origin/main (your call to push) - #2 settings.json/CLAUDE.md local overrides (left uncommitted)
…tree if split
Bug: under cross-domain split (e.g. PAI_DIR=~/neilos/.claude/PAI,
CLAUDE_CONFIG_DIR=~/.claude), the installer dumped the entire bundle
(top-level CLAUDE.md, hooks/, skills/, agents/, PAI/) into paiDir.
Result: pai.ts ended up at \${paiDir}/PAI/TOOLS/pai.ts (double-PAI),
while consumers (the alias at L1529 etc.) compute \${paiDir}/TOOLS/pai.ts
— miss. User-reported error:
Module not found "/Users/neil/neilos/.claude/PAI/TOOLS/pai.ts"
Root cause: my commit c2a37f2 redefined paiDir from "claude home" to
"actual PAI data root", but runRepository still used paiDir as the
bundle install target. The bundle layout is claude-home rooted.
Fix:
- Bundle install target → claudeDir (always; bundle is claude-home
rooted)
- mkdir of install root → claudeDir (was paiDir)
- git clone target → claudeDir (was paiDir)
- After bundle lands, if paiDir != \${claudeDir}/PAI, relocate the
bundle's PAI subtree to user-configured paiDir (cpSync then rmSync;
pre-existing paiDir contents are removed since this is the
install-fresh path).
Default config behavior unchanged: paiDir == claudeDir/PAI, so the
relocation branch is a no-op.
Verification: 19/19 paths.test.ts GREEN; actions.ts smoke-loads.
Manual installer re-run under cross-domain split is the next step
for end-to-end verification.
observability.ts imports 'yaml' at the top level but it was not declared in package.json or bun.lock. On every cold start Bun cannot resolve it, the observability module silently fails to load, and the Pulse dashboard returns 404 on all routes. Add yaml@^2.8.3 as a runtime dependency and update bun.lock.
manage.sh has two sed invocations on the plist template — one in the \`start)\` case (line 38) and one in the \`install)\` case (line 107). Commit c3bba4a updated only the \`start)\` sed to substitute the new __PULSE_DIR__ / __CLAUDE_CONFIG_DIR__ / __PAI_DIR__ placeholders; the \`install)\` sed kept the original 2-arg version (only __HOME__ and __BUN_PATH__). Result: under cross-domain split, \`manage.sh install\` (the path runRepository takes via installPulse) wrote a plist with literal \`__PULSE_DIR__\` as WorkingDirectory and as the StandardOut/Err paths + literal \`__CLAUDE_CONFIG_DIR__\` / \`__PAI_DIR__\` in EnvironmentVariables. launchd exits 78 (EX_CONFIG) trying to chdir to a nonexistent directory; port 31337 never binds. Fix: mirror the same 5-arg sed in the install case. Pulse install now succeeds end-to-end under cross-domain split — verified at ~/neilos/.claude: PAI Pulse installed and verified on port 31337 (bun: /opt/homebrew/bin/bun) WorkingDirectory: /Users/neil/neilos/.claude/PAI/PULSE port 31337: LISTEN Could be DRY-cleaned by extracting the sed into a helper function; keeping it duplicated for now to minimize diff (KISS).
Previously the installer required PAI_BUNDLE_DIR=/path/to/bundle to be exported before invocation when running from a checked-out source tree. Without it, detectLocalBundle returned null and the installer fell through to \`git clone https://github.com/danielmiessler/PAI.git\` — pulling the public repo's main branch instead of the local working copy. Confusing for contributors / pre-release testers. Fix: detectLocalBundle now walks up from import.meta.dir looking for a parent directory that has all BUNDLE_MARKERS. Capped at 12 levels. Explicit PAI_BUNDLE_DIR still wins (highest precedence). Safety: at the call site, if the auto-detected bundle equals claudeDir (the install destination), ignore it — copying a directory onto itself would loop or corrupt the in-progress install. Falls through to git clone in that case (rare edge: user runs the installer from inside their already-installed ~/.claude tree). Smoke test confirms: walking up from .../Releases/v5.0.0/.claude/PAI/PAI-Install/engine/ finds the bundle at .../Releases/v5.0.0/.claude/ in 3 iterations. Tests: 19/19 paths.test.ts GREEN; actions.ts smoke-loads. Net result: the recipe PAI_BUNDLE_DIR=... bun run PAI/PAI-Install/main.ts --mode cli becomes simply bun run PAI/PAI-Install/main.ts --mode cli when invoked from inside a bundle checkout.
Bug: Claude Code reads its settings.json from \$CLAUDE_CONFIG_DIR (or
~/.claude when unset). The installer only wrote \`alias pai='bun .../
TOOLS/pai.ts'\` to the user's rc — no env exports. So when the user
launched \`pai\` after a custom-location install, the spawned \`claude\`
inherited the user's shell env which lacked CLAUDE_CONFIG_DIR, fell
back to ~/.claude, and read the wrong settings.json.
User reported the symptom:
\$ pai
...
~/.claude ← Claude Code's "current dir" line, but install was at ~/neilos/.claude
\$ env | grep CLAUDE
(empty)
\$ env | grep PAI
PAI_DIR=/Users/neil/neilos/.claude/PAI ← only this got exported (somewhere else)
Fix: write a 4-line block to the user's rc:
# PAI alias
export CLAUDE_CONFIG_DIR="\${claudeDir}"
export PAI_DIR="\${paiDir}"
alias pai='bun \${paiDir}/TOOLS/pai.ts'
Fish shell variant uses \`set -gx\` instead of \`export\`. Block-aware
regex replaces any prior PAI block (marker + exports + alias) cleanly
on re-install.
For default config (~/.claude) the exports are no-ops behaviorally
but make the resolution explicit — also benefits users who later move
to cross-domain split without needing to manually export.
Tests: 19/19 paths.test.ts GREEN.
Drop dead exports, inline trivial helper, consolidate repeated logic, trim WHAT-not-WHY comment narration. No behavior change; full regression green (41 bun tests + 12 bats). - hooks/lib/paths.ts, PAI/lib/paths.ts: drop unused claudePath() export from both mirrors (zero callers; mirror-drift guard keeps them in sync) - PAI/PAI-Install/engine/paths.ts: inline clean() to one-liner; drop unused homedir import; collapse precedence chain to ternary; trim redundant doc comments - PAI/PULSE/manage.sh: extract render_plist() — two identical 5-line sed invocations (start/install) collapse to one definition - PAI/PAI-Install/engine/actions.ts: collapse rewriteHomeRefs four .replace() chains into one regex with replacer; trim sibling-locality comment whose body restated the one-liner above it Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend (commit 2c896d1) already routes runtime paths through getPaiDir()/ paiPath(); this pass closes the display-layer gap so users with custom CLAUDE_CONFIG_DIR / PAI_DIR see correct paths in the Pulse dashboard, banner, security/finances/performance/knowledge/air pages, and the AgentWatchdog command emit. React display strings: switched to NEXT_PUBLIC_PAI_USER_DIR / NEXT_PUBLIC_PAI_TOOLS_DIR / NEXT_PUBLIC_CLAUDE_DIR / etc., baked at install-time `next build`. Hoisted module-level consts where multiple references share a path (TemplateOnboarding, performance, finances). Build flow: manage.sh install now runs `bun install && bun run build` before render_plist, with PAI_TILDE / CLAUDE_TILDE locals feeding the NEXT_PUBLIC_* env block. Subshell hardened with `set -e`. Pre-built Observability/out/ dropped from the tree (.gitignore + git rm --cached). Backend cleanups: handleOnboardingState now uses getUserDir() for marker/ identity paths; modules/hooks.ts AgentWatchdog command interpolates ${paiPath('TOOLS','AgentWatchdog.ts')} instead of literal $HOME path. Six comment-hygiene sweeps replace ~/.claude/PAI/... mentions with $PAI_DIR/$CLAUDE_CONFIG_DIR throughout PULSE. Installer: extended actions.ts settings.json rewriter regex to also match the bare `~/.claude` form (in addition to $HOME/.claude / ${HOME}/.claude). Source form preserved in output: tilde→tildified path (prose, permission rules), $HOME→absolute path (executable commands). This catches Write(~/.claude/**) permission rules and security-section prose that the prior regex missed. Verification: - 41/41 paths.test.ts + drift-guard GREEN - Build smoke with custom NEXT_PUBLIC_ env: bundle bakes resolved paths - Live rewrite of running settings.json: 9 patterns rewrote correctly, zero ~/.claude or $HOME/.claude refs remained Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Hey @neilinger, thanks for raising this, and sorry it sat for a while. We're changing how LifeOS ships. Instead of cloning a full That's aimed right at what you hit here. The old "one directory, one layout, hope it matches your setup" approach is exactly what broke for so many people, and the new model should handle it far better because your AI does the integration per machine instead of us guessing. So we're closing this in prep for that release. If it still bites you once the skill-based version is out, reopen or file a fresh one and we'll jump on it. Appreciate you taking the time. |
Why
CLAUDE_CONFIG_DIRandPAI_DIRare documented inPAI/DOCUMENTATION/PAISystemArchitecture.md("Directory Structure") as the two env vars that control where PAI installs and runs. Until now, they didn't actually do anything — paths were hardcoded across the codebase, so setting either var had no effect.Successor to #778 (v4.0.3 —
fix/pai-dir-hardcoded-paths, narrower scope: 26 runtime files). This PR is the v5.0.0 successor: same goal, but with the centralized two-domainlib/paths.{ts,sh}libraries, full installer support, and ~150 migrated call sites — informed by additional PAI experience since #778.The concrete user scenario this unblocks: keep Claude Code's tool config (
~/.claude— settings, hooks, skills, agents) where Claude Code expects it, while putting the personal PAI data tree (MEMORY,USER,ALGORITHM) somewhere managed independently — a synced volume, a separate filesystem, an encrypted home, etc. The two domains are related but separable: PAI defaults to nesting inside Claude's config, but the install can pull them apart whenPAI_DIRpoints to an absolute path outsideCLAUDE_CONFIG_DIR.A single
PAI_ROOTwould conflate "Anthropic's tool config" with "my data" and lose that flexibility. Two vars is the minimum that respects the seam.Architecture
The two domains are orthogonal once both are resolved.
PAI_DIRmay live outsideCLAUDE_CONFIG_DIR. Cross-domain access goes through absolute paths from env vars — never relative filesystem imports — so the layout works whetherPAI_DIRnests insideCLAUDE_CONFIG_DIR(default) or sits on a different filesystem entirely.What changed
hooks/lib/paths.ts+PAI/lib/paths.ts— mirror libraries, each self-contained so consumers in one domain never reach across the filesystem to import from the other. Resolve env vars with absolute-path validation, whitespace handling, and$HOME/~expansion. Byte-identical bodies below the docblock; a unit test guards drift.PAI/lib/paths.sh— shell-script helper providing the same contract.process.env.CLAUDE_CONFIG_DIR ?? ...patterns to the centralized helpers.CLAUDE_CONFIG_DIR/PAI_DIRenv vars, adds--claude-config-dir/--pai-dirCLI flags, rewritessettings.jsonplaceholders, fixes plist substitution, and exports the resolved paths in the shell-rc snippet.Behavioral note:
settings.jsonportabilityThe installer now writes resolved absolute paths into
settings.json(e.g./Users/alice/.claude/hooks/...) instead of$HOME/.claude/...literals.Why this is required. Claude Code expands
$HOMEat runtime but does not expand$CLAUDE_CONFIG_DIR. IfCLAUDE_CONFIG_DIR != ~/.claudeand the literals stayed in the file, hooks would resolve to the wrong tree. Resolving at install time is the only way to make custom install roots work without changes inside Claude Code itself.Single-machine impact: none. Hooks, permissions, and credential paths resolve to the same physical files under default config.
Multi-machine impact: a regression for dotfile sync. The generated
settings.jsonis no longer portable across machines without re-running the installer. Users who version-control~/.claude/settings.jsonwill see the file pin to the install machine's username and home directory. Accepted as the cost of supporting custom roots.Test evidence
hooks/lib/paths.test.ts,PAI/PAI-Install/engine/paths.test.ts) — GREENPAI/lib/paths.bats) — GREENskills/Evals/Suites/Regression/paths-resolution-regression.yaml) — GREENlib/paths.tsfiles; if they ever diverge, the test failsDefault-config compatibility
For users with no env vars set, runtime behavior is identical to pre-branch.
CLAUDE_CONFIG_DIRdefaults to~/.claude,PAI_DIRdefaults to${CLAUDE_CONFIG_DIR}/PAI. No migration step required. (See "Behavioral note" above for the on-disk caveat.)Drive-by fix
Includes one small fix that's strictly speaking unrelated to install paths:
PAI/PULSE/Observability/observability.tsalready importsyamlonmain, butPAI/PULSE/Observability/package.jsondoesn't declare it. Addingyaml: ^2.8.3to the dependency list. Surfaced while testing the install-paths feature — kept inline rather than in a separate PR because it's a one-line bug fix for a latent issue.Out of scope
{{DA_NAME}}) — separate PR.