Refactor commands to accept plist names and enhance list with rich tables

- Add resolve_plist_name() for flexible plist identifier resolution
  - Accepts: stem, label, filename, or full path
  - Provides helpful error messages for ambiguous or missing plists
- Update edit, launch, show, unload, delete to use plist names instead of command paths
- Enhance list command with rich library for formatted three-column table
  - Agent Name | Command | Details (args, intervals, calendar)
- Add rich>=13.0.0 dependency to script header and pyproject.toml
- Update tests to use plist name resolution
- Update README with new usage examples

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-01-13 10:50:27 -08:00
commit b41c42d781
5 changed files with 199 additions and 69 deletions

View file

@ -22,7 +22,7 @@ Or clone and run the script directly (it's already executable).
## Usage
```bash
# List all mcquack-managed LaunchAgents
# List all mcquack-managed LaunchAgents (shows formatted table)
mcquack list
# Create and load an executable as a LaunchAgent
@ -34,20 +34,24 @@ mcquack create mycommand
# Create with additional arguments (note the -- separator)
mcquack create /path/to/your/script -- --arg1 value1 --arg2
# Manage LaunchAgents by name (after creation)
# You can use: just the stem ("myscript"), the label ("mcquack.eblume.myscript"),
# the filename ("mcquack.eblume.myscript.plist"), or the full path
# Kickstart (immediately run) the LaunchAgent
mcquack launch /path/to/your/script
mcquack launch myscript
# Show the current arguments configured in the plist
mcquack show /path/to/your/script
mcquack show myscript
# Edit the plist in $EDITOR (falls back to vi)
mcquack edit /path/to/your/script
mcquack edit myscript
# Unload (stop) the LaunchAgent
mcquack unload /path/to/your/script
mcquack unload myscript
# Delete the LaunchAgent plist file
mcquack delete /path/to/your/script
mcquack delete myscript
```
### Command Resolution: Paths vs Commands

View file

@ -1,7 +1,7 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = ["typer>=0.9.0"]
# dependencies = ["typer>=0.9.0", "rich>=13.0.0"]
# ///
"""mcquack - A simple macOS LaunchAgent manager for executable scripts."""
@ -13,6 +13,8 @@ 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")
@ -83,6 +85,75 @@ def resolve_command(command: str) -> tuple[Path, bool]:
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.
@ -193,16 +264,48 @@ def list_agents() -> None:
typer.echo("No mcquack-managed LaunchAgents found.")
return
console = Console()
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Agent Name", style="green")
table.add_column("Command", style="yellow")
table.add_column("Details", style="dim")
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}")
# 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:
typer.echo(f"{plist_path.name}: error reading ({e})")
table.add_row(plist_path.name, f"error: {e}", "-")
console.print(table)
@app.command()
@ -325,18 +428,22 @@ def create(
@app.command()
def launch(
command: Annotated[str, typer.Argument(help="Command or path to the executable")],
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> 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)
"""Kickstart a LaunchAgent.
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist. Use 'create' first.", err=True)
raise typer.Exit(1)
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")
label = get_label(command_path)
uid = os.getuid()
result = subprocess.run(
@ -354,16 +461,16 @@ def launch(
@app.command()
def show(
command: Annotated[str, typer.Argument(help="Command or path to the executable")],
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> 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)
"""Show the arguments configured in a plist.
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist.", err=True)
raise typer.Exit(1)
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)
@ -392,20 +499,19 @@ def show(
@app.command()
def edit(
command: Annotated[str, typer.Argument(help="Command or path to the executable")],
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> None:
"""Edit a plist in $EDITOR.
Opens the plist file for the given command in your default editor.
Opens the plist file 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)
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")
@ -420,6 +526,11 @@ def edit(
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
@ -428,21 +539,26 @@ def edit(
typer.echo(f"Warning: launchctl load failed: {result.stderr}", err=True)
raise typer.Exit(1)
typer.echo(f"Reloaded: {get_label(command_path)}")
typer.echo(f"Reloaded: {label}")
@app.command()
def unload(
command: Annotated[str, typer.Argument(help="Command or path to the executable")],
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> 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)
"""Unload a LaunchAgent.
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist.", err=True)
raise typer.Exit(1)
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
@ -451,21 +567,21 @@ def unload(
typer.echo(f"Error: launchctl unload failed: {result.stderr}", err=True)
raise typer.Exit(1)
typer.echo(f"Unloaded: {get_label(command_path)}")
typer.echo(f"Unloaded: {label}")
@app.command()
def delete(
command: Annotated[str, typer.Argument(help="Command or path to the executable")],
plist_identifier: Annotated[str, typer.Argument(help="Plist name, label, stem, or full path")],
) -> 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)
"""Delete a LaunchAgent plist.
if not plist_path.exists():
typer.echo(f"Error: {plist_path} does not exist.", err=True)
raise typer.Exit(1)
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)

View file

@ -7,7 +7,7 @@ authors = [
{ name = "Erich Blume", email = "blume.erich@gmail.com" }
]
requires-python = ">=3.14"
dependencies = ["typer>=0.9.0"]
dependencies = ["typer>=0.9.0", "rich>=13.0.0"]
[project.scripts]
mcquack = "mcquack:app"

View file

@ -127,7 +127,7 @@ class TestEdit:
monkeypatch.setattr(subprocess, "run", patched_run)
monkeypatch.setenv("EDITOR", "test-editor")
result = runner.invoke(app, ["edit", str(mock_script)])
result = runner.invoke(app, ["edit", "test_script"])
assert result.exit_code == 0
assert "Edited:" in result.stdout
assert "Reloaded:" in result.stdout
@ -153,16 +153,18 @@ class TestEdit:
monkeypatch.setattr(subprocess, "run", patched_run)
monkeypatch.delenv("EDITOR", raising=False)
result = runner.invoke(app, ["edit", str(mock_script)])
result = runner.invoke(app, ["edit", "test_script"])
assert result.exit_code == 0
assert len(editor_calls) == 1
assert editor_calls[0][0] == "vi"
def test_edit_nonexistent(self, mock_dirs, mock_script):
"""Test editing nonexistent agent."""
result = runner.invoke(app, ["edit", str(mock_script)])
result = runner.invoke(app, ["edit", "test_script"])
assert result.exit_code == 1
assert "does not exist" in result.output
assert ("No plist found" in result.output or
"does not exist" in result.output or
"No mcquack-managed LaunchAgents found" in result.output)
def test_edit_editor_failure(self, mock_dirs, mock_script, mock_launchctl, monkeypatch):
"""Test that edit fails if editor exits with error."""
@ -178,7 +180,7 @@ class TestEdit:
monkeypatch.setattr(subprocess, "run", patched_run)
monkeypatch.setenv("EDITOR", "failing-editor")
result = runner.invoke(app, ["edit", str(mock_script)])
result = runner.invoke(app, ["edit", "test_script"])
assert result.exit_code == 1
assert "editor exited with code" in result.output
@ -196,7 +198,7 @@ class TestShow:
cmd += ["--"] + script_args
runner.invoke(app, cmd)
result = runner.invoke(app, ["show", str(mock_script)])
result = runner.invoke(app, ["show", "test_script"])
assert result.exit_code == 0
assert "Label:" in result.stdout
assert "test_script" in result.stdout
@ -204,9 +206,11 @@ class TestShow:
def test_show_nonexistent(self, mock_dirs, mock_script):
"""Test showing nonexistent agent."""
result = runner.invoke(app, ["show", str(mock_script)])
result = runner.invoke(app, ["show", "test_script"])
assert result.exit_code == 1
assert "does not exist" in result.output
assert ("No plist found" in result.output or
"does not exist" in result.output or
"No mcquack-managed LaunchAgents found" in result.output)
class TestDelete:
@ -214,7 +218,7 @@ class TestDelete:
"""Test deleting an agent."""
runner.invoke(app, ["create", str(mock_script)])
result = runner.invoke(app, ["delete", str(mock_script)])
result = runner.invoke(app, ["delete", "test_script"])
assert result.exit_code == 0
assert "Deleted:" in result.stdout
@ -224,9 +228,11 @@ class TestDelete:
def test_delete_nonexistent(self, mock_dirs, mock_script):
"""Test deleting nonexistent agent."""
result = runner.invoke(app, ["delete", str(mock_script)])
result = runner.invoke(app, ["delete", "test_script"])
assert result.exit_code == 1
assert "does not exist" in result.output
assert ("No plist found" in result.output or
"does not exist" in result.output or
"No mcquack-managed LaunchAgents found" in result.output)
class TestArgumentSeparator:
@ -437,7 +443,7 @@ class TestStartInterval:
"""Test that show command displays StartInterval."""
runner.invoke(app, ["create", str(mock_script), "--interval", "300"])
result = runner.invoke(app, ["show", str(mock_script)])
result = runner.invoke(app, ["show", "test_script"])
assert result.exit_code == 0
assert "StartInterval: 300" in result.stdout or "300" in result.stdout
@ -491,7 +497,7 @@ class TestThrottleInterval:
"""Test that show command displays ThrottleInterval."""
runner.invoke(app, ["create", str(mock_script), "--interval", "300", "--throttle", "60"])
result = runner.invoke(app, ["show", str(mock_script)])
result = runner.invoke(app, ["show", "test_script"])
assert result.exit_code == 0
assert "ThrottleInterval: 60" in result.stdout
@ -960,7 +966,7 @@ class TestStartCalendarInterval:
"""Test that show command displays StartCalendarInterval."""
runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour=2,Minute=0"])
result = runner.invoke(app, ["show", str(mock_script)])
result = runner.invoke(app, ["show", "test_script"])
assert result.exit_code == 0
assert "StartCalendarInterval" in result.stdout

6
uv.lock generated
View file

@ -85,6 +85,7 @@ name = "mcquack"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
{ name = "typer" },
]
@ -95,7 +96,10 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "typer", specifier = ">=0.9.0" }]
requires-dist = [
{ name = "rich", specifier = ">=13.0.0" },
{ name = "typer", specifier = ">=0.9.0" },
]
[package.metadata.requires-dev]
dev = [