cli: add first-party launchd install/uninstall commands for autostart

Introduce built-in launchd management to remove shell-wrapper indirection and
make macOS autostart reliable.

- Add `install-launchagent` and `uninstall-launchagent` CLI subcommands.
- Create launchd helper module to:
  - write `~/Library/LaunchAgents/<label>.plist`
  - invoke app via `sys.executable -m mpd_now_playable.cli` (absolute interpreter path)
  - set `RunAtLoad`, `KeepAlive`
  - run `launchctl bootout/bootstrap/kickstart` for install and `bootout` for uninstall
  - support `--label` and `--force`
- Add tests for subcommand dispatch, plist generation, and launchctl flow.
- Document the new launchd workflow in README.
  - Run markdownlint

This keeps the setup DRY/KISS, avoids PATH/shell expansion pitfalls in launchd,
and gives users a supported and easy autostart path out of the box.
This commit is contained in:
Götz 2026-03-01 13:07:13 -05:00 committed by goetzc
parent eb5a9d5745
commit 491e7dea1f
6 changed files with 308 additions and 35 deletions

View file

@ -1,3 +1,4 @@
import argparse
import asyncio
import sys
from collections.abc import Iterable
@ -7,6 +8,7 @@ from rich import print as rich_print
from .__version__ import __version__
from .config.load import loadConfig
from .config.model import Config
from .launchd import DEFAULT_LABEL, install_launchagent, uninstall_launchagent
from .mpd.listener import MpdStateListener
from .song_receiver import (
Receiver,
@ -24,20 +26,63 @@ async def listen(
def print_help() -> None:
print("Usage: mpd-now-playable [OPTIONS]")
print("")
print("Options:")
print(" -h, --help Show this help message and exit.")
print(" -v, --version Show version and exit.")
parser = build_parser()
parser.print_help()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="mpd-now-playable")
parser.add_argument(
"-v",
"--version",
action="store_true",
help="Show version and exit.",
)
subparsers = parser.add_subparsers(dest="command")
install_cmd = subparsers.add_parser(
"install-launchagent",
help="Install and start a per-user launchd service.",
)
install_cmd.add_argument(
"--label",
default=DEFAULT_LABEL,
help=f"launchd label to install (default: {DEFAULT_LABEL}).",
)
install_cmd.add_argument(
"--force",
action="store_true",
help="Replace an existing plist with the same label.",
)
uninstall_cmd = subparsers.add_parser(
"uninstall-launchagent",
help="Unload and remove a per-user launchd service.",
)
uninstall_cmd.add_argument(
"--label",
default=DEFAULT_LABEL,
help=f"launchd label to uninstall (default: {DEFAULT_LABEL}).",
)
return parser
def main() -> None:
args = set(sys.argv[1:])
if "-h" in args or "--help" in args:
print_help()
parser = build_parser()
args = parser.parse_args(sys.argv[1:])
if args.version:
rich_print(f"mpd-now-playable v{__version__}")
return
if "-v" in args or "--version" in args:
print(f"mpd-now-playable v{__version__}")
if args.command == "install-launchagent":
path = install_launchagent(label=args.label, force=args.force)
rich_print(f"Installed LaunchAgent at {path}")
return
if args.command == "uninstall-launchagent":
path = uninstall_launchagent(label=args.label)
rich_print(f"Uninstalled LaunchAgent at {path}")
return
rich_print(f"mpd-now-playable v{__version__}")