- Config file (settings.json) becomes source of truth for serve options - Replace install-service with muxplex service <command> subgroup - Clean up CLI structure (upgrade/update alias, serve flag precedence) - Thin wrappers over systemctl/launchctl for install/uninstall/start/stop/restart/status/logs - Backward compat: install-service remains as deprecated alias
15 KiB
CLI & Service Management Refactor Design
Goal
Refactor the muxplex CLI so that settings.json is the single source of truth for serve options, replace install-service with a muxplex service <command> subcommand group, and clean up the CLI structure.
Background
Three problems with the current CLI:
-
Serve options are CLI-only.
host,port,auth, andsession-ttlexist as argparse flags but aren't insettings.json. The systemd unit file runsmuxplexwith whatever flags were baked intoExecStartat install time — currently no--hostflag, so it defaults to127.0.0.1. A service that only listens on localhost is useless. The macOS launchd plist hardcodes--host 0.0.0.0, creating a platform inconsistency. -
Service management requires copy-pasting platform commands. After
muxplex install-service, the user gets a wall ofsystemctl --user daemon-reload && systemctl --user enable --now muxplexorlaunchctl bootstrap gui/{uid} ...that they have to manually run. There's nomuxplexcommand to stop, restart, check status, view logs, or uninstall the service. -
CLI structure has minor warts.
upgradeandupdateare separate subparsers with duplicated--forcearguments instead of using argparse aliases. Theservesubparser exists but doesn't accept any flags (the flags live on the root parser).
Approach
Follow the caddy model (COE-approved): config file is the source of truth, service file is one line, no desync possible. Service management wrappers are thin — 3-5 lines of Python calling systemctl --user or launchctl directly, not an abstraction layer.
Architecture
Config File as Source of Truth
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ CLI flags │──┐ │ settings.json │ │ Hardcoded │
│ (one-time │ │ │ (~/.config/muxplex/) │ │ defaults │
│ override) │ │ │ (persistent config) │ │ (code) │
└─────────────────┘ │ └──────────────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ Precedence: CLI flag > settings.json > default │
└─────────────────────────────────────────────────┘
│
▼
┌────────────────┐
│ muxplex serve │
│ (FastAPI app) │
└────────────────┘
Service File Simplification
Before (Linux):
ExecStart=/usr/bin/python3 -m muxplex ← no host/port
Before (macOS):
<string>muxplex</string>
<string>--host</string>
<string>0.0.0.0</string> ← hardcoded flag
After (both platforms):
ExecStart={muxplex_bin} serve ← reads settings.json
Components
1. Extended settings.json Schema
Add host, port, auth, and session_ttl to DEFAULT_SETTINGS in settings.py:
{
"host": "127.0.0.1",
"port": 8088,
"auth": "pam",
"session_ttl": 604800,
"new_session_template": "tmux new-session -d -s {name}",
"delete_session_template": "tmux kill-session -t {name}",
"default_session": null,
"sort_order": "manual",
"hidden_sessions": [],
"window_size_largest": false,
"auto_open_created": true
}
The existing load_settings() / save_settings() / patch_settings() functions already merge saved values over defaults and ignore unknown keys — no structural changes needed. Just add the four new keys to DEFAULT_SETTINGS.
2. serve() Config Resolution
The serve() function in cli.py currently accepts host, port, auth, session_ttl as direct parameters from argparse. Refactor to:
- Load settings from
~/.config/muxplex/settings.jsonviaload_settings() - Override with any CLI flags that were explicitly passed (not argparse defaults)
- Pass resolved values to uvicorn / the FastAPI app
To distinguish "user passed --port 8088" from "argparse default 8088", use argparse default=None for all serve flags. If the value is None, fall back to settings.json, then to hardcoded default.
def serve(host=None, port=None, auth=None, session_ttl=None):
settings = load_settings()
host = host or settings.get("host", "127.0.0.1")
port = port or settings.get("port", 8088)
auth = auth or settings.get("auth", "pam")
session_ttl = session_ttl if session_ttl is not None else settings.get("session_ttl", 604800)
# ... start uvicorn
3. muxplex service <command> Subcommand Group
Replace install-service with:
| Command | Description |
|---|---|
muxplex service install |
Write systemd unit / launchd plist + 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 + port |
muxplex service logs |
Tail the service log |
Each is a thin wrapper — 3-5 lines calling the platform's native service commands.
Platform detection: sys.platform == 'darwin' → launchd, else → systemd.
Linux (systemd) implementations
# install
def _systemd_install():
_write_systemd_unit()
subprocess.run(["systemctl", "--user", "daemon-reload"])
subprocess.run(["systemctl", "--user", "enable", "--now", "muxplex"])
# uninstall
def _systemd_uninstall():
subprocess.run(["systemctl", "--user", "stop", "muxplex"])
subprocess.run(["systemctl", "--user", "disable", "muxplex"])
unit_path.unlink(missing_ok=True)
subprocess.run(["systemctl", "--user", "daemon-reload"])
# start / stop / restart
def _systemd_start():
subprocess.run(["systemctl", "--user", "start", "muxplex"])
def _systemd_stop():
subprocess.run(["systemctl", "--user", "stop", "muxplex"])
def _systemd_restart():
subprocess.run(["systemctl", "--user", "restart", "muxplex"])
# status
def _systemd_status():
subprocess.run(["systemctl", "--user", "status", "muxplex", "--no-pager"])
# logs
def _systemd_logs():
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"])
macOS (launchd) implementations
# install
def _launchd_install():
_write_launchd_plist()
subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)])
# uninstall
def _launchd_uninstall():
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"])
plist_path.unlink(missing_ok=True)
# start
def _launchd_start():
subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)])
# stop
def _launchd_stop():
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"])
# restart
def _launchd_restart():
_launchd_stop()
_launchd_start()
# status
def _launchd_status():
result = subprocess.run(["launchctl", "print", f"gui/{uid}/{label}"], ...)
# Parse and display: running/stopped, PID, exit status
# logs
def _launchd_logs():
subprocess.run(["tail", "-f", "/tmp/muxplex.log"])
4. Service File Templates (No CLI Flags)
systemd unit:
[Unit]
Description=muxplex — web-based tmux session dashboard
After=network.target
[Service]
Type=simple
ExecStart={muxplex_bin_or_python} serve
Restart=on-failure
RestartSec=5s
Environment=PATH={safe_path}
[Install]
WantedBy=default.target
launchd plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.muxplex</string>
<key>ProgramArguments</key>
<array>
<string>{muxplex_bin}</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{homebrew_paths}:{base_path}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/muxplex.log</string>
<key>StandardErrorPath</key>
<string>/tmp/muxplex.err</string>
</dict>
</plist>
No --host 0.0.0.0 or other flags. The service runs muxplex serve which reads settings.json. Users configure via:
settings.jsondirectly- The Settings UI in the browser (Server tab — future)
muxplex serve --host 0.0.0.0for one-time override
5. Backward Compatibility
muxplex install-service remains as an alias for muxplex service install with a deprecation notice:
⚠ 'muxplex install-service' is deprecated. Use 'muxplex service install' instead.
Remove after 2-3 releases.
6. CLI Help Cleanup — upgrade / update Alias
Currently upgrade and update are separate subparsers with duplicated --force arguments. Replace with a single parser using argparse aliases:
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", ...)
This shows a single help line instead of two.
Data Flow
Serve startup
1. argparse parses CLI → args.host (None if not passed), args.port, etc.
2. load_settings() reads ~/.config/muxplex/settings.json
3. Resolve: CLI flag if not None → settings.json value → hardcoded default
4. os.environ.setdefault() for MUXPLEX_PORT, MUXPLEX_AUTH, MUXPLEX_SESSION_TTL
5. uvicorn.run(app, host=resolved_host, port=resolved_port)
Service install
1. Detect platform (darwin vs linux)
2. Resolve muxplex binary path (shutil.which("muxplex") or sys.executable + "-m muxplex")
3. Write service file with "muxplex serve" (no flags)
4. Enable + start service
5. Prompt about host setting if currently 127.0.0.1
Service lifecycle (user perspective)
$ muxplex service install
Service installed and started.
Note: host is 127.0.0.1 (localhost only). Set to 0.0.0.0 for network access? [Y/n]
→ Settings updated: host = 0.0.0.0
→ Service restarted on 0.0.0.0:8088
$ muxplex service status
muxplex: running (PID 12345)
Listening: 0.0.0.0:8088
Uptime: 2h 15m
$ muxplex service logs
[tails journalctl or /tmp/muxplex.log]
$ muxplex service stop
Service stopped.
$ muxplex service uninstall
Service stopped, disabled, and removed.
Error Handling
| Scenario | Behavior |
|---|---|
service start when already running |
Print "already running" + current PID, exit 0 |
service stop when not running |
Print "not running", exit 0 |
service install when already installed |
Overwrite service file, restart. Print "reinstalled" |
service uninstall when not installed |
Print "not installed", exit 0 |
service logs with no log output |
journalctl or tail handles this natively |
settings.json missing at serve time |
Use hardcoded defaults (existing behavior) |
settings.json corrupt JSON |
Use hardcoded defaults, print warning (existing behavior) |
Invalid host or port in settings |
Uvicorn fails with its own error — no extra validation needed |
Testing Strategy
Unit tests
- Config resolution precedence: Verify CLI flag > settings.json > default for each of
host,port,auth,session_ttl. Use monkeypatch to setSETTINGS_PATHto a temp file. - Settings schema: Verify
load_settings()returns new keys (host,port,auth,session_ttl) with correct defaults when file is missing/empty. - Backward compat: Verify old settings.json files (without new keys) load correctly with defaults filled in.
Service command tests
- Install: Mock
subprocess.run, verify correct systemd/launchd commands called per platform. - Uninstall: Mock subprocess, verify stop + disable + file removal.
- Start/stop/restart/status/logs: Mock subprocess, verify correct platform commands.
- Deprecation alias: Verify
install-servicecallsservice installand prints deprecation warning.
CLI structure tests
- Argparse: Verify
upgradeandupdateboth route to the same handler. - Serve flags: Verify
--host,--port,--auth,--session-ttlare accepted on both root parser andservesubparser. - Default behavior: Verify bare
muxplex(no subcommand) callsserve().
Integration test
- Start
muxplex serve --port 9999→ verify it listens on 9999 (CLI override). - Write
{"port": 7777}to settings.json → startmuxplex serve→ verify it listens on 7777 (config file). - Write
{"port": 7777}to settings.json → startmuxplex serve --port 9999→ verify it listens on 9999 (CLI wins).
Files to Modify
| File | Changes |
|---|---|
muxplex/settings.py |
Add host, port, auth, session_ttl to DEFAULT_SETTINGS |
muxplex/cli.py |
Major refactor: serve() reads config, service subcommand group, upgrade aliases, install-service deprecation, argparse restructure |
muxplex/main.py |
Accept serve options from resolved config instead of reading env vars directly (minor) |
tests/test_cli.py |
New tests for config resolution, service commands, deprecation alias |
tests/test_settings.py |
Tests for new default keys |
Open Questions
-
Should
muxplex service installauto-set host to0.0.0.0? A service listening on localhost only is useless. Recommendation: prompt the user — "Current host is 127.0.0.1 (localhost only). Set to 0.0.0.0 for network access? [Y/n]". Default yes. This writes tosettings.jsonso the service picks it up. -
Should
muxplex serve --host 0.0.0.0save to settings.json? Recommendation: no — CLI flags are one-time overrides. Persistent changes go through settings.json directly, the Settings UI, or a futuremuxplex config setcommand. Keeps the mental model simple. -
Should
serveflags live on the root parser or theservesubparser? Currently they're on root (somuxplex --host 0.0.0.0works). Keep them on both — root parser for convenience,servesubparser for explicitness. Both route to the sameserve()function.