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:
parent
e55cff15ee
commit
b41c42d781
5 changed files with 199 additions and 69 deletions
16
README.md
16
README.md
|
|
@ -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
|
||||
|
|
|
|||
210
mcquack.py
210
mcquack.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
6
uv.lock
generated
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue