mcquack/mcquack.py
Erich Blume e55cff15ee Rename 'script' to 'command' with intelligent path resolution
- Rename script argument to command throughout codebase
- Add resolve_command() function for smart path/command handling
- Paths (containing /, ~, or .) are expanded to absolute paths and validated
- Commands (no /) are searched on $PATH and kept as-is in plist
- Add comprehensive test suite with 13 new tests for path resolution
- Verify all resolved paths stay within expected boundaries
- Update README with command resolution details and examples

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 10:25:07 -08:00

521 lines
17 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 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<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 it has wings, I can crash it."
- Launchpad McQuack
"""
typer.echo(art)
if __name__ == "__main__":
app()