Skip to content

feat: configurable install locations via CLAUDE_CONFIG_DIR and PAI_DIR#1160

Closed
neilinger wants to merge 22 commits into
danielmiessler:mainfrom
neilinger:feat/configurable-install-paths
Closed

feat: configurable install locations via CLAUDE_CONFIG_DIR and PAI_DIR#1160
neilinger wants to merge 22 commits into
danielmiessler:mainfrom
neilinger:feat/configurable-install-paths

Conversation

@neilinger

Copy link
Copy Markdown
Contributor

Why

CLAUDE_CONFIG_DIR and PAI_DIR are documented in PAI/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-domain lib/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 when PAI_DIR points to an absolute path outside CLAUDE_CONFIG_DIR.

A single PAI_ROOT would conflate "Anthropic's tool config" with "my data" and lose that flexibility. Two vars is the minimum that respects the seam.

Architecture

CLAUDE_CONFIG_DIR  →  hooks, skills, agents, settings.json
PAI_DIR            →  MEMORY, ALGORITHM, TOOLS, USER, DOCUMENTATION

The two domains are orthogonal once both are resolved. PAI_DIR may live outside CLAUDE_CONFIG_DIR. Cross-domain access goes through absolute paths from env vars — never relative filesystem imports — so the layout works whether PAI_DIR nests inside CLAUDE_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.
  • ~150 consumer files migrated from hardcoded paths or ad-hoc process.env.CLAUDE_CONFIG_DIR ?? ... patterns to the centralized helpers.
  • Installer rework — honors CLAUDE_CONFIG_DIR / PAI_DIR env vars, adds --claude-config-dir / --pai-dir CLI flags, rewrites settings.json placeholders, fixes plist substitution, and exports the resolved paths in the shell-rc snippet.

Behavioral note: settings.json portability

The 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 $HOME at runtime but does not expand $CLAUDE_CONFIG_DIR. If CLAUDE_CONFIG_DIR != ~/.claude and 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.json is no longer portable across machines without re-running the installer. Users who version-control ~/.claude/settings.json will see the file pin to the install machine's username and home directory. Accepted as the cost of supporting custom roots.

Test evidence

  • 41 bun unit tests (hooks/lib/paths.test.ts, PAI/PAI-Install/engine/paths.test.ts) — GREEN
  • 12 bats integration tests (PAI/lib/paths.bats) — GREEN
  • Evals regression suite (skills/Evals/Suites/Regression/paths-resolution-regression.yaml) — GREEN
  • Mirror-drift guard — byte-identity check between the two lib/paths.ts files; if they ever diverge, the test fails

Default-config compatibility

For users with no env vars set, runtime behavior is identical to pre-branch. CLAUDE_CONFIG_DIR defaults to ~/.claude, PAI_DIR defaults 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.ts already imports yaml on main, but PAI/PULSE/Observability/package.json doesn't declare it. Adding yaml: ^2.8.3 to 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

  • Mirror lib is deliberate (each domain self-contained, no cross-domain relative imports) — not proposed for elimination here.
  • v4.0.3 / earlier release backports — preserved on side branches, not part of this PR.
  • Pre-existing template substitution bugs (e.g. {{DA_NAME}}) — separate PR.
  • Architecture redesign — this PR implements the existing spec, doesn't redesign it.

neilinger and others added 22 commits May 3, 2026 09:09
…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>
@danielmiessler

Copy link
Copy Markdown
Owner

Hey @neilinger, thanks for raising this, and sorry it sat for a while.

We're changing how LifeOS ships. Instead of cloning a full ~/.claude directory and running it as a complete system, LifeOS is becoming a skill you install through an agentic installer. The installer hands integration to your own AI, which reads your actual machine (your OS, your paths, your harness) and wires the hooks and system prompt in where they belong.

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.

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.

3 participants