Merge remote-tracking branch 'origin/main' into feat/server-list-ux
This commit is contained in:
+102
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user