diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..651b806 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -29,6 +29,7 @@ import threading import time from dataclasses import dataclass from pathlib import Path +from typing import NamedTuple from urllib.parse import urlparse CONFIG_FILENAME = "claude_desktop_config.json" @@ -134,6 +135,16 @@ def profile_from_path(path: str | os.PathLike) -> Profile: label = p.parent.name or p.name return Profile(label=label, path=p, config_exists=p.is_file()) +def profile_targets_claude_desktop(profile: Profile) -> bool: + """ + True when `profile` points at a Claude Desktop config + (claude_desktop_config.json), as opposed to Claude Code (~/.claude.json + or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions + like "Restart Claude Desktop" so they never show up for a Claude Code + profile — restarting the CLI makes no sense. + """ + return Path(profile.path).name == CONFIG_FILENAME + # --------------------------------------------------------------------------- # # Load / extract / apply @@ -189,6 +200,25 @@ def extract_servers(cfg: dict) -> list[ServerEntry]: return out +def resolve_name_collision(name: str, existing: set[str]) -> str: + """ + Return a name guaranteed not to collide with `existing`. + + If `name` isn't already taken it's returned unchanged. Otherwise a + `-2`, `-3`, ... suffix is appended until the result is unique — this is + the "keep both (renamed)" branch used by paste/import when the user + doesn't want to overwrite an existing server of the same name. + """ + if name not in existing: + return name + n = 2 + candidate = f"{name}-{n}" + while candidate in existing: + n += 1 + candidate = f"{name}-{n}" + return candidate + + def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict: """ Write the server list back into `cfg` in place, preserving every other key @@ -380,6 +410,30 @@ def config_mtime(path: Path | str) -> float | None: return None +class ConfigStat(NamedTuple): + """A snapshot of a config file's mtime + size. + + Pairing size with mtime hardens stale-file detection beyond bare mtime + equality: a concurrent external write can land within the filesystem's + mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime + restored by the writing process, in which case mtime alone would miss the + change. Comparing both fields catches those cases without the cost of a + full content hash. + """ + + mtime: float + size: int + + +def config_fingerprint(path: Path | str) -> ConfigStat | None: + """Return the file's (mtime, size) snapshot, or None if it does not exist.""" + try: + st = Path(path).stat() + except OSError: + return None + return ConfigStat(st.st_mtime, st.st_size) + + def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: """ Compare original_cfg (what BCC loaded) with the current on-disk state. @@ -474,7 +528,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1270,6 +1324,25 @@ def diagnostics_text(name: str, data: dict) -> str: return "\n".join(L) +def server_log_path(name: str) -> Path | None: + """ + The platform-specific Claude Desktop MCP server log file for `name`, or + None if it doesn't exist yet (nothing has been logged for this server). + + macOS : ~/Library/Logs/Claude/mcp-server-.log (one file per server) + Windows: %APPDATA%\\Claude\\logs\\mcp.log (one shared file) + Other platforms: Claude Desktop doesn't ship a log in a known location -> None. + """ + if sys.platform == "darwin": + p = Path.home() / "Library" / "Logs" / "Claude" / f"mcp-server-{name}.log" + elif os.name == "nt": + appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + p = appdata / "Claude" / "logs" / "mcp.log" + else: + return None + return p if p.is_file() else None + + _STDERR_CAP = 4096 # bytes @@ -1459,3 +1532,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None + + +# --------------------------------------------------------------------------- # +# Restart Claude Desktop (issue #9) +# +# Scoped strictly to Claude DESKTOP, the GUI app -- never Claude Code (the +# CLI), which has no long-running process to bounce. Callers should gate this +# behind profile_targets_claude_desktop() before offering it in the UI. +# --------------------------------------------------------------------------- # +class RestartResult(NamedTuple): + """Outcome of a restart_claude_desktop() attempt.""" + + success: bool + detail: str + + +def _run_quiet(cmd: list[str]) -> None: + """Best-effort fire-and-forget command. Never raises: a nonzero exit (e.g. + pkill finding nothing to kill) is expected and not an error.""" + with contextlib.suppress(OSError): + subprocess.run(cmd, capture_output=True) + + +def _restart_claude_desktop_macos() -> RestartResult: + _run_quiet(["pkill", "-x", "Claude"]) + try: + result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() or "'open -a Claude' failed" + return RestartResult(False, detail) + return RestartResult(True, "Claude Desktop restarted.") + + +def _claude_windows_start_menu_shortcut() -> Path: + appdata = os.environ.get("APPDATA", str(Path.home())) + return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk" + + +def _restart_claude_desktop_windows() -> RestartResult: + _run_quiet(["taskkill", "/IM", "Claude.exe", "/F"]) + shortcut = _claude_windows_start_menu_shortcut() + try: + # `cmd /c start "" ` launches detached, the same as double-clicking + # the Start-menu shortcut, and returns immediately. + result = subprocess.run( + ["cmd", "/c", "start", "", str(shortcut)], capture_output=True, text=True + ) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + if result.returncode != 0: + detail = ( + result.stderr or result.stdout or "" + ).strip() or "failed to relaunch Claude Desktop" + return RestartResult(False, detail) + return RestartResult(True, "Claude Desktop restarted.") + + +def _restart_claude_desktop_linux() -> RestartResult: + _run_quiet(["pkill", "claude"]) + try: + # The Linux launcher is the app itself (no "open"-style helper), so it + # has to be started detached rather than waited on. + subprocess.Popen( + ["claude"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + return RestartResult(True, "Claude Desktop restarted.") + + +def restart_claude_desktop() -> RestartResult: + """ + Kill and relaunch the Claude Desktop app so a freshly saved config takes + effect. The app not currently running is NOT a failure -- pkill/taskkill + exiting non-zero just means "nothing to kill", and we go straight to + relaunching. Only a failed relaunch is reported as success=False. + """ + if sys.platform == "darwin": + return _restart_claude_desktop_macos() + if sys.platform.startswith("win"): + return _restart_claude_desktop_windows() + return _restart_claude_desktop_linux()