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.
This commit is contained in:
2026-07-07 20:26:53 -04:00
parent 832e5fa048
commit 6c51bac1e0
+101 -1
View File
@@ -29,6 +29,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import NamedTuple
from urllib.parse import urlparse from urllib.parse import urlparse
CONFIG_FILENAME = "claude_desktop_config.json" 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()) 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 # Load / extract / apply
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -474,7 +486,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str:
out = text out = text
for junk in _JUNK_CHARS: for junk in _JUNK_CHARS:
out = out.replace(junk, "") 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(): for smart, ascii_q in _QUOTE_MAP.items():
out = out.replace(smart, ascii_q) out = out.replace(smart, ascii_q)
if out != text: if out != text:
@@ -1459,3 +1471,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N
out["args"] = args out["args"] = args
return out, f"'{c}'{resolved}" return out, f"'{c}'{resolved}"
return data, None 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 "" <target>` 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()