# 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](./2026-03-31-cli-phase1-config-serve.md) before starting this phase. **Design doc:** [`docs/plans/2026-03-31-cli-service-refactor-design.md`](./2026-03-31-cli-service-refactor-design.md) **Goal:** Replace `muxplex install-service` with a full `muxplex service ` 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`: ```python """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`: ```python """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** ```bash 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`: ```python # --------------------------------------------------------------------------- # 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: ```python # --------------------------------------------------------------------------- # 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): ```python 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** ```bash 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`: ```python # --------------------------------------------------------------------------- # 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("test") 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: ```python # --------------------------------------------------------------------------- # launchd implementations # --------------------------------------------------------------------------- _LAUNCHD_PLIST_TEMPLATE = """\ Label com.muxplex ProgramArguments {muxplex_bin} serve EnvironmentVariables PATH {safe_path} RunAtLoad KeepAlive StandardOutPath /tmp/muxplex.log StandardErrorPath /tmp/muxplex.err """ 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** ```bash 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` (add `service` subparser 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`: ```python # --------------------------------------------------------------------------- # 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`): ```python # 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()`: ```python 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** ```bash 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` (update `install-service` dispatch to call `service_install()`) **Step 1: Write the failing test** Add at the end of `muxplex/tests/test_cli.py`: ```python 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: ```python 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: ```python 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** ```bash 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 with `from 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** ```bash 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): ```markdown ### 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: ```bash # 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 |