taskswarm-cli 0.1.0__py3-none-any.whl

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.
taskswarm/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Argument-parsing entry point. Ported from src/cli.ts (which uses
4
+ `commander`); this port uses the stdlib `argparse` to avoid a CLI-framework
5
+ dependency. Subcommands, flags, and `--json` output shapes are kept
6
+ equivalent to the npm CLI's `--help` output and behavior. Console entry
7
+ points: `taskswarm` / `taskswarm-cli`, both installed via
8
+ python/pyproject.toml's `[project.scripts]`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ import uuid
17
+ from datetime import datetime, timezone
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from .adapters.claude_code_adapter import install_claude_code_hooks
21
+ from .adapters.generic_adapter import GenericAdapter
22
+ from .adapters.types import AdapterValidationError
23
+ from .client.api_client import ApiClientError, get_sessions, post_event
24
+ from .client.tasks_registry import TaskRecord, add_task, list_tasks
25
+ from .schema.events import AGENT_STATUSES, AGENT_TYPES
26
+ from .server.config import load_or_create_config, rotate_token
27
+ from .server.server import start_server
28
+
29
+ PACKAGE_VERSION = "0.1.0"
30
+
31
+
32
+ def _print_json(data: Any) -> None:
33
+ print(json.dumps(data, indent=2))
34
+
35
+
36
+ def _fail(message: str, as_json: bool = False) -> None:
37
+ """Reports a command failure and sets a non-zero exit code. Honors the
38
+ CLI's `--json` contract: when the invocation requested `--json`, the
39
+ error is printed as parseable `{"error": "<message>"}` on stdout (never
40
+ stderr, so a caller piping/parsing stdout still gets valid JSON) instead
41
+ of the plain-text `Error: ...` message."""
42
+ if as_json:
43
+ _print_json({"error": message})
44
+ else:
45
+ print(f"Error: {message}", file=sys.stderr)
46
+
47
+
48
+ def _cmd_start(args: argparse.Namespace) -> int:
49
+ try:
50
+ running = start_server()
51
+ except OSError as error:
52
+ _fail(f"failed to start server: {error}", args.json)
53
+ return 1
54
+
55
+ if args.json:
56
+ _print_json({"url": running.url, "host": running.config.host, "port": running.config.port})
57
+ else:
58
+ print(f"TaskSwarm server listening on http://{running.config.host}:{running.config.port}")
59
+ print(f"Live status page: {running.url}")
60
+ print("Press Ctrl+C to stop.")
61
+
62
+ try:
63
+ while True:
64
+ import time
65
+
66
+ time.sleep(3600)
67
+ except KeyboardInterrupt:
68
+ running.close()
69
+ return 0
70
+
71
+
72
+ def _cmd_task_add(args: argparse.Namespace) -> int:
73
+ record = TaskRecord(
74
+ id=str(uuid.uuid4()),
75
+ title=args.title,
76
+ repo=args.repo,
77
+ created_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
78
+ )
79
+ try:
80
+ add_task(record)
81
+ except OSError as error:
82
+ _fail(f"failed to save task: {error}", args.json)
83
+ return 1
84
+
85
+ if args.json:
86
+ _print_json(record.to_dict())
87
+ else:
88
+ print(f"Task created: {record.id}")
89
+ print(f" title: {record.title}")
90
+ print(f" repo: {record.repo}")
91
+ return 0
92
+
93
+
94
+ def _cmd_task_list(args: argparse.Namespace) -> int:
95
+ tasks = list_tasks()
96
+ status_by_id: Dict[str, str] = {}
97
+ try:
98
+ config = load_or_create_config()
99
+ sessions = get_sessions(config.to_dict())
100
+ for session in sessions:
101
+ status_by_id[session["session_id"]] = session["latest"]["status"]
102
+ except ApiClientError:
103
+ # Server not running (or unreachable) -- task list still works, just
104
+ # without live status enrichment.
105
+ pass
106
+
107
+ rows = [{**task, "status": status_by_id.get(task["id"], "unknown")} for task in tasks]
108
+
109
+ if args.json:
110
+ _print_json(rows)
111
+ return 0
112
+ if len(rows) == 0:
113
+ print("No tasks yet. Create one with `taskswarm task add --title <t> --repo <path>`.")
114
+ return 0
115
+ for row in rows:
116
+ print(f"{row['id']} [{row['status']}] {row['title']} ({row['repo']})")
117
+ return 0
118
+
119
+
120
+ def _cmd_agent_report_status(args: argparse.Namespace) -> int:
121
+ adapter = GenericAdapter()
122
+ raw_input: Dict[str, Any] = {
123
+ "session_id": args.task,
124
+ "repo": args.repo,
125
+ "status": args.state,
126
+ "agent_type": args.agent_type,
127
+ }
128
+ if args.blocked_reason:
129
+ raw_input["blocked_reason"] = args.blocked_reason
130
+
131
+ try:
132
+ input_data = adapter.to_event_input(raw_input)
133
+ config = load_or_create_config()
134
+ event = post_event(config.to_dict(), input_data)
135
+ except (AdapterValidationError, ApiClientError) as error:
136
+ _fail(str(error), args.json)
137
+ return 1
138
+
139
+ if args.json:
140
+ _print_json(event)
141
+ else:
142
+ print(f"Reported {event['session_id']} -> {event['status']}")
143
+ return 0
144
+
145
+
146
+ def _cmd_token_rotate(args: argparse.Namespace) -> int:
147
+ try:
148
+ new_token = rotate_token()
149
+ except OSError as error:
150
+ _fail(f"failed to rotate token: {error}", args.json)
151
+ return 1
152
+
153
+ if args.json:
154
+ _print_json({"token": new_token})
155
+ else:
156
+ print("Bearer token rotated. Update any configured clients/hooks with the new value:")
157
+ print(new_token)
158
+ return 0
159
+
160
+
161
+ def _cmd_hooks_install(args: argparse.Namespace) -> int:
162
+ if args.adapter != "claude-code":
163
+ _fail(f'unknown adapter "{args.adapter}". Supported adapters: claude-code', args.json)
164
+ return 1
165
+ try:
166
+ result = install_claude_code_hooks(
167
+ scope=args.scope,
168
+ project_dir=args.project_dir,
169
+ home_dir=os.path.expanduser("~"),
170
+ )
171
+ except (AdapterValidationError, OSError) as error:
172
+ _fail(f"failed to install hooks: {error}", args.json)
173
+ return 1
174
+
175
+ if args.json:
176
+ _print_json(result.to_dict())
177
+ elif result.changed:
178
+ print(f"Installed Claude Code Stop/Notification hooks -> {result.settings_path}")
179
+ else:
180
+ print(f"Claude Code hooks already installed at {result.settings_path}")
181
+ return 0
182
+
183
+
184
+ def _cmd_hooks_claude_code_relay(args: argparse.Namespace) -> int:
185
+ """Reads a Claude Code hook payload from stdin and relays it to the
186
+ local TaskSwarm server. Installed automatically by `taskswarm hooks
187
+ install claude-code`; not meant to be run by hand. This command must
188
+ never fail the hook (which could interrupt the coding session), so
189
+ every error path logs to stderr and still exits 0."""
190
+ from .adapters.claude_code_adapter import ClaudeCodeAdapter
191
+
192
+ try:
193
+ raw = sys.stdin.read()
194
+ payload = json.loads(raw)
195
+ adapter = ClaudeCodeAdapter()
196
+ input_data = adapter.to_event_input(payload)
197
+ config = load_or_create_config()
198
+ post_event(config.to_dict(), input_data)
199
+ except Exception as error: # noqa: BLE001 -- must never propagate a nonzero exit into the hook
200
+ sys.stderr.write(f"taskswarm hook relay: {error}\n")
201
+ return 0
202
+
203
+
204
+ def build_parser() -> argparse.ArgumentParser:
205
+ parser = argparse.ArgumentParser(
206
+ prog="taskswarm",
207
+ description=(
208
+ "Self-hosted, event-driven coordination for parallel coding-agent sessions "
209
+ "(Claude Code, Codex, Cursor)."
210
+ ),
211
+ )
212
+ parser.add_argument("--version", action="version", version=f"taskswarm {PACKAGE_VERSION}")
213
+ subparsers = parser.add_subparsers(dest="command")
214
+
215
+ start_parser = subparsers.add_parser(
216
+ "start", help="Start the TaskSwarm server and print the live status page URL"
217
+ )
218
+ start_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
219
+ start_parser.set_defaults(func=_cmd_start)
220
+
221
+ task_parser = subparsers.add_parser("task", help="Manage locally tracked tasks")
222
+ task_subparsers = task_parser.add_subparsers(dest="task_command")
223
+
224
+ task_add_parser = task_subparsers.add_parser("add", help="Register a new task")
225
+ task_add_parser.add_argument("--title", required=True, help="human-readable task title")
226
+ task_add_parser.add_argument("--repo", required=True, help="path to the repository the task operates on")
227
+ task_add_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
228
+ task_add_parser.set_defaults(func=_cmd_task_add)
229
+
230
+ task_list_parser = task_subparsers.add_parser(
231
+ "list", help="List tracked tasks, enriched with live status when the server is reachable"
232
+ )
233
+ task_list_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
234
+ task_list_parser.set_defaults(func=_cmd_task_list)
235
+
236
+ agent_parser = subparsers.add_parser("agent", help="Report agent session status")
237
+ agent_subparsers = agent_parser.add_subparsers(dest="agent_command")
238
+
239
+ report_status_parser = agent_subparsers.add_parser(
240
+ "report-status",
241
+ help="Report a status transition for a task/session to the local TaskSwarm server",
242
+ )
243
+ report_status_parser.add_argument("--task", required=True, help="task/session id (the id returned by `task add`)")
244
+ report_status_parser.add_argument("--repo", required=True, help="path to the repository the session operates on")
245
+ report_status_parser.add_argument("--state", required=True, choices=AGENT_STATUSES, help="new status")
246
+ report_status_parser.add_argument(
247
+ "--blocked-reason", dest="blocked_reason", default=None,
248
+ help="reason, shown when status is blocked/needs-review/failed",
249
+ )
250
+ report_status_parser.add_argument(
251
+ "--agent-type", dest="agent_type", choices=AGENT_TYPES, default="generic", help="reporting agent"
252
+ )
253
+ report_status_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
254
+ report_status_parser.set_defaults(func=_cmd_agent_report_status)
255
+
256
+ token_parser = subparsers.add_parser("token", help="Manage the API bearer token")
257
+ token_subparsers = token_parser.add_subparsers(dest="token_command")
258
+
259
+ token_rotate_parser = token_subparsers.add_parser(
260
+ "rotate", help="Generate a new bearer token, invalidating the old one"
261
+ )
262
+ token_rotate_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
263
+ token_rotate_parser.set_defaults(func=_cmd_token_rotate)
264
+
265
+ hooks_parser = subparsers.add_parser("hooks", help="Manage agent hook integrations")
266
+ hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_command")
267
+
268
+ hooks_install_parser = hooks_subparsers.add_parser(
269
+ "install", help="Install TaskSwarm hooks for an agent integration (currently: claude-code)"
270
+ )
271
+ hooks_install_parser.add_argument("adapter", help="adapter name, e.g. claude-code")
272
+ hooks_install_parser.add_argument(
273
+ "--scope", choices=["project", "local", "user"], default="project",
274
+ help="settings.json scope to write hooks into",
275
+ )
276
+ hooks_install_parser.add_argument(
277
+ "--project-dir", dest="project_dir", default=os.getcwd(),
278
+ help="project directory (for project/local scope)",
279
+ )
280
+ hooks_install_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
281
+ hooks_install_parser.set_defaults(func=_cmd_hooks_install)
282
+
283
+ hooks_relay_parser = hooks_subparsers.add_parser(
284
+ "claude-code-relay",
285
+ help=(
286
+ "Internal: reads a Claude Code hook payload from stdin and relays it to the "
287
+ "local TaskSwarm server. Installed automatically by `taskswarm hooks install "
288
+ "claude-code`; not meant to be run by hand."
289
+ ),
290
+ )
291
+ hooks_relay_parser.set_defaults(func=_cmd_hooks_claude_code_relay)
292
+
293
+ return parser
294
+
295
+
296
+ def run_cli(argv: List[str]) -> int:
297
+ """`argv` follows the sys.argv convention: argv[0] is the program name,
298
+ the real arguments start at argv[1]. Returns the process exit code."""
299
+ parser = build_parser()
300
+ args = parser.parse_args(argv[1:])
301
+
302
+ if not hasattr(args, "func"):
303
+ parser.print_help()
304
+ return 0
305
+ return args.func(args)
306
+
307
+
308
+ def main() -> None:
309
+ try:
310
+ code = run_cli(sys.argv)
311
+ except SystemExit:
312
+ raise
313
+ except KeyboardInterrupt:
314
+ sys.exit(130)
315
+ except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
316
+ print(str(error), file=sys.stderr)
317
+ sys.exit(1)
318
+ else:
319
+ sys.exit(code)
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
File without changes
@@ -0,0 +1,61 @@
1
+ """Talks to the local TaskSwarm server over HTTP. Ported from
2
+ src/cli/api-client.ts. Uses only `urllib.request` -- no HTTP client
3
+ dependency, matching the zero-runtime-dependency goal of this package."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any, Dict, List
10
+
11
+
12
+ class ApiClientError(Exception):
13
+ def __init__(self, message: str, context: Any = None) -> None:
14
+ super().__init__(message)
15
+ self.context = context
16
+
17
+
18
+ def _base_url(host: str, port: int) -> str:
19
+ return f"http://{host}:{port}"
20
+
21
+
22
+ def post_event(config: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]:
23
+ """POSTs an event to the local TaskSwarm server. Raises ApiClientError on any failure."""
24
+ base_url = _base_url(config["host"], config["port"])
25
+ request = urllib.request.Request(
26
+ f"{base_url}/events",
27
+ data=json.dumps(input_data).encode("utf-8"),
28
+ method="POST",
29
+ headers={
30
+ "Content-Type": "application/json",
31
+ "Authorization": f"Bearer {config['token']}",
32
+ },
33
+ )
34
+ try:
35
+ with urllib.request.urlopen(request, timeout=10) as response:
36
+ return json.loads(response.read().decode("utf-8"))
37
+ except urllib.error.HTTPError as error:
38
+ body = error.read().decode("utf-8", errors="replace")
39
+ raise ApiClientError(f"server rejected event ({error.code}): {body}") from error
40
+ except urllib.error.URLError as error:
41
+ raise ApiClientError(
42
+ f"could not reach TaskSwarm server at {base_url} -- is it running? (`taskswarm start`)",
43
+ error,
44
+ ) from error
45
+
46
+
47
+ def get_sessions(config: Dict[str, Any]) -> List[Dict[str, Any]]:
48
+ """GETs current session states from the local TaskSwarm server."""
49
+ base_url = _base_url(config["host"], config["port"])
50
+ request = urllib.request.Request(
51
+ f"{base_url}/events",
52
+ headers={"Authorization": f"Bearer {config['token']}"},
53
+ )
54
+ try:
55
+ with urllib.request.urlopen(request, timeout=10) as response:
56
+ data = json.loads(response.read().decode("utf-8"))
57
+ except urllib.error.HTTPError as error:
58
+ raise ApiClientError(f"server returned {error.code}") from error
59
+ except urllib.error.URLError as error:
60
+ raise ApiClientError(f"could not reach TaskSwarm server at {base_url}", error) from error
61
+ return data.get("sessions", [])
@@ -0,0 +1,119 @@
1
+ """Local, server-independent registry of tasks the user has created via
2
+ `taskswarm task add`. TaskSwarm's wire schema (AgentEvent) is intentionally
3
+ generic and has no `title` field, so human-friendly titles live here rather
4
+ than on the server -- `task add` works even before the server has ever been
5
+ started. Ported from src/cli/tasks-registry.ts."""
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import time
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List
14
+
15
+ from ..server.config import get_taskswarm_home
16
+ from ..util.sync_sleep import sleep_sync_ms
17
+
18
+
19
+ @dataclass
20
+ class TaskRecord:
21
+ id: str
22
+ title: str
23
+ repo: str
24
+ created_at: str
25
+
26
+ def to_dict(self) -> Dict[str, Any]:
27
+ return asdict(self)
28
+
29
+
30
+ def get_tasks_registry_path() -> str:
31
+ return str(Path(get_taskswarm_home()) / "tasks.json")
32
+
33
+
34
+ def _ensure_home_dir(path: str) -> None:
35
+ directory = os.path.dirname(path)
36
+ if directory and not os.path.exists(directory):
37
+ os.makedirs(directory, mode=0o700, exist_ok=True)
38
+
39
+
40
+ def list_tasks() -> List[Dict[str, Any]]:
41
+ path = get_tasks_registry_path()
42
+ if not os.path.exists(path):
43
+ return []
44
+ raw = Path(path).read_text(encoding="utf-8").strip()
45
+ if len(raw) == 0:
46
+ return []
47
+ return json.loads(raw)
48
+
49
+
50
+ _LOCK_RETRY_DELAY_MS = 10
51
+ _LOCK_TIMEOUT_MS = 5000
52
+ # A lock older than this is assumed to be left behind by a crashed process.
53
+ _STALE_LOCK_MS = 10000
54
+
55
+
56
+ def _lock_path_for(registry_path: str) -> str:
57
+ return f"{registry_path}.lock"
58
+
59
+
60
+ def _acquire_lock(lock_path: str) -> None:
61
+ """Acquires an exclusive lock by atomically creating `lock_path`
62
+ (O_CREAT|O_EXCL, so exactly one concurrent caller can win). Retries with
63
+ backoff while the lock is held by someone else, reclaiming it if it
64
+ looks abandoned (e.g. the holder crashed before releasing it) so a dead
65
+ lock file can never wedge the CLI forever."""
66
+ deadline = time.monotonic() + (_LOCK_TIMEOUT_MS / 1000.0)
67
+ while True:
68
+ try:
69
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
70
+ os.close(fd)
71
+ return
72
+ except FileExistsError:
73
+ try:
74
+ age_ms = (time.time() - os.stat(lock_path).st_mtime) * 1000
75
+ if age_ms > _STALE_LOCK_MS:
76
+ os.unlink(lock_path)
77
+ continue
78
+ except FileNotFoundError:
79
+ # Lock vanished between our EEXIST and this stat (the holder
80
+ # just finished) -- loop around and try to acquire it again.
81
+ continue
82
+ if time.monotonic() > deadline:
83
+ raise TimeoutError(
84
+ f"timed out waiting for the tasks registry lock at {lock_path} "
85
+ "(another taskswarm process may be stuck)"
86
+ )
87
+ sleep_sync_ms(_LOCK_RETRY_DELAY_MS)
88
+
89
+
90
+ def _release_lock(lock_path: str) -> None:
91
+ try:
92
+ os.unlink(lock_path)
93
+ except FileNotFoundError:
94
+ # best-effort: if it's already gone (e.g. reclaimed as stale by
95
+ # another waiter) there's nothing left to release.
96
+ pass
97
+
98
+
99
+ def add_task(record: TaskRecord) -> None:
100
+ """Registers a new task. Guarded by a lockfile around the
101
+ read-modify-write cycle so concurrent add_task() calls (e.g. several
102
+ agent sessions starting at once) never race: without it, two callers
103
+ can both read the same on-disk list, each append their own record in
104
+ memory, and the second write silently overwrites the first caller's
105
+ write, dropping a task with no error."""
106
+ path = get_tasks_registry_path()
107
+ _ensure_home_dir(path)
108
+ lock_path = _lock_path_for(path)
109
+ _acquire_lock(lock_path)
110
+ try:
111
+ tasks = list_tasks()
112
+ tasks.append(record.to_dict())
113
+ Path(path).write_text(json.dumps(tasks, indent=2) + "\n", encoding="utf-8")
114
+ try:
115
+ os.chmod(path, 0o600)
116
+ except OSError:
117
+ pass
118
+ finally:
119
+ _release_lock(lock_path)
@@ -0,0 +1,3 @@
1
+ from .dispatch import NotifyOptions, notify, should_notify
2
+
3
+ __all__ = ["NotifyOptions", "notify", "should_notify"]
@@ -0,0 +1,88 @@
1
+ """Decides whether a status transition warrants a notification, and fires
2
+ the enabled channels. Ported from src/notify/index.ts."""
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Dict, Optional
8
+
9
+ from ..schema.events import AgentEvent, NOTIFY_ON_STATUSES
10
+ from .ntfy import send_ntfy_notification
11
+ from .os_notify import send_os_notification
12
+
13
+
14
+ @dataclass
15
+ class NotifyOptions:
16
+ ntfy: Optional[Dict[str, Any]] = None
17
+ # Injectable for tests; defaults to the real OS notifier.
18
+ os_notifier: Optional[Callable[[str, str], None]] = None
19
+ # Injectable for tests; defaults to the real ntfy.sh sender.
20
+ ntfy_sender: Optional[Callable[[str, str, str], None]] = None
21
+ # Called whenever the ntfy channel raises, so failures never crash the server.
22
+ on_ntfy_error: Optional[Callable[[BaseException], None]] = None
23
+
24
+
25
+ def should_notify(
26
+ status: str,
27
+ previous_status: Optional[str],
28
+ blocked_reason: Optional[str] = None,
29
+ previous_blocked_reason: Optional[str] = None,
30
+ ) -> bool:
31
+ """True if a transition from `previous_status`/`previous_blocked_reason`
32
+ to `status`/`blocked_reason` should notify.
33
+
34
+ Dedup keys on the (status, blocked_reason) pair, not status alone: two
35
+ different permission prompts in a row are both 'needs-review', and two
36
+ distinct idle nudges in a row are both 'blocked', but each carries a
37
+ different (or newly-present) blocked_reason and is a genuinely new event
38
+ a human should see -- not a duplicate to swallow. Only a same-status
39
+ event whose blocked_reason also didn't change is treated as a repeat."""
40
+ if status not in NOTIFY_ON_STATUSES:
41
+ return False
42
+ if status != previous_status:
43
+ return True
44
+ return blocked_reason != previous_blocked_reason
45
+
46
+
47
+ def _format_message(event: AgentEvent) -> "tuple[str, str]":
48
+ title = f"TaskSwarm: {event.session_id} {event.status}"
49
+ parts = [f"repo: {event.repo}", f"agent: {event.agent_type}"]
50
+ if event.blocked_reason:
51
+ parts.append(f"reason: {event.blocked_reason}")
52
+ return title, " | ".join(parts)
53
+
54
+
55
+ def notify(
56
+ event: AgentEvent,
57
+ previous_status: Optional[str],
58
+ previous_blocked_reason: Optional[str],
59
+ options: Optional[NotifyOptions] = None,
60
+ ) -> None:
61
+ """Evaluates an event against its session's previous status/
62
+ blocked_reason and fires the enabled notification channels if the
63
+ transition warrants it. Local OS notification is always-on; ntfy.sh
64
+ only fires when explicitly opted in."""
65
+ options = options or NotifyOptions()
66
+ if not should_notify(event.status, previous_status, event.blocked_reason, previous_blocked_reason):
67
+ return
68
+
69
+ title, message = _format_message(event)
70
+ os_notifier = options.os_notifier or send_os_notification
71
+ os_notifier(title, message)
72
+
73
+ ntfy_config = options.ntfy or {}
74
+ if ntfy_config.get("enabled") and ntfy_config.get("topicUrl"):
75
+ ntfy_sender = options.ntfy_sender or send_ntfy_notification
76
+
77
+ def _fire() -> None:
78
+ try:
79
+ ntfy_sender(ntfy_config["topicUrl"], title, message)
80
+ except Exception as error: # noqa: BLE001 -- notification channel must never raise into the caller
81
+ if options.on_ntfy_error:
82
+ options.on_ntfy_error(error)
83
+
84
+ # Fire-and-forget, same as the TS version's un-awaited
85
+ # `ntfySender(...).catch(...)`: the network call to ntfy.sh must
86
+ # never block the HTTP response path for the /events POST that
87
+ # triggered it.
88
+ threading.Thread(target=_fire, daemon=True).start()
@@ -0,0 +1,61 @@
1
+ """ntfy.sh channel -- explicitly opt-in only. Never called unless the caller
2
+ has an enabled ntfy config with a topic URL; TaskSwarm's "self-hosted by
3
+ default" claim depends on this never firing implicitly. Ported from
4
+ src/notify/ntfy.ts."""
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import urllib.error
9
+ import urllib.request
10
+ from urllib.parse import urlparse
11
+
12
+ _PRIVATE_OR_LINK_LOCAL_IPV4 = [
13
+ re.compile(r"^10\."),
14
+ re.compile(r"^172\.(1[6-9]|2\d|3[01])\."),
15
+ re.compile(r"^192\.168\."),
16
+ re.compile(r"^169\.254\."), # includes cloud metadata endpoints, e.g. 169.254.169.254
17
+ re.compile(r"^0\."),
18
+ ]
19
+
20
+
21
+ def validate_ntfy_url(topic_url: str):
22
+ """Rejects a topic URL that isn't https (unless it targets loopback,
23
+ useful for a local self-hosted ntfy instance during testing) or that
24
+ targets a private/link-local address -- guards against a misconfigured
25
+ or tampered config.json silently sending event data in plaintext or to
26
+ an internal/metadata endpoint."""
27
+ parsed = urlparse(topic_url)
28
+ if not parsed.scheme or not parsed.hostname:
29
+ raise ValueError(f'invalid ntfy topic URL: "{topic_url}"')
30
+ hostname = parsed.hostname
31
+ is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
32
+ if parsed.scheme != "https" and not is_loopback:
33
+ raise ValueError(
34
+ f'ntfy topic URL must use https:// (got "{parsed.scheme}") -- plaintext http:// '
35
+ "is only allowed for a loopback host, to avoid sending notification content "
36
+ "unencrypted over a network"
37
+ )
38
+ if not is_loopback and any(pattern.match(hostname) for pattern in _PRIVATE_OR_LINK_LOCAL_IPV4):
39
+ raise ValueError(
40
+ f'ntfy topic URL host "{hostname}" is a private/link-local address -- refusing to '
41
+ "send notifications there by default"
42
+ )
43
+ return parsed
44
+
45
+
46
+ def send_ntfy_notification(topic_url: str, title: str, message: str) -> None:
47
+ validate_ntfy_url(topic_url)
48
+ request = urllib.request.Request(
49
+ topic_url,
50
+ data=message.encode("utf-8"),
51
+ method="POST",
52
+ headers={"Title": title, "Priority": "default"},
53
+ )
54
+ try:
55
+ with urllib.request.urlopen(request, timeout=10) as response:
56
+ if response.status >= 400:
57
+ raise RuntimeError(f"ntfy.sh notification failed: {response.status}")
58
+ except urllib.error.HTTPError as error:
59
+ raise RuntimeError(f"ntfy.sh notification failed: {error.code} {error.reason}") from error
60
+ except urllib.error.URLError as error:
61
+ raise RuntimeError(f"ntfy.sh notification failed: {error.reason}") from error