Initial commit
This commit is contained in:
commit
0bcb1b8785
2 changed files with 314 additions and 0 deletions
65
README.md
Normal file
65
README.md
Normal file
|
|
@ -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.<scriptname>.plist
|
||||
```
|
||||
|
||||
The generated plist configures the script to:
|
||||
- Run at load
|
||||
- Keep alive (restart if it exits)
|
||||
- Log stdout/stderr to `~/Library/Logs/mcquack.<scriptname>.{out,err}.log`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
249
mcquack.py
Executable file
249
mcquack.py
Executable file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue