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 +44 -0
- trigr-0.1.0/PKG-INFO +8 -0
- trigr-0.1.0/pyproject.toml +20 -0
- trigr-0.1.0/src/trigr/__init__.py +1 -0
- trigr-0.1.0/src/trigr/cli.py +333 -0
- trigr-0.1.0/src/trigr/config.py +89 -0
- trigr-0.1.0/src/trigr/models.py +71 -0
- trigr-0.1.0/src/trigr/notify.py +14 -0
- trigr-0.1.0/src/trigr/plist.py +111 -0
- trigr-0.1.0/src/trigr/runner.py +109 -0
- trigr-0.1.0/src/trigr/store.py +73 -0
- trigr-0.1.0/tests/__init__.py +0 -0
- trigr-0.1.0/tests/test_models.py +79 -0
- trigr-0.1.0/tests/test_plist.py +59 -0
- trigr-0.1.0/tests/test_runner.py +94 -0
- trigr-0.1.0/uv.lock +313 -0
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,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
|