trigr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
trigr-0.1.0/CLAUDE.md ADDED
@@ -0,0 +1,44 @@
1
+ # trigr
2
+
3
+ Lightweight CLI that compiles task specs (TOML) into native macOS `launchd` plists. No daemon — launchd *is* the scheduler.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ TOML task → trigr add → launchd plist → launchd fires → trigr run → execute → log + notify
9
+ ```
10
+
11
+ ## Key Paths
12
+
13
+ - Config: `~/.config/trigr/`
14
+ - Tasks: `~/.config/trigr/tasks/*.toml`
15
+ - Logs: `~/.config/trigr/logs/`
16
+ - DB: `~/.config/trigr/history.db`
17
+ - Plists: `~/Library/LaunchAgents/com.trigr.*.plist`
18
+
19
+ ## Commands
20
+
21
+ - `trigr init` — create dirs, capture env
22
+ - `trigr add <file.toml>` — register + load
23
+ - `trigr remove <name>` — unload + delete
24
+ - `trigr enable/disable <name>` — load/unload in launchd
25
+ - `trigr list [--json]` — show all tasks
26
+ - `trigr show <name> [--json]` — show config
27
+ - `trigr logs [name] [-n 20] [--json]` — run history
28
+ - `trigr run <name>` — execute immediately
29
+ - `trigr edit <name>` — edit in $EDITOR
30
+
31
+ ## Dev
32
+
33
+ ```bash
34
+ uv sync
35
+ uv run pytest
36
+ uv tool install . # installs `trigr` globally
37
+ ```
38
+
39
+ ## Notes
40
+
41
+ - Uses `plistlib` (stdlib) for plist generation
42
+ - `fcntl.flock` for run locking (skip if already running)
43
+ - SQLite for run history, osascript for notifications
44
+ - Env captured at `trigr init` time and baked into plists
trigr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: trigr
3
+ Version: 0.1.0
4
+ Summary: Lightweight CLI that compiles task specs into launchd schedules
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic>=2.11.0
7
+ Requires-Dist: rich>=14.0.0
8
+ Requires-Dist: typer>=0.16.0
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "trigr"
3
+ version = "0.1.0"
4
+ description = "Lightweight CLI that compiles task specs into launchd schedules"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "typer>=0.16.0",
8
+ "pydantic>=2.11.0",
9
+ "rich>=14.0.0",
10
+ ]
11
+
12
+ [project.scripts]
13
+ trigr = "trigr.cli:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=8.4.0", "ruff>=0.11.0"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,333 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import tomllib
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from trigr.config import TASKS_DIR, ensure_init, init
14
+ from trigr.models import TaskConfig
15
+ from trigr.plist import is_loaded, load_plist, plist_path, remove_plist, unload_plist, write_plist
16
+ from trigr.runner import run_task
17
+ from trigr.store import get_runs
18
+
19
+ app = typer.Typer(help="Compile task specs into launchd schedules.", no_args_is_help=True)
20
+ console = Console()
21
+
22
+
23
+ def _load_task_from_file(path: Path) -> TaskConfig:
24
+ """Load and validate a task config from a TOML file."""
25
+ with open(path, "rb") as f:
26
+ data = tomllib.load(f)
27
+ return TaskConfig(**data)
28
+
29
+
30
+ def _all_tasks() -> list[TaskConfig]:
31
+ """Load all task configs from the tasks directory."""
32
+ tasks: list[TaskConfig] = []
33
+ for toml_file in sorted(TASKS_DIR.glob("*.toml")):
34
+ try:
35
+ tasks.append(_load_task_from_file(toml_file))
36
+ except Exception:
37
+ pass
38
+ return tasks
39
+
40
+
41
+ @app.command(name="init")
42
+ def init_cmd() -> None:
43
+ """Initialize trigr: create dirs, capture env, init database."""
44
+ init()
45
+ console.print("Initialized trigr.", style="green")
46
+
47
+
48
+ @app.command()
49
+ def add(file: Path) -> None:
50
+ """Register a task from a TOML file, generate plist, and load into launchd."""
51
+ ensure_init()
52
+ if not file.exists():
53
+ console.print(f"File not found: {file}", style="red")
54
+ raise typer.Exit(1)
55
+
56
+ task = _load_task_from_file(file)
57
+
58
+ # Copy TOML to tasks dir
59
+ dest = TASKS_DIR / f"{task.name}.toml"
60
+ if dest.exists():
61
+ console.print(f"Task '{task.name}' already exists. Use 'trigr remove' first.", style="red")
62
+ raise typer.Exit(1)
63
+
64
+ shutil.copy2(file, dest)
65
+
66
+ # Generate and write plist
67
+ plist_file = write_plist(task)
68
+
69
+ # Load into launchd if enabled
70
+ if task.enabled:
71
+ load_plist(task.name)
72
+ console.print(f"Added and loaded task '{task.name}'.", style="green")
73
+ else:
74
+ console.print(f"Added task '{task.name}' (disabled).", style="yellow")
75
+
76
+ console.print(f" TOML: {dest}")
77
+ console.print(f" Plist: {plist_file}")
78
+
79
+
80
+ @app.command()
81
+ def remove(name: str) -> None:
82
+ """Unload and remove a task (plist + task file)."""
83
+ ensure_init()
84
+ task_file = TASKS_DIR / f"{name}.toml"
85
+
86
+ if not task_file.exists():
87
+ console.print(f"Task '{name}' not found.", style="red")
88
+ raise typer.Exit(1)
89
+
90
+ # Unload from launchd
91
+ unload_plist(name)
92
+ remove_plist(name)
93
+ task_file.unlink()
94
+ console.print(f"Removed task '{name}'.", style="green")
95
+
96
+
97
+ @app.command()
98
+ def enable(name: str) -> None:
99
+ """Load a task into launchd."""
100
+ ensure_init()
101
+ task_file = TASKS_DIR / f"{name}.toml"
102
+ if not task_file.exists():
103
+ console.print(f"Task '{name}' not found.", style="red")
104
+ raise typer.Exit(1)
105
+
106
+ # Regenerate plist in case it's missing
107
+ task = _load_task_from_file(task_file)
108
+ write_plist(task)
109
+
110
+ if load_plist(name):
111
+ console.print(f"Enabled task '{name}'.", style="green")
112
+ else:
113
+ console.print(f"Failed to enable task '{name}'.", style="red")
114
+ raise typer.Exit(1)
115
+
116
+
117
+ @app.command()
118
+ def disable(name: str) -> None:
119
+ """Unload a task from launchd."""
120
+ ensure_init()
121
+ if unload_plist(name):
122
+ console.print(f"Disabled task '{name}'.", style="green")
123
+ else:
124
+ console.print(f"Failed to disable task '{name}'.", style="red")
125
+ raise typer.Exit(1)
126
+
127
+
128
+ @app.command(name="list")
129
+ def list_cmd(
130
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
131
+ ) -> None:
132
+ """List all tasks with status and last run info."""
133
+ ensure_init()
134
+ tasks = _all_tasks()
135
+
136
+ if as_json:
137
+ output = []
138
+ for task in tasks:
139
+ runs = get_runs(task.name, limit=1)
140
+ last_run = runs[0] if runs else None
141
+ output.append({
142
+ "name": task.name,
143
+ "description": task.description,
144
+ "trigger": task.trigger.type.value,
145
+ "action": task.action.type,
146
+ "enabled": task.enabled,
147
+ "loaded": is_loaded(task.name),
148
+ "last_run": last_run,
149
+ })
150
+ typer.echo(json.dumps(output, indent=2))
151
+ return
152
+
153
+ if not tasks:
154
+ console.print("No tasks registered.", style="yellow")
155
+ return
156
+
157
+ table = Table(title="Scheduled Tasks")
158
+ table.add_column("Name", style="cyan")
159
+ table.add_column("Trigger")
160
+ table.add_column("Action")
161
+ table.add_column("Status")
162
+ table.add_column("Last Run")
163
+
164
+ for task in tasks:
165
+ loaded = is_loaded(task.name)
166
+ status = "[green]loaded[/green]" if loaded else "[yellow]unloaded[/yellow]"
167
+
168
+ runs = get_runs(task.name, limit=1)
169
+ if runs:
170
+ last = runs[0]
171
+ code = last["exit_code"]
172
+ color = "green" if code == 0 else "red"
173
+ last_run = f"[{color}]exit {code}[/{color}] @ {last['finished_at'][:19]}"
174
+ else:
175
+ last_run = "[dim]never[/dim]"
176
+
177
+ table.add_row(
178
+ task.name,
179
+ task.trigger.type.value,
180
+ task.action.type,
181
+ status,
182
+ last_run,
183
+ )
184
+
185
+ console.print(table)
186
+
187
+
188
+ @app.command()
189
+ def show(
190
+ name: str,
191
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
192
+ ) -> None:
193
+ """Show full task configuration."""
194
+ ensure_init()
195
+ task_file = TASKS_DIR / f"{name}.toml"
196
+ if not task_file.exists():
197
+ console.print(f"Task '{name}' not found.", style="red")
198
+ raise typer.Exit(1)
199
+
200
+ task = _load_task_from_file(task_file)
201
+
202
+ if as_json:
203
+ typer.echo(task.model_dump_json(indent=2))
204
+ return
205
+
206
+ console.print(f"[bold cyan]{task.name}[/bold cyan]")
207
+ if task.description:
208
+ console.print(f" {task.description}")
209
+ console.print(f" Trigger: {task.trigger.type.value}")
210
+ console.print(f" Action: {task.action.type}")
211
+ console.print(f" Timeout: {task.action.timeout}s")
212
+ console.print(f" Loaded: {is_loaded(name)}")
213
+ console.print(f" Plist: {plist_path(name)}")
214
+ console.print(f" TOML: {task_file}")
215
+
216
+
217
+ @app.command()
218
+ def logs(
219
+ name: str | None = typer.Argument(None, help="Task name (all tasks if omitted)"),
220
+ n: int = typer.Option(20, "-n", help="Number of entries"),
221
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
222
+ ) -> None:
223
+ """Show run history from SQLite."""
224
+ ensure_init()
225
+ runs = get_runs(name, limit=n)
226
+
227
+ if as_json:
228
+ typer.echo(json.dumps(runs, indent=2))
229
+ return
230
+
231
+ if not runs:
232
+ console.print("No runs recorded.", style="yellow")
233
+ return
234
+
235
+ table = Table(title="Run History")
236
+ table.add_column("ID", style="dim")
237
+ table.add_column("Task", style="cyan")
238
+ table.add_column("Started")
239
+ table.add_column("Duration")
240
+ table.add_column("Exit")
241
+
242
+ for run in runs:
243
+ code = run["exit_code"]
244
+ exit_style = "green" if code == 0 else "red"
245
+
246
+ started = run["started_at"][:19]
247
+ # Compute duration
248
+ if run["finished_at"] and run["started_at"]:
249
+ from datetime import datetime
250
+ try:
251
+ s = datetime.fromisoformat(run["started_at"])
252
+ f = datetime.fromisoformat(run["finished_at"])
253
+ dur = f"{(f - s).total_seconds():.1f}s"
254
+ except Exception:
255
+ dur = "?"
256
+ else:
257
+ dur = "?"
258
+
259
+ table.add_row(
260
+ str(run["id"]),
261
+ run["task_name"],
262
+ started,
263
+ dur,
264
+ f"[{exit_style}]{code}[/{exit_style}]",
265
+ )
266
+
267
+ console.print(table)
268
+
269
+
270
+ @app.command()
271
+ def run(name: str) -> None:
272
+ """Execute a task immediately (runner entrypoint for launchd)."""
273
+ ensure_init()
274
+ exit_code = run_task(name)
275
+ raise typer.Exit(exit_code)
276
+
277
+
278
+ @app.command()
279
+ def edit(name: str) -> None:
280
+ """Open task TOML in $EDITOR, re-validate and regenerate plist on save."""
281
+ ensure_init()
282
+ task_file = TASKS_DIR / f"{name}.toml"
283
+ if not task_file.exists():
284
+ console.print(f"Task '{name}' not found.", style="red")
285
+ raise typer.Exit(1)
286
+
287
+ editor = os.environ.get("EDITOR", "nano")
288
+
289
+ # Copy to temp file for editing
290
+ content_before = task_file.read_text()
291
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as tmp:
292
+ tmp.write(content_before)
293
+ tmp_path = tmp.name
294
+
295
+ try:
296
+ subprocess.run([editor, tmp_path], check=True)
297
+ content_after = Path(tmp_path).read_text()
298
+ finally:
299
+ Path(tmp_path).unlink(missing_ok=True)
300
+
301
+ if content_after == content_before:
302
+ console.print("No changes made.", style="yellow")
303
+ return
304
+
305
+ # Validate new content
306
+ try:
307
+ import tomllib as tl
308
+ task = TaskConfig(**tl.loads(content_after))
309
+ except Exception as e:
310
+ console.print(f"Invalid config: {e}", style="red")
311
+ console.print("Changes discarded.", style="yellow")
312
+ raise typer.Exit(1)
313
+
314
+ # Save and regenerate
315
+ was_loaded = is_loaded(name)
316
+ if was_loaded:
317
+ unload_plist(name)
318
+
319
+ task_file.write_text(content_after)
320
+ write_plist(task)
321
+
322
+ if was_loaded:
323
+ load_plist(task.name)
324
+
325
+ console.print(f"Updated task '{name}'.", style="green")
326
+
327
+
328
+ def main() -> None:
329
+ app()
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
@@ -0,0 +1,89 @@
1
+ import os
2
+ import shutil
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+ CONFIG_DIR = Path.home() / ".config" / "trigr"
7
+ TASKS_DIR = CONFIG_DIR / "tasks"
8
+ LOGS_DIR = CONFIG_DIR / "logs"
9
+ LOCKS_DIR = CONFIG_DIR / "locks"
10
+ DB_PATH = CONFIG_DIR / "history.db"
11
+ ENV_FILE = CONFIG_DIR / "env"
12
+ PLIST_DIR = Path.home() / "Library" / "LaunchAgents"
13
+ PLIST_PREFIX = "com.trigr"
14
+
15
+ ENV_KEYS = ["PATH", "HOME", "SHELL", "USER", "LANG"]
16
+
17
+
18
+ def init() -> None:
19
+ """Create dirs, capture env, init SQLite."""
20
+ for d in [CONFIG_DIR, TASKS_DIR, LOGS_DIR, LOCKS_DIR]:
21
+ d.mkdir(parents=True, exist_ok=True)
22
+ PLIST_DIR.mkdir(parents=True, exist_ok=True)
23
+
24
+ # Capture environment
25
+ lines: list[str] = []
26
+ for key in ENV_KEYS:
27
+ val = os.environ.get(key, "")
28
+ if val:
29
+ lines.append(f"{key}={val}")
30
+ trigr_path = shutil.which("trigr")
31
+ if trigr_path:
32
+ lines.append(f"TRIGR_PATH={trigr_path}")
33
+ ENV_FILE.write_text("\n".join(lines) + "\n")
34
+
35
+ # Init SQLite
36
+ _init_db()
37
+
38
+
39
+ def _init_db() -> None:
40
+ con = sqlite3.connect(DB_PATH)
41
+ con.executescript("""
42
+ CREATE TABLE IF NOT EXISTS runs (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ task_name TEXT NOT NULL,
45
+ started_at TEXT NOT NULL,
46
+ finished_at TEXT,
47
+ exit_code INTEGER,
48
+ stdout TEXT,
49
+ stderr TEXT
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_name);
52
+
53
+ CREATE TABLE IF NOT EXISTS state (
54
+ task_name TEXT PRIMARY KEY,
55
+ last_value TEXT,
56
+ updated_at TEXT
57
+ );
58
+ """)
59
+ con.close()
60
+
61
+
62
+ def ensure_init() -> None:
63
+ """Auto-init if config dir doesn't exist."""
64
+ if not CONFIG_DIR.exists():
65
+ init()
66
+ elif not DB_PATH.exists():
67
+ _init_db()
68
+
69
+
70
+ def load_env() -> dict[str, str]:
71
+ """Load captured environment from env file."""
72
+ env: dict[str, str] = {}
73
+ if ENV_FILE.exists():
74
+ for line in ENV_FILE.read_text().splitlines():
75
+ if "=" in line:
76
+ key, _, val = line.partition("=")
77
+ env[key] = val
78
+ return env
79
+
80
+
81
+ def get_trigr_path() -> str:
82
+ """Get the absolute path to the trigr binary."""
83
+ env = load_env()
84
+ if "TRIGR_PATH" in env:
85
+ return env["TRIGR_PATH"]
86
+ path = shutil.which("trigr")
87
+ if path:
88
+ return path
89
+ return "trigr"
@@ -0,0 +1,71 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, model_validator
4
+
5
+
6
+ class TriggerType(str, Enum):
7
+ cron = "cron"
8
+ interval = "interval"
9
+ watch = "watch"
10
+
11
+
12
+ class CronSchedule(BaseModel):
13
+ minute: int | None = None
14
+ hour: int | None = None
15
+ day: int | None = None # day of month
16
+ weekday: int | None = None # 0=Sunday
17
+ month: int | None = None
18
+
19
+
20
+ class TriggerConfig(BaseModel):
21
+ type: TriggerType
22
+ cron: CronSchedule | None = None
23
+ interval_seconds: int | None = None
24
+ watch_paths: list[str] | None = None
25
+
26
+ @model_validator(mode="after")
27
+ def validate_trigger(self) -> "TriggerConfig":
28
+ match self.type:
29
+ case TriggerType.cron:
30
+ if self.cron is None:
31
+ raise ValueError("cron trigger requires [trigger.cron] section")
32
+ case TriggerType.interval:
33
+ if self.interval_seconds is None:
34
+ raise ValueError("interval trigger requires interval_seconds")
35
+ case TriggerType.watch:
36
+ if not self.watch_paths:
37
+ raise ValueError("watch trigger requires watch_paths")
38
+ return self
39
+
40
+
41
+ class ActionConfig(BaseModel):
42
+ type: str # "script" or "claude"
43
+ command: str | None = None
44
+ prompt: str | None = None
45
+ working_dir: str | None = None
46
+ timeout: int = 300
47
+
48
+ @model_validator(mode="after")
49
+ def validate_action(self) -> "ActionConfig":
50
+ if self.type == "script" and not self.command:
51
+ raise ValueError("script action requires command")
52
+ if self.type == "claude" and not self.prompt:
53
+ raise ValueError("claude action requires prompt")
54
+ if self.type not in ("script", "claude"):
55
+ raise ValueError(f"unknown action type: {self.type}")
56
+ return self
57
+
58
+
59
+ class NotifyConfig(BaseModel):
60
+ on_success: bool = False
61
+ on_failure: bool = True
62
+ title: str | None = None
63
+
64
+
65
+ class TaskConfig(BaseModel):
66
+ name: str
67
+ description: str = ""
68
+ trigger: TriggerConfig
69
+ action: ActionConfig
70
+ notify: NotifyConfig = NotifyConfig()
71
+ enabled: bool = True
@@ -0,0 +1,14 @@
1
+ import subprocess
2
+
3
+
4
+ def send_notification(title: str, body: str) -> None:
5
+ """Send a macOS notification via osascript."""
6
+ # Escape double quotes for AppleScript
7
+ title = title.replace('"', '\\"')
8
+ body = body.replace('"', '\\"')
9
+ script = f'display notification "{body}" with title "{title}"'
10
+ subprocess.run(
11
+ ["osascript", "-e", script],
12
+ capture_output=True,
13
+ timeout=10,
14
+ )
@@ -0,0 +1,111 @@
1
+ import plistlib
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from trigr.config import LOGS_DIR, PLIST_DIR, PLIST_PREFIX, get_trigr_path, load_env
6
+ from trigr.models import TaskConfig, TriggerType
7
+
8
+
9
+ def plist_label(name: str) -> str:
10
+ return f"{PLIST_PREFIX}.{name}"
11
+
12
+
13
+ def plist_path(name: str) -> Path:
14
+ return PLIST_DIR / f"{plist_label(name)}.plist"
15
+
16
+
17
+ def generate_plist(task: TaskConfig) -> dict:
18
+ """Generate a launchd plist dict from a TaskConfig."""
19
+ trigr_path = get_trigr_path()
20
+ env = load_env()
21
+ # Remove TRIGR_PATH from env vars passed to plist
22
+ env.pop("TRIGR_PATH", None)
23
+
24
+ plist: dict = {
25
+ "Label": plist_label(task.name),
26
+ "ProgramArguments": [trigr_path, "run", task.name],
27
+ "EnvironmentVariables": env,
28
+ "StandardOutPath": str(LOGS_DIR / f"{task.name}.out.log"),
29
+ "StandardErrorPath": str(LOGS_DIR / f"{task.name}.err.log"),
30
+ "RunAtLoad": False,
31
+ }
32
+
33
+ match task.trigger.type:
34
+ case TriggerType.cron:
35
+ cal: dict = {}
36
+ cron = task.trigger.cron
37
+ assert cron is not None
38
+ if cron.minute is not None:
39
+ cal["Minute"] = cron.minute
40
+ if cron.hour is not None:
41
+ cal["Hour"] = cron.hour
42
+ if cron.day is not None:
43
+ cal["Day"] = cron.day
44
+ if cron.weekday is not None:
45
+ cal["Weekday"] = cron.weekday
46
+ if cron.month is not None:
47
+ cal["Month"] = cron.month
48
+ plist["StartCalendarInterval"] = cal
49
+
50
+ case TriggerType.interval:
51
+ assert task.trigger.interval_seconds is not None
52
+ plist["StartInterval"] = task.trigger.interval_seconds
53
+
54
+ case TriggerType.watch:
55
+ assert task.trigger.watch_paths is not None
56
+ plist["WatchPaths"] = [str(Path(p).expanduser().resolve()) for p in task.trigger.watch_paths]
57
+
58
+ return plist
59
+
60
+
61
+ def write_plist(task: TaskConfig) -> Path:
62
+ """Generate and write plist file. Returns the plist path."""
63
+ plist = generate_plist(task)
64
+ path = plist_path(task.name)
65
+ with open(path, "wb") as f:
66
+ plistlib.dump(plist, f)
67
+ return path
68
+
69
+
70
+ def remove_plist(name: str) -> None:
71
+ """Remove plist file if it exists."""
72
+ path = plist_path(name)
73
+ if path.exists():
74
+ path.unlink()
75
+
76
+
77
+ def load_plist(name: str) -> bool:
78
+ """Load (enable) a plist into launchd. Returns True on success."""
79
+ path = plist_path(name)
80
+ if not path.exists():
81
+ return False
82
+ result = subprocess.run(
83
+ ["launchctl", "load", str(path)],
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ return result.returncode == 0
88
+
89
+
90
+ def unload_plist(name: str) -> bool:
91
+ """Unload (disable) a plist from launchd. Returns True on success."""
92
+ path = plist_path(name)
93
+ if not path.exists():
94
+ return False
95
+ result = subprocess.run(
96
+ ["launchctl", "unload", str(path)],
97
+ capture_output=True,
98
+ text=True,
99
+ )
100
+ return result.returncode == 0
101
+
102
+
103
+ def is_loaded(name: str) -> bool:
104
+ """Check if a task is currently loaded in launchd."""
105
+ label = plist_label(name)
106
+ result = subprocess.run(
107
+ ["launchctl", "list", label],
108
+ capture_output=True,
109
+ text=True,
110
+ )
111
+ return result.returncode == 0