diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..7473d5d 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" @@ -135,6 +136,17 @@ def profile_from_path(path: str | os.PathLike) -> Profile: 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 # --------------------------------------------------------------------------- # @@ -474,7 +486,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("\xa0", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1459,3 +1471,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()