feat: detect MSIX-virtualized Claude config path + warn (#7)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s

This commit is contained in:
Cowork Supervisor
2026-07-08 11:51:23 -04:00
parent 4c6fe7c5aa
commit 8c456c9a89
3 changed files with 264 additions and 0 deletions
+102
View File
@@ -191,6 +191,97 @@ def app_support_base() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
def msix_config_paths(localappdata: str | os.PathLike | None = None) -> list[Path]:
"""
Find MSIX/Store-packaged Claude Desktop configs.
When Claude Desktop is installed from the Microsoft Store (MSIX), Windows
virtualizes its filesystem writes to a per-package folder under
`%LOCALAPPDATA%\\Packages\\<PackageFamilyName>\\LocalCache\\Roaming\\Claude\\`
instead of the normal `%APPDATA%\\Claude\\`. A user (or BCC) editing the
plain %APPDATA% path can end up changing a file the running app never
reads -- see anthropics/claude-code issues #26073, #29100, #38830.
Globs `<localappdata>/Packages/*Claude*/LocalCache/Roaming/Claude/
claude_desktop_config.json` and returns every match that actually exists,
sorted for determinism. `localappdata` defaults to the %LOCALAPPDATA% env
var (falling back to the usual Windows path) but is accepted as a
parameter so this is unit-testable with tmp_path on any platform.
This function itself is platform-independent (it just globs whatever
directory it's given); callers that care about the *current* machine
should gate on sys.platform -- see `detect_msix_claude`.
"""
base = (
Path(localappdata)
if localappdata is not None
else Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
)
packages = base / "Packages"
if not packages.is_dir():
return []
out: list[Path] = []
for pkg_dir in sorted(packages.glob("*Claude*")):
cfg = pkg_dir / "LocalCache" / "Roaming" / "Claude" / CONFIG_FILENAME
if cfg.is_file():
out.append(cfg)
return out
def detect_msix_claude(
appdata: str | os.PathLike | None = None,
localappdata: str | os.PathLike | None = None,
) -> Path | None:
"""
Best-effort detection of an MSIX-virtualized Claude Desktop install.
Returns the first virtualized `claude_desktop_config.json` found (see
`msix_config_paths`), or None when not running on Windows, no matching
package folder exists, or a package folder exists but has no config file
written yet. The sys.platform gate makes this a safe no-op to call
unconditionally from discovery/diagnostics code on macOS/Linux.
`appdata`/`localappdata` are threaded through (rather than read straight
from os.environ) purely so the whole detection path is unit-testable via
tmp_path + monkeypatch without mutating real env vars.
"""
if not sys.platform.startswith("win"):
return None
hits = msix_config_paths(localappdata)
return hits[0] if hits else None
def msix_warning_text(
appdata: str | os.PathLike | None = None,
localappdata: str | os.PathLike | None = None,
) -> str | None:
"""
A one-line, paste-safe warning for the diagnostics/status surface when
Claude Desktop looks like an MSIX/Store install whose real config lives
somewhere other than the plain %APPDATA%\\Claude\\ path. Returns None
when nothing was detected (including on non-Windows platforms) or when
the virtualized path and the plain path happen to coincide -- i.e. there
is nothing surprising to warn about. Contains only filesystem paths, no
env values or secrets.
"""
real = detect_msix_claude(appdata, localappdata)
if real is None:
return None
plain_base = (
Path(appdata)
if appdata is not None
else Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
)
plain_cfg = plain_base / "Claude" / CONFIG_FILENAME
if plain_cfg == real:
return None
return (
"Claude Desktop looks like it's installed from the Microsoft Store (MSIX). "
f"Windows virtualizes its config, so edits to {plain_cfg} may be silently "
f"ignored by the running app. The real config is at: {real}"
)
def discover_profiles() -> list[Profile]:
"""
Find every `Claude*` data directory in the platform's app-support base
@@ -198,6 +289,11 @@ def discover_profiles() -> list[Profile]:
Claude Desktop: scans the platform app-support folder for any `Claude*`
directory (catches `Claude`, `Claude-Work`, etc.).
Windows/MSIX: if Claude Desktop was installed from the Microsoft Store,
its real config lives in a virtualized per-package folder rather than the
plain %APPDATA%\\Claude\\ path above (see `detect_msix_claude`); when
that's detected, it's surfaced here as its own profile so the user can
edit the file the app actually reads.
Claude Code: user-scope MCP servers live in ~/.claude.json (that's what
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
@@ -213,6 +309,12 @@ def discover_profiles() -> list[Profile]:
cfg = d / CONFIG_FILENAME
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
msix_cfg = detect_msix_claude()
if msix_cfg is not None and str(msix_cfg) not in {str(p.path) for p in out}:
out.append(
Profile(label="Claude (Microsoft Store / MSIX)", path=msix_cfg, config_exists=True)
)
home = Path.home()
cc_cfg = home / ".claude.json"
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))