diff --git a/README.md b/README.md index a0f5893..88eab10 100644 --- a/README.md +++ b/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 diff --git a/mcquack.py b/mcquack.py index 7b242ed..2e6d97b 100755 --- a/mcquack.py +++ b/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) diff --git a/pyproject.toml b/pyproject.toml index 4e8f136..ab81cbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/test_mcquack.py b/test_mcquack.py index 27bc70f..0990dc8 100644 --- a/test_mcquack.py +++ b/test_mcquack.py @@ -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 diff --git a/uv.lock b/uv.lock index 8a2a132..0161610 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [