diff --git a/muxplex/service.py b/muxplex/service.py
index b3674d1..f5992cf 100644
--- a/muxplex/service.py
+++ b/muxplex/service.py
@@ -44,8 +44,7 @@ _LAUNCHD_PLIST_TEMPLATE = """\
{label}
ProgramArguments
- {muxplex_bin}
- serve
+{program_arguments_xml}
EnvironmentVariables
@@ -91,6 +90,33 @@ def _resolve_muxplex_bin() -> str:
return f"{sys.executable} -m muxplex"
+def _resolve_muxplex_bin_for_launchd() -> list[str]:
+ """Return the argv token list for the muxplex binary in a launchd plist.
+
+ Uses Option A: prefer ``~/.local/bin/muxplex`` (stable uv-tool
+ console-script symlink that survives ``uv tool reinstall``). Falls back
+ to ``shutil.which("muxplex")``, then to ``[sys.executable, "-m",
+ "muxplex"]`` as explicitly split tokens.
+
+ Each element must become its own ```` in ProgramArguments.
+ launchd does **not** shell-split inside a ````; an element like
+ ``"python3 -m muxplex"`` is treated as a literal executable name, causing
+ the daemon to silently fail to start.
+ """
+ # Option A: stable console-script symlink installed by `uv tool`
+ local_bin = Path.home() / ".local" / "bin" / "muxplex"
+ if local_bin.exists() and os.access(str(local_bin), os.X_OK):
+ return [str(local_bin)]
+
+ # Fall back to PATH lookup
+ which = shutil.which("muxplex")
+ if which:
+ return [which]
+
+ # Last resort: explicit python -m invocation — correctly split into tokens
+ return [sys.executable, "-m", "muxplex"]
+
+
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
@@ -184,11 +210,20 @@ def _systemd_logs() -> None:
def _launchd_install() -> None:
- muxplex_bin = _resolve_muxplex_bin()
+ bin_args = _resolve_muxplex_bin_for_launchd()
+ argv = bin_args + ["serve"]
+ # Each argv token is its own element. launchd does NOT
+ # shell-split inside a , so we must NOT put the whole command
+ # (e.g. "python3 -m muxplex") into a single element.
+ program_arguments_xml = "\n".join(
+ f" {arg}" for arg in argv
+ )
base_path = os.environ.get("PATH", "/usr/bin:/bin")
safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}"
plist_content = _LAUNCHD_PLIST_TEMPLATE.format(
- label=_LAUNCHD_LABEL, muxplex_bin=muxplex_bin, safe_path=safe_path
+ label=_LAUNCHD_LABEL,
+ program_arguments_xml=program_arguments_xml,
+ safe_path=safe_path,
)
_LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True)
_LAUNCHD_PLIST_PATH.write_text(plist_content)
diff --git a/muxplex/tests/test_service.py b/muxplex/tests/test_service.py
index 790d047..89b7298 100644
--- a/muxplex/tests/test_service.py
+++ b/muxplex/tests/test_service.py
@@ -667,3 +667,51 @@ def test_service_install_hides_tls_tip_on_localhost(capsys, tmp_path, monkeypatc
assert "muxplex setup-tls" not in out, (
f"TLS tip must NOT appear in service install output when host is 127.0.0.1, got: {out!r}"
)
+
+
+# ---------------------------------------------------------------------------
+# v0.6.7 fix — launchd plist ProgramArguments must use separate tokens
+# ---------------------------------------------------------------------------
+
+
+def test_launchd_plist_program_arguments_are_separate_strings(monkeypatch, tmp_path):
+ """_launchd_install emits each argv token as its own in ProgramArguments.
+
+ The v0.6.6 bug: a single containing e.g.
+ "python3 -m muxplex" caused launchd to look for a literal executable
+ named "python3 -m muxplex" (with spaces) — which doesn't exist — so the
+ daemon silently failed to start on every boot.
+ """
+ import os
+ import plistlib
+
+ import muxplex.service as svc
+
+ plist_dir = tmp_path / "LaunchAgents"
+ plist_path = plist_dir / "com.muxplex.plist"
+
+ monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir)
+ monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path)
+ monkeypatch.setattr(os, "getuid", lambda: 501)
+ monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: None)
+ monkeypatch.setattr(svc, "_prompt_host_if_localhost", lambda: None)
+ monkeypatch.setattr(svc, "_show_tls_nudge_if_needed", lambda: None)
+
+ svc._launchd_install()
+
+ assert plist_path.exists(), "plist file must be written by _launchd_install"
+
+ plist_data = plistlib.loads(plist_path.read_bytes())
+ prog_args = plist_data.get("ProgramArguments", [])
+
+ assert len(prog_args) >= 2, (
+ f"ProgramArguments must have at least 2 elements, got: {prog_args!r}"
+ )
+ assert prog_args[-1] == "serve", (
+ f"Last ProgramArguments element must be 'serve', got: {prog_args!r}"
+ )
+ for arg in prog_args:
+ assert " " not in arg, (
+ f"ProgramArguments element must not contain spaces "
+ f"(embedded-space arg trap): {arg!r} in {prog_args!r}"
+ )