fix: honor $CLAUDE_CONFIG_DIR instead of hardcoding ~/.claude#270
fix: honor $CLAUDE_CONFIG_DIR instead of hardcoding ~/.claude#270halindrome wants to merge 5 commits into
Conversation
Claude Code stores its transcripts and settings under $CLAUDE_CONFIG_DIR when that variable is set, falling back to ~/.claude. CodeIsland only ever looked at ~/.claude, so users with a custom config dir saw no Claude sessions at all — and with hideWhenNoSession enabled the app hid itself entirely, which reads as a crash. Add ClaudeConfigPaths, a small resolver in CodeIslandCore, and route every Claude path through it. The chain is: 1. claude_config_dir preference (new Settings field) 2. $CLAUDE_CONFIG_DIR 3. ~/.config/claude-code, when it actually holds Claude state 4. ~/.claude The preference exists because CodeIsland is normally launched from Finder or as a login item, where no shell environment is inherited — reading the env var alone would not have fixed the reported case. The XDG probe checks for projects/ or settings.json so an empty directory left behind by another tool cannot shadow a real ~/.claude. Behavior is unchanged for users without a custom config dir: with no preference, no env var, and no populated ~/.config/claude-code, resolution still returns ~/.claude. Covered by 9 new tests against a pure, injectable resolve() core. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YL1NKtwrKxPSxkBEuKGWNW
Review caught two defects in the previous commit.
The colon-splitting was based on a claim I had not verified. Claude Code
reads CLAUDE_CONFIG_DIR verbatim as a single path — there is no PATH-style
list handling anywhere in it. Splitting on ":" was therefore not just
unnecessary but actively harmful: ":" is legal in an APFS filename, so a
directory like ~/Projects/AI:ML/claude was silently truncated to
~/Projects/AI.
Claude Code also applies .normalize("NFC") to the resolved path. Match that,
so a decomposed path — which some macOS APIs hand back — resolves to the
same directory rather than missing it.
Also drop defaultConfigDir, which was declared and never referenced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YL1NKtwrKxPSxkBEuKGWNW
Self-review (QA pass)Ran a review pass over this diff before asking for maintainer time. Two defects found and fixed in FixedColon-splitting was wrong and harmful. The first version split function Tsl(){ return process.env.CLAUDE_CONFIG_DIR }
aQe.join(process.env.CLAUDE_CONFIG_DIR || Lfl.homedir(), e)Since Missing NFC normalization. Claude Code applies Also removed Remaining observations (not addressed — your call)
Verification
|
Resolves findings from the round-1 QA review. F1 (major) — the XDG probe could shadow a live ~/.claude. Claude Code has no XDG rung: it reads $CLAUDE_CONFIG_DIR, else ~/.claude. Probing ~/.config/ claude-code before checking whether ~/.claude was populated meant a stale XDG directory silently repointed us away from the directory Claude Code actually reads — reintroducing issue wxtsky#269's symptom through a different door. A live ~/.claude now wins. Liveness is judged by projects/ alone, NOT settings.json: CodeIsland's own hook installer creates settings.json, so counting it would let us manufacture the signal we then read back — an otherwise-empty ~/.claude holding only a CodeIsland-written settings.json would outrank the user's real config dir permanently. F2 (major) — install and uninstall could resolve to different directories, so hook entries left at the old location kept firing and became unreachable by uninstall, which still reported success. Both paths now sweep the non-resolved candidate dirs, touching only files carrying our marker. F3 (major) — preference/env values were accepted verbatim, so a relative path or a typo was resolved against the app's cwd and the hook installer then created the bogus directory and reported success. Non-absolute values (and bare "/") now fall through to auto-detect instead. F4 (major) — RemoteInstaller's embedded Python still hardcoded ~/.claude, and its guard let a custom-config user through (no ~/.claude, but claude on PATH), creating a stale dir on the remote host. It now mirrors the resolver. F9 (minor) — configDir() re-resolved on every call, including stat() syscalls on the main thread from SettingsView.body. Memoized on its inputs, which keeps the live Settings preview correct while removing steady-state syscalls. F5/F6/F8 (minor) — zh + zh-Hant strings added; tests pin the preference-key parity across modules and the Claude rootOverride (without which fullPath silently becomes ~/settings.json). Also fixes a test-isolation bug this surfaced: RemoteInstallerHookMergeTests sandboxes HOME but leaked $CLAUDE_CONFIG_DIR, so once the remote script began honoring that variable the suite wrote hooks into the developer's real config dir. Stripped, matching the existing CODEX_HOME precedent. F10 (hypothetical) left as-is; documented in the PR thread. 559 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YL1NKtwrKxPSxkBEuKGWNW
QA Round 1Reviewed by a 3-lens Claude panel (contract/security, regression/edges, schema/tests) synthesized into one finding set. 11 findings: 4 major, 7 minor. All 4 majors fixed in Contract VerificationContract source: issue #269. 9 satisfied, 5 partially-satisfied, 1 untested, 0 not-satisfied. The partials drove the major findings below and are now closed.
The notable one (F1)The first version probed The subtlety in the fix: liveness is judged by F10 — not fixed, deliberatelyNFC is applied to preference/env values but the Test isolation bug surfaced during the roundWorth flagging separately, since it bit this machine: once the remote installer began honoring SAST review skippedCode scanning is not available for Dependabot (SCA) — advisory, repo-wideDependabot alerts unavailable (not enabled, or token lacks Schema change: none detected (macOS Swift app, no DDL or migration files in the diff). Test suite: 559 tests, 0 failures (16 new). Rebuilt and re-verified end-to-end after the fixes. QA performed by Claude Code (claude-opus-4-8) |
Round 2 reviewed round 1's fixes and found them incomplete in several places.
Two corrections to the round-1 commit message first, since it overstated what
that commit did:
- It said the RemoteInstaller guard "let a custom-config user through ...
It now mirrors the resolver." Only `claude_root` changed; the guard line
was untouched, and `git diff` shows it as context. The guard bug was
described as fixed when it was not.
- "Mirrors the resolver" was also wrong for the resolver itself: the Python
applied no absoluteness check, no trailing-slash strip, and no NFC
normalization, so it accepted exactly the relative values round-1 F3 was
raised to reject.
Both are now actually fixed. The guard no longer treats `claude` on PATH as
licence to CREATE a config dir when resolution merely fell through to the
~/.claude default — that planted a stale directory on the remote host which
the local sweep cannot reach.
R2-F3 (major) — the stale-hook sweep only knew two hardcoded home candidates,
so a dir previously resolved via the preference or $CLAUDE_CONFIG_DIR was
never swept and its entries fired forever against a deleted hook script. The
resolved dir is now persisted at install time and included in the candidate
set, which covers custom-to-custom and custom-to-default transitions.
R2-F9 (minor) — that filter compared an NFC-normalized configDir() against
hand-built literals; a decomposed home path could let the *resolved* dir slip
through and be swept, deleting the hooks just installed. Both sides are now
canonicalized.
R2-F5/F6 (major) — all six localized descriptions and the type's own header
doc still stated the pre-F1 resolution order, so every locale told the user
something the code stopped doing.
R2-F4 (major) — the sweep rewrites files on disk outside the resolved dir and
had zero tests. Split the per-directory logic out and specced it: the marker
guard leaves an unrelated settings.json byte-identical, our entries are
stripped while user entries survive, a missing file is a no-op.
R2-F8 (minor) — testUnicodePathsAreNormalizedToNFC was vacuous. Swift compares
Strings by canonical equivalence, so it passed with the normalization deleted.
It now compares UTF-8 bytes; verified against a stripped implementation.
R2-F7 (minor) — invalidateCache() had no caller; wired into install().
R2-F10 (observation) — the remote hook read transcripts from ~/.claude/projects,
so fixing the installer delivered no end-to-end benefit. It now resolves the
same way.
564 tests, 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YL1NKtwrKxPSxkBEuKGWNW
QA Round 2Fresh 3-lens panel against the round-1 fixes. 10 findings: 7 major, 3 minor. All fixed in Round 2 was a harder review than round 1, because it reviewed round 1's own work — and found it incomplete in several places. Two corrections to the round-1 commit messageBefore the findings, two things that commit claimed and should not have:
Both are now genuinely fixed. Flagging them explicitly because an inaccurate commit message is worse than no message — it stops the next reader from looking. Findings
The one that mattered most (R2-F3)Round 1 added the sweep to keep install and uninstall symmetric, but
The symmetry claim held only for transitions whose old value was one of the two hardcoded candidates — i.e. not the custom-dir case this PR exists for. The resolved dir is now persisted at install time and included in the sweep. R2-F8 — a test that proved nothing
R2-F10 — why the remote fix was inertRound 1 fixed where the remote installer writes hooks, but the installed hook still read transcripts from SAST review skippedCode scanning is not available for Dependabot (SCA) — advisory, repo-wideDependabot alerts unavailable. Skipped — non-blocking. Schema change: none detected. Test suite: 564 tests, 0 failures (21 new across both rounds). Rebuilt, reinstalled, and re-verified end-to-end after the fixes; the real Round 2 fixed 7 majors, so a round 3 is warranted before this is considered settled. QA performed by Claude Code (claude-opus-4-8) |
Three QA rounds found 33 findings. The finding rate did not decline (4, 7, 4
majors), but their location was consistent: the resolver and its call sites
stabilized after round 1, while every major since came from scope added while
responding to review comments — the remote installer and the stale-hook sweep.
Those two areas produced 10 of the 12 majors in rounds 2 and 3.
Round 3 made the case plainly. The round-2 "fix" to the remote installer guard
placed an early return ahead of the pre-existing shutil.which check, making it
unreachable and silently turning "installs hooks into ~/.claude" into "installs
nothing" for any host with Claude Code present but not yet run. That is a
regression against main, in code this PR does not need, that cannot be verified
without a remote host.
So this commit removes that scope rather than iterating on it again:
- RemoteInstaller.swift, codeisland-remote-hook.py and
RemoteInstallerHookMergeTests revert to origin/main.
- The cross-directory stale-hook sweep and its last-resolved-dir persistence
are removed, along with ClaudeStaleHookSweepTests.
What remains is the fix issue wxtsky#269 actually asks for: the resolver, the six
local call sites, the hook-install location, the Settings field, and their
tests. It is verified end-to-end on a real machine and has produced no majors
since round 1.
Known limitation, documented on the PR and deferred to a follow-up issue: a
user who changes their resolved config dir after installing hooks will have
entries left behind at the old location. That is the pre-existing behavior for
every other CLI in ConfigInstaller, it is not a regression, and the sweep that
attempted to fix it was itself the source of five majors.
Also carried forward from round 3:
- isLiveConfigDir now requires projects/ to be a DIRECTORY. fileExists is
true for a regular file of that name, so a stray file could mark a config
dir live. Covered by a test against a real temp tree — asserting it through
the injected probe would have proved nothing, since that injection is
exactly what abstracts the distinction away.
- Removed a __pycache__/*.pyc committed by an earlier syntax check and
shipped inside the app bundle; added it to .gitignore.
560 tests, 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YL1NKtwrKxPSxkBEuKGWNW
QA Round 3⚠ Claude panel found 12 findings: 4 major, 8 minor. Outcome: the PR has been narrowed rather than patched againThree rounds, and the major count did not decline — 4, 7, 4. But their location was consistent:
The fix issue #269 asks for stabilized after round 1. Everything since came from scope I added while responding to review comments. R3-F1 settled it: my round-2 "fix" to the remote guard put an early return ahead of the pre-existing So
Diff: 16 files / 617 insertions → 12 files / 428. Deferred work is tracked in #271, written up with the specific traps each attempt hit. Findings resolved by narrowingR3-F1, F2, F3, F4, F6, F7, F8, F10, F12 all lived in the removed scope. Two were carried forward:
R3-F9 remains open by choice: The pattern worth namingThree of the findings across rounds 2–3 were assertions that could not fail: a String comparison where Swift's canonical equivalence made it vacuous, a test asserting the opposite of its own name, and a persistence mechanism with no executable coverage. Each looked like a test. None was one. I would not have found any of them without an adversarial reviewer explicitly checking whether assertions fail against broken code. SAST review skippedCode scanning is not available for Schema change: none. Test suite: 560 tests, 0 failures. Rebuilt, reinstalled, re-verified end-to-end: sessions discovered with Approval left to the maintainer, as always. QA performed by Claude Code (claude-opus-4-8) |
New upstream PR wxtsky#270 (wxtsky/CodeIsland, Jul 19) fixes zero session detection for users with a custom Claude config directory. Our fork has the same bug at 8 hardcoded ~/.claude sites. Added T-079 to kanban (high priority, S effort) and recorded the scout in CLAUDE.md. GitHub issue creation attempted but GitHub returned persistent 503; tracked in kanban instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111nM8vmqbeMaNg6qXPiFB2
Fixes #269.
Problem
Claude Code locates
projects/andsettings.jsonvia$CLAUDE_CONFIG_DIR, falling back to~/.claudeonly when unset. CodeIsland hardcoded~/.claudeat every call site, so users with a custom config dir got zero session detection — and withhideWhenNoSessionon, the app hid itself completely, which presents as a crash.Approach
Adds
ClaudeConfigPathstoCodeIslandCoreand routes the Claude paths through it:claude_config_dirpreference (new Settings field)$CLAUDE_CONFIG_DIR~/.claude, when populated~/.config/claude-code, when populated~/.claudeThree decisions worth calling out:
Why a preference and not just the env var. CodeIsland is normally launched from Finder or as a login item, and that process inherits no shell environment — the env var is absent exactly when it matters. An env-var-only version still finds nothing on a Finder launch; I verified this. The preference is the only channel that reliably survives.
Why a live
~/.claudeoutranks the XDG probe. Claude Code has no XDG rung at all. Probing~/.config/claude-codefirst meant a stale XDG directory could silently repoint CodeIsland away from the directory Claude Code actually reads — reintroducing this issue's symptom through a different door.Why liveness is a
projects/directory and notsettings.json. CodeIsland's own hook installer createssettings.json. Counting it would let the app manufacture the signal it then reads back: an otherwise-empty~/.claudeholding only a CodeIsland-writtensettings.jsonwould outrank the user's real config dir permanently. It must also be a directory —fileExistsis true for a stray regular file of that name.The hook installer reuses the existing
rootOverride/displayPathOverridemechanism already used for$CODEX_HOME.Scope
This PR deliberately covers local session discovery and hook placement only.
Earlier revisions also changed
RemoteInstaller, the remote hook script, and added a cross-directory stale-hook cleanup sweep. Three QA rounds found repeated defects in those areas — including a regression againstmain— while the resolver itself stayed clean after round 1. They are reverted here and tracked in #271, with the specific traps each attempt hit written up, because they cannot be meaningfully verified without a remote host.Known limitation: a user who changes their resolved config dir after installing hooks will have entries left at the old location. This is the pre-existing behavior for every CLI in
ConfigInstallerand not a regression from this PR; see #271.Compatibility
No behavior change for users without a custom config dir: a populated
~/.claudestill wins, and with nothing populated anywhere resolution returns~/.claudeas before. Both covered by tests.Testing
:in APFS filenames, non-absolute rejection, empty-XDG, both-populated precedence, settings.json-is-not-liveness, and file-vs-directory — the last against a real temp tree, since asserting it through the injected probe would prove nothing.rootOverride, without whichfullPathsilently becomes~/settings.json.~/.claudeabsent, no preference set, and the app launched via LaunchServices (so$CLAUDE_CONFIG_DIRis not inherited), sessions are discovered purely through the directory probe.ja/ko/tr/zh/zh-Hantstrings added.Three rounds of QA are recorded in the comments below, including the findings that were mine.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YL1NKtwrKxPSxkBEuKGWNW