Files
muxplex/docs/plans/2026-03-31-cli-phase1-config-serve.md
Brian Krabach 92a7d63ff5 docs: add federation_key to README settings table + commit CLI plan files
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.
2026-04-03 05:52:25 -07:00

28 KiB

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. Design doc: docs/plans/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:

# ---------------------------------------------------------------------------
# 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:

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

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:

# ---------------------------------------------------------------------------
# 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:

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

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:

# ---------------------------------------------------------------------------
# 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:

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:

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

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:

# ---------------------------------------------------------------------------
# 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

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:

# ---------------------------------------------------------------------------
# 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:

    # 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

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

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:

## 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

# 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