Summary
LIFEOS_StatusLine.sh already states the rule, in shipped code:
# Source .env for API keys. Canonical location is $HOME/.claude/.env (which is
# typically a symlink to $HOME/.config/LIFEOS/.env). The historical $HOME/.claude/LIFEOS/.env
# path is wrong and has been removed everywhere else — do not reintroduce it.
— LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh:134-136
It has not been removed everywhere else. Seven files still compute exactly that path.
const envPath = process.env.LIFEOS_CONFIG_DIR
? join(process.env.LIFEOS_CONFIG_DIR, '.env') // -> ~/.claude/LIFEOS/.env (nothing creates this)
: join(homedir(), '.claude', '.env') // -> ~/.claude/.env (correct, but unreachable)
settings.system.json:9 sets LIFEOS_CONFIG_DIR="$HOME/.claude/LIFEOS" — that variable names the LIFEOS directory, not the directory holding .env. So the ternary always takes the first branch, and the correct path sitting right there in the else is dead code.
Why this surfaces now, and why nobody has reported it
Until #1451 (merged 2026-07-09), LIFEOS_CONFIG_DIR was injected as the literal, unexpanded string $HOME/.claude/LIFEOS. The file read therefore failed against a garbage path for everyone, and every tool silently fell through to process.env. #1451 correctly expanded the variable — which made the wrong-but-now-real path ~/.claude/LIFEOS/.env reachable for the first time. The bug in its current form is five days old.
It is also masked, which is why it survives in a mature package. Every affected tool checks process.env first:
const API_KEY = process.env.GROK_API_KEY || process.env.XAI_API_KEY || env.GROK_API_KEY || env.XAI_API_KEY
so anyone who exports keys in their shell never touches the broken branch. And bun auto-loads ./.env from the current working directory, so a tool run from ~/.claude picks the key up by accident and appears to work — its own path resolution never actually reads the file.
It only bites when a user (a) keeps keys in ~/.claude/.env as the docs instruct, and (b) runs a tool from any cwd other than ~/.claude — which is what happens whenever a skill or subagent runs from a project directory.
Severity: latent, not critical. Filing it because it contradicts the project's own stated doctrine and because the template case below regenerates it.
Reproduce
On main (HEAD 03a969d), with XAI_API_KEY set in ~/.claude/.env:
cd ~/.claude
bun LIFEOS/TOOLS/Grok.ts --web-only "say OK"
# -> OK (bun auto-loaded ./.env from cwd; the tool's own resolution never ran)
cd /tmp
bun ~/.claude/LIFEOS/TOOLS/Grok.ts --web-only "say OK"
# -> Error: GROK_API_KEY (or XAI_API_KEY) not set in ~/.claude/.env
The error message names the very file it declined to read.
Affected files (all at HEAD)
| File |
Line |
LIFEOS/TOOLS/Grok.ts |
65 |
LIFEOS/TOOLS/GrokAudit.ts |
110 |
LIFEOS/TOOLS/PerplexitySearch.ts |
49 |
LIFEOS/TOOLS/YouTubeApi.ts |
54 |
LIFEOS/TOOLS/llcli/llcli.ts |
70 |
skills/AudioEditor/Tools/Polish.ts |
40 |
skills/CreateCLI/Workflows/CreateCli.md |
205 |
CreateCli.md is the one that matters most: it is the template new CLIs are generated from, so the bug is generative — every tool created from it inherits the wrong path. Its fallback is wrong in a third way (~/.claude/LifeOS/.env), and the parser it teaches uses .split('=')[1], which truncates any value containing = (e.g. a base64 key with padding).
Two things I want to raise honestly, not hide
-
DOCUMENTATION/Tools/Containment.md:84 says LIFEOS/.env and ~/.config/LIFEOS/.env "are symlinks to" the canonical file. I could find no code that creates those symlinks — no symlinkSync or ln -s in the tree targets .env, and install.sh contains zero .env references. If that symlink is meant to be created by the installer, then this issue is really "the installer doesn't create it," and the fix is different. Happy to be corrected here.
-
The same file says "no central env helper exists by design." A shared loader is the obvious fix for seven copies of the same broken expression, but I don't want to fight a deliberate architectural choice. If you'd rather each tool keep its own parser, the minimal fix is to repoint all seven at ~/.claude/.env and leave it there — say which you prefer.
Proposed fix
Either (a) repoint the seven sites at ~/.claude/.env, or (b) a single shared LIFEOS/TOOLS/EnvLoader.ts the tools import.
I prototyped (b) and then put the prototype through five independent review passes across three model families (three Anthropic, one xAI, one OpenAI). That was instructive: a correct resolver turned out to need far more than the one-line path swap this looks like at first. Each of the properties below started as a bug the reviews found in an earlier version of my own fix:
- Canonical must win. Putting
$LIFEOS_CONFIG_DIR/.env first lets a stale or planted ~/.claude/LIFEOS/.env silently override live secrets — and that directory is agent-writable and git-tracked. Support it only as a lowest-precedence gap-filler.
- Merge on truthiness, not key presence.
XAI_API_KEY= (a half-edited file) parses to ''; if that empty string claims the merge slot it blocks the real value — resurrecting the "API key not set" symptom through the fix itself.
CLAUDE_CONFIG_DIR replaces the root; it must not gap-fill. An isolated test/tenant root that inherits missing keys from ~/.claude/.env or from a still-set LIFEOS_CONFIG_DIR silently burns production secrets against production accounts.
- Path-selecting env vars are as untrusted as secret values.
bun autoloads a cwd ./.env into process.env before the tool runs, so a hostile repo can set CLAUDE_CONFIG_DIR/LIFEOS_CONFIG_DIR there and redirect the loader at an attacker-controlled .env. Filtering secret values doesn't catch this — the redirect is in file selection, upstream of any value.
- Every consumer must actually route through the resolver. Several tools kept a local
process.env.X || env.X, so the shared defenses didn't reach them. A shared helper only helps the tools that call it.
- Absolute candidates only; return a copy, not the live cache; use
Object.hasOwn not in; strip inline # comments before they reach an Authorization header. Each of these was a live defect at some point in the prototype.
That list is the actual point. A correct resolver needs ~9 subtle properties — which is precisely the argument against seven hand-rolled copies. Seven tools each re-implementing this by hand guarantees seven subtly-divergent, subtly-broken versions, which is what the codebase has today (and what my own first sweep reproduced — I initially missed llcli and Polish).
I recognise this pushes against Containment.md's "no central env helper exists by design." If you'd rather keep that design, (a) is viable — but then the six properties above have to be duplicated correctly into each of the seven sites, and the CreateCLI template that mints new ones. I have (b) implemented and tested against every property above (23 tests) and can open a PR if the approach is welcome. Your call on (a) vs (b).
Summary
LIFEOS_StatusLine.shalready states the rule, in shipped code:It has not been removed everywhere else. Seven files still compute exactly that path.
settings.system.json:9setsLIFEOS_CONFIG_DIR="$HOME/.claude/LIFEOS"— that variable names the LIFEOS directory, not the directory holding.env. So the ternary always takes the first branch, and the correct path sitting right there in theelseis dead code.Why this surfaces now, and why nobody has reported it
Until #1451 (merged 2026-07-09),
LIFEOS_CONFIG_DIRwas injected as the literal, unexpanded string$HOME/.claude/LIFEOS. The file read therefore failed against a garbage path for everyone, and every tool silently fell through toprocess.env. #1451 correctly expanded the variable — which made the wrong-but-now-real path~/.claude/LIFEOS/.envreachable for the first time. The bug in its current form is five days old.It is also masked, which is why it survives in a mature package. Every affected tool checks
process.envfirst:so anyone who exports keys in their shell never touches the broken branch. And bun auto-loads
./.envfrom the current working directory, so a tool run from~/.claudepicks the key up by accident and appears to work — its own path resolution never actually reads the file.It only bites when a user (a) keeps keys in
~/.claude/.envas the docs instruct, and (b) runs a tool from any cwd other than~/.claude— which is what happens whenever a skill or subagent runs from a project directory.Severity: latent, not critical. Filing it because it contradicts the project's own stated doctrine and because the template case below regenerates it.
Reproduce
On
main(HEAD03a969d), withXAI_API_KEYset in~/.claude/.env:The error message names the very file it declined to read.
Affected files (all at HEAD)
LIFEOS/TOOLS/Grok.tsLIFEOS/TOOLS/GrokAudit.tsLIFEOS/TOOLS/PerplexitySearch.tsLIFEOS/TOOLS/YouTubeApi.tsLIFEOS/TOOLS/llcli/llcli.tsskills/AudioEditor/Tools/Polish.tsskills/CreateCLI/Workflows/CreateCli.mdCreateCli.mdis the one that matters most: it is the template new CLIs are generated from, so the bug is generative — every tool created from it inherits the wrong path. Its fallback is wrong in a third way (~/.claude/LifeOS/.env), and the parser it teaches uses.split('=')[1], which truncates any value containing=(e.g. a base64 key with padding).Two things I want to raise honestly, not hide
DOCUMENTATION/Tools/Containment.md:84saysLIFEOS/.envand~/.config/LIFEOS/.env"are symlinks to" the canonical file. I could find no code that creates those symlinks — nosymlinkSyncorln -sin the tree targets.env, andinstall.shcontains zero.envreferences. If that symlink is meant to be created by the installer, then this issue is really "the installer doesn't create it," and the fix is different. Happy to be corrected here.The same file says "no central env helper exists by design." A shared loader is the obvious fix for seven copies of the same broken expression, but I don't want to fight a deliberate architectural choice. If you'd rather each tool keep its own parser, the minimal fix is to repoint all seven at
~/.claude/.envand leave it there — say which you prefer.Proposed fix
Either (a) repoint the seven sites at
~/.claude/.env, or (b) a single sharedLIFEOS/TOOLS/EnvLoader.tsthe tools import.I prototyped (b) and then put the prototype through five independent review passes across three model families (three Anthropic, one xAI, one OpenAI). That was instructive: a correct resolver turned out to need far more than the one-line path swap this looks like at first. Each of the properties below started as a bug the reviews found in an earlier version of my own fix:
$LIFEOS_CONFIG_DIR/.envfirst lets a stale or planted~/.claude/LIFEOS/.envsilently override live secrets — and that directory is agent-writable and git-tracked. Support it only as a lowest-precedence gap-filler.XAI_API_KEY=(a half-edited file) parses to''; if that empty string claims the merge slot it blocks the real value — resurrecting the "API key not set" symptom through the fix itself.CLAUDE_CONFIG_DIRreplaces the root; it must not gap-fill. An isolated test/tenant root that inherits missing keys from~/.claude/.envor from a still-setLIFEOS_CONFIG_DIRsilently burns production secrets against production accounts.bunautoloads a cwd./.envintoprocess.envbefore the tool runs, so a hostile repo can setCLAUDE_CONFIG_DIR/LIFEOS_CONFIG_DIRthere and redirect the loader at an attacker-controlled.env. Filtering secret values doesn't catch this — the redirect is in file selection, upstream of any value.process.env.X || env.X, so the shared defenses didn't reach them. A shared helper only helps the tools that call it.Object.hasOwnnotin; strip inline# commentsbefore they reach anAuthorizationheader. Each of these was a live defect at some point in the prototype.That list is the actual point. A correct resolver needs ~9 subtle properties — which is precisely the argument against seven hand-rolled copies. Seven tools each re-implementing this by hand guarantees seven subtly-divergent, subtly-broken versions, which is what the codebase has today (and what my own first sweep reproduced — I initially missed
llcliandPolish).I recognise this pushes against
Containment.md's "no central env helper exists by design." If you'd rather keep that design, (a) is viable — but then the six properties above have to be duplicated correctly into each of the seven sites, and theCreateCLItemplate that mints new ones. I have (b) implemented and tested against every property above (23 tests) and can open a PR if the approach is welcome. Your call on (a) vs (b).