#!/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 (must come after '--')"), ] = None, ) -> None: """Create and load a LaunchAgent for a script. To pass arguments to your script, use '--' to separate mcquack options from script arguments. For example: mcquack create my_script.sh -- --config /path/to/config Without '--', options like --help apply to mcquack itself: mcquack create my_script.sh --help # Shows this help mcquack create my_script.sh -- --help # Passes --help to the 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 (must come after '--')"), ] = None, ) -> None: """Edit the arguments in a plist. To update script arguments, use '--' to separate mcquack options from script arguments. For example: mcquack edit my_script.sh -- --verbose --debug This replaces all existing script arguments. To clear arguments, omit them: mcquack edit my_script.sh """ 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()