Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
142 changes: 142 additions & 0 deletions LifeOS/CUSTOM_HOME.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Custom LifeOS Home — design & implementation notes

This document summarizes how LifeOS supports installing into a **custom home**
(a config root other than `~/.claude`, e.g. a project-scoped `~/Project/.claude`
so an isolated second LifeOS can live beside the global one), and how the
codebase was made root-agnostic. It is a reviewer-facing companion to the PR;
`INSTALL.md § 2.5` covers the user-facing launch story.

## The override

- **Env var `LIFEOS_HOME`** (highest priority) and **`install.sh --home <dir>`**
(which exports it) select the config root. `detectEnv()` resolves
`configRoot = --config-root || LIFEOS_HOME || CLAUDE_CONFIG_DIR ||
dirname(LIFEOS_DIR) || ~/.claude`. `LIFEOS_DIR` is the persisted recovery path
for later sessions after the bootstrap shell exits.
- `configDir` (private USER data) follows the custom home
(`<configRoot>/USER-data`), overridable with `--config-dir`, so two instances
never share USER data — isolation is the whole point. A direct custom
`--config-root` or `CLAUDE_CONFIG_DIR` gets the same isolated default. The legacy poisoned
`LIFEOS_CONFIG_DIR=<configRoot>/LIFEOS` value is ignored and repaired.

The bootstrap temporarily launches Claude Code with
`CLAUDE_CONFIG_DIR=<configRoot>` so `/lifeos-setup` can discover the staged skill
from any caller directory. The installed `lifeos` launcher preserves normal
global+project merging for `<project>/.claude` roots by starting from the project
parent; arbitrary custom roots use `CLAUDE_CONFIG_DIR` for the spawned process.

## Three layers had to become root-aware

**1. The install / deploy pipeline** (DetectEnv, DeployCore, InstallSettings,
InstallHooks, ScaffoldUser, LinkUser, DeployComponents). These already accept
`--config-root` / `--config-dir`; `InstallSettings` retargets the settings
template's `~/.claude` strings (env values, permission globs, guidance) and
`InstallHooks` retargets the payload's hook commands to `<configRoot>`. The
written `settings.json` must contain **zero** `~/.claude` references.

**2. The runtime layer** (the ~230 files under `LIFEOS/PULSE`, `LIFEOS/TOOLS`,
`hooks/`, `skills/*`, plus service plists and shell scripts). Historically each
computed `join(homedir(), ".claude")` inline, so a custom-home install would
still read/write `~/.claude` at runtime — leaking state (e.g. `MEMORY/STATE/…`)
into the wrong tree. This is the layer the PR makes root-agnostic.

**3. The instruction layer** — the model-read markdown (SKILL.md files, skill
workflows, `LIFEOS/DOCUMENTATION`, agents). These files carry literal shell
commands and file references like `bun ~/.claude/LIFEOS/TOOLS/Foo.ts`, and the
model executes them verbatim — on a custom-home install they fail (or worse,
touch a parallel default install). The payload markdown uses semantic path
placeholders in model-facing prose and tool instructions:

- `{{LIFEOS_DIR}}` → `<configRoot>/LIFEOS` — for everything under `LIFEOS/`.
- `{{LIFEOS_ROOT}}` → `<configRoot>` — for root-level paths (`skills/`,
`hooks/`, `agents/`, `History/`, `Pulse/`, `settings.json`, `.env`, …).
- `{{LIFEOS_CONFIG_DIR}}` → the private USER-data home.

`LoadContext.hook.ts` grounds all three placeholders with their concrete values
at every `SessionStart`, including subagent sessions, and instructs the model to
resolve them before invoking Read/Edit/Write/Glob/Grep/Bash. Claude Code does
not natively substitute arbitrary `{{...}}` tokens; this is an explicit LifeOS
model-context contract. The settings template still carries `LIFEOS_ROOT`,
`LIFEOS_DIR`, and `LIFEOS_CONFIG_DIR` as environment variables for executable
shell code and runtime processes. As a fail-safe, `PreToolGuard.hook.ts` rejects
unresolved LifeOS placeholders in Bash commands and Read/Write/Edit/Glob/Grep
path fields before a tool can touch the wrong tree; placeholder text in document
content remains allowed.

The two layers stay deliberately distinct: prose and model tool paths use
`{{LIFEOS_*}}`; executable shell snippets use quoted variables such as
`"${LIFEOS_DIR}/TOOLS/Doctor.ts"`. A skill's own `SKILL.md` may use Claude
Code's native `${CLAUDE_SKILL_DIR}` substitution. Supporting workflow/reference
files do not rely on that substitution because they are loaded later through
Read rather than rendered as the skill entrypoint. Harness session storage is
rooted at `{{LIFEOS_ROOT}}/projects/`, so arbitrary `CLAUDE_CONFIG_DIR` installs
do not fall back to the real `~/.claude` tree.

## How we changed all the paths — one pattern, applied mechanically

There is a single source of truth for "where does LifeOS live", resolved in this
order (never a bare hardcode):

1. `CLAUDE_PLUGIN_ROOT` — packed-plugin install (flattened root plays `~/.claude`).
2. `LIFEOS_DIR` env → `dirname()` — set in `settings.json` env and every service
plist, so all harness-spawned contexts resolve correctly.
3. **Self-location** — walk up from the module's own file location to the ancestor
containing a `LIFEOS/` child. Covers a bare `bun Tool.ts` with no env, and is
what stops hook state leaking into `~/.claude` when a hook runs detached.
4. `~/.claude` — plain-install default; byte-identical to the old behavior.

Concretely:

- **TypeScript runtime** — a dependency-free resolver `LIFEOS/TOOLS/lifeos-root.ts`
(exports `claudeDir()`, `lifeosDir()`, `claudePath()`, …). The hooks tree keeps
its own `hooks/lib/paths.ts` (`getClaudeDir()` / `getLifeosDir()`) so it survives
the plugin packer's tree flattening; both share the same 4-step logic incl. the
self-location fallback. Every file that hardcoded `join(HOME, ".claude", …)` was
rewritten to route through the resolver via an **idempotent codemod** covering
the idiom set (`join`/`resolve`/`path.join`/aliases, `${HOME}/.claude` template
literals, `HOME + "/.claude"` concatenation). The codemod is conservative: it
rewrites only well-defined path-building idioms and leaves comments, user-facing
prose, string comparisons (`entry === ".claude"`), and regexes untouched.
- **launchd / systemd services** (Pulse, deriver, menu-bar) — the plist/service
templates use a `__LIFEOS_DIR__` / `__CLAUDE_DIR__` placeholder (not
`__HOME__/.claude`), and `manage.sh` / `manage-deriver.sh` / `MenuBar/install.sh`
derive their target from the **script's own location** (they ship inside
`<configRoot>/LIFEOS/PULSE`) and substitute it in. Plists also carry `LIFEOS_DIR`
in their environment so the spawned process resolves the same root. `manage.sh
render` prints the substituted unit without loading it (dry-run + test hook).
- **Shell scripts** — hardcoded `$HOME/.claude/LIFEOS` became
`"${LIFEOS_DIR:-$HOME/.claude/LIFEOS}"` (env with default), or self-location
where the script sits in the tree.
- **`Services.ts`** — the launchd control surface resolves its base via the
canonical resolver instead of `join(HOME, ".claude")`.

## Backward compatibility

For a default install (`LIFEOS_HOME` unset), every path resolves to `~/.claude`
exactly as before — the resolver's default branch is byte-identical, the settings
template's `~/.claude` globs are left untouched, and hook commands still emit
`$HOME/.claude/...`.

## Verification

`Tools/InstallIntegrationTest.ts` (bun, throwaway `$HOME` under `mktemp`) proves it
end-to-end:

- **Scenario A** — full custom-home install; asserts everything lands under the
custom root and the written `settings.json` has **zero** `.claude` references,
the symlink contract holds, and nothing leaks to `<home>/.claude`.
- **Scenario B** — ambient legacy config-dir recovery plus explicit self-symlink guard.
- **Scenario C** — default-install regression: with no `LIFEOS_HOME`, everything
lands in `~/.claude`; default permission globs and hook commands remain unchanged.
- **Scenario D** — Pulse runtime + launchd wiring: the resolver ships under the
custom root, a PULSE module routes through it, and `manage.sh render` emits a
plist with **zero** `.claude`, `WorkingDirectory` and `LIFEOS_DIR` pointing at
the custom root, and no unresolved placeholder.
- **Lifecycle/edge checks** — execute both deployed resolvers with root env vars
scrubbed, launch a fake Claude process through both the bootstrap and installed
launcher, recover a later session from `LIFEOS_DIR`, repair the legacy default
config-dir collision, verify direct `--config-root` / `CLAUDE_CONFIG_DIR`
isolation, exercise a root containing spaces, audit statusline/runtime commands,
and assert the packaged nested LifeOS installer matches the authoritative skill.

The current suite passes **93/93** checks.
36 changes: 33 additions & 3 deletions LifeOS/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,36 @@ bun Tools/DetectEnv.ts

Read its output. It reports the OS (macOS / Linux / Windows), the harness (Claude Code / Cursor / Cline / Codex / Gemini / other), the config root, and whether LifeOS is already present. **Every path below comes from this — don't assume `~/.claude` or any single harness.**

### 2.5 The install-location choice — default home or a custom directory

LifeOS normally installs into the harness's config root — right for a human who wants LifeOS in **every** session. But that makes LifeOS global: its `CLAUDE.md`, hooks, and settings load into *every* Claude Code session, including plain coding work. A human who also wants to use Claude Code **without** LifeOS should install it into a custom directory instead — LifeOS then loads only when launched against that root, and plain `claude` stays vanilla. The same mechanism gives you an **isolated second instance** (e.g. a project-scoped assistant in `~/MyProject/.claude`).

**Ask once.** If `LIFEOS_HOME` is not already set, offer your human the choice now, before anything is written:

> "Install LifeOS into the default location (`<configRoot from DetectEnv>`) so it's active in every session, or into a custom directory so you can keep using Claude Code without LifeOS and opt in per launch?"

Default answer → proceed unchanged; don't mention custom homes again. Custom answer → export the directory they name as `LIFEOS_HOME` for **every** subsequent step (and re-run `DetectEnv` to confirm the resolved `configRoot`/`configDir`). If `LIFEOS_HOME` is already set (e.g. by `install.sh --home`), skip the question and just confirm the target.

The override, settable up front instead:

```
export LIFEOS_HOME=~/MyProject/.claude # or: bash install.sh --home ~/MyProject/.claude
```

What it changes, and what it doesn't:

- `LIFEOS_HOME` moves **only where LifeOS installs** (`configRoot`: `LIFEOS/`, `settings.json`, `hooks/`, `skills/`, `CLAUDE.md`). It is NOT `CLAUDE_CONFIG_DIR` — it doesn't relocate the harness's own config or disable merging with your global `~/.claude` at runtime.
- The private user-data home (`configDir`, symlink target of `<configRoot>/LIFEOS/USER`) follows the custom home to `<configRoot>/USER-data`, so two instances never share one USER tree. Override with `--config-dir` on `ScaffoldUser`/`LinkUser` if you want it elsewhere.
- The install tools retarget everything the harness reads literally — `settings.json` env values and permission globs, and every hook command — to the custom root. `DetectEnv` reports the resolved `configRoot`/`configDir`; verify the written `settings.json` contains no `~/.claude` references.
- The bootstrap launches `/lifeos-setup` with a temporary `CLAUDE_CONFIG_DIR=<configRoot>` so Claude Code can discover the staged skill even when the installer was run from another directory. Written custom settings persist both `LIFEOS_HOME` and `LIFEOS_DIR`, so later setup/update runs recover the same root.

**The runtime half (don't skip):** installing into a custom home only places the files — the harness must also LOAD them. For Claude Code there are two ways:

1. **Project-scoped `.claude`** (recommended for a per-project assistant): install into `<project>/.claude` and launch Claude Code from `<project>` — it picks the directory's `.claude` up as project settings alongside your global config.
2. **Whole-config relocation:** launch with `CLAUDE_CONFIG_DIR=<configRoot> claude` — the harness then uses ONLY that config dir (no global merge). This is the right tool when the instance should be fully self-contained.

Wire the `lifeos` launch command (step 7) against the custom `<configRoot>` either way. The launcher preserves global+project merging for a `<project>/.claude` root by launching from `<project>`; for an arbitrary custom root it sets `CLAUDE_CONFIG_DIR` for the spawned process.

### 3. Scan for conflicts (read-only)

```
Expand Down Expand Up @@ -97,15 +127,15 @@ The payload ships the launcher — `install/LIFEOS/TOOLS/lifeos.ts` — which sp

- **Claude Code (zsh / bash)** — append to `~/.zshrc` (or `~/.bashrc`):
```
alias lifeos='bun <configRoot>/LIFEOS/TOOLS/lifeos.ts -s <configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md'
alias lifeos='bun "<configRoot>/LIFEOS/TOOLS/lifeos.ts" -s "<configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md"'
```
fish: `alias lifeos "bun <configRoot>/LIFEOS/TOOLS/lifeos.ts -s <configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md"; funcsave lifeos`. After this, **`lifeos` launches Claude WITH the constitution**; plain `claude` stays vanilla (which is fine — the user opts in by launching `lifeos`).
fish: `alias lifeos 'bun "<configRoot>/LIFEOS/TOOLS/lifeos.ts" -s "<configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md"'; funcsave lifeos`. After this, **`lifeos` launches Claude WITH the constitution and the matching custom settings/hooks/skills**; plain `claude` stays vanilla (which is fine — the user opts in by launching `lifeos`).

- **Any other harness** — use that harness's own system-prompt flag against the same file. e.g. pi: `pi --append-system-prompt <configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md`. If a harness has no system-prompt flag, load `LIFEOS_SYSTEM_PROMPT.md` through its context file (AGENTS.md / rules) as the closest equivalent, and tell your human plainly that the constitution is loading as context, not as a true system-prompt layer.

If your human declines the shell edit, give them the one-line launch command to run by hand so the constitution still loads:
```
bun <configRoot>/LIFEOS/TOOLS/lifeos.ts -s <configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md
bun "<configRoot>/LIFEOS/TOOLS/lifeos.ts" -s "<configRoot>/LIFEOS/LIFEOS_SYSTEM_PROMPT.md"
```

### 8. Choose the components — install all, or pick a subset (WITH PERMISSION)
Expand Down
5 changes: 2 additions & 3 deletions LifeOS/Tools/ActivateImports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@
*/

import { join } from "node:path";
import { activateImports, detectDevTree } from "./InstallEngine";
import { activateImports, detectDevTree, resolveConfigRoot } from "./InstallEngine";

function main(): void {
const a = process.argv.slice(2);
const get = (f: string): string | undefined => {
const i = a.indexOf(f);
return i >= 0 && a[i + 1] && !a[i + 1].startsWith("--") ? a[i + 1] : undefined;
};
const home = process.env.HOME || "";
const configRoot = get("--config-root") || process.env.CLAUDE_CONFIG_DIR || join(home, ".claude");
const configRoot = resolveConfigRoot(get("--config-root"));
const apply = a.includes("--apply");
const allowDev = a.includes("--allow-dev");

Expand Down
8 changes: 3 additions & 5 deletions LifeOS/Tools/DeployComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import { execFileSync } from "node:child_process";
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { copyMissing, detectDevTree } from "./InstallEngine";
import { copyMissing, detectDevTree, resolveConfigRoot, shellQuote } from "./InstallEngine";

// Enhancement components are the à-la-carte half of setup. The "LifeOS Core"
// (skills + system prompt + base settings + CLAUDE.md) is installed by Setup's
Expand Down Expand Up @@ -155,9 +155,7 @@ function deployStatusline(ctx: Ctx): ComponentResult {
// Build the settings.json command from the ACTUAL install root (ctx.lifeosDir),
// not a hardcoded ~/.claude — a custom --config-root (e.g. ~/.claude-fable) places
// the script under its own LIFEOS/, and the old literal pointed at the wrong tree.
const command = scriptPath.startsWith(`${ctx.home}/`)
? `$HOME/${scriptPath.slice(ctx.home.length + 1)}`
: scriptPath;
const command = shellQuote(scriptPath);

if (!av.inLive && !av.inPayload) {
r.blockers.push(`LIFEOS_StatusLine.sh not in live tree (${scriptPath}) or payload`);
Expand Down Expand Up @@ -386,7 +384,7 @@ function deploy(component: Component, ctx: Ctx): ComponentResult {
function main(): void {
const a = process.argv.slice(2);
const home = process.env.HOME || "";
const configRoot = arg(a, "--config-root") || process.env.CLAUDE_CONFIG_DIR || join(home, ".claude");
const configRoot = resolveConfigRoot(arg(a, "--config-root"));
const skillRoot = arg(a, "--skill-root") || join(import.meta.dir, "..");
const apply = a.includes("--apply");
const allowDev = a.includes("--allow-dev");
Expand Down
6 changes: 2 additions & 4 deletions LifeOS/Tools/DeployCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
*/

import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { copyMissing, detectDevTree } from "./InstallEngine";
import { copyMissing, detectDevTree, resolveConfigRoot } from "./InstallEngine";

// Runtime top-level entries this tool does NOT deploy:
// - USER shipped separately as a scaffold (ScaffoldUser) + symlinked (LinkUser)
Expand Down Expand Up @@ -166,8 +165,7 @@ function deployDependencies(payloadInstall: string, configRoot: string, apply: b

function main(): void {
const a = process.argv.slice(2);
const home = process.env.HOME || homedir();
const configRoot = arg(a, "--config-root") || process.env.CLAUDE_CONFIG_DIR || join(home, ".claude");
const configRoot = resolveConfigRoot(arg(a, "--config-root"));
const skillRoot = arg(a, "--skill-root") || join(import.meta.dir, "..");
const payloadInstall = join(skillRoot, "install");
const apply = a.includes("--apply");
Expand Down
Loading