Fixes test_readme_documents_all_settings_keys — federation_key was added by the federation feature but not documented in README. Also commits the CLI refactor plan files as historical ADRs.
33 KiB
CLI Refactor Phase 2: Service Subcommand Group
Execution: Use the subagent-driven-development workflow to implement this plan.
Phase: 2 of 2. Complete Phase 1 before starting this phase.
Design doc: docs/plans/2026-03-31-cli-service-refactor-design.md
Goal: Replace muxplex install-service with a full muxplex service <command> subcommand group (install, uninstall, start, stop, restart, status, logs) — thin wrappers over systemctl --user (Linux) and launchctl (macOS).
Architecture: Create a new muxplex/service.py module containing platform-dispatched functions for each service operation. The existing _install_systemd() and _install_launchd() functions in cli.py are moved and simplified (no CLI flags in service files — they just run muxplex serve, which reads settings.json). A new service subparser in main() dispatches to these functions. The old install-service subparser remains as a deprecated alias.
Tech Stack: Python 3.11+, argparse, subprocess, pytest, monkeypatch/capsys
Working directory: /home/bkrabach/dev/web-tmux/muxplex/
Prerequisite: Phase 1 must be complete — settings.json must contain host, port, auth, session_ttl keys and serve() must read from settings.json. Service files generated in this phase rely on muxplex serve reading config from disk (no flags in ExecStart/ProgramArguments).
Task 1: Create the service module with platform detection
Files:
- Create:
muxplex/service.py - Test:
muxplex/tests/test_service.py
Step 1: Write the failing tests
Create muxplex/tests/test_service.py:
"""Tests for muxplex/service.py — service lifecycle management."""
import sys
from pathlib import Path
from unittest.mock import patch
def test_service_module_importable():
"""muxplex.service must be importable."""
from muxplex.service import service_install # noqa: F401
from muxplex.service import service_uninstall # noqa: F401
from muxplex.service import service_start # noqa: F401
from muxplex.service import service_stop # noqa: F401
from muxplex.service import service_restart # noqa: F401
from muxplex.service import service_status # noqa: F401
from muxplex.service import service_logs # noqa: F401
def test_is_darwin_detection(monkeypatch):
"""_is_darwin() returns True on macOS, False on Linux."""
from muxplex import service as svc_mod
monkeypatch.setattr(sys, "platform", "darwin")
assert svc_mod._is_darwin() is True
monkeypatch.setattr(sys, "platform", "linux")
assert svc_mod._is_darwin() is False
def test_resolve_muxplex_bin():
"""_resolve_muxplex_bin() returns a path that includes 'muxplex'."""
from muxplex.service import _resolve_muxplex_bin
result = _resolve_muxplex_bin()
assert "muxplex" in result.lower() or "python" in result.lower()
Step 2: Run tests to verify they fail
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_service.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'muxplex.service'
Step 3: Create the service module skeleton
Create muxplex/service.py:
"""Service lifecycle management for muxplex.
Thin wrappers over systemctl (Linux) and launchctl (macOS).
Each function is 3-10 lines — no abstraction layer, just direct subprocess calls.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SYSTEMD_UNIT_DIR = Path.home() / ".config" / "systemd" / "user"
_SYSTEMD_UNIT_PATH = _SYSTEMD_UNIT_DIR / "muxplex.service"
_LAUNCHD_PLIST_DIR = Path.home() / "Library" / "LaunchAgents"
_LAUNCHD_PLIST_PATH = _LAUNCHD_PLIST_DIR / "com.muxplex.plist"
_LAUNCHD_LABEL = "com.muxplex"
# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------
def _is_darwin() -> bool:
"""Return True if running on macOS."""
return sys.platform == "darwin"
def _resolve_muxplex_bin() -> str:
"""Find the muxplex executable path.
Prefers `shutil.which('muxplex')`, falls back to `sys.executable -m muxplex`.
"""
found = shutil.which("muxplex")
if found:
return found
return f"{sys.executable} -m muxplex"
# ---------------------------------------------------------------------------
# Public API — each dispatches to platform-specific implementation
# ---------------------------------------------------------------------------
def service_install() -> None:
"""Write service file, enable, and start the service."""
if _is_darwin():
_launchd_install()
else:
_systemd_install()
def service_uninstall() -> None:
"""Stop, disable, and remove the service file."""
if _is_darwin():
_launchd_uninstall()
else:
_systemd_uninstall()
def service_start() -> None:
"""Start the service."""
if _is_darwin():
_launchd_start()
else:
_systemd_start()
def service_stop() -> None:
"""Stop the service."""
if _is_darwin():
_launchd_stop()
else:
_systemd_stop()
def service_restart() -> None:
"""Stop and start the service."""
if _is_darwin():
_launchd_restart()
else:
_systemd_restart()
def service_status() -> None:
"""Show service status."""
if _is_darwin():
_launchd_status()
else:
_systemd_status()
def service_logs() -> None:
"""Tail service logs."""
if _is_darwin():
_launchd_logs()
else:
_systemd_logs()
# ---------------------------------------------------------------------------
# systemd implementations
# ---------------------------------------------------------------------------
# (filled in Task 2)
def _systemd_install() -> None:
raise NotImplementedError
def _systemd_uninstall() -> None:
raise NotImplementedError
def _systemd_start() -> None:
raise NotImplementedError
def _systemd_stop() -> None:
raise NotImplementedError
def _systemd_restart() -> None:
raise NotImplementedError
def _systemd_status() -> None:
raise NotImplementedError
def _systemd_logs() -> None:
raise NotImplementedError
# ---------------------------------------------------------------------------
# launchd implementations
# ---------------------------------------------------------------------------
# (filled in Task 3)
def _launchd_install() -> None:
raise NotImplementedError
def _launchd_uninstall() -> None:
raise NotImplementedError
def _launchd_start() -> None:
raise NotImplementedError
def _launchd_stop() -> None:
raise NotImplementedError
def _launchd_restart() -> None:
raise NotImplementedError
def _launchd_status() -> None:
raise NotImplementedError
def _launchd_logs() -> None:
raise NotImplementedError
Step 4: Run tests to verify they pass
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_service.py -v
Expected: ALL PASS
Step 5: Commit
cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/service.py muxplex/tests/test_service.py && git commit -m "feat: create service.py module skeleton with platform dispatch"
Task 2: Implement systemd service commands
Files:
- Modify:
muxplex/service.py(replace systemd stubs with real implementations) - Test:
muxplex/tests/test_service.py
Step 1: Write the failing tests
Add to muxplex/tests/test_service.py:
# ---------------------------------------------------------------------------
# systemd implementations (Phase 2, Task 2)
# ---------------------------------------------------------------------------
def test_systemd_install_writes_unit_and_enables(tmp_path, monkeypatch):
"""_systemd_install() writes unit file, runs daemon-reload, and enable --now."""
import muxplex.service as svc_mod
unit_dir = tmp_path / "systemd" / "user"
unit_path = unit_dir / "muxplex.service"
monkeypatch.setattr(svc_mod, "_SYSTEMD_UNIT_DIR", unit_dir)
monkeypatch.setattr(svc_mod, "_SYSTEMD_UNIT_PATH", unit_path)
monkeypatch.setattr(svc_mod, "_is_darwin", lambda: False)
calls = []
monkeypatch.setattr(
subprocess, "run", lambda cmd, **kw: calls.append(cmd)
)
svc_mod._systemd_install()
assert unit_path.exists()
content = unit_path.read_text()
assert "muxplex" in content
assert "serve" in content
# Must NOT contain --host, --port, or other flags
assert "--host" not in content
assert "--port" not in content
# Verify subprocess calls
assert any("daemon-reload" in str(c) for c in calls)
assert any("enable" in str(c) for c in calls)
def test_systemd_uninstall_stops_disables_removes(tmp_path, monkeypatch):
"""_systemd_uninstall() stops, disables, removes unit, and reloads daemon."""
import muxplex.service as svc_mod
unit_path = tmp_path / "muxplex.service"
unit_path.write_text("[Unit]\nDescription=test\n")
monkeypatch.setattr(svc_mod, "_SYSTEMD_UNIT_PATH", unit_path)
calls = []
monkeypatch.setattr(
subprocess, "run", lambda cmd, **kw: calls.append(cmd)
)
svc_mod._systemd_uninstall()
assert not unit_path.exists()
assert any("stop" in str(c) for c in calls)
assert any("disable" in str(c) for c in calls)
assert any("daemon-reload" in str(c) for c in calls)
def test_systemd_start_calls_systemctl(monkeypatch):
"""_systemd_start() calls systemctl --user start muxplex."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
from muxplex.service import _systemd_start
_systemd_start()
assert len(calls) == 1
assert calls[0] == ["systemctl", "--user", "start", "muxplex"]
def test_systemd_stop_calls_systemctl(monkeypatch):
"""_systemd_stop() calls systemctl --user stop muxplex."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
from muxplex.service import _systemd_stop
_systemd_stop()
assert len(calls) == 1
assert calls[0] == ["systemctl", "--user", "stop", "muxplex"]
def test_systemd_restart_calls_systemctl(monkeypatch):
"""_systemd_restart() calls systemctl --user restart muxplex."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
from muxplex.service import _systemd_restart
_systemd_restart()
assert len(calls) == 1
assert calls[0] == ["systemctl", "--user", "restart", "muxplex"]
def test_systemd_status_calls_systemctl(monkeypatch):
"""_systemd_status() calls systemctl --user status muxplex --no-pager."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
from muxplex.service import _systemd_status
_systemd_status()
assert len(calls) == 1
assert calls[0] == ["systemctl", "--user", "status", "muxplex", "--no-pager"]
def test_systemd_logs_calls_journalctl(monkeypatch):
"""_systemd_logs() calls journalctl --user -u muxplex -f."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
from muxplex.service import _systemd_logs
_systemd_logs()
assert len(calls) == 1
assert calls[0] == ["journalctl", "--user", "-u", "muxplex", "-f"]
Step 2: Run tests to verify they fail
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_service.py -v -k "systemd" --no-header 2>&1 | tail -20
Expected: FAIL — NotImplementedError
Step 3: Implement systemd functions
In muxplex/service.py, replace the systemd stubs with:
# ---------------------------------------------------------------------------
# systemd implementations
# ---------------------------------------------------------------------------
_SYSTEMD_UNIT_TEMPLATE = """\
[Unit]
Description=muxplex — web-based tmux session dashboard
After=network.target
[Service]
Type=simple
ExecStart={exec_start}
Restart=on-failure
RestartSec=5s
Environment=PATH={safe_path}
[Install]
WantedBy=default.target
"""
def _systemd_install() -> None:
muxplex_bin = _resolve_muxplex_bin()
if " " in muxplex_bin:
# sys.executable -m muxplex — split into command
exec_start = f"{muxplex_bin} serve"
else:
exec_start = f"{muxplex_bin} serve"
safe_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
unit_content = _SYSTEMD_UNIT_TEMPLATE.format(
exec_start=exec_start, safe_path=safe_path
)
_SYSTEMD_UNIT_DIR.mkdir(parents=True, exist_ok=True)
_SYSTEMD_UNIT_PATH.write_text(unit_content)
print(f" Wrote {_SYSTEMD_UNIT_PATH}")
subprocess.run(["systemctl", "--user", "daemon-reload"])
subprocess.run(["systemctl", "--user", "enable", "--now", "muxplex"])
print(" Service installed and started.")
_prompt_host_if_localhost()
def _systemd_uninstall() -> None:
subprocess.run(["systemctl", "--user", "stop", "muxplex"])
subprocess.run(["systemctl", "--user", "disable", "muxplex"])
_SYSTEMD_UNIT_PATH.unlink(missing_ok=True)
subprocess.run(["systemctl", "--user", "daemon-reload"])
print(" Service stopped, disabled, and removed.")
def _systemd_start() -> None:
subprocess.run(["systemctl", "--user", "start", "muxplex"])
def _systemd_stop() -> None:
subprocess.run(["systemctl", "--user", "stop", "muxplex"])
def _systemd_restart() -> None:
subprocess.run(["systemctl", "--user", "restart", "muxplex"])
def _systemd_status() -> None:
subprocess.run(["systemctl", "--user", "status", "muxplex", "--no-pager"])
def _systemd_logs() -> None:
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"])
Also add the host prompt helper function (used by both platforms):
def _prompt_host_if_localhost() -> None:
"""If host is 127.0.0.1, prompt user to set to 0.0.0.0 for network access."""
from muxplex.settings import load_settings, patch_settings # noqa: PLC0415
settings = load_settings()
if settings.get("host") == "127.0.0.1":
print(
"\n Note: host is 127.0.0.1 (localhost only)."
" Set to 0.0.0.0 for network access? [Y/n] ",
end="",
)
try:
answer = input().strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer in ("", "y", "yes"):
patch_settings({"host": "0.0.0.0"})
print(" → Settings updated: host = 0.0.0.0")
print(" → Restart the service to apply: muxplex service restart")
Step 4: Run tests to verify they pass
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_service.py -v -k "systemd" --no-header 2>&1 | tail -20
Expected: ALL PASS
Step 5: Commit
cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/service.py muxplex/tests/test_service.py && git commit -m "feat: implement systemd service commands (install, uninstall, start, stop, restart, status, logs)"
Task 3: Implement launchd service commands
Files:
- Modify:
muxplex/service.py(replace launchd stubs) - Test:
muxplex/tests/test_service.py
Step 1: Write the failing tests
Add to muxplex/tests/test_service.py:
# ---------------------------------------------------------------------------
# launchd implementations (Phase 2, Task 3)
# ---------------------------------------------------------------------------
def test_launchd_install_writes_plist_and_bootstraps(tmp_path, monkeypatch):
"""_launchd_install() writes plist and calls launchctl bootstrap."""
import muxplex.service as svc_mod
plist_dir = tmp_path / "LaunchAgents"
plist_path = plist_dir / "com.muxplex.plist"
monkeypatch.setattr(svc_mod, "_LAUNCHD_PLIST_DIR", plist_dir)
monkeypatch.setattr(svc_mod, "_LAUNCHD_PLIST_PATH", plist_path)
monkeypatch.setattr(svc_mod, "_is_darwin", lambda: True)
monkeypatch.setattr(os, "getuid", lambda: 501)
# Suppress the host prompt
monkeypatch.setattr("builtins.input", lambda *a: "n")
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
svc_mod._launchd_install()
assert plist_path.exists()
content = plist_path.read_text()
assert "com.muxplex" in content
assert "serve" in content
# Must NOT contain --host, --port
assert "--host" not in content
assert "--port" not in content
assert any("bootstrap" in str(c) for c in calls)
def test_launchd_uninstall_bootouts_and_removes(tmp_path, monkeypatch):
"""_launchd_uninstall() calls bootout and removes plist."""
import muxplex.service as svc_mod
plist_path = tmp_path / "com.muxplex.plist"
plist_path.write_text("<plist>test</plist>")
monkeypatch.setattr(svc_mod, "_LAUNCHD_PLIST_PATH", plist_path)
monkeypatch.setattr(os, "getuid", lambda: 501)
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
svc_mod._launchd_uninstall()
assert not plist_path.exists()
assert any("bootout" in str(c) for c in calls)
def test_launchd_stop_calls_bootout(monkeypatch):
"""_launchd_stop() calls launchctl bootout."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
monkeypatch.setattr(os, "getuid", lambda: 501)
from muxplex.service import _launchd_stop
_launchd_stop()
assert len(calls) == 1
assert "bootout" in str(calls[0])
def test_launchd_logs_tails_log_file(monkeypatch):
"""_launchd_logs() calls tail -f /tmp/muxplex.log."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
from muxplex.service import _launchd_logs
_launchd_logs()
assert len(calls) == 1
assert calls[0] == ["tail", "-f", "/tmp/muxplex.log"]
Step 2: Run tests to verify they fail
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_service.py -v -k "launchd" --no-header 2>&1 | tail -20
Expected: FAIL — NotImplementedError
Step 3: Implement launchd functions
In muxplex/service.py, replace the launchd stubs with:
# ---------------------------------------------------------------------------
# launchd implementations
# ---------------------------------------------------------------------------
_LAUNCHD_PLIST_TEMPLATE = """\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.muxplex</string>
<key>ProgramArguments</key>
<array>
<string>{muxplex_bin}</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{safe_path}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/muxplex.log</string>
<key>StandardErrorPath</key>
<string>/tmp/muxplex.err</string>
</dict>
</plist>
"""
def _launchd_install() -> None:
muxplex_bin = _resolve_muxplex_bin()
safe_path = (
"/opt/homebrew/bin:/usr/local/bin:"
+ os.environ.get("PATH", "/usr/bin:/bin")
)
plist_content = _LAUNCHD_PLIST_TEMPLATE.format(
muxplex_bin=muxplex_bin, safe_path=safe_path
)
_LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True)
_LAUNCHD_PLIST_PATH.write_text(plist_content)
print(f" Wrote {_LAUNCHD_PLIST_PATH}")
uid = os.getuid()
subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)])
print(" Service installed and started.")
_prompt_host_if_localhost()
def _launchd_uninstall() -> None:
uid = os.getuid()
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"])
_LAUNCHD_PLIST_PATH.unlink(missing_ok=True)
print(" Service stopped, disabled, and removed.")
def _launchd_start() -> None:
uid = os.getuid()
subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)])
def _launchd_stop() -> None:
uid = os.getuid()
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"])
def _launchd_restart() -> None:
_launchd_stop()
_launchd_start()
def _launchd_status() -> None:
uid = os.getuid()
subprocess.run(
["launchctl", "print", f"gui/{uid}/{_LAUNCHD_LABEL}"],
)
def _launchd_logs() -> None:
subprocess.run(["tail", "-f", "/tmp/muxplex.log"])
Step 4: Run tests to verify they pass
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_service.py -v --no-header 2>&1 | tail -25
Expected: ALL PASS
Step 5: Commit
cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/service.py muxplex/tests/test_service.py && git commit -m "feat: implement launchd service commands (install, uninstall, start, stop, restart, status, logs)"
Task 4: Wire service subparser into main()
Files:
- Modify:
muxplex/cli.py(addservicesubparser with sub-subparsers) - Test:
muxplex/tests/test_cli.py
Step 1: Write the failing tests
Add at the end of muxplex/tests/test_cli.py:
# ---------------------------------------------------------------------------
# Service subcommand group (Phase 2)
# ---------------------------------------------------------------------------
def test_service_install_dispatches(monkeypatch):
"""'muxplex service install' must call service_install()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_install", lambda: calls.append("install")):
with patch("sys.argv", ["muxplex", "service", "install"]):
main()
assert calls == ["install"]
def test_service_uninstall_dispatches(monkeypatch):
"""'muxplex service uninstall' must call service_uninstall()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_uninstall", lambda: calls.append("uninstall")):
with patch("sys.argv", ["muxplex", "service", "uninstall"]):
main()
assert calls == ["uninstall"]
def test_service_start_dispatches(monkeypatch):
"""'muxplex service start' must call service_start()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_start", lambda: calls.append("start")):
with patch("sys.argv", ["muxplex", "service", "start"]):
main()
assert calls == ["start"]
def test_service_stop_dispatches(monkeypatch):
"""'muxplex service stop' must call service_stop()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_stop", lambda: calls.append("stop")):
with patch("sys.argv", ["muxplex", "service", "stop"]):
main()
assert calls == ["stop"]
def test_service_restart_dispatches(monkeypatch):
"""'muxplex service restart' must call service_restart()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_restart", lambda: calls.append("restart")):
with patch("sys.argv", ["muxplex", "service", "restart"]):
main()
assert calls == ["restart"]
def test_service_status_dispatches(monkeypatch):
"""'muxplex service status' must call service_status()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_status", lambda: calls.append("status")):
with patch("sys.argv", ["muxplex", "service", "status"]):
main()
assert calls == ["status"]
def test_service_logs_dispatches(monkeypatch):
"""'muxplex service logs' must call service_logs()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_logs", lambda: calls.append("logs")):
with patch("sys.argv", ["muxplex", "service", "logs"]):
main()
assert calls == ["logs"]
def test_service_subcommand_in_help():
"""'muxplex --help' must list 'service' as a subcommand."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
assert "service" in buf.getvalue().lower()
Step 2: Run tests to verify they fail
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py::test_service_install_dispatches -v
Expected: FAIL — no service subparser exists yet
Step 3: Add the service subparser to main()
In muxplex/cli.py, in the main() function, add the service subparser (after the install-service parser, before show-password):
# service subcommand group
service_parser = sub.add_parser(
"service", help="Manage the muxplex background service"
)
service_sub = service_parser.add_subparsers(dest="service_command")
service_sub.add_parser("install", help="Install + enable + start the service")
service_sub.add_parser("uninstall", help="Stop + disable + remove the service")
service_sub.add_parser("start", help="Start the service")
service_sub.add_parser("stop", help="Stop the service")
service_sub.add_parser("restart", help="Stop + start the service")
service_sub.add_parser("status", help="Show service status")
service_sub.add_parser("logs", help="Tail service logs")
Then add the dispatch in the if/elif chain in main():
elif args.command == "service":
from muxplex.service import ( # noqa: PLC0415
service_install,
service_logs,
service_restart,
service_start,
service_status,
service_stop,
service_uninstall,
)
cmd = getattr(args, "service_command", None)
if cmd == "install":
service_install()
elif cmd == "uninstall":
service_uninstall()
elif cmd == "start":
service_start()
elif cmd == "stop":
service_stop()
elif cmd == "restart":
service_restart()
elif cmd == "status":
service_status()
elif cmd == "logs":
service_logs()
else:
service_parser.print_help()
Step 4: Run tests to verify they pass
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "service" --no-header 2>&1 | tail -25
Expected: ALL PASS
Step 5: Commit
cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: wire service subcommand group into CLI (install, uninstall, start, stop, restart, status, logs)"
Task 5: Update install-service deprecation to forward to service install
Files:
- Modify:
muxplex/cli.py(updateinstall-servicedispatch to callservice_install())
Step 1: Write the failing test
Add at the end of muxplex/tests/test_cli.py:
def test_install_service_deprecated_calls_service_install(monkeypatch, capsys):
"""'muxplex install-service' must print deprecation AND call service_install()."""
from muxplex.cli import main
calls = []
with patch("muxplex.service.service_install", lambda: calls.append("install")):
with patch("sys.argv", ["muxplex", "install-service"]):
main()
assert calls == ["install"]
captured = capsys.readouterr()
assert "deprecated" in captured.err.lower()
Step 2: Run test to verify it fails
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py::test_install_service_deprecated_calls_service_install -v
Expected: FAIL — currently install-service calls the old install_service() function, not service_install() from the new module
Step 3: Update the dispatch
In muxplex/cli.py, in main(), change the install-service dispatch from:
if args.command == "install-service":
print(
"⚠ 'muxplex install-service' is deprecated."
" Use 'muxplex service install' instead.",
file=sys.stderr,
)
install_service(system=args.system)
to:
if args.command == "install-service":
print(
"⚠ 'muxplex install-service' is deprecated."
" Use 'muxplex service install' instead.",
file=sys.stderr,
)
from muxplex.service import service_install # noqa: PLC0415
service_install()
Step 4: Run tests to verify they pass
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py::test_install_service_deprecated_calls_service_install -v
Expected: PASS
Step 5: Commit
cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "refactor: install-service deprecated alias now forwards to service_install()"
Task 6: Clean up old install_service code in cli.py
Files:
- Modify:
muxplex/cli.py(remove old_install_systemd(),_install_launchd(),install_service()functions)
Step 1: Identify the old functions to remove
The old functions in cli.py are:
_install_launchd()(around line 345)_install_systemd()(around line 425)install_service()(around line 475)
Important: The upgrade() function calls install_service() internally (to restart the service after upgrade). Check if upgrade() references need updating too.
Step 2: Update upgrade() to use the new service module
In the upgrade() function, find where it calls install_service() and replace with the new service module:
- Replace
install_service()calls withfrom muxplex.service import service_restart; service_restart()(after an upgrade, we want restart, not full reinstall) - Or if the upgrade function reinstalls the service file, use
service_install()instead
Check the upgrade() function to understand the flow and decide which is appropriate.
Step 3: Remove the old functions
Delete _install_launchd(), _install_systemd(), and install_service() from cli.py. These are fully replaced by muxplex/service.py.
Step 4: Run the full test suite
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v --no-header 2>&1 | tail -40
Expected: All PASS. If any tests reference the old install_service() function, update them to use the new service_install() from muxplex.service.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "refactor: remove old install_service/launchd/systemd from cli.py, replaced by service module"
Task 7: Update README with service commands
Files:
- Modify:
README.md
Step 1: Add service commands section to README
In README.md, add a new section after the "Other commands" table (which was updated in Phase 1):
### Service management
```bash
muxplex service install # Write service file + enable + start
muxplex service uninstall # Stop + disable + remove service file
muxplex service start # Start the service
muxplex service stop # Stop the service
muxplex service restart # Stop + start
muxplex service status # Show running/stopped + PID
muxplex service logs # Tail service logs
The service runs muxplex serve with no flags — it reads all options from ~/.config/muxplex/settings.json. To change host/port, edit the config and restart:
# Edit settings to bind to all interfaces
# (or use the Settings UI in the browser)
muxplex service restart
Also remove or update any remaining references to `muxplex install-service` in the install/setup sections.
**Step 2: Verify README renders correctly**
Run: `cd /home/bkrabach/dev/web-tmux/muxplex && head -160 README.md`
**Step 3: Commit**
```bash
cd /home/bkrabach/dev/web-tmux/muxplex && git add README.md && git commit -m "docs: add service subcommand documentation to README"
Task 8: Final verification and push
Step 1: Run the full test suite
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v --tb=short 2>&1 | tail -50
Expected: All tests pass
Step 2: Run linting
Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m ruff check muxplex/ --fix && python -m ruff format muxplex/
Step 3: Verify git log
Run: cd /home/bkrabach/dev/web-tmux/muxplex && git log --oneline -15
Expected: See all Phase 1 + Phase 2 commits
Step 4: Push
Run: cd /home/bkrabach/dev/web-tmux/muxplex && git push
Summary of Changes
| File | What changed |
|---|---|
muxplex/service.py |
New file — platform-dispatched service lifecycle management (install, uninstall, start, stop, restart, status, logs) for both systemd and launchd |
muxplex/cli.py |
Added service subparser with sub-subparsers; install-service deprecated alias now forwards to service_install(); removed old _install_systemd(), _install_launchd(), install_service() functions; updated upgrade() to use new service module |
muxplex/tests/test_service.py |
New file — tests for all service commands on both platforms (mocked subprocess) |
muxplex/tests/test_cli.py |
Tests for service subcommand dispatch and deprecated install-service forwarding |
README.md |
Added service management section with all 7 subcommands |