#!/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 shutil import subprocess 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(command_path: Path) -> Path: """Get the plist path for a given command.""" label = f"{PLIST_PREFIX}.{command_path.stem}" return LAUNCH_AGENTS_DIR / f"{label}.plist" def get_label(command_path: Path) -> str: """Get the launchd label for a given command.""" return f"{PLIST_PREFIX}.{command_path.stem}" def resolve_command(command: str) -> tuple[Path, bool]: """Resolve a command to a Path and indicate if it should be kept as command name. Returns: tuple: (resolved_path, keep_as_command_name) - resolved_path: The Path to the executable - keep_as_command_name: True if command should remain as-is (not expanded to absolute) If command is a path (contains / or starts with ~ or .): - Expands ~ and resolves to absolute path - Validates existence and executability - Returns (resolved_path, False) to use absolute path in plist If command is not a path (no /): - Searches $PATH using shutil.which() - Returns (path_to_command, True) to keep command name as-is - Raises error if not found on PATH """ # Check if this is a path (contains / or starts with ~ or .) is_path = "/" in command or command.startswith("~") or command.startswith(".") if is_path: # Treat as path: expand and resolve path = Path(command).expanduser().resolve() if not path.exists(): typer.echo(f"Error: {path} does not exist.", err=True) raise typer.Exit(1) if not os.access(path, os.X_OK): typer.echo(f"Error: {path} is not executable.", err=True) raise typer.Exit(1) return (path, False) else: # Not a path: search $PATH which_result = shutil.which(command) if which_result is None: typer.echo(f"Error: command '{command}' not found in PATH.", err=True) raise typer.Exit(1) # Verify the found command is executable if not os.access(which_result, os.X_OK): typer.echo(f"Error: {which_result} is not executable.", err=True) raise typer.Exit(1) # Return the command name as-is (don't expand to absolute path) return (Path(command), True) def parse_calendar(calendar_str: str) -> dict[str, int]: """Parse a calendar string like 'Hour=2,Minute=0' into a dictionary. Valid keys: Month (1-12), Day (1-31), Weekday (0-7), Hour (0-23), Minute (0-59) """ valid_keys = {"Month", "Day", "Weekday", "Hour", "Minute"} ranges = { "Month": (1, 12), "Day": (1, 31), "Weekday": (0, 7), "Hour": (0, 23), "Minute": (0, 59), } result = {} parts = calendar_str.split(",") for part in parts: part = part.strip() if "=" not in part: raise ValueError(f"Invalid calendar format: '{part}' (missing '=')") key, value = part.split("=", 1) key = key.strip() value = value.strip() if key not in valid_keys: raise ValueError( f"Invalid calendar key: '{key}' (valid keys: {', '.join(sorted(valid_keys))})" ) try: int_value = int(value) except ValueError: raise ValueError(f"Invalid calendar value for {key}: '{value}' (must be an integer)") min_val, max_val = ranges[key] if not (min_val <= int_value <= max_val): raise ValueError( f"Invalid calendar value for {key}: {int_value} (must be {min_val}-{max_val})" ) result[key] = int_value return result def create_plist_dict( command_path: Path, args: list[str], program_arg: str | None = None, interval: int | None = None, throttle: int | None = None, calendar: list[dict[str, int]] | None = None, ) -> dict: """Create a plist dictionary for a command. Args: command_path: Path used for label generation (stem) args: Additional arguments to pass to the program program_arg: The actual program path/command to execute (if None, uses command_path.resolve()) interval: StartInterval value throttle: ThrottleInterval value calendar: StartCalendarInterval value """ label = get_label(command_path) # Use program_arg if provided, otherwise fall back to resolved command_path program = program_arg if program_arg is not None else str(command_path.resolve()) program_args = [program] + args plist = { "Label": label, "ProgramArguments": program_args, "RunAtLoad": True, "StandardOutPath": str(LOGS_DIR / f"mcquack.{command_path.stem}.out.log"), "StandardErrorPath": str(LOGS_DIR / f"mcquack.{command_path.stem}.err.log"), } # If scheduling is used, don't keep alive (let the schedule handle it) if interval is not None: plist["KeepAlive"] = False plist["StartInterval"] = interval elif calendar is not None: plist["KeepAlive"] = False # Single calendar entry is a dict, multiple entries are a list if len(calendar) == 1: plist["StartCalendarInterval"] = calendar[0] else: plist["StartCalendarInterval"] = calendar else: plist["KeepAlive"] = True if throttle is not None: plist["ThrottleInterval"] = throttle return plist @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", []) command = program_args[0] if program_args else "unknown" typer.echo(f"{label}: {command}") except Exception as e: typer.echo(f"{plist_path.name}: error reading ({e})") @app.command() def create( command: Annotated[str, typer.Argument(help="Command or path to the executable")], command_args: Annotated[ list[str] | None, typer.Argument(help="Arguments to pass to the command (must come after '--')"), ] = None, interval: Annotated[ int | None, typer.Option( "--interval", help="Run every N seconds (StartInterval)", min=1, ), ] = None, throttle: Annotated[ int | None, typer.Option( "--throttle", help="Minimum seconds between runs (ThrottleInterval)", min=1, ), ] = None, calendar: Annotated[ list[str] | None, typer.Option( "--calendar", help="Schedule with calendar interval (e.g., 'Hour=2,Minute=0'). Can be specified multiple times.", ), ] = None, ) -> None: """Create and load a LaunchAgent for a script. The command argument can be: - A path (absolute or relative, with / or ~ or .): will be expanded and validated - A command name (no /): will be searched on $PATH To pass arguments to your command, use '--' to separate mcquack options from command 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 command Scheduling options: mcquack create script.sh --interval 300 # Run every 5 minutes mcquack create script.sh --interval 300 --throttle 60 # Run every 5 min, throttle 60s mcquack create script.sh --calendar "Hour=2,Minute=0" # Every day at 2 AM mcquack create script.sh --calendar "Weekday=1,Hour=9,Minute=0" # Mondays at 9 AM """ # Resolve command to path and determine if it should be kept as command name resolved_path, keep_as_command_name = resolve_command(command) # For label generation, use the original command name (stem of the resolved path) command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path # Validate mutually exclusive options if interval is not None and calendar is not None: typer.echo( "Error: --interval and --calendar are mutually exclusive. Use one or the other.", err=True, ) raise typer.Exit(1) plist_path = get_plist_path(command_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) # Parse calendar strings if provided parsed_calendar = None if calendar is not None: try: parsed_calendar = [parse_calendar(cal_str) for cal_str in calendar] except ValueError as e: typer.echo(f"Error: {e}", 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) # Determine what to use in ProgramArguments program_arg = command if keep_as_command_name else str(resolved_path) plist = create_plist_dict( command_path, command_args or [], program_arg=program_arg, interval=interval, throttle=throttle, calendar=parsed_calendar, ) 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(command_path)}") @app.command() def launch( command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Kickstart a LaunchAgent.""" resolved_path, _ = resolve_command(command) command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path plist_path = get_plist_path(command_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(command_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( command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Show the arguments configured in a plist.""" resolved_path, _ = resolve_command(command) command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path plist_path = get_plist_path(command_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)}") # Display scheduling information if "StartInterval" in plist: typer.echo(f"StartInterval: {plist['StartInterval']}") if "ThrottleInterval" in plist: typer.echo(f"ThrottleInterval: {plist['ThrottleInterval']}") if "StartCalendarInterval" in plist: typer.echo(f"StartCalendarInterval: {plist['StartCalendarInterval']}") typer.echo(f"Stdout: {plist.get('StandardOutPath', 'N/A')}") typer.echo(f"Stderr: {plist.get('StandardErrorPath', 'N/A')}") @app.command() def edit( command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Edit a plist in $EDITOR. Opens the plist file for the given command in your default editor. After editing, the agent is automatically reloaded. """ resolved_path, _ = resolve_command(command) command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path plist_path = get_plist_path(command_path) if not plist_path.exists(): typer.echo(f"Error: {plist_path} does not exist. Use 'create' first.", err=True) raise typer.Exit(1) editor = os.environ.get("EDITOR", "vi") # Unload first subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True) # Open in editor result = subprocess.run([editor, str(plist_path)]) if result.returncode != 0: typer.echo(f"Error: editor exited with code {result.returncode}", err=True) raise typer.Exit(1) typer.echo(f"Edited: {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(command_path)}") @app.command() def unload( command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Unload a LaunchAgent.""" resolved_path, _ = resolve_command(command) command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path plist_path = get_plist_path(command_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(command_path)}") @app.command() def delete( command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Delete a LaunchAgent plist.""" resolved_path, _ = resolve_command(command) command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path plist_path = get_plist_path(command_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}") @app.command(hidden=True) def quack() -> None: """Quack!""" art = r""" ___ _ _____ _ | \ _ _ __| |_|_ _|_ _| |___ ___ | |) | || / _| / / | |/ _` | / -_|_-< |___/ \_,_\__|_\_\ |_|\__,_|_\___/__/ '' .``,l" "[}ll1)f/1I !__YpQm+:]", ~>. ,i,i `!>~1cXUXvcLbccL_{1! . +YM) :U1. . ";|XmkodUf{?_?{xCCZw\[{[~>">{}{]{i `>]11[][[)UmYrz+fx1}{{}<_]{{{{{?. "I<___]+^Qo**h)}}]}{{}1+}1{}{{{< `+}{{{{}]<|8Mkou?{{{{{}1?,~][[[?l ;{{}}}[[]?v0r?|{{{{{{{1?` `^` ![}{{}[]i .?{{[]}}~' .,;;,` i]_fC|> "{vxfXQcv{, .>/zJJJCUXXXJz] '?vYCCUXcvvvcXJJU> !XJJYv\?>l::,I[Yu' )C/l.