mcquack/mcquack.py
Erich Blume c7a0b04573 Add uv package structure and pytest test framework
Set up mcquack as a proper uv package with flat layout (no src/ directory).
Uses hatchling build backend to support single-file module structure.
Added pytest as dev dependency with fixtures for testing against
temporary directories instead of actual macOS LaunchAgent paths.

- Rename list() to list_agents() to avoid shadowing builtin (Python 3.14 compat)
- Add mock_dirs fixture that monkeypatches LAUNCH_AGENTS_DIR and LOGS_DIR
- Add mock_script and mock_launchctl fixtures
- 26 tests covering list, create, edit, show, delete commands
- Parameterized tests for argument handling
- XML validation tests independent of plistlib

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:18:17 -08:00

249 lines
7.8 KiB
Python
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = ["typer>=0.9.0"]
# ///
"""mcquack - A simple macOS LaunchAgent manager for executable scripts."""
import os
import plistlib
import subprocess
import sys
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer(help="mcquack - A simple macOS LaunchAgent manager")
LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
LOGS_DIR = Path.home() / "Library" / "Logs"
PLIST_PREFIX = "mcquack.eblume"
def get_plist_path(script_path: Path) -> Path:
"""Get the plist path for a given script."""
label = f"{PLIST_PREFIX}.{script_path.stem}"
return LAUNCH_AGENTS_DIR / f"{label}.plist"
def get_label(script_path: Path) -> str:
"""Get the launchd label for a given script."""
return f"{PLIST_PREFIX}.{script_path.stem}"
def create_plist_dict(script_path: Path, args: list[str]) -> dict:
"""Create a plist dictionary for a script."""
label = get_label(script_path)
program_args = [str(script_path.resolve())] + args
return {
"Label": label,
"ProgramArguments": program_args,
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": str(LOGS_DIR / f"mcquack.{script_path.stem}.out.log"),
"StandardErrorPath": str(LOGS_DIR / f"mcquack.{script_path.stem}.err.log"),
}
@app.command("list")
def list_agents() -> None:
"""List all mcquack-managed LaunchAgents."""
if not LAUNCH_AGENTS_DIR.exists():
typer.echo("No LaunchAgents directory found.")
return
plists = sorted(LAUNCH_AGENTS_DIR.glob(f"{PLIST_PREFIX}.*.plist"))
if not plists:
typer.echo("No mcquack-managed LaunchAgents found.")
return
for plist_path in plists:
try:
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
label = plist.get("Label", "unknown")
program_args = plist.get("ProgramArguments", [])
script = program_args[0] if program_args else "unknown"
typer.echo(f"{label}: {script}")
except Exception as e:
typer.echo(f"{plist_path.name}: error reading ({e})")
@app.command()
def create(
script: Annotated[Path, typer.Argument(help="Path to the executable script")],
script_args: Annotated[list[str] | None, typer.Argument(help="Arguments to pass to the script")] = None,
) -> None:
"""Create and load a LaunchAgent for a script."""
script_path = script.resolve()
if not script_path.exists():
typer.echo(f"Error: {script_path} does not exist.", err=True)
raise typer.Exit(1)
if not os.access(script_path, os.X_OK):
typer.echo(f"Error: {script_path} is not executable.", err=True)
raise typer.Exit(1)
plist_path = get_plist_path(script_path)
if plist_path.exists():
typer.echo(f"Error: {plist_path} already exists. Use 'edit' to modify or 'delete' first.", err=True)
raise typer.Exit(1)
# Ensure directories exist
LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
plist = create_plist_dict(script_path, script_args or [])
with open(plist_path, "wb") as f:
plistlib.dump(plist, f)
typer.echo(f"Created: {plist_path}")
# Load the plist
result = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True)
if result.returncode != 0:
typer.echo(f"Warning: launchctl load failed: {result.stderr}", err=True)
raise typer.Exit(1)
typer.echo(f"Loaded: {get_label(script_path)}")
@app.command()
def launch(
script: Annotated[Path, typer.Argument(help="Path to the executable script")],
) -> None:
"""Kickstart a LaunchAgent."""
script_path = script.resolve()
plist_path = get_plist_path(script_path)
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist. Use 'create' first.", err=True)
raise typer.Exit(1)
label = get_label(script_path)
uid = os.getuid()
result = subprocess.run(
["launchctl", "kickstart", f"gui/{uid}/{label}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
typer.echo(f"Error: launchctl kickstart failed: {result.stderr}", err=True)
raise typer.Exit(1)
typer.echo(f"Kickstarted: {label}")
@app.command()
def show(
script: Annotated[Path, typer.Argument(help="Path to the executable script")],
) -> None:
"""Show the arguments configured in a plist."""
script_path = script.resolve()
plist_path = get_plist_path(script_path)
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist.", err=True)
raise typer.Exit(1)
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
program_args = plist.get("ProgramArguments", [])
typer.echo(f"Label: {plist.get('Label', 'unknown')}")
typer.echo(f"Script: {program_args[0] if program_args else 'unknown'}")
typer.echo(f"Arguments: {' '.join(program_args[1:]) if len(program_args) > 1 else '(none)'}")
typer.echo(f"RunAtLoad: {plist.get('RunAtLoad', False)}")
typer.echo(f"KeepAlive: {plist.get('KeepAlive', False)}")
typer.echo(f"Stdout: {plist.get('StandardOutPath', 'N/A')}")
typer.echo(f"Stderr: {plist.get('StandardErrorPath', 'N/A')}")
@app.command()
def edit(
script: Annotated[Path, typer.Argument(help="Path to the executable script")],
script_args: Annotated[list[str] | None, typer.Argument(help="New arguments for the script")] = None,
) -> None:
"""Edit the arguments in a plist."""
script_path = script.resolve()
plist_path = get_plist_path(script_path)
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist. Use 'create' first.", err=True)
raise typer.Exit(1)
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
program_args = plist.get("ProgramArguments", [])
script_in_plist = program_args[0] if program_args else str(script_path)
# Update arguments
plist["ProgramArguments"] = [script_in_plist] + (script_args or [])
# Unload first
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
with open(plist_path, "wb") as f:
plistlib.dump(plist, f)
typer.echo(f"Updated: {plist_path}")
# Reload
result = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True)
if result.returncode != 0:
typer.echo(f"Warning: launchctl load failed: {result.stderr}", err=True)
raise typer.Exit(1)
typer.echo(f"Reloaded: {get_label(script_path)}")
@app.command()
def unload(
script: Annotated[Path, typer.Argument(help="Path to the executable script")],
) -> None:
"""Unload a LaunchAgent."""
script_path = script.resolve()
plist_path = get_plist_path(script_path)
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist.", err=True)
raise typer.Exit(1)
result = subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True)
if result.returncode != 0:
typer.echo(f"Error: launchctl unload failed: {result.stderr}", err=True)
raise typer.Exit(1)
typer.echo(f"Unloaded: {get_label(script_path)}")
@app.command()
def delete(
script: Annotated[Path, typer.Argument(help="Path to the executable script")],
) -> None:
"""Delete a LaunchAgent plist."""
script_path = script.resolve()
plist_path = get_plist_path(script_path)
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist.", err=True)
raise typer.Exit(1)
# Unload first (ignore errors)
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
plist_path.unlink()
typer.echo(f"Deleted: {plist_path}")
if __name__ == "__main__":
app()