From 6c51bac1e0257ed29d2f1da2fa320eedcadb38b2 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:26:53 -0400 Subject: [PATCH 1/3] feat: restart_claude_desktop() core logic (#9) Platform-abstracted restart: macOS uses pkill -x Claude + open -a Claude, Windows uses taskkill + relaunch via the Start-menu shortcut, Linux uses pkill claude + a detached Popen relaunch. Not finding a running process is not an error -- only a failed relaunch is reported as failure. Also adds profile_targets_claude_desktop() to distinguish Claude Desktop profiles (claude_desktop_config.json) from Claude Code profiles, used to gate the restart button in the GUI. --- bcc_core.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) 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() From fd2c3567a0dbbb22fce530646a0079c71b9869ab Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:32:14 -0400 Subject: [PATCH 2/3] feat: Restart Claude Desktop button after save (#9) After a successful save, shows a 'Restart Claude Desktop' button next to the status line. Only shown when the saved profile targets Claude Desktop (core.profile_targets_claude_desktop) -- never for Claude Code, which has no GUI process to bounce. Clicking it calls core.restart_claude_desktop() and reports success/failure. The button hides again on further edits or when switching/reloading profiles. --- bcc.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/bcc.py b/bcc.py index 599eaac..213f5a1 100644 --- a/bcc.py +++ b/bcc.py @@ -1135,9 +1135,19 @@ class MainWindow(QMainWindow): root.addLayout(self._build_actionbar()) + status_row = QHBoxLayout() + status_row.setContentsMargins(0, 0, 0, 0) self.status = QLabel("Ready.") self.status.setObjectName("statusbar") - root.addWidget(self.status) + status_row.addWidget(self.status, 1) + self.restart_btn = QPushButton("Restart Claude Desktop") + self.restart_btn.setToolTip( + "Quit and relaunch Claude Desktop so the saved config takes effect" + ) + self.restart_btn.clicked.connect(self._restart_claude_desktop) + self.restart_btn.hide() + status_row.addWidget(self.restart_btn) + root.addLayout(status_row) self._restore_layout() self.reload_profiles() @@ -1384,6 +1394,7 @@ class MainWindow(QMainWindow): self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False + self.restart_btn.hide() self._undo_stack.clear() self.undo_btn.setEnabled(False) self._refresh_tables(select_index=0 if self.servers else -1) @@ -1753,6 +1764,7 @@ class MainWindow(QMainWindow): f"Merged & saved {self.current_profile.path}{bnote}" f" · Restart {self.current_profile.label} to apply." ) + self._offer_restart_button() return # else OVERWRITE: fall through to normal write @@ -1770,6 +1782,29 @@ class MainWindow(QMainWindow): self.status.setText( f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply." ) + self._offer_restart_button() + + # --- restart Claude Desktop (issue #9) -------------------------------- # + def _offer_restart_button(self): + """Show the 'Restart Claude Desktop' button after a successful save, + but only when the just-saved profile is Claude Desktop -- restarting + makes no sense for Claude Code, which has no GUI process to bounce.""" + if self.current_profile and core.profile_targets_claude_desktop(self.current_profile): + self.restart_btn.show() + else: + self.restart_btn.hide() + + def _restart_claude_desktop(self): + self.restart_btn.setEnabled(False) + try: + result = core.restart_claude_desktop() + finally: + self.restart_btn.setEnabled(True) + self.restart_btn.hide() + if result.success: + self.status.setText(f"{self.status.text()} · {result.detail}") + else: + QMessageBox.warning(self, "Restart failed", result.detail) def _restore_from_backup(self): if not self.current_profile: @@ -1798,6 +1833,7 @@ class MainWindow(QMainWindow): # --- dirty / status -------------------------------------------------- # def _mark_dirty(self): self.dirty = True + self.restart_btn.hide() self._validate() self._update_status(saved=False) From 16961a5cc89116f44930128378f5580e18dbaa3e Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:40:57 -0400 Subject: [PATCH 3/3] test: cover restart_claude_desktop() and profile_targets_claude_desktop() (#9) Mocks subprocess.run/Popen and patches sys.platform per-OS branch (darwin, win32, linux) -- never actually kills or launches anything. Covers: correct command sequence per platform, pkill/taskkill exiting non-zero (nothing to kill) is NOT treated as failure, a failed relaunch IS reported as failure, and profile_targets_claude_desktop() correctly distinguishes Claude Desktop configs from Claude Code / legacy settings.json profiles. --- tests/test_core.py | 156 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index aaf2faf..978ca27 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -638,3 +638,159 @@ def test_args_secret_warning_env_not_triggered(): def test_args_secret_warning_empty(): assert c.args_secret_warning({}) is None assert c.args_secret_warning({"args": []}) is None + + +# --------------------------------------------------------------------------- # +# restart_claude_desktop / profile_targets_claude_desktop (issue #9) +# --------------------------------------------------------------------------- # +def test_profile_targets_claude_desktop_true_for_desktop_config(): + p = c.Profile(label="Claude", path="/x/Claude/claude_desktop_config.json", config_exists=True) + assert c.profile_targets_claude_desktop(p) + + +def test_profile_targets_claude_desktop_false_for_claude_code(): + p = c.Profile(label="Claude Code", path="/home/me/.claude.json", config_exists=True) + assert not c.profile_targets_claude_desktop(p) + + +def test_profile_targets_claude_desktop_false_for_legacy_settings(): + p = c.Profile( + label="Claude Code (legacy settings.json)", + path="/home/me/.claude/settings.json", + config_exists=True, + ) + assert not c.profile_targets_claude_desktop(p) + + +class _FakeCompletedProcess: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_restart_claude_desktop_macos_commands(monkeypatch): + """macOS: pkill -x "Claude" then open -a Claude, in that order.""" + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + assert calls[0] == ["pkill", "-x", "Claude"] + assert calls[1] == ["open", "-a", "Claude"] + + +def test_restart_claude_desktop_macos_pkill_not_running_is_fine(monkeypatch): + """pkill exiting non-zero (nothing to kill) must NOT be treated as failure.""" + + def fake_run(cmd, **kwargs): + if cmd[0] == "pkill": + return _FakeCompletedProcess(returncode=1) # no matching process + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + + +def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch): + def fake_run(cmd, **kwargs): + if cmd[0] == "open": + return _FakeCompletedProcess(returncode=1, stderr="Unable to find application") + return _FakeCompletedProcess(returncode=1) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert not result.success + assert "Unable to find application" in result.detail + + +def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkeypatch): + """pkill raising OSError (binary missing) must be swallowed, not propagated -- + relaunch is still attempted.""" + + def fake_run(cmd, **kwargs): + if cmd[0] == "pkill": + raise OSError("pkill not found") + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + + +def test_restart_claude_desktop_windows_commands(monkeypatch): + """Windows: taskkill the process, then relaunch via the Start-menu shortcut.""" + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "win32") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + assert calls[0] == ["taskkill", "/IM", "Claude.exe", "/F"] + assert calls[1][:3] == ["cmd", "/c", "start"] + assert calls[1][-1].endswith("Claude.lnk") + + +def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch): + def fake_run(cmd, **kwargs): + if cmd[0] == "cmd": + return _FakeCompletedProcess(returncode=1, stderr="not found") + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "win32") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert not result.success + assert "not found" in result.detail + + +def test_restart_claude_desktop_linux_commands(monkeypatch): + """Linux: pkill claude, then relaunch via a detached Popen (no waiting).""" + run_calls = [] + popen_calls = [] + + def fake_run(cmd, **kwargs): + run_calls.append(cmd) + return _FakeCompletedProcess(returncode=0) + + class _FakePopen: + def __init__(self, cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + + monkeypatch.setattr(c.sys, "platform", "linux") + monkeypatch.setattr(c.subprocess, "run", fake_run) + monkeypatch.setattr(c.subprocess, "Popen", _FakePopen) + result = c.restart_claude_desktop() + assert result.success + assert run_calls == [["pkill", "claude"]] + cmd, kwargs = popen_calls[0] + assert cmd == ["claude"] + assert kwargs.get("start_new_session") is True + + +def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch): + def fake_run(cmd, **kwargs): + return _FakeCompletedProcess(returncode=0) + + def fake_popen(cmd, **kwargs): + raise OSError("no such file or directory") + + monkeypatch.setattr(c.sys, "platform", "linux") + monkeypatch.setattr(c.subprocess, "run", fake_run) + monkeypatch.setattr(c.subprocess, "Popen", fake_popen) + result = c.restart_claude_desktop() + assert not result.success + assert "Claude Desktop" in result.detail