Add a hidden 'quack' command that displays DuckTales ASCII art of Launchpad McQuack. Also document the development workflow in README, including how to run tests with uv and the optional venv setup. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
9.8 KiB
Python
Executable file
319 lines
9.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 (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}")
|
|
|
|
|
|
@app.command(hidden=True)
|
|
def quack() -> None:
|
|
"""Quack!"""
|
|
art = r"""
|
|
___ _ _____ _
|
|
| \ _ _ __| |_|_ _|_ _| |___ ___
|
|
| |) | || / _| / / | |/ _` | / -_|_-<
|
|
|___/ \_,_\__|_\_\ |_|\__,_|_\___/__/
|
|
|
|
'' .``,l"
|
|
"[}ll1)f/1I
|
|
!__YpQm+:]",
|
|
~>. ,i,i<nxnav(,:" .
|
|
;/Zj, ::'::[trcx(i<-" `i``umv~'
|
|
[do| .":]z]<<[; :fzLwddmw_
|
|
cWr xxdvzr\fr\_:-~' "QOL0b\;
|
|
X*> `!>~1cXUXvcLbccL_{1! . +YM)
|
|
:U1. <fUmpZUxXmphkqwv-]?+ .i~-}>.
|
|
";|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. <n,.
|
|
`/\. ;{+
|
|
<{~ ![}[i:"^`^`
|
|
'"I+}}[I .-{}}}}[[[}-`
|
|
;--][[}{}}}l 'i][}}}[]]+^
|
|
;[[[}}}}[_, .";II" .
|
|
.```:!!;^
|
|
|
|
"If we're not back by dawn... call the press!"
|
|
- Launchpad McQuack
|
|
"""
|
|
typer.echo(art)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|