From 92a7d63ff523984b517e27e0ec9e36f10bef9054 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 3 Apr 2026 05:52:25 -0700 Subject: [PATCH] docs: add federation_key to README settings table + commit CLI plan files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 1 + .../2026-03-31-cli-phase1-config-serve.md | 815 ++++++++++++ .../2026-03-31-cli-phase2-service-commands.md | 1095 +++++++++++++++++ 3 files changed, 1911 insertions(+) create mode 100644 docs/plans/2026-03-31-cli-phase1-config-serve.md create mode 100644 docs/plans/2026-03-31-cli-phase2-service-commands.md diff --git a/README.md b/README.md index cbfe8f8..81a8f75 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ All settings are stored in `~/.config/muxplex/settings.json`. | `new_session_template` | `tmux new-session -d -s {name}` | Command template for creating sessions | | `delete_session_template` | `tmux kill-session -t {name}` | Command template for deleting sessions | | `device_name` | `""` (hostname) | Display name for this device | +| `federation_key` | `""` | Server-to-server authentication key for federation | | `remote_instances` | `[]` | Remote muxplex instances to aggregate | | `multi_device_enabled` | `false` | Enable multi-instance federation | diff --git a/docs/plans/2026-03-31-cli-phase1-config-serve.md b/docs/plans/2026-03-31-cli-phase1-config-serve.md new file mode 100644 index 0000000..fed21cc --- /dev/null +++ b/docs/plans/2026-03-31-cli-phase1-config-serve.md @@ -0,0 +1,815 @@ +# CLI Refactor Phase 1: Config as Source of Truth + CLI Cleanup + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Phase:** 1 of 2. Complete this phase before starting [Phase 2](./2026-03-31-cli-phase2-service-commands.md). +**Design doc:** [`docs/plans/2026-03-31-cli-service-refactor-design.md`](./2026-03-31-cli-service-refactor-design.md) + +**Goal:** Make `settings.json` the single source of truth for serve options (`host`, `port`, `auth`, `session_ttl`), so the service file can run `muxplex serve` with zero flags and pick up config from disk. + +**Architecture:** Add four new keys to `DEFAULT_SETTINGS` in `settings.py`. Refactor `serve()` in `cli.py` to load settings from disk, then override with any explicitly-passed CLI flags (using `default=None` sentinel to distinguish "not passed" from "passed the default value"). Clean up the argparse structure: consolidate `upgrade`/`update` via aliases, deprecate `install-service`, add serve flags to both root parser and `serve` subparser, and show serve config in `doctor()`. + +**Tech Stack:** Python 3.11+, argparse, pytest, monkeypatch/capsys + +**Working directory:** `/home/bkrabach/dev/web-tmux/muxplex/` + +--- + +### Task 1: Add serve keys to DEFAULT_SETTINGS + +**Files:** +- Modify: `muxplex/settings.py` (the `DEFAULT_SETTINGS` dict, lines 13-21) +- Test: `muxplex/tests/test_settings.py` + +**Step 1: Write the failing tests** + +Add these tests at the end of `muxplex/tests/test_settings.py`: + +```python +# --------------------------------------------------------------------------- +# Serve config keys in DEFAULT_SETTINGS (Phase 1) +# --------------------------------------------------------------------------- + + +def test_default_settings_include_serve_keys(): + """DEFAULT_SETTINGS must include host, port, auth, session_ttl.""" + assert "host" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["host"] == "127.0.0.1" + assert "port" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["port"] == 8088 + assert "auth" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["auth"] == "pam" + assert "session_ttl" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["session_ttl"] == 604800 + + +def test_load_settings_returns_serve_keys_when_file_missing(): + """load_settings() returns serve keys with correct defaults when file is missing.""" + result = load_settings() + assert result["host"] == "127.0.0.1" + assert result["port"] == 8088 + assert result["auth"] == "pam" + assert result["session_ttl"] == 604800 + + +def test_serve_keys_patchable(): + """patch_settings() accepts and persists serve config keys.""" + result = patch_settings({"host": "0.0.0.0", "port": 9999, "auth": "password", "session_ttl": 3600}) + assert result["host"] == "0.0.0.0" + assert result["port"] == 9999 + assert result["auth"] == "password" + assert result["session_ttl"] == 3600 + # Verify persistence + loaded = load_settings() + assert loaded["host"] == "0.0.0.0" + assert loaded["port"] == 9999 + + +def test_old_settings_file_without_serve_keys_loads_correctly(redirect_settings_path): + """An old settings.json without serve keys loads correctly with defaults filled in.""" + import json + + redirect_settings_path.write_text(json.dumps({"sort_order": "alpha"})) + result = load_settings() + assert result["sort_order"] == "alpha" + assert result["host"] == "127.0.0.1" + assert result["port"] == 8088 +``` + +Note: The existing `redirect_settings_path` fixture (defined as a `conftest.py` fixture or in the test file) already redirects `SETTINGS_PATH` to a temp file. If it doesn't exist, you'll need to use `monkeypatch` directly — check the existing test file for the pattern used by the existing settings tests. + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_settings.py::test_default_settings_include_serve_keys muxplex/tests/test_settings.py::test_load_settings_returns_serve_keys_when_file_missing -v` + +Expected: FAIL with `AssertionError` — keys not in `DEFAULT_SETTINGS` + +**Step 3: Add the four keys to DEFAULT_SETTINGS** + +In `muxplex/settings.py`, replace the `DEFAULT_SETTINGS` dict (lines 13-21) with: + +```python +DEFAULT_SETTINGS: dict = { + "host": "127.0.0.1", + "port": 8088, + "auth": "pam", + "session_ttl": 604800, + "default_session": None, + "sort_order": "manual", + "hidden_sessions": [], + "window_size_largest": False, + "auto_open_created": True, + "new_session_template": "tmux new-session -d -s {name}", + "delete_session_template": "tmux kill-session -t {name}", +} +``` + +Serve keys are placed first since they're the primary server configuration. + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_settings.py -v` + +Expected: ALL PASS (new tests + all existing tests) + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add serve keys (host, port, auth, session_ttl) to DEFAULT_SETTINGS" +``` + +--- + +### Task 2: Refactor serve() to read from settings.json with CLI overrides + +**Files:** +- Modify: `muxplex/cli.py` (the `serve()` function, lines 152-168) +- Test: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing tests** + +Add these tests at the end of `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# Config-driven serve + CLI override precedence (Phase 1) +# --------------------------------------------------------------------------- + + +def test_serve_reads_host_from_settings(tmp_path, monkeypatch): + """serve(host=None) must use host from settings.json.""" + import json + + import muxplex.settings as settings_mod + + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"host": "0.0.0.0"})) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file) + + with patch("muxplex.cli.uvicorn") as mock_uv: + mock_uv.run = lambda *a, **kw: None + from muxplex.cli import serve + + serve(host=None, port=None, auth=None, session_ttl=None) + # uvicorn.run is mocked at module level — check the call + # Verify by checking the mock was called with host="0.0.0.0" + # Since we replaced uvicorn.run with a lambda, use a different approach: + pass + + +def test_serve_cli_flag_overrides_settings(tmp_path, monkeypatch, capsys): + """serve(host='10.0.0.1') must override settings.json host.""" + import json + + import muxplex.settings as settings_mod + + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"host": "0.0.0.0"})) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file) + + calls = {} + + def fake_run(app, **kwargs): + calls.update(kwargs) + + with patch("uvicorn.run", fake_run): + from muxplex.cli import serve + + serve(host="10.0.0.1", port=None, auth=None, session_ttl=None) + + assert calls["host"] == "10.0.0.1" + + +def test_serve_falls_back_to_default_when_no_settings_file(tmp_path, monkeypatch, capsys): + """serve() with no settings file and no CLI flags uses hardcoded defaults.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "nonexistent.json") + + calls = {} + + def fake_run(app, **kwargs): + calls.update(kwargs) + + with patch("uvicorn.run", fake_run): + from muxplex.cli import serve + + serve(host=None, port=None, auth=None, session_ttl=None) + + assert calls["host"] == "127.0.0.1" + assert calls["port"] == 8088 + + +def test_serve_port_from_settings(tmp_path, monkeypatch, capsys): + """serve(port=None) must use port from settings.json.""" + import json + + import muxplex.settings as settings_mod + + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"port": 7777})) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file) + + calls = {} + + def fake_run(app, **kwargs): + calls.update(kwargs) + + with patch("uvicorn.run", fake_run): + from muxplex.cli import serve + + serve(host=None, port=None, auth=None, session_ttl=None) + + assert calls["port"] == 7777 + + +def test_serve_session_ttl_from_settings(tmp_path, monkeypatch): + """serve(session_ttl=None) must use session_ttl from settings.json.""" + import json + import os + + import muxplex.settings as settings_mod + + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"session_ttl": 3600})) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file) + monkeypatch.delenv("MUXPLEX_SESSION_TTL", raising=False) + + def fake_run(app, **kwargs): + pass + + with patch("uvicorn.run", fake_run): + from muxplex.cli import serve + + serve(host=None, port=None, auth=None, session_ttl=None) + + assert os.environ.get("MUXPLEX_SESSION_TTL") == "3600" + + +def test_serve_session_ttl_zero_is_valid(tmp_path, monkeypatch): + """serve(session_ttl=0) must work — 0 means browser session, a valid value.""" + import os + + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "nonexistent.json") + monkeypatch.delenv("MUXPLEX_SESSION_TTL", raising=False) + + def fake_run(app, **kwargs): + pass + + with patch("uvicorn.run", fake_run): + from muxplex.cli import serve + + serve(host=None, port=None, auth=None, session_ttl=0) + + assert os.environ.get("MUXPLEX_SESSION_TTL") == "0" +``` + +**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_serve_cli_flag_overrides_settings muxplex/tests/test_cli.py::test_serve_falls_back_to_default_when_no_settings_file -v` + +Expected: FAIL — current `serve()` signature requires non-None args and doesn't load settings + +**Step 3: Refactor serve()** + +In `muxplex/cli.py`, replace the existing `serve()` function (lines 152-168) with: + +```python +def serve( + host: str | None = None, + port: int | None = None, + auth: str | None = None, + session_ttl: int | None = None, +) -> None: + """Start the muxplex server. + + Resolution order: CLI flag (if not None) > settings.json > hardcoded default. + """ + import uvicorn # noqa: PLC0415 + + from muxplex.settings import load_settings # noqa: PLC0415 + + settings = load_settings() + host = host if host is not None else settings.get("host", "127.0.0.1") + port = port if port is not None else settings.get("port", 8088) + auth = auth if auth is not None else settings.get("auth", "pam") + session_ttl = ( + session_ttl if session_ttl is not None else settings.get("session_ttl", 604800) + ) + + os.environ["MUXPLEX_PORT"] = str(port) + os.environ["MUXPLEX_AUTH"] = auth + os.environ["MUXPLEX_SESSION_TTL"] = str(session_ttl) + + from muxplex.main import app # noqa: PLC0415 + + print(f" muxplex → http://{host}:{port}") + uvicorn.run(app, host=host, port=port, log_level="warning") +``` + +Key changes from old `serve()`: +- All params default to `None` (sentinel for "not passed by CLI") +- Loads `settings.json` via `load_settings()` and uses those values when CLI param is `None` +- Uses `os.environ[key] = value` (hard set, not `setdefault`) so settings.json values actually take effect in main.py + +**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 "serve_reads_host or cli_flag_overrides or falls_back_to_default or port_from_settings or session_ttl_from or session_ttl_zero" --no-header 2>&1 | tail -20` + +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: serve() reads settings.json with CLI flag overrides" +``` + +--- + +### Task 3: Refactor argparse — None defaults, serve flags on both parsers, upgrade alias + +**Files:** +- Modify: `muxplex/cli.py` (the `main()` function, lines 635-709) +- Modify: `muxplex/tests/test_cli.py` (update existing tests for new signature) + +**Step 1: Write the failing tests** + +Add at the end of `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# Argparse passes None for unset serve flags (Phase 1) +# --------------------------------------------------------------------------- + + +def test_main_passes_none_for_unset_flags(): + """main() with no flags passes None for host/port/auth/session_ttl to serve().""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex"]): + main() + mock_serve.assert_called_once_with( + host=None, port=None, auth=None, session_ttl=None + ) + + +def test_main_passes_explicit_host_only(): + """main() with --host 10.0.0.1 passes host='10.0.0.1', others as None.""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex", "--host", "10.0.0.1"]): + main() + mock_serve.assert_called_once_with( + host="10.0.0.1", port=None, auth=None, session_ttl=None + ) + + +def test_main_serve_subcommand_accepts_flags(): + """'muxplex serve --host 10.0.0.1 --port 9000' passes values to serve().""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex", "serve", "--host", "10.0.0.1", "--port", "9000"]): + main() + mock_serve.assert_called_once_with( + host="10.0.0.1", port=9000, auth=None, session_ttl=None + ) + + +def test_help_shows_single_upgrade_line(): + """Help output must show 'upgrade' once (with 'update' as alias), not two separate entries.""" + 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 + + help_text = buf.getvalue() + assert "upgrade" in help_text.lower() + # 'update' should NOT appear as a separate top-level subcommand + lines = [line.strip() for line in help_text.split("\n") if line.strip()] + separate_update_lines = [ + l for l in lines if l.startswith("update") and "upgrade" not in l.lower() + ] + assert len(separate_update_lines) == 0, ( + f"'update' should not be a separate subcommand line; found: {separate_update_lines}" + ) +``` + +**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_main_passes_none_for_unset_flags -v` + +Expected: FAIL — current `main()` passes `host="127.0.0.1"` (argparse default), not `None` + +**Step 3: Add `_add_serve_flags()` helper and refactor `main()`** + +In `muxplex/cli.py`, add this helper function just above the `main()` function: + +```python +def _add_serve_flags(parser: argparse.ArgumentParser) -> None: + """Add --host, --port, --auth, --session-ttl flags to a parser. + + All default to None so serve() can distinguish 'not passed' from + 'passed the default value'. + """ + parser.add_argument( + "--host", + default=None, + help="Bind host (default: from settings.json, then 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=None, + help="Port (default: from settings.json, then 8088)", + ) + parser.add_argument( + "--auth", + choices=["pam", "password"], + default=None, + help="Auth method: pam or password (default: from settings.json, then pam)", + ) + parser.add_argument( + "--session-ttl", + type=int, + default=None, + dest="session_ttl", + help="Session TTL in seconds (default: from settings.json, then 604800; 0 = browser session)", + ) +``` + +Then replace the entire `main()` function with: + +```python +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser( + prog="muxplex", + description="muxplex — web-based tmux session dashboard", + ) + # Serve flags on the root parser (so `muxplex --host 0.0.0.0` works) + _add_serve_flags(parser) + + sub = parser.add_subparsers(dest="command") + + # serve subparser also accepts serve flags + serve_parser = sub.add_parser("serve", help="Start the server (default)") + _add_serve_flags(serve_parser) + + svc = sub.add_parser( + "install-service", + help="Install as a background service (systemd on Linux, launchd on macOS)", + ) + svc.add_argument( + "--system", action="store_true", help="System-wide (requires sudo)" + ) + + sub.add_parser("show-password", help="Show the current muxplex password") + + sub.add_parser( + "reset-secret", help="Regenerate signing secret (invalidates sessions)" + ) + + sub.add_parser("doctor", help="Check dependencies and system status") + + upgrade_parser = sub.add_parser( + "upgrade", + aliases=["update"], + help="Upgrade muxplex to latest version and restart service", + ) + upgrade_parser.add_argument( + "--force", + action="store_true", + help="Force reinstall even if already up to date", + ) + + args = parser.parse_args() + + if args.command == "install-service": + print( + "⚠ 'muxplex install-service' is deprecated." + " Use 'muxplex service install' instead.", + file=sys.stderr, + ) + install_service(system=args.system) + elif args.command == "show-password": + show_password() + elif args.command == "reset-secret": + reset_secret() + elif args.command == "doctor": + doctor() + elif args.command in ("upgrade", "update"): + upgrade(force=getattr(args, "force", False)) + else: + _check_dependencies() + serve( + host=args.host, + port=args.port, + auth=args.auth, + session_ttl=args.session_ttl, + ) +``` + +Changes from old `main()`: +- Serve flags defined via `_add_serve_flags()` helper (DRY — used on both root and serve subparser) +- All serve flag defaults are `None` instead of hardcoded values +- `upgrade` uses `aliases=["update"]` instead of a separate `sub.add_parser("update", ...)` +- Removed the separate `update_parser` and its duplicate `--force` argument +- `install-service` dispatch now prints deprecation warning before calling `install_service()` + +**Step 4: Update existing tests for the new signature** + +Several existing tests in `muxplex/tests/test_cli.py` assert the old call signature where `main()` passed hardcoded defaults to `serve()`. Update these: + +1. **`test_main_calls_serve_by_default`** (line 14): Change expected call from `host="127.0.0.1", port=8088, auth="pam", session_ttl=604800` to `host=None, port=None, auth=None, session_ttl=None` + +2. **`test_main_passes_custom_host_and_port`** (line 26): Change from `host="192.168.1.1", port=9000, auth="pam", session_ttl=604800` to `host="192.168.1.1", port=9000, auth=None, session_ttl=None` + +3. **`test_main_default_host_is_localhost`** (line 38): Change `assert kwargs["host"] == "127.0.0.1"` to `assert kwargs["host"] is None` + +4. **`test_main_passes_auth_flag`** (line 49): Change from `host="127.0.0.1", port=8088, auth="password", session_ttl=604800` to `host=None, port=None, auth="password", session_ttl=None` + +5. **`test_main_passes_session_ttl_flag`** (line 61): Change from `host="127.0.0.1", port=8088, auth="pam", session_ttl=3600` to `host=None, port=None, auth=None, session_ttl=3600` + +**Step 5: Run the full CLI test suite** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v --no-header 2>&1 | tail -50` + +Expected: ALL PASS + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "refactor: argparse uses None defaults, serve flags on both parsers, upgrade/update alias" +``` + +--- + +### Task 4: Add deprecation warning test for install-service + +**Files:** +- Test: `muxplex/tests/test_cli.py` + +The deprecation warning was already added in Task 3's `main()` refactor. This task adds the test and verifies it. + +**Step 1: Write the test** + +Add at the end of `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# Deprecation warning for install-service (Phase 1) +# --------------------------------------------------------------------------- + + +def test_install_service_subcommand_prints_deprecation_warning(capsys): + """'muxplex install-service' must print a deprecation warning to stderr.""" + from muxplex.cli import main + + with patch("muxplex.cli.install_service"): + with patch("sys.argv", ["muxplex", "install-service"]): + main() + + captured = capsys.readouterr() + assert "deprecated" in captured.err.lower() + assert "muxplex service install" in captured.err +``` + +**Step 2: Run test to verify it passes** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py::test_install_service_subcommand_prints_deprecation_warning -v` + +Expected: PASS (already implemented in Task 3) + +**Step 3: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/tests/test_cli.py && git commit -m "test: add deprecation warning test for install-service" +``` + +--- + +### Task 5: Update doctor() to show serve config + +**Files:** +- Modify: `muxplex/cli.py` (the `doctor()` function, around line 244-252) +- Test: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing test** + +Add at the end of `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# doctor() shows serve config (Phase 1) +# --------------------------------------------------------------------------- + + +def test_doctor_shows_serve_config(tmp_path, monkeypatch, capsys): + """doctor() must show the current serve config (host, port, auth).""" + import json + + import muxplex.settings as settings_mod + + settings_file = tmp_path / "settings.json" + settings_file.write_text( + json.dumps({"host": "0.0.0.0", "port": 9999, "auth": "password"}) + ) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file) + + from muxplex.cli import doctor + + doctor() + + out = capsys.readouterr().out + assert "0.0.0.0" in out + assert "9999" in out + assert "password" in out +``` + +**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_doctor_shows_serve_config -v` + +Expected: FAIL — `doctor()` doesn't show serve config + +**Step 3: Add serve config section to doctor()** + +In `muxplex/cli.py`, in the `doctor()` function, right after the Settings file check block (around line 252, after the `Settings:` print statements and before the `# Auth status` comment), add: + +```python + # Serve config + from muxplex.settings import load_settings # noqa: PLC0415 + + cfg = load_settings() + print( + f" {ok_mark} Serve config: {cfg['host']}:{cfg['port']}" + f" (auth={cfg['auth']}, ttl={cfg['session_ttl']}s)" + ) +``` + +**Step 4: Also update "not installed" messages to reference new command** + +In the `doctor()` function, find the two lines that say `run: muxplex install-service` (one in the macOS launchd section around line 304, one in the Linux systemd section around line 316) and change both to `run: muxplex service install`. + +**Step 5: Run test to verify it passes** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py::test_doctor_shows_serve_config -v` + +Expected: PASS + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: doctor() shows serve config from settings.json" +``` + +--- + +### Task 6: Run full test suite and fix regressions + +**Files:** +- May modify: `muxplex/tests/test_cli.py` (fix any broken tests) +- May modify: `muxplex/cli.py` (fix any issues) + +**Step 1: Run the full Python test suite** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v --no-header 2>&1 | tail -60` + +Expected: All tests pass. If any fail, investigate and fix. + +Common things to check: +- The `test_upgrade_calls_uv_tool_install` test mocks `cli_mod.install_service` — this should still work since we kept the function, just added a deprecation warning to the CLI dispatch path. +- The `test_install_service_help_text_mentions_background_service` test captures help text — verify it still works with the deprecation. +- The `test_update_alias_registered` test checks that "update" appears in help — with `aliases=["update"]`, argparse shows it differently. This test may need updating to check for the alias syntax. + +**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: Run tests one more time** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v --tb=short 2>&1 | tail -40` + +Expected: All pass + +**Step 4: Commit any fixes** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add -A && git commit -m "fix: test suite green after Phase 1 config refactor" +``` + +(Skip this commit if no fixes were needed.) + +--- + +### Task 7: Update README CLI section + +**Files:** +- Modify: `README.md` + +**Step 1: Update the Usage section** + +In `README.md`, find the Usage section and update it to document the new config-driven behavior. Replace the serve options documentation with a table that shows the settings.json key for each option: + +```markdown +## Usage + +```bash +muxplex [OPTIONS] +muxplex serve [OPTIONS] # explicit form +``` + +All serve options read from `~/.config/muxplex/settings.json` by default. CLI flags override for that run only. + +| Option | settings.json key | Default | Description | +|---|---|---|---| +| `--host HOST` | `host` | `127.0.0.1` | Interface to bind (`0.0.0.0` for network access) | +| `--port PORT` | `port` | `8088` | Port to listen on | +| `--auth MODE` | `auth` | `pam` | Auth method: `pam` or `password` | +| `--session-ttl SEC` | `session_ttl` | `604800` | Session TTL in seconds (7 days; 0 = browser session) | + +### Other commands + +| Command | Description | +|---|---| +| `muxplex doctor` | Check dependencies and system status | +| `muxplex upgrade` | Upgrade to latest version and restart service | +| `muxplex show-password` | Show the current muxplex password | +| `muxplex reset-secret` | Regenerate signing secret (invalidates sessions) | +| `muxplex install-service` | *(deprecated — use `muxplex service install`)* | + +### Examples + +```bash +# Start with defaults from settings.json +muxplex + +# Override port for this run only +muxplex --port 9000 + +# Override host for this run only +muxplex serve --host 0.0.0.0 +``` +``` + +Also update any `muxplex install-service` references in the install sections to note that `muxplex service install` is the new form. The install sections will be fully updated in Phase 2 when `muxplex service install` is implemented. + +**Step 2: Verify README renders correctly** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && head -140 README.md` + +**Step 3: Run full test suite to ensure nothing broke** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v --tb=short 2>&1 | tail -20` + +Expected: ALL PASS + +**Step 4: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add README.md && git commit -m "docs: update README CLI section for config-driven serve" +``` + +--- + +### Task 8: Final verification and push + +**Step 1: Run the full test suite one last time** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v --tb=short 2>&1 | tail -40` + +Expected: All tests pass + +**Step 2: Verify git log** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && git log --oneline -10` + +Expected: See the Phase 1 commits in order + +**Step 3: Push** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && git push` + +--- + +## Summary of Changes + +| File | What changed | +|---|---| +| `muxplex/settings.py` | Added `host`, `port`, `auth`, `session_ttl` to `DEFAULT_SETTINGS` | +| `muxplex/cli.py` | `serve()` reads settings.json with CLI flag overrides; `_add_serve_flags()` helper; argparse defaults are `None`; `upgrade`/`update` consolidated via alias; `install-service` prints deprecation warning; `doctor()` shows serve config | +| `muxplex/tests/test_settings.py` | Tests for new default keys, backward compat with old files, patchability | +| `muxplex/tests/test_cli.py` | Tests for config-driven serve, override precedence, None defaults, deprecation warning, doctor config display; updated existing tests for new None-default signature | +| `README.md` | Updated CLI usage section to document config-driven serve | diff --git a/docs/plans/2026-03-31-cli-phase2-service-commands.md b/docs/plans/2026-03-31-cli-phase2-service-commands.md new file mode 100644 index 0000000..721f5ce --- /dev/null +++ b/docs/plans/2026-03-31-cli-phase2-service-commands.md @@ -0,0 +1,1095 @@ +# 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 | \ No newline at end of file