- Rename 'Agent Name' column to 'Label' - Enable text wrapping on Command and Details columns (no_wrap=False, overflow="fold") Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
637 lines
20 KiB
Python
Executable file
637 lines
20 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")
|
|
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}")
|
|
|
|
details_str = '\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()
|