diff --git a/bcc.py b/bcc.py index a7fde1c..1d17d13 100644 --- a/bcc.py +++ b/bcc.py @@ -1265,9 +1265,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() @@ -1514,6 +1524,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) @@ -1890,6 +1901,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 @@ -1907,6 +1919,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: @@ -1935,6 +1970,7 @@ class MainWindow(QMainWindow): # --- dirty / status -------------------------------------------------- # def _mark_dirty(self): self.dirty = True + self.restart_btn.hide() self._validate() self._update_status(saved=False) diff --git a/bcc_core.py b/bcc_core.py index 509efa5..fd79671 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -136,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 # --------------------------------------------------------------------------- # @@ -1522,3 +1533,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() diff --git a/tests/test_core.py b/tests/test_core.py index 89cada9..1f28ca8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -754,3 +754,159 @@ def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(c.Path, "home", lambda: tmp_path) assert c.server_log_path("brave-search") 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