commit 0bcb1b8785a87a4969df8f509bd1d27f1a94dcec Author: Erich Blume Date: Sun Jan 11 18:22:16 2026 -0800 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f88616 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# mcquack + +A simple macOS LaunchAgent manager for executable scripts. + +Named after Launchpad McQuack, the fearless (if accident-prone) pilot from DuckTales. + +## Requirements + +- macOS (uses launchctl and ~/Library/LaunchAgents) +- [uv](https://github.com/astral-sh/uv) + +## Installation + +No installation required! Run directly with: + +```bash +uvx git+https://github.com/eblume/mcquack +``` + +Or clone and run the script directly (it's already executable). + +## Usage + +```bash +# List all mcquack-managed LaunchAgents +mcquack list + +# Create and load an executable as a LaunchAgent +mcquack create /path/to/your/script + +# Create with additional arguments for the script +mcquack create /path/to/your/script -- --arg1 value1 --arg2 + +# Kickstart (immediately run) the LaunchAgent +mcquack launch /path/to/your/script + +# Show the current arguments configured in the plist +mcquack show /path/to/your/script + +# Edit the arguments in the plist +mcquack edit /path/to/your/script -- --new-arg1 --new-arg2 + +# Unload (stop) the LaunchAgent +mcquack unload /path/to/your/script + +# Delete the LaunchAgent plist file +mcquack delete /path/to/your/script +``` + +## How it works + +mcquack creates plist files in `~/Library/LaunchAgents/` with the naming convention: + +``` +mcquack.eblume..plist +``` + +The generated plist configures the script to: +- Run at load +- Keep alive (restart if it exits) +- Log stdout/stderr to `~/Library/Logs/mcquack..{out,err}.log` + +## License + +MIT diff --git a/mcquack.py b/mcquack.py new file mode 100755 index 0000000..dea3e39 --- /dev/null +++ b/mcquack.py @@ -0,0 +1,249 @@ +#!/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() +def list() -> 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()