mcquack/mcquack.py
Erich Blume ea4d949799 Enhance list command with visual separators
Add row borders and detail item separators to improve readability of the
rich table output. Each agent row now displays borders across all columns,
and detail items are separated by short horizontal bars.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:03:33 -08:00

639 lines
21 KiB
Python
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = ["typer>=0.9.0", "rich>=13.0.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
from rich.console import Console
from rich.table import Table
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 resolve_plist_name(plist_identifier: str) -> Path:
"""Resolve a plist identifier to an actual plist path.
Accepts:
- Full filename: 'mcquack.eblume.mycommand.plist'
- Label: 'mcquack.eblume.mycommand'
- Stem: 'mycommand'
- Full path: '/Users/.../Library/LaunchAgents/mcquack.eblume.mycommand.plist'
Returns the resolved plist path, or raises an error if ambiguous/not found.
"""
# If it's already a full path to an existing file, use it
as_path = Path(plist_identifier)
if as_path.is_absolute() and as_path.exists():
return as_path
# Normalize the identifier (remove .plist extension if present)
identifier = plist_identifier
if identifier.endswith('.plist'):
identifier = identifier[:-6]
# Get all mcquack plists
if not LAUNCH_AGENTS_DIR.exists():
typer.echo(f"Error: No LaunchAgents directory found at {LAUNCH_AGENTS_DIR}", err=True)
raise typer.Exit(1)
all_plists = list(LAUNCH_AGENTS_DIR.glob(f"{PLIST_PREFIX}.*.plist"))
if not all_plists:
typer.echo("Error: No mcquack-managed LaunchAgents found.", err=True)
raise typer.Exit(1)
# Try exact match on label first
exact_match = LAUNCH_AGENTS_DIR / f"{identifier}.plist"
if exact_match.exists():
return exact_match
# Try with prefix if not already prefixed
if not identifier.startswith(f"{PLIST_PREFIX}."):
with_prefix = LAUNCH_AGENTS_DIR / f"{PLIST_PREFIX}.{identifier}.plist"
if with_prefix.exists():
return with_prefix
# Try fuzzy matching on stem
matches = []
for plist_path in all_plists:
# Extract the stem (everything after the prefix)
plist_label = plist_path.stem
if plist_label.startswith(f"{PLIST_PREFIX}."):
stem = plist_label[len(f"{PLIST_PREFIX}."):]
if stem == identifier:
matches.append(plist_path)
if len(matches) == 1:
return matches[0]
elif len(matches) > 1:
typer.echo(f"Error: Ambiguous plist identifier '{plist_identifier}'. Did you mean:", err=True)
for match in matches:
typer.echo(f" - {match.stem}", err=True)
raise typer.Exit(1)
else:
typer.echo(f"Error: No plist found matching '{plist_identifier}'.", err=True)
# Show suggestions
typer.echo("Available plists:", err=True)
for plist_path in all_plists:
typer.echo(f" - {plist_path.stem}", err=True)
raise typer.Exit(1)
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
console = Console()
table = Table(show_header=True, header_style="bold cyan", show_lines=True)
table.add_column("Label", style="green", no_wrap=False)
table.add_column("Command", style="yellow", no_wrap=False, overflow="fold")
table.add_column("Details", style="dim", no_wrap=False, overflow="fold")
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"
# Build details column with multiple lines
details = []
if len(program_args) > 1:
args_str = ' '.join(program_args[1:])
details.append(f"Args: {args_str}")
if "StartInterval" in plist:
details.append(f"StartInterval: {plist['StartInterval']}s")
if "ThrottleInterval" in plist:
details.append(f"ThrottleInterval: {plist['ThrottleInterval']}s")
if "StartCalendarInterval" in plist:
calendar = plist["StartCalendarInterval"]
if isinstance(calendar, list):
details.append(f"StartCalendarInterval: {len(calendar)} entries")
else:
calendar_str = ', '.join(f"{k}={v}" for k, v in calendar.items())
details.append(f"StartCalendarInterval: {calendar_str}")
# Join details with a short horizontal separator
separator = "" * 8
details_str = f'\n{separator}\n'.join(details) if details else "-"
table.add_row(label, command, details_str)
except Exception as e:
table.add_row(plist_path.name, f"error: {e}", "-")
console.print(table)
@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(
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> None:
"""Kickstart a LaunchAgent.
Examples:
mcquack launch mycommand
mcquack launch mcquack.eblume.mycommand
mcquack launch mcquack.eblume.mycommand.plist
"""
plist_path = resolve_plist_name(plist_identifier)
# Get label from plist
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
label = plist.get("Label", "unknown")
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(
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> None:
"""Show the arguments configured in a plist.
Examples:
mcquack show mycommand
mcquack show mcquack.eblume.mycommand
mcquack show mcquack.eblume.mycommand.plist
"""
plist_path = resolve_plist_name(plist_identifier)
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(
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> None:
"""Edit a plist in $EDITOR.
Opens the plist file in your default editor.
After editing, the agent is automatically reloaded.
Examples:
mcquack edit mycommand
mcquack edit mcquack.eblume.mycommand
mcquack edit mcquack.eblume.mycommand.plist
"""
plist_path = resolve_plist_name(plist_identifier)
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}")
# Get label from plist for reload message
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
label = plist.get("Label", "unknown")
# 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: {label}")
@app.command()
def unload(
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> None:
"""Unload a LaunchAgent.
Examples:
mcquack unload mycommand
mcquack unload mcquack.eblume.mycommand
mcquack unload mcquack.eblume.mycommand.plist
"""
plist_path = resolve_plist_name(plist_identifier)
# Get label from plist
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
label = plist.get("Label", "unknown")
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: {label}")
@app.command()
def delete(
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> None:
"""Delete a LaunchAgent plist.
Examples:
mcquack delete mycommand
mcquack delete mcquack.eblume.mycommand
mcquack delete mcquack.eblume.mycommand.plist
"""
plist_path = resolve_plist_name(plist_identifier)
# 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()