diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20e698c..e09e423 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/
## [Unreleased]
+### Added
+- **`mm setup` / `mm uninstall` registram os agents do launchd** depois de
+ `brew` / `pipx` / `pip`. Homebrew instalava só o CLI; log diário e
+ alerta de bateria ficavam no `./install.sh`. `mm setup` grava os plists
+ em `~/Library/LaunchAgents/` apontando para o `mm` invocado (caminho
+ absoluto, sem seguir o symlink do Cellar) e manda stdout/stderr do
+ agent para Application Support. `mm uninstall` tira só os agents —
+ o CLI e o CSV ficam. `./install.sh` passa a chamar `mm setup`.
+
## [0.1.2] — 2026-08-14
### Added
diff --git a/COMMANDS.md b/COMMANDS.md
index f8f764e..b244dcf 100644
--- a/COMMANDS.md
+++ b/COMMANDS.md
@@ -19,6 +19,7 @@ mm --version # installed version
| **Hardware/system** | [`mm`](#mm-status), [`mm battery`](#mm-battery), [`mm health`](#mm-health), [`mm disk`](#mm-disk), [`mm clean`](#mm-clean), [`mm net`](#mm-net) |
| **Visualization** | [`mm watch`](#mm-watch), [`mm doctor`](#mm-doctor) |
| **Battery — history** | [`mm log`](#mm-log), [`mm history`](#mm-history), [`mm alerts`](#mm-alerts) |
+| **Setup** | [`mm setup`](#mm-setup), [`mm uninstall`](#mm-uninstall) |
| **Security** | [`mm security`](#mm-security) |
| **Development** | [`mm dev`](#mm-dev), [`mm dev --all`](#mm-dev---all), [`mm dev --check`](#mm-dev---check) |
@@ -224,6 +225,36 @@ mm alerts
---
+## Setup
+
+### `mm setup`
+**What it does:** registers the two launchd agents so Homebrew / pipx / pip installs get the same background automation as `./install.sh`.
+
+**Writes** `~/Library/LaunchAgents/com.macmanager.battery-log.plist` and `com.macmanager.battery-alert.plist`, pointing at the `mm` you just ran (absolute path, without following Homebrew's Cellar symlink). Agent stdout/stderr go to `~/Library/Application Support/mac-manager/`.
+
+**When to use:** once after `brew install` or `pipx install`. Safe to re-run after an upgrade.
+
+```bash
+mm setup
+```
+
+macOS only. Exits `1` on Linux or if the `mm` binary cannot be found.
+
+---
+
+### `mm uninstall`
+**What it does:** unloads and deletes those launchd agents. Does **not** uninstall the CLI, the venv, or the CSV history.
+
+**When to use:** you want to stop daily logs and notifications but keep `mm`.
+
+```bash
+mm uninstall
+```
+
+To remove the CLI itself: `brew uninstall mac-manager` or `pipx uninstall mac-manager`. Source installs can still run `./uninstall.sh` (agents + symlink + venv).
+
+---
+
## Security
### `mm security`
@@ -315,7 +346,7 @@ mm dev --check
## Commands invoked by launchd (background)
-You normally **don't** run these manually — they stay active automatically after `install.sh`:
+You normally **don't** run these manually — they stay active after `mm setup` (or `./install.sh`):
| When it runs | Command | What it does |
|---|---|---|
@@ -327,10 +358,10 @@ To confirm they're active:
launchctl list | grep macmanager
```
-To stop/restart manually:
+To stop/restart:
```bash
-launchctl unload ~/Library/LaunchAgents/com.macmanager.battery-log.plist
-launchctl load ~/Library/LaunchAgents/com.macmanager.battery-log.plist
+mm uninstall # stop
+mm setup # start / refresh after an upgrade
```
---
@@ -361,6 +392,7 @@ mm history -n 30
## Output and exit codes
- All commands exit with `0` on success.
+- `mm setup` exits `1` on non-macOS or if the `mm` binary cannot be found / launchctl rejects the plist.
- `mm alerts` always exits with `0` (even when it fires a critical alert — it's just a notification).
- Commands that fail to collect a metric (e.g. sandbox blocking) return `WARN`/`?` instead of erroring out.
@@ -370,12 +402,14 @@ mm history -n 30
|---|---|
| `~/Library/Application Support/mac-manager/battery.csv` | Append-only history of battery measurements |
| `~/Library/Application Support/mac-manager/.alert_state` | Timestamps of the last notification of each type (JSON, controls cooldown) |
-| `logs/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` (source install) |
-| `logs/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` (source install) |
+| `~/Library/Application Support/mac-manager/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` |
+| `~/Library/Application Support/mac-manager/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` |
## Reinstall / uninstall
```bash
-./install.sh # creates venv, symlink ~/.local/bin/mm and loads launchd agents
-./uninstall.sh # removes everything (preserves CSVs in Application Support)
+mm setup # register (or refresh) launchd agents — Homebrew, pipx, source
+mm uninstall # remove launchd agents only; keeps CLI and CSV
+./install.sh # source install: venv, symlink ~/.local/bin/mm, then mm setup
+./uninstall.sh # source install: mm uninstall + symlink + venv
```
diff --git a/README.md b/README.md
index 1404c32..30bea3d 100644
--- a/README.md
+++ b/README.md
@@ -39,11 +39,15 @@ pipx install mac-manager # isolated, recommended
pip install --user mac-manager
```
-After installation, the `mm` command is on your `PATH`.
+After installation, the `mm` command is on your `PATH`. Register the
+background agents (daily battery log + alerts) with:
-> `pipx`/`pip` installs give you the CLI only. The background
-> `launchd` agents (daily log + battery alerts) are set up by the
-> source install below, or you can register them manually later.
+```bash
+mm setup
+```
+
+`mm uninstall` removes those agents. It does **not** uninstall the CLI —
+use `brew uninstall mac-manager` or `pipx uninstall mac-manager` for that.
### From source
@@ -90,6 +94,12 @@ source ~/.zshrc
| `mm history -n 20` | Last N recorded measurements |
| `mm alerts` | Runs the alert check (called by launchd) |
+### Setup
+| Command | What it does |
+|---|---|
+| `mm setup` | Registers launchd agents (daily log at 09:00 + alerts every 15 min) |
+| `mm uninstall` | Removes those agents; keeps the CLI and battery history |
+
### Security
| Command | What it does |
|---|---|
@@ -153,11 +163,19 @@ mac-manager/
## Uninstallation
```bash
-./uninstall.sh
+mm uninstall # launchd agents only (Homebrew / pipx / source)
+./uninstall.sh # source install: agents + symlink + venv
+```
+
+`mm uninstall` does not remove the CLI. For packaged installs:
+
+```bash
+brew uninstall mac-manager
+# or
+pipx uninstall mac-manager
```
-Removes launchd agents, symlink and venv. Preserves battery history in
-`~/Library/Application Support/mac-manager/`.
+Battery history in `~/Library/Application Support/mac-manager/` is preserved.
## Status
diff --git a/install.sh b/install.sh
index 5319ae3..a4b7c1d 100755
--- a/install.sh
+++ b/install.sh
@@ -32,23 +32,16 @@ mkdir -p "$HOME/.local/bin"
ln -sf "$SCRIPT_DIR/mm" "$HOME/.local/bin/mm"
echo "==> symlink: $HOME/.local/bin/mm -> $SCRIPT_DIR/mm"
-# 4. Ensure logs directory
-mkdir -p "$SCRIPT_DIR/logs"
-
-# 5. Install launchd agents (daily log + alerts)
-LAUNCH_DIR="$HOME/Library/LaunchAgents"
-mkdir -p "$LAUNCH_DIR"
-
-for name in com.macmanager.battery-log com.macmanager.battery-alert; do
- src="launchd/${name}.plist"
- dest="$LAUNCH_DIR/${name}.plist"
- sed "s|{{SCRIPT_DIR}}|$SCRIPT_DIR|g" "$src" > "$dest"
- launchctl unload "$dest" 2>/dev/null || true
- launchctl load "$dest"
- echo "==> launchd: $name loaded"
-done
+# 4. Register launchd agents via the same path brew/pipx users get:
+# `mm setup` writes plists that point at this wrapper and log into
+# ~/Library/Application Support/mac-manager/.
+if "$HOME/.local/bin/mm" setup; then
+ echo "==> launchd agents registered"
+else
+ echo "[warning] could not register launchd agents. Run: mm setup" >&2
+fi
-# 6. PATH check
+# 5. PATH check
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*)
@@ -75,4 +68,5 @@ echo "Active automation:"
echo " • Daily log at 09:00 → ~/Library/Application Support/mac-manager/battery.csv"
echo " • Battery alerts every 15 min (native notifications)"
echo ""
-echo "To uninstall everything: ./uninstall.sh"
+echo "To unregister agents: mm uninstall"
+echo "To uninstall everything (source install): ./uninstall.sh"
diff --git a/launchd/com.macmanager.battery-alert.plist b/launchd/com.macmanager.battery-alert.plist
index 14de836..dc1a7db 100644
--- a/launchd/com.macmanager.battery-alert.plist
+++ b/launchd/com.macmanager.battery-alert.plist
@@ -1,6 +1,7 @@
+
Label
@@ -8,7 +9,7 @@
ProgramArguments
- {{SCRIPT_DIR}}/mm
+ {{MM_BIN}}
alerts
@@ -20,8 +21,8 @@
StandardOutPath
- {{SCRIPT_DIR}}/logs/launchd-alert.out
+ {{LOGS_DIR}}/launchd-alert.out
StandardErrorPath
- {{SCRIPT_DIR}}/logs/launchd-alert.err
+ {{LOGS_DIR}}/launchd-alert.err
diff --git a/launchd/com.macmanager.battery-log.plist b/launchd/com.macmanager.battery-log.plist
index bcefd1c..2a8bc28 100644
--- a/launchd/com.macmanager.battery-log.plist
+++ b/launchd/com.macmanager.battery-log.plist
@@ -1,6 +1,7 @@
+
Label
@@ -8,7 +9,7 @@
ProgramArguments
- {{SCRIPT_DIR}}/mm
+ {{MM_BIN}}
log
@@ -25,8 +26,8 @@
StandardOutPath
- {{SCRIPT_DIR}}/logs/launchd-log.out
+ {{LOGS_DIR}}/launchd-log.out
StandardErrorPath
- {{SCRIPT_DIR}}/logs/launchd-log.err
+ {{LOGS_DIR}}/launchd-log.err
diff --git a/macmanager/agents.py b/macmanager/agents.py
new file mode 100644
index 0000000..b597a4d
--- /dev/null
+++ b/macmanager/agents.py
@@ -0,0 +1,309 @@
+"""Registro dos agents launchd (log diário + alertas de bateria).
+
+`brew`/`pipx` instalam só o CLI. `mm setup` grava os plists em
+`~/Library/LaunchAgents/` apontando para o `mm` que o usuário acabou
+de invocar — caminho absoluto, sem seguir symlink, para o Cellar do
+Homebrew não quebrar no próximo upgrade.
+"""
+
+from __future__ import annotations
+
+import os
+import platform
+import shutil
+import subprocess
+import sys
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from subprocess import CompletedProcess
+from xml.sax.saxutils import escape
+
+from macmanager.logger import ensure_logs
+from macmanager.ui import console, safe_text
+
+LABEL_LOG = "com.macmanager.battery-log"
+LABEL_ALERT = "com.macmanager.battery-alert"
+
+LaunchctlRunner = Callable[[Sequence[str]], CompletedProcess[str]]
+
+
+@dataclass(frozen=True)
+class AgentSpec:
+ label: str
+ extra_args: tuple[str, ...]
+ stdout_name: str
+ stderr_name: str
+ start_interval: int | None = None
+ calendar_hour: int | None = None
+ calendar_minute: int | None = None
+ summary: str = ""
+
+
+AGENTS: tuple[AgentSpec, ...] = (
+ AgentSpec(
+ label=LABEL_LOG,
+ extra_args=("log",),
+ stdout_name="launchd-log.out",
+ stderr_name="launchd-log.err",
+ calendar_hour=9,
+ calendar_minute=0,
+ summary="daily at 09:00 → mm log",
+ ),
+ AgentSpec(
+ label=LABEL_ALERT,
+ extra_args=("alerts",),
+ stdout_name="launchd-alert.out",
+ stderr_name="launchd-alert.err",
+ start_interval=900,
+ summary="every 15 min → mm alerts",
+ ),
+)
+
+
+class MmBinaryNotFoundError(FileNotFoundError):
+ """Não achamos um executável `mm` para colocar no plist."""
+
+
+def launch_agents_dir(*, home: Path | None = None) -> Path:
+ return (home or Path.home()) / "Library" / "LaunchAgents"
+
+
+def _absolute(path: Path) -> Path:
+ """Absoluto sem seguir symlink — `/opt/homebrew/bin/mm` precisa sobreviver ao upgrade."""
+ return path.expanduser().absolute()
+
+
+def resolve_mm_bin(
+ *,
+ argv0: str | None = None,
+ executable: str | None = None,
+ env: Mapping[str, str] | None = None,
+ which: Callable[[str], str | None] | None = None,
+) -> Path:
+ """Caminho que o launchd deve exec. Prioridade: env, argv0, venv, PATH."""
+ environ = os.environ if env is None else env
+ override = (environ.get("MACMANAGER_BIN") or "").strip()
+ if override:
+ path = _absolute(Path(override))
+ if not path.is_file():
+ raise MmBinaryNotFoundError(f"MACMANAGER_BIN is not a file: {path}")
+ return path
+
+ raw = argv0 if argv0 is not None else sys.argv[0]
+ candidate = _absolute(Path(raw))
+ if candidate.name in {"mm", "mm.exe"} and candidate.is_file():
+ return candidate
+
+ py = Path(executable if executable is not None else sys.executable)
+ sibling = _absolute(py.parent / "mm")
+ if sibling.is_file():
+ return sibling
+
+ finder = shutil.which if which is None else which
+ found = finder("mm")
+ if found:
+ return _absolute(Path(found))
+
+ raise MmBinaryNotFoundError(
+ "could not find the `mm` executable. Run `mm setup` via the installed "
+ "command (not `python -m`), or set MACMANAGER_BIN to its absolute path."
+ )
+
+
+def _xml_string(value: str) -> str:
+ return f"{escape(value)}"
+
+
+def render_plist(*, spec: AgentSpec, mm_bin: Path, logs_dir: Path) -> str:
+ """Gera o XML do LaunchAgent. Paths entram escapados (SSID não, mas `&` em path sim)."""
+ args = "\n".join(f" {_xml_string(part)}" for part in (str(mm_bin), *spec.extra_args))
+ if spec.start_interval is not None:
+ schedule = f" StartInterval\n {spec.start_interval}"
+ else:
+ hour = spec.calendar_hour if spec.calendar_hour is not None else 9
+ minute = spec.calendar_minute if spec.calendar_minute is not None else 0
+ schedule = (
+ " StartCalendarInterval\n"
+ " \n"
+ " Hour\n"
+ f" {hour}\n"
+ " Minute\n"
+ f" {minute}\n"
+ " "
+ )
+ stdout = _xml_string(str(logs_dir / spec.stdout_name))
+ stderr = _xml_string(str(logs_dir / spec.stderr_name))
+ return (
+ '\n'
+ '\n'
+ '\n'
+ "\n"
+ " Label\n"
+ f" {_xml_string(spec.label)}\n"
+ "\n"
+ " ProgramArguments\n"
+ " \n"
+ f"{args}\n"
+ " \n"
+ "\n"
+ f"{schedule}\n"
+ "\n"
+ " RunAtLoad\n"
+ " \n"
+ "\n"
+ " StandardOutPath\n"
+ f" {stdout}\n"
+ " StandardErrorPath\n"
+ f" {stderr}\n"
+ "\n"
+ "\n"
+ )
+
+
+def _default_launchctl(args: Sequence[str]) -> CompletedProcess[str]:
+ return subprocess.run(list(args), capture_output=True, text=True, check=False)
+
+
+def _gui_target(uid: int, label: str) -> str:
+ return f"gui/{uid}/{label}"
+
+
+def unload_agent(
+ spec: AgentSpec,
+ plist: Path,
+ *,
+ uid: int,
+ runner: LaunchctlRunner,
+) -> None:
+ """Tira o agent do launchd. bootout primeiro; unload se o macOS for antigo."""
+ bootout = runner(["launchctl", "bootout", _gui_target(uid, spec.label)])
+ if bootout.returncode != 0 and plist.is_file():
+ runner(["launchctl", "unload", str(plist)])
+
+
+def load_agent(
+ spec: AgentSpec,
+ plist: Path,
+ *,
+ uid: int,
+ runner: LaunchctlRunner,
+) -> CompletedProcess[str]:
+ loaded = runner(["launchctl", "bootstrap", f"gui/{uid}", str(plist)])
+ if loaded.returncode == 0:
+ return loaded
+ return runner(["launchctl", "load", str(plist)])
+
+
+def install_agents(
+ *,
+ mm_bin: Path,
+ agents_dir: Path,
+ logs_dir: Path,
+ uid: int,
+ runner: LaunchctlRunner,
+ darwin: bool,
+) -> list[tuple[AgentSpec, Path, bool]]:
+ """Escreve os plists e, no Mac, (re)carrega no launchd.
+
+ Retorna (spec, path, loaded). `loaded` é True se o launchctl aceitou
+ ou se pulamos o launchctl (`darwin=False`, nos testes).
+ """
+ agents_dir.mkdir(parents=True, exist_ok=True)
+ logs_dir.mkdir(parents=True, exist_ok=True)
+ results: list[tuple[AgentSpec, Path, bool]] = []
+ for spec in AGENTS:
+ dest = agents_dir / f"{spec.label}.plist"
+ dest.write_text(render_plist(spec=spec, mm_bin=mm_bin, logs_dir=logs_dir), encoding="utf-8")
+ loaded = True
+ if darwin:
+ unload_agent(spec, dest, uid=uid, runner=runner)
+ loaded = load_agent(spec, dest, uid=uid, runner=runner).returncode == 0
+ results.append((spec, dest, loaded))
+ return results
+
+
+def remove_agents(
+ *,
+ agents_dir: Path,
+ uid: int,
+ runner: LaunchctlRunner,
+ darwin: bool,
+) -> list[str]:
+ """Descarrega e apaga os plists. Devolve os labels que existiam no disco."""
+ removed: list[str] = []
+ for spec in AGENTS:
+ dest = agents_dir / f"{spec.label}.plist"
+ if not dest.is_file():
+ continue
+ if darwin:
+ unload_agent(spec, dest, uid=uid, runner=runner)
+ dest.unlink()
+ removed.append(spec.label)
+ return removed
+
+
+def cmd_setup(args=None) -> int:
+ """Registra os agents. Só faz sentido no macOS."""
+ system = platform.system()
+ if system != "Darwin":
+ console.print("[red]mm setup[/] is only available on macOS (launchd).")
+ return 1
+
+ try:
+ mm_bin = resolve_mm_bin()
+ except MmBinaryNotFoundError as exc:
+ console.print(f"[red]{safe_text(exc)}[/]")
+ return 1
+
+ logs_dir = ensure_logs()
+ results = install_agents(
+ mm_bin=mm_bin,
+ agents_dir=launch_agents_dir(),
+ logs_dir=logs_dir,
+ uid=os.getuid(),
+ runner=_default_launchctl,
+ darwin=True,
+ )
+
+ console.print("[bold]Registered launchd agents[/]")
+ failed = False
+ for spec, dest, loaded in results:
+ mark = "[green]ok[/]" if loaded else "[red]launchctl failed[/]"
+ if not loaded:
+ failed = True
+ console.print(f" {mark} {spec.label} ({spec.summary})")
+ console.print(f" {safe_text(dest)}")
+ console.print()
+ console.print(f"Binary: {safe_text(mm_bin)}")
+ console.print(f"Logs: {safe_text(logs_dir)}")
+ console.print()
+ console.print("Daily log at 09:00 and battery alerts every 15 min.")
+ console.print("To stop them later: [bold]mm uninstall[/]")
+ return 1 if failed else 0
+
+
+def cmd_uninstall(args=None) -> int:
+ """Remove só os agents. brew/pipx/venv ficam — isso não é `brew uninstall`."""
+ darwin = platform.system() == "Darwin"
+ removed = remove_agents(
+ agents_dir=launch_agents_dir(),
+ uid=os.getuid(),
+ runner=_default_launchctl,
+ darwin=darwin,
+ )
+ if not removed:
+ console.print("No Mac Manager launchd agents were installed.")
+ return 0
+
+ console.print("[bold]Removed launchd agents[/]")
+ for label in removed:
+ console.print(f" • {label}")
+ console.print()
+ console.print("Battery history in ~/Library/Application Support/mac-manager/ was preserved.")
+ console.print()
+ console.print("To remove the CLI itself:")
+ console.print(" brew uninstall mac-manager")
+ console.print(" # or: pipx uninstall mac-manager")
+ return 0
diff --git a/macmanager/cli.py b/macmanager/cli.py
index 7f8dd17..2d93569 100644
--- a/macmanager/cli.py
+++ b/macmanager/cli.py
@@ -49,6 +49,14 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("alerts", help="Evaluates and fires alerts (called by launchd)")
sub.add_parser("status", help="Quick summary (battery + system + disk)")
sub.add_parser("security", help="Security audit (FileVault, SIP, Firewall, ...)")
+ sub.add_parser(
+ "setup",
+ help="Register launchd agents (daily log + battery alerts)",
+ )
+ sub.add_parser(
+ "uninstall",
+ help="Remove launchd agents (does not uninstall the CLI)",
+ )
dev = sub.add_parser("dev", help="Inventory of development tools")
dev.add_argument(
@@ -101,6 +109,8 @@ def main(argv: list[str] | None = None) -> int:
"alerts": "macmanager.alerts:cmd_alerts",
"security": "macmanager.security:cmd_security",
"dev": "macmanager.dev:cmd_dev",
+ "setup": "macmanager.agents:cmd_setup",
+ "uninstall": "macmanager.agents:cmd_uninstall",
"status": None,
}
@@ -111,8 +121,8 @@ def main(argv: list[str] | None = None) -> int:
module_path, fn_name = handler.split(":")
module = __import__(module_path, fromlist=[fn_name])
- getattr(module, fn_name)(args)
- return 0
+ result = getattr(module, fn_name)(args)
+ return int(result) if result is not None else 0
if __name__ == "__main__":
diff --git a/mm b/mm
index b79b0a1..24fb722 100755
--- a/mm
+++ b/mm
@@ -20,6 +20,10 @@ if [ ! -x "$VENV_PY" ]; then
exit 1
fi
+# Absolute wrapper path for `mm setup` — launchd must exec this file,
+# not `python -m` (argv0 is cli.py when we use -m).
+export MACMANAGER_BIN="$SCRIPT_DIR/mm"
+
# Run from the project root to ensure the package is discovered,
# regardless of the caller's cwd (terminal or launchd).
cd "$SCRIPT_DIR"
diff --git a/tests/test_agents.py b/tests/test_agents.py
new file mode 100644
index 0000000..b0b3b69
--- /dev/null
+++ b/tests/test_agents.py
@@ -0,0 +1,310 @@
+"""Contract tests for `macmanager.agents`.
+
+Não chamamos launchctl de verdade — o CI roda em Linux. O contrato é:
+plist válido, path do `mm` sem seguir symlink, setup/uninstall idempotentes.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from io import StringIO
+from pathlib import Path
+from subprocess import CompletedProcess
+
+import pytest
+from rich.console import Console
+
+from macmanager.agents import (
+ AGENTS,
+ LABEL_ALERT,
+ LABEL_LOG,
+ MmBinaryNotFoundError,
+ cmd_setup,
+ cmd_uninstall,
+ install_agents,
+ launch_agents_dir,
+ remove_agents,
+ render_plist,
+ resolve_mm_bin,
+)
+from macmanager.cli import build_parser
+
+
+def _touch_mm(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("#!/bin/sh\necho mm\n", encoding="utf-8")
+ path.chmod(0o755)
+ return path
+
+
+def _ok(args: Sequence[str]) -> CompletedProcess[str]:
+ return CompletedProcess(list(args), 0, "", "")
+
+
+def _fail(args: Sequence[str]) -> CompletedProcess[str]:
+ return CompletedProcess(list(args), 1, "", "failed")
+
+
+class TestLaunchAgentsDir:
+ def test_under_library(self, tmp_path: Path) -> None:
+ assert launch_agents_dir(home=tmp_path) == tmp_path / "Library" / "LaunchAgents"
+
+
+class TestResolveMmBin:
+ def test_env_override_wins(self, tmp_path: Path) -> None:
+ mm = _touch_mm(tmp_path / "custom" / "mm")
+ resolved = resolve_mm_bin(
+ argv0=str(tmp_path / "other" / "mm"),
+ env={"MACMANAGER_BIN": str(mm)},
+ which=lambda _name: None,
+ )
+ assert resolved == mm
+
+ def test_env_override_must_exist(self, tmp_path: Path) -> None:
+ with pytest.raises(MmBinaryNotFoundError, match="MACMANAGER_BIN"):
+ resolve_mm_bin(
+ env={"MACMANAGER_BIN": str(tmp_path / "missing")},
+ which=lambda _name: None,
+ )
+
+ def test_argv0_named_mm(self, tmp_path: Path) -> None:
+ mm = _touch_mm(tmp_path / "bin" / "mm")
+ resolved = resolve_mm_bin(argv0=str(mm), env={}, which=lambda _name: None)
+ assert resolved == mm
+
+ def test_does_not_follow_homebrew_symlink(self, tmp_path: Path) -> None:
+ cellar = _touch_mm(tmp_path / "Cellar" / "mac-manager" / "0.1.2" / "libexec" / "bin" / "mm")
+ shim = tmp_path / "opt" / "homebrew" / "bin" / "mm"
+ shim.parent.mkdir(parents=True)
+ shim.symlink_to(cellar)
+ resolved = resolve_mm_bin(argv0=str(shim), env={}, which=lambda _name: None)
+ assert resolved == shim
+ assert resolved.resolve() == cellar
+
+ def test_venv_sibling_of_python(self, tmp_path: Path) -> None:
+ python = _touch_mm(tmp_path / "venv" / "bin" / "python")
+ mm = _touch_mm(tmp_path / "venv" / "bin" / "mm")
+ resolved = resolve_mm_bin(
+ argv0=str(tmp_path / "macmanager" / "cli.py"),
+ executable=str(python),
+ env={},
+ which=lambda _name: None,
+ )
+ assert resolved == mm
+
+ def test_which_fallback(self, tmp_path: Path) -> None:
+ mm = _touch_mm(tmp_path / "path" / "mm")
+ resolved = resolve_mm_bin(
+ argv0=str(tmp_path / "cli.py"),
+ executable=str(tmp_path / "python3"),
+ env={},
+ which=lambda name: str(mm) if name == "mm" else None,
+ )
+ assert resolved == mm
+
+ def test_raises_when_nothing_found(self, tmp_path: Path) -> None:
+ with pytest.raises(MmBinaryNotFoundError, match="could not find"):
+ resolve_mm_bin(
+ argv0=str(tmp_path / "cli.py"),
+ executable=str(tmp_path / "python3"),
+ env={},
+ which=lambda _name: None,
+ )
+
+
+class TestRenderPlist:
+ def test_log_agent_uses_calendar_interval(self, tmp_path: Path) -> None:
+ spec = AGENTS[0]
+ xml = render_plist(spec=spec, mm_bin=tmp_path / "mm", logs_dir=tmp_path / "logs")
+ assert spec.label in xml
+ assert "log" in xml
+ assert "StartCalendarInterval" in xml
+ assert "9" in xml
+ assert "0" in xml
+ assert "StartInterval" not in xml
+ assert str(tmp_path / "logs" / "launchd-log.out") in xml
+
+ def test_alert_agent_uses_start_interval(self, tmp_path: Path) -> None:
+ spec = AGENTS[1]
+ xml = render_plist(spec=spec, mm_bin=tmp_path / "mm", logs_dir=tmp_path / "logs")
+ assert spec.label in xml
+ assert "alerts" in xml
+ assert "StartInterval" in xml
+ assert "900" in xml
+ assert "StartCalendarInterval" not in xml
+
+ def test_escapes_xml_in_paths(self, tmp_path: Path) -> None:
+ mm = tmp_path / "a&b" / "mm"
+ logs = tmp_path / "logs"
+ xml = render_plist(spec=AGENTS[0], mm_bin=mm, logs_dir=logs)
+ assert "a&b" in xml
+ assert "logs<x>" in xml
+ assert "a&b" not in xml
+
+
+class TestInstallAndRemove:
+ def test_writes_both_plists_without_launchctl(self, tmp_path: Path) -> None:
+ calls: list[list[str]] = []
+
+ def runner(args: Sequence[str]) -> CompletedProcess[str]:
+ calls.append(list(args))
+ return _ok(args)
+
+ mm = tmp_path / "mm"
+ agents_dir = tmp_path / "LaunchAgents"
+ logs_dir = tmp_path / "logs"
+ results = install_agents(
+ mm_bin=mm,
+ agents_dir=agents_dir,
+ logs_dir=logs_dir,
+ uid=501,
+ runner=runner,
+ darwin=False,
+ )
+ assert calls == []
+ assert {spec.label for spec, _path, loaded in results} == {LABEL_LOG, LABEL_ALERT}
+ assert all(loaded for _spec, _path, loaded in results)
+ log_plist = (agents_dir / f"{LABEL_LOG}.plist").read_text(encoding="utf-8")
+ assert str(mm) in log_plist
+ assert logs_dir.is_dir()
+
+ def test_reloads_via_bootout_then_bootstrap(self, tmp_path: Path) -> None:
+ calls: list[list[str]] = []
+
+ def runner(args: Sequence[str]) -> CompletedProcess[str]:
+ calls.append(list(args))
+ return _ok(args)
+
+ install_agents(
+ mm_bin=tmp_path / "mm",
+ agents_dir=tmp_path / "LaunchAgents",
+ logs_dir=tmp_path / "logs",
+ uid=501,
+ runner=runner,
+ darwin=True,
+ )
+ labels = [c[2] for c in calls if c[:2] == ["launchctl", "bootout"]]
+ assert f"gui/501/{LABEL_LOG}" in labels
+ assert f"gui/501/{LABEL_ALERT}" in labels
+ bootstraps = [c for c in calls if c[:2] == ["launchctl", "bootstrap"]]
+ assert len(bootstraps) == 2
+ assert all(c[2] == "gui/501" for c in bootstraps)
+
+ def test_falls_back_to_load_when_bootstrap_fails(self, tmp_path: Path) -> None:
+ def runner(args: Sequence[str]) -> CompletedProcess[str]:
+ if "bootstrap" in args or "bootout" in args:
+ return _fail(args)
+ return _ok(args)
+
+ results = install_agents(
+ mm_bin=tmp_path / "mm",
+ agents_dir=tmp_path / "LaunchAgents",
+ logs_dir=tmp_path / "logs",
+ uid=501,
+ runner=runner,
+ darwin=True,
+ )
+ assert all(loaded for _spec, _path, loaded in results)
+
+ def test_remove_deletes_plists(self, tmp_path: Path) -> None:
+ agents_dir = tmp_path / "LaunchAgents"
+ install_agents(
+ mm_bin=tmp_path / "mm",
+ agents_dir=agents_dir,
+ logs_dir=tmp_path / "logs",
+ uid=501,
+ runner=_ok,
+ darwin=False,
+ )
+ removed = remove_agents(agents_dir=agents_dir, uid=501, runner=_ok, darwin=False)
+ assert removed == [LABEL_LOG, LABEL_ALERT]
+ assert list(agents_dir.glob("*.plist")) == []
+
+ def test_remove_is_idempotent_when_missing(self, tmp_path: Path) -> None:
+ removed = remove_agents(
+ agents_dir=tmp_path / "LaunchAgents",
+ uid=501,
+ runner=_ok,
+ darwin=False,
+ )
+ assert removed == []
+
+
+class TestCmdSetup:
+ def test_refuses_non_darwin(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Linux")
+ buf = StringIO()
+ monkeypatch.setattr(
+ "macmanager.agents.console",
+ Console(file=buf, force_terminal=True, width=80),
+ )
+ assert cmd_setup() == 1
+ assert "only available on macOS" in buf.getvalue()
+
+ def test_registers_on_darwin(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ mm = _touch_mm(tmp_path / "mm")
+ agents_dir = tmp_path / "LaunchAgents"
+ logs_dir = tmp_path / "logs"
+ monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Darwin")
+ monkeypatch.setattr("macmanager.agents.resolve_mm_bin", lambda: mm)
+ monkeypatch.setattr("macmanager.agents.ensure_logs", lambda: logs_dir)
+ monkeypatch.setattr("macmanager.agents.launch_agents_dir", lambda: agents_dir)
+ monkeypatch.setattr("macmanager.agents.os.getuid", lambda: 501)
+ monkeypatch.setattr("macmanager.agents._default_launchctl", _ok)
+ buf = StringIO()
+ monkeypatch.setattr(
+ "macmanager.agents.console",
+ Console(file=buf, force_terminal=True, width=80),
+ )
+ assert cmd_setup() == 0
+ out = buf.getvalue()
+ assert LABEL_LOG in out
+ assert LABEL_ALERT in out
+ assert (agents_dir / f"{LABEL_LOG}.plist").is_file()
+
+
+class TestCmdUninstall:
+ def test_reports_when_nothing_installed(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Linux")
+ monkeypatch.setattr(
+ "macmanager.agents.launch_agents_dir", lambda: tmp_path / "LaunchAgents"
+ )
+ buf = StringIO()
+ monkeypatch.setattr(
+ "macmanager.agents.console",
+ Console(file=buf, force_terminal=True, width=80),
+ )
+ assert cmd_uninstall() == 0
+ assert "No Mac Manager launchd agents" in buf.getvalue()
+
+ def test_removes_agents(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ agents_dir = tmp_path / "LaunchAgents"
+ install_agents(
+ mm_bin=tmp_path / "mm",
+ agents_dir=agents_dir,
+ logs_dir=tmp_path / "logs",
+ uid=501,
+ runner=_ok,
+ darwin=False,
+ )
+ monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Darwin")
+ monkeypatch.setattr("macmanager.agents.launch_agents_dir", lambda: agents_dir)
+ monkeypatch.setattr("macmanager.agents.os.getuid", lambda: 501)
+ monkeypatch.setattr("macmanager.agents._default_launchctl", _ok)
+ buf = StringIO()
+ monkeypatch.setattr(
+ "macmanager.agents.console",
+ Console(file=buf, force_terminal=True, width=80),
+ )
+ assert cmd_uninstall() == 0
+ assert "Removed launchd agents" in buf.getvalue()
+ assert list(agents_dir.glob("*.plist")) == []
+
+
+class TestParser:
+ def test_setup_and_uninstall_are_advertised(self) -> None:
+ help_text = build_parser().format_help()
+ assert "setup" in help_text
+ assert "uninstall" in help_text
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
index 1633e68..e0303b5 100644
--- a/tests/test_smoke.py
+++ b/tests/test_smoke.py
@@ -21,6 +21,7 @@
PUBLIC_MODULES = [
"macmanager",
+ "macmanager.agents",
"macmanager.alerts",
"macmanager.battery",
"macmanager.cache",
@@ -75,6 +76,8 @@ def test_cli_parser_builds_and_advertises_all_commands() -> None:
"status",
"security",
"dev",
+ "setup",
+ "uninstall",
):
assert command in help_text, f"CLI help is missing the `{command}` subcommand"
diff --git a/uninstall.sh b/uninstall.sh
index e288bc9..3ba1203 100755
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
-# Removes launchd agents, symlink and venv. Does not delete the CSV logs.
+# Removes launchd agents (via `mm uninstall` when available), symlink and
+# venv. Does not delete the CSV logs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -7,6 +8,11 @@ LAUNCH_DIR="$HOME/Library/LaunchAgents"
echo "==> Mac Manager · uninstall"
+if [ -x "$HOME/.local/bin/mm" ]; then
+ "$HOME/.local/bin/mm" uninstall || true
+fi
+
+# Fallback for installs that predate `mm uninstall`.
for name in com.macmanager.battery-log com.macmanager.battery-alert; do
plist="$LAUNCH_DIR/${name}.plist"
if [ -f "$plist" ]; then