hookbell 0.2.0__tar.gz → 0.3.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.
- {hookbell-0.2.0/hookbell.egg-info → hookbell-0.3.0}/PKG-INFO +22 -3
- {hookbell-0.2.0 → hookbell-0.3.0}/README.md +21 -2
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/__init__.py +1 -1
- hookbell-0.3.0/hookbell/cli.py +145 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/sns.py +2 -2
- {hookbell-0.2.0 → hookbell-0.3.0/hookbell.egg-info}/PKG-INFO +22 -3
- {hookbell-0.2.0 → hookbell-0.3.0}/pyproject.toml +2 -2
- hookbell-0.3.0/tests/test_cli.py +251 -0
- hookbell-0.2.0/hookbell/cli.py +0 -68
- hookbell-0.2.0/tests/test_cli.py +0 -116
- {hookbell-0.2.0 → hookbell-0.3.0}/LICENSE +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/MANIFEST.in +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/docs/CONTRIBUTING.md +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/__init__.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/event.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/stdin.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/transcript.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/__init__.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/base.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/factory.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/slack.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notify_style.py +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/py.typed +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/SOURCES.txt +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/dependency_links.txt +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/entry_points.txt +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/not-zip-safe +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/requires.txt +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/top_level.txt +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/setup.cfg +0 -0
- {hookbell-0.2.0 → hookbell-0.3.0}/tests/test_notify_style.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hookbell
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Notifies Slack, as a Claude Code hook or as a notify-compatible CLI.
|
|
5
5
|
Author-email: Yukihiko Shinoda <yuk.hik.future@gmail.com>
|
|
6
6
|
Maintainer-email: Yukihiko Shinoda <yuk.hik.future@gmail.com>
|
|
@@ -147,8 +147,27 @@ Point Claude Code's `Notification`, `PermissionRequest`, and `Stop` hooks at hoo
|
|
|
147
147
|
|
|
148
148
|
Hookbell recognizes a Claude Code hook payload by its `transcript_path` field and posts a message
|
|
149
149
|
built from that transcript: the assistant's own last message when there is one, or a description of
|
|
150
|
-
the pending permission request otherwise. A failed notification here is
|
|
151
|
-
|
|
150
|
+
the pending permission request otherwise. A failed notification here is swallowed rather than raised,
|
|
151
|
+
so a flaky network never turns into hook-failure noise; turn on logging (below) to see why it failed.
|
|
152
|
+
|
|
153
|
+
### How do I see why a notification failed?
|
|
154
|
+
|
|
155
|
+
Hookbell writes no log by default. Add `--log-level` to the command (or set `HOOKBELL_LOG_LEVEL`) to
|
|
156
|
+
write hookbell's own logs to `$XDG_CACHE_HOME/hookbell/slack.log` (`~/.cache/hookbell/slack.log` when
|
|
157
|
+
`XDG_CACHE_HOME` is unset):
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{ "type": "command", "command": "uvx hookbell --log-level warning" }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The log file lives outside your project, so it never ends up in a commit, and only you can read it.
|
|
164
|
+
At `debug`, the log includes the raw hook payload, which carries your conversation and tool inputs.
|
|
165
|
+
|
|
166
|
+
Other libraries stay at WARNING whatever `--log-level` you choose, because their DEBUG output can
|
|
167
|
+
contain credentials — the AWS SDK logs request headers and response bodies verbatim. When you really
|
|
168
|
+
need it, `--dangerously-debug-all-loggers` sets every logger to DEBUG. It is accepted only on the
|
|
169
|
+
command line, never from an environment variable, and prints a warning on every run; remove it as
|
|
170
|
+
soon as you finish debugging.
|
|
152
171
|
|
|
153
172
|
### How do I use hookbell in a Docker container?
|
|
154
173
|
|
|
@@ -91,8 +91,27 @@ Point Claude Code's `Notification`, `PermissionRequest`, and `Stop` hooks at hoo
|
|
|
91
91
|
|
|
92
92
|
Hookbell recognizes a Claude Code hook payload by its `transcript_path` field and posts a message
|
|
93
93
|
built from that transcript: the assistant's own last message when there is one, or a description of
|
|
94
|
-
the pending permission request otherwise. A failed notification here is
|
|
95
|
-
|
|
94
|
+
the pending permission request otherwise. A failed notification here is swallowed rather than raised,
|
|
95
|
+
so a flaky network never turns into hook-failure noise; turn on logging (below) to see why it failed.
|
|
96
|
+
|
|
97
|
+
### How do I see why a notification failed?
|
|
98
|
+
|
|
99
|
+
Hookbell writes no log by default. Add `--log-level` to the command (or set `HOOKBELL_LOG_LEVEL`) to
|
|
100
|
+
write hookbell's own logs to `$XDG_CACHE_HOME/hookbell/slack.log` (`~/.cache/hookbell/slack.log` when
|
|
101
|
+
`XDG_CACHE_HOME` is unset):
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{ "type": "command", "command": "uvx hookbell --log-level warning" }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The log file lives outside your project, so it never ends up in a commit, and only you can read it.
|
|
108
|
+
At `debug`, the log includes the raw hook payload, which carries your conversation and tool inputs.
|
|
109
|
+
|
|
110
|
+
Other libraries stay at WARNING whatever `--log-level` you choose, because their DEBUG output can
|
|
111
|
+
contain credentials — the AWS SDK logs request headers and response bodies verbatim. When you really
|
|
112
|
+
need it, `--dangerously-debug-all-loggers` sets every logger to DEBUG. It is accepted only on the
|
|
113
|
+
command line, never from an environment variable, and prints a warning on every run; remove it as
|
|
114
|
+
soon as you finish debugging.
|
|
96
115
|
|
|
97
116
|
### How do I use hookbell in a Docker container?
|
|
98
117
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Copyright (c) 2026 Yukihiko Shinoda
|
|
2
|
+
"""Console script for hookbell."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from logging import DEBUG
|
|
9
|
+
from logging import WARNING
|
|
10
|
+
from logging import NullHandler
|
|
11
|
+
from logging import basicConfig
|
|
12
|
+
from logging import getLogger
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from hookbell.claude_code.event import ClaudeCodeHookEvent
|
|
18
|
+
from hookbell.claude_code.stdin import ClaudeCodeStdin
|
|
19
|
+
from hookbell.notifiers.factory import NotifierFactory
|
|
20
|
+
from hookbell.notify_style import PlainTextNotification
|
|
21
|
+
|
|
22
|
+
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cache_home() -> Path:
|
|
26
|
+
"""Return the XDG base directory for user-specific cache files.
|
|
27
|
+
|
|
28
|
+
Per the XDG Base Directory Specification, an unset, empty, or relative $XDG_CACHE_HOME is ignored in favor of
|
|
29
|
+
~/.cache.
|
|
30
|
+
"""
|
|
31
|
+
xdg_cache_home = Path(os.environ.get("XDG_CACHE_HOME", ""))
|
|
32
|
+
return xdg_cache_home if xdg_cache_home.is_absolute() else Path.home() / ".cache"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def log_file_path() -> Path:
|
|
36
|
+
"""Return hookbell's log file path under the user's cache directory.
|
|
37
|
+
|
|
38
|
+
The path is absolute on purpose: Claude Code runs hooks with the user's project as the current directory, so a
|
|
39
|
+
relative path would land in that project's working tree, where it could be committed.
|
|
40
|
+
"""
|
|
41
|
+
return _cache_home() / "hookbell" / "slack.log"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_root_logger_to_log_file(root_level: int) -> None:
|
|
45
|
+
log_file = log_file_path()
|
|
46
|
+
log_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
47
|
+
# The log file can hold sensitive data (raw hook payloads, or credentials when every logger is at DEBUG), so
|
|
48
|
+
# only the owner may read it. touch()'s mode applies only on creation, so chmod() covers a pre-existing file.
|
|
49
|
+
log_file.touch(mode=0o600)
|
|
50
|
+
log_file.chmod(0o600)
|
|
51
|
+
basicConfig(filename=log_file, level=root_level)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def configure_logging(level: str | None, *, dangerously_debug_all_loggers: bool = False) -> None:
|
|
55
|
+
"""Configure where and how verbosely hookbell logs.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
level: Level for hookbell's own loggers. Third-party loggers stay at WARNING regardless, since their DEBUG
|
|
59
|
+
output (e.g. botocore's wire traces) can contain credentials. None disables logging entirely.
|
|
60
|
+
dangerously_debug_all_loggers: Set the root logger, and so every third-party logger, to DEBUG. Overrides
|
|
61
|
+
level.
|
|
62
|
+
"""
|
|
63
|
+
if dangerously_debug_all_loggers:
|
|
64
|
+
click.echo(
|
|
65
|
+
f"Warning: --dangerously-debug-all-loggers writes DEBUG logs of every library to {log_file_path()}. "
|
|
66
|
+
"They can include credentials (e.g. AWS SDK wire traces). Remove the option once you finish debugging.",
|
|
67
|
+
err=True,
|
|
68
|
+
)
|
|
69
|
+
_write_root_logger_to_log_file(DEBUG)
|
|
70
|
+
return
|
|
71
|
+
if level is None:
|
|
72
|
+
# Without any handler, the logging module's last-resort handler would still print WARNING and above to
|
|
73
|
+
# stderr.
|
|
74
|
+
basicConfig(handlers=[NullHandler()])
|
|
75
|
+
return
|
|
76
|
+
_write_root_logger_to_log_file(WARNING)
|
|
77
|
+
getLogger("hookbell").setLevel(level.upper())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.command()
|
|
81
|
+
@click.option(
|
|
82
|
+
"--log-level",
|
|
83
|
+
envvar="HOOKBELL_LOG_LEVEL",
|
|
84
|
+
type=click.Choice(LOG_LEVELS, case_sensitive=False),
|
|
85
|
+
default=None,
|
|
86
|
+
help="Write hookbell's logs at this level to $XDG_CACHE_HOME/hookbell/slack.log. Logging is off when omitted.",
|
|
87
|
+
)
|
|
88
|
+
# Reason for no envvar: an environment variable silently persists in shell profiles and container settings, so this
|
|
89
|
+
# option is accepted only on the command line, where it stays visible in the hook command itself.
|
|
90
|
+
@click.option(
|
|
91
|
+
"--dangerously-debug-all-loggers",
|
|
92
|
+
is_flag=True,
|
|
93
|
+
help="Write DEBUG logs of every library, including credentials in AWS SDK wire traces, to the log file.",
|
|
94
|
+
)
|
|
95
|
+
def main(log_level: str | None, *, dangerously_debug_all_loggers: bool) -> int:
|
|
96
|
+
"""Notify Slack, either as a Claude Code hook or from any piped input.
|
|
97
|
+
|
|
98
|
+
Reads stdin (unless it is a TTY) and decides which mode applies: a Claude Code hook payload (JSON carrying
|
|
99
|
+
"transcript_path") is reported through its referenced transcript; anything else is posted as free-form text.
|
|
100
|
+
"""
|
|
101
|
+
configure_logging(log_level, dangerously_debug_all_loggers=dangerously_debug_all_loggers)
|
|
102
|
+
raw_stdin = "" if sys.stdin.isatty() else sys.stdin.read()
|
|
103
|
+
claude_code_stdin = ClaudeCodeStdin.parse(raw_stdin) if raw_stdin else None
|
|
104
|
+
if claude_code_stdin is not None:
|
|
105
|
+
_notify_claude_code_hook(claude_code_stdin)
|
|
106
|
+
else:
|
|
107
|
+
_notify_plain_text(raw_stdin)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _notify_claude_code_hook(claude_code_stdin: ClaudeCodeStdin) -> None:
|
|
112
|
+
# Reason: This runs as a Claude Code hook, where a failed notification is best-effort side-
|
|
113
|
+
# channel noise rather than something that should surface as this hook's own failure. Logging
|
|
114
|
+
# and swallowing keeps a transient issue (a malformed transcript line, the network being
|
|
115
|
+
# briefly down, an unreachable webhook, or both destinations being configured at once) from
|
|
116
|
+
# producing hook-failure feedback for a non-critical path. The failure surface spans stdlib
|
|
117
|
+
# json, pathlib and urllib, boto3, plus future code, so narrowing to specific exception types
|
|
118
|
+
# would leave gaps:
|
|
119
|
+
# - Pylint broad-exception-caught (W0718): no narrower alternative fits an evolving surface
|
|
120
|
+
# https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/broad-exception-caught.html
|
|
121
|
+
try:
|
|
122
|
+
NotifierFactory.from_environment().notify(ClaudeCodeHookEvent(claude_code_stdin).text)
|
|
123
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
124
|
+
getLogger(__name__).exception("Failed to notify Claude Code hook event")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _notify_plain_text(raw_stdin: str) -> None:
|
|
128
|
+
# Reason: same broad failure surface as _notify_claude_code_hook above, but this branch is a
|
|
129
|
+
# direct, interactive invocation, so the failure is surfaced to the caller by re-raising as
|
|
130
|
+
# ClickException instead of only logged. Click's standalone mode ignores this
|
|
131
|
+
# command callback's own return value for the process exit code (only a raised exception, or an
|
|
132
|
+
# explicit ctx.exit(), controls it), and ClickException is the one exception type Click always
|
|
133
|
+
# turns into a clean "Error: ..." message on stderr plus a guaranteed exit code of exactly 1:
|
|
134
|
+
# - Pylint broad-exception-caught (W0718): no narrower alternative fits an evolving surface
|
|
135
|
+
# https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/broad-exception-caught.html
|
|
136
|
+
try:
|
|
137
|
+
NotifierFactory.from_environment().notify(PlainTextNotification(raw_stdin).text)
|
|
138
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
139
|
+
raise click.ClickException(str(error)) from error
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
# Command.main() rather than the equivalent main(): Pylint checks main() against the undecorated signature, not
|
|
144
|
+
# the click.Command the decorators return, so it reports the options Click parses from sys.argv as missing.
|
|
145
|
+
sys.exit(main.main()) # pragma: no cover
|
|
@@ -20,8 +20,8 @@ class SnsNotifier(Notifier):
|
|
|
20
20
|
https://docs.aws.amazon.com/chatbot/latest/adminguide/custom-notifs.html#event-schema
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
-
TOPIC_ARN_SECRET_PATH = Path("/run/secrets/
|
|
24
|
-
AWS_PROFILE_SECRET_PATH = Path("/run/secrets/
|
|
23
|
+
TOPIC_ARN_SECRET_PATH = Path("/run/secrets/hookbell_sns_topic_arn")
|
|
24
|
+
AWS_PROFILE_SECRET_PATH = Path("/run/secrets/hookbell_aws_profile")
|
|
25
25
|
TOPIC_ARN_ENVIRONMENT_VARIABLE = "HOOKBELL_SNS_TOPIC_ARN"
|
|
26
26
|
AWS_PROFILE_ENVIRONMENT_VARIABLE = "HOOKBELL_AWS_PROFILE"
|
|
27
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hookbell
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Notifies Slack, as a Claude Code hook or as a notify-compatible CLI.
|
|
5
5
|
Author-email: Yukihiko Shinoda <yuk.hik.future@gmail.com>
|
|
6
6
|
Maintainer-email: Yukihiko Shinoda <yuk.hik.future@gmail.com>
|
|
@@ -147,8 +147,27 @@ Point Claude Code's `Notification`, `PermissionRequest`, and `Stop` hooks at hoo
|
|
|
147
147
|
|
|
148
148
|
Hookbell recognizes a Claude Code hook payload by its `transcript_path` field and posts a message
|
|
149
149
|
built from that transcript: the assistant's own last message when there is one, or a description of
|
|
150
|
-
the pending permission request otherwise. A failed notification here is
|
|
151
|
-
|
|
150
|
+
the pending permission request otherwise. A failed notification here is swallowed rather than raised,
|
|
151
|
+
so a flaky network never turns into hook-failure noise; turn on logging (below) to see why it failed.
|
|
152
|
+
|
|
153
|
+
### How do I see why a notification failed?
|
|
154
|
+
|
|
155
|
+
Hookbell writes no log by default. Add `--log-level` to the command (or set `HOOKBELL_LOG_LEVEL`) to
|
|
156
|
+
write hookbell's own logs to `$XDG_CACHE_HOME/hookbell/slack.log` (`~/.cache/hookbell/slack.log` when
|
|
157
|
+
`XDG_CACHE_HOME` is unset):
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{ "type": "command", "command": "uvx hookbell --log-level warning" }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The log file lives outside your project, so it never ends up in a commit, and only you can read it.
|
|
164
|
+
At `debug`, the log includes the raw hook payload, which carries your conversation and tool inputs.
|
|
165
|
+
|
|
166
|
+
Other libraries stay at WARNING whatever `--log-level` you choose, because their DEBUG output can
|
|
167
|
+
contain credentials — the AWS SDK logs request headers and response bodies verbatim. When you really
|
|
168
|
+
need it, `--dangerously-debug-all-loggers` sets every logger to DEBUG. It is accepted only on the
|
|
169
|
+
command line, never from an environment variable, and prints a warning on every run; remove it as
|
|
170
|
+
soon as you finish debugging.
|
|
152
171
|
|
|
153
172
|
### How do I use hookbell in a Docker container?
|
|
154
173
|
|
|
@@ -15,7 +15,7 @@ dev = [
|
|
|
15
15
|
|
|
16
16
|
[project]
|
|
17
17
|
name = "hookbell"
|
|
18
|
-
version = "0.
|
|
18
|
+
version = "0.3.0"
|
|
19
19
|
description = "Notifies Slack, as a Claude Code hook or as a notify-compatible CLI."
|
|
20
20
|
readme = "README.md"
|
|
21
21
|
requires-python = ">=3.8"
|
|
@@ -79,7 +79,7 @@ hookbell = "hookbell.cli:main"
|
|
|
79
79
|
skips = ["tests/*"]
|
|
80
80
|
|
|
81
81
|
[tool.bumpversion]
|
|
82
|
-
current_version = "0.
|
|
82
|
+
current_version = "0.3.0"
|
|
83
83
|
commit = true
|
|
84
84
|
tag = true
|
|
85
85
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Copyright (c) 2026 Yukihiko Shinoda
|
|
2
|
+
"""Tests for hookbell.cli."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
|
|
11
|
+
# Reason: Bandit flags any import of subprocess, but its only use here runs a constant argv without a shell, which
|
|
12
|
+
# TestLoggingConfiguration needs to observe the logging configuration in a fresh interpreter:
|
|
13
|
+
# - Blacklist Imports - B404: import_subprocess — Bandit documentation
|
|
14
|
+
# https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess
|
|
15
|
+
import subprocess # nosec B404
|
|
16
|
+
import sys
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
import pytest
|
|
21
|
+
from click.testing import CliRunner
|
|
22
|
+
|
|
23
|
+
from hookbell import cli
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from unittest.mock import MagicMock
|
|
28
|
+
|
|
29
|
+
from pytest_mock import MockerFixture
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def _slack_webhook_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
monkeypatch.setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/T000/B000/XXX")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture(name="urlopen")
|
|
38
|
+
def _urlopen_fixture(mocker: MockerFixture) -> MagicMock:
|
|
39
|
+
mock = mocker.patch("hookbell.notifiers.slack.request.urlopen")
|
|
40
|
+
mock.return_value.__enter__.return_value.read.return_value = b"ok"
|
|
41
|
+
return mock
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestMainHelp:
|
|
45
|
+
def test_shows_usage(self) -> None:
|
|
46
|
+
result = CliRunner().invoke(cli.main, ["--help"])
|
|
47
|
+
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
assert "Show this message and exit." in result.output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestMainPlainTextMode:
|
|
53
|
+
"""Tests for main() when stdin is not a Claude Code hook payload."""
|
|
54
|
+
|
|
55
|
+
def test_notifies_the_default_text_when_stdin_was_not_piped(self, urlopen: MagicMock) -> None:
|
|
56
|
+
result = CliRunner().invoke(cli.main, input=None)
|
|
57
|
+
|
|
58
|
+
assert result.exit_code == 0
|
|
59
|
+
posted = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
|
60
|
+
assert posted["blocks"][0]["text"]["text"] == "Finished!"
|
|
61
|
+
|
|
62
|
+
def test_notifies_the_piped_plain_text(self, urlopen: MagicMock) -> None:
|
|
63
|
+
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
64
|
+
|
|
65
|
+
assert result.exit_code == 0
|
|
66
|
+
posted = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
|
67
|
+
assert "Hello world" in posted["blocks"][0]["text"]["text"]
|
|
68
|
+
|
|
69
|
+
def test_reports_notification_failures_as_a_clean_error_and_exits_1(self, mocker: MockerFixture) -> None:
|
|
70
|
+
mocker.patch("hookbell.notifiers.slack.request.urlopen", side_effect=OSError("boom"))
|
|
71
|
+
|
|
72
|
+
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
73
|
+
|
|
74
|
+
assert result.exit_code == 1
|
|
75
|
+
assert "Error: boom" in result.output
|
|
76
|
+
|
|
77
|
+
def test_reports_both_destinations_configured_as_a_clean_error_and_exits_1(
|
|
78
|
+
self,
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Report the NotifierFactory conflict error the same way as any other notification failure."""
|
|
82
|
+
monkeypatch.setenv("HOOKBELL_SNS_TOPIC_ARN", "arn:aws:sns:us-east-1:123456789012:hookbell")
|
|
83
|
+
|
|
84
|
+
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
85
|
+
|
|
86
|
+
assert result.exit_code == 1
|
|
87
|
+
assert "exactly one destination" in result.output
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestMainClaudeCodeHookMode:
|
|
91
|
+
"""Tests for main() when stdin is a Claude Code hook payload."""
|
|
92
|
+
|
|
93
|
+
def test_notifies_through_the_referenced_transcript(self, tmp_path: Path, urlopen: MagicMock) -> None:
|
|
94
|
+
"""Notify with text composed from the payload's referenced transcript."""
|
|
95
|
+
entry = {"message": {"content": [{"type": "text", "text": "Hello from assistant"}]}}
|
|
96
|
+
transcript_path = tmp_path / "transcript.jsonl"
|
|
97
|
+
transcript_path.write_text(json.dumps(entry) + "\n", encoding="utf-8")
|
|
98
|
+
payload = json.dumps({"hook_event_name": "Stop", "transcript_path": str(transcript_path)})
|
|
99
|
+
|
|
100
|
+
result = CliRunner().invoke(cli.main, input=payload)
|
|
101
|
+
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
posted = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
|
104
|
+
assert "Hello from assistant" in posted["blocks"][0]["text"]["text"]
|
|
105
|
+
|
|
106
|
+
def test_swallows_notification_failures_and_still_exits_0(self, tmp_path: Path) -> None:
|
|
107
|
+
payload = json.dumps({"hook_event_name": "Stop", "transcript_path": str(tmp_path / "missing.jsonl")})
|
|
108
|
+
|
|
109
|
+
result = CliRunner().invoke(cli.main, input=payload)
|
|
110
|
+
|
|
111
|
+
assert result.exit_code == 0
|
|
112
|
+
|
|
113
|
+
def test_swallows_both_destinations_configured_and_still_exits_0(
|
|
114
|
+
self,
|
|
115
|
+
tmp_path: Path,
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Swallow the NotifierFactory conflict error the same way as any other notification failure."""
|
|
119
|
+
monkeypatch.setenv("HOOKBELL_SNS_TOPIC_ARN", "arn:aws:sns:us-east-1:123456789012:hookbell")
|
|
120
|
+
entry = {"message": {"content": [{"type": "text", "text": "Hello from assistant"}]}}
|
|
121
|
+
transcript_path = tmp_path / "transcript.jsonl"
|
|
122
|
+
transcript_path.write_text(json.dumps(entry) + "\n", encoding="utf-8")
|
|
123
|
+
payload = json.dumps({"hook_event_name": "Stop", "transcript_path": str(transcript_path)})
|
|
124
|
+
|
|
125
|
+
result = CliRunner().invoke(cli.main, input=payload)
|
|
126
|
+
|
|
127
|
+
assert result.exit_code == 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestMainLogging:
|
|
131
|
+
"""Tests for how main() hands its logging options to configure_logging()."""
|
|
132
|
+
|
|
133
|
+
@pytest.mark.usefixtures("urlopen")
|
|
134
|
+
def test_writes_no_log_file_by_default(self) -> None:
|
|
135
|
+
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
136
|
+
|
|
137
|
+
assert result.exit_code == 0
|
|
138
|
+
assert not cli.log_file_path().exists()
|
|
139
|
+
|
|
140
|
+
@pytest.mark.usefixtures("urlopen")
|
|
141
|
+
def test_log_level_option_enables_the_log_file(self) -> None:
|
|
142
|
+
result = CliRunner().invoke(cli.main, ["--log-level", "debug"], input="Hello world\n")
|
|
143
|
+
|
|
144
|
+
assert result.exit_code == 0
|
|
145
|
+
assert cli.log_file_path().is_file()
|
|
146
|
+
|
|
147
|
+
@pytest.mark.usefixtures("urlopen")
|
|
148
|
+
def test_log_level_is_read_from_the_environment(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
149
|
+
"""Accept HOOKBELL_LOG_LEVEL so the level can be set without editing the hook command."""
|
|
150
|
+
monkeypatch.setenv("HOOKBELL_LOG_LEVEL", "info")
|
|
151
|
+
|
|
152
|
+
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
153
|
+
|
|
154
|
+
assert result.exit_code == 0
|
|
155
|
+
assert cli.log_file_path().is_file()
|
|
156
|
+
|
|
157
|
+
def test_rejects_an_unknown_log_level(self) -> None:
|
|
158
|
+
result = CliRunner().invoke(cli.main, ["--log-level", "verbose"], input="Hello world\n")
|
|
159
|
+
|
|
160
|
+
assert result.exit_code == click.UsageError.exit_code
|
|
161
|
+
|
|
162
|
+
@pytest.mark.usefixtures("urlopen")
|
|
163
|
+
def test_dangerously_debug_all_loggers_warns_on_stderr(self) -> None:
|
|
164
|
+
# result.output includes stderr both on Click < 8.2 (mixed by default) and on Click >= 8.2 (interleaved).
|
|
165
|
+
result = CliRunner().invoke(cli.main, ["--dangerously-debug-all-loggers"], input="Hello world\n")
|
|
166
|
+
|
|
167
|
+
assert result.exit_code == 0
|
|
168
|
+
assert "Warning: --dangerously-debug-all-loggers" in result.output
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestConfigureLogging:
|
|
172
|
+
"""Tests for configure_logging(), observed in a fresh interpreter per test."""
|
|
173
|
+
|
|
174
|
+
# Loggers whose DEBUG output contains wire traces: request/response bodies, headers, and URLs that can carry
|
|
175
|
+
# credentials (e.g. botocore.endpoint's Authorization header, botocore.parsers' STS/SSO response bodies).
|
|
176
|
+
WIRE_TRACE_LOGGERS = ("botocore", "boto3", "urllib3", "httpx", "httpcore")
|
|
177
|
+
|
|
178
|
+
# Reason: The configuration must be observed in a fresh interpreter. Under pytest, the root logger already
|
|
179
|
+
# has pytest's own handlers, so logging.basicConfig() silently does nothing and an in-process check would pass
|
|
180
|
+
# regardless of what hookbell configures.
|
|
181
|
+
PROBE_SCRIPT = """
|
|
182
|
+
import json
|
|
183
|
+
import logging
|
|
184
|
+
import sys
|
|
185
|
+
|
|
186
|
+
from hookbell.cli import configure_logging
|
|
187
|
+
|
|
188
|
+
configure_logging(**json.loads(sys.argv[1]))
|
|
189
|
+
print(json.dumps({name: logging.getLogger(name).getEffectiveLevel() for name in sys.argv[2:]}))
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def test_wire_trace_loggers_not_debug(self, tmp_path: Path) -> None:
|
|
193
|
+
"""Keep third-party wire trace loggers above DEBUG even when hookbell's own logs are at DEBUG."""
|
|
194
|
+
effective_levels = self._probe(tmp_path, tmp_path / "cache", level="DEBUG")
|
|
195
|
+
|
|
196
|
+
assert effective_levels.pop("hookbell") == logging.DEBUG
|
|
197
|
+
assert {name: level for name, level in effective_levels.items() if level <= logging.DEBUG} == {}
|
|
198
|
+
|
|
199
|
+
def test_dangerously_debug_all_loggers_sets_wire_trace_loggers_to_debug(self, tmp_path: Path) -> None:
|
|
200
|
+
effective_levels = self._probe(tmp_path, tmp_path / "cache", level=None, dangerously_debug_all_loggers=True)
|
|
201
|
+
|
|
202
|
+
assert set(effective_levels.values()) == {logging.DEBUG}
|
|
203
|
+
|
|
204
|
+
def test_writes_no_log_file_when_level_is_none(self, tmp_path: Path) -> None:
|
|
205
|
+
cache_home = tmp_path / "cache"
|
|
206
|
+
|
|
207
|
+
self._probe(tmp_path, cache_home, level=None)
|
|
208
|
+
|
|
209
|
+
assert not cache_home.exists()
|
|
210
|
+
|
|
211
|
+
def test_writes_log_file_under_cache_home_not_working_directory(self, tmp_path: Path) -> None:
|
|
212
|
+
"""Keep the log file out of the working tree of whichever project Claude Code runs the hook in."""
|
|
213
|
+
working_directory = tmp_path / "project"
|
|
214
|
+
working_directory.mkdir()
|
|
215
|
+
cache_home = tmp_path / "cache"
|
|
216
|
+
|
|
217
|
+
self._probe(working_directory, cache_home, level="DEBUG")
|
|
218
|
+
|
|
219
|
+
assert (cache_home / "hookbell" / "slack.log").is_file()
|
|
220
|
+
assert not list(working_directory.iterdir())
|
|
221
|
+
|
|
222
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Windows doesn't support POSIX permission bits.")
|
|
223
|
+
def test_log_file_is_readable_only_by_its_owner(self, tmp_path: Path) -> None:
|
|
224
|
+
cache_home = tmp_path / "cache"
|
|
225
|
+
|
|
226
|
+
self._probe(tmp_path, cache_home, level="DEBUG")
|
|
227
|
+
|
|
228
|
+
assert stat.S_IMODE((cache_home / "hookbell" / "slack.log").stat().st_mode) == 0o600 # noqa: PLR2004
|
|
229
|
+
|
|
230
|
+
def _probe(self, cwd: Path, cache_home: Path, **kwargs: str | bool | None) -> dict[str, int]:
|
|
231
|
+
"""Run configure_logging(**kwargs) in a fresh interpreter and return the effective level of each logger.
|
|
232
|
+
|
|
233
|
+
XDG_CACHE_HOME points at a test-owned directory so the probe never writes into the real user's ~/.cache.
|
|
234
|
+
"""
|
|
235
|
+
# Reason: Every argv element is sys.executable, a constant of this class, or JSON-encoded test arguments, and
|
|
236
|
+
# no shell is used, but Ruff can't trace the attribute references back to their constant values, and Bandit
|
|
237
|
+
# flags every shell-less call regardless of the input:
|
|
238
|
+
# - Ruff rules - subprocess-without-shell-equals-true (S603)
|
|
239
|
+
# https://docs.astral.sh/ruff/rules/subprocess-without-shell-equals-true/
|
|
240
|
+
# - B603: Test for use of subprocess without shell equals true — Bandit documentation
|
|
241
|
+
# https://bandit.readthedocs.io/en/latest/plugins/b603_subprocess_without_shell_equals_true.html
|
|
242
|
+
completed = subprocess.run( # noqa: S603 # nosec B603
|
|
243
|
+
[sys.executable, "-c", self.PROBE_SCRIPT, json.dumps(kwargs), "hookbell", *self.WIRE_TRACE_LOGGERS],
|
|
244
|
+
capture_output=True,
|
|
245
|
+
check=True,
|
|
246
|
+
cwd=cwd,
|
|
247
|
+
env={**os.environ, "XDG_CACHE_HOME": str(cache_home)},
|
|
248
|
+
text=True,
|
|
249
|
+
)
|
|
250
|
+
effective_levels: dict[str, int] = json.loads(completed.stdout)
|
|
251
|
+
return effective_levels
|
hookbell-0.2.0/hookbell/cli.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2026 Yukihiko Shinoda
|
|
2
|
-
"""Console script for hookbell."""
|
|
3
|
-
|
|
4
|
-
import sys
|
|
5
|
-
from logging import DEBUG
|
|
6
|
-
from logging import basicConfig
|
|
7
|
-
from logging import getLogger
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
import click
|
|
11
|
-
|
|
12
|
-
from hookbell.claude_code.event import ClaudeCodeHookEvent
|
|
13
|
-
from hookbell.claude_code.stdin import ClaudeCodeStdin
|
|
14
|
-
from hookbell.notifiers.factory import NotifierFactory
|
|
15
|
-
from hookbell.notify_style import PlainTextNotification
|
|
16
|
-
|
|
17
|
-
basicConfig(filename=Path("slack.log"), level=DEBUG)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@click.command()
|
|
21
|
-
def main() -> int:
|
|
22
|
-
"""Notify Slack, either as a Claude Code hook or from any piped input.
|
|
23
|
-
|
|
24
|
-
Reads stdin (unless it is a TTY) and decides which mode applies: a Claude Code hook payload (JSON carrying
|
|
25
|
-
"transcript_path") is reported through its referenced transcript; anything else is posted as free-form text.
|
|
26
|
-
"""
|
|
27
|
-
raw_stdin = "" if sys.stdin.isatty() else sys.stdin.read()
|
|
28
|
-
claude_code_stdin = ClaudeCodeStdin.parse(raw_stdin) if raw_stdin else None
|
|
29
|
-
if claude_code_stdin is not None:
|
|
30
|
-
_notify_claude_code_hook(claude_code_stdin)
|
|
31
|
-
else:
|
|
32
|
-
_notify_plain_text(raw_stdin)
|
|
33
|
-
return 0
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _notify_claude_code_hook(claude_code_stdin: ClaudeCodeStdin) -> None:
|
|
37
|
-
# Reason: This runs as a Claude Code hook, where a failed notification is best-effort side-
|
|
38
|
-
# channel noise rather than something that should surface as this hook's own failure. Logging
|
|
39
|
-
# and swallowing keeps a transient issue (a malformed transcript line, the network being
|
|
40
|
-
# briefly down, an unreachable webhook, or both destinations being configured at once) from
|
|
41
|
-
# producing hook-failure feedback for a non-critical path. The failure surface spans stdlib
|
|
42
|
-
# json, pathlib and urllib, boto3, plus future code, so narrowing to specific exception types
|
|
43
|
-
# would leave gaps:
|
|
44
|
-
# - Pylint broad-exception-caught (W0718): no narrower alternative fits an evolving surface
|
|
45
|
-
# https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/broad-exception-caught.html
|
|
46
|
-
try:
|
|
47
|
-
NotifierFactory.from_environment().notify(ClaudeCodeHookEvent(claude_code_stdin).text)
|
|
48
|
-
except Exception: # pylint: disable=broad-exception-caught
|
|
49
|
-
getLogger(__name__).exception("Failed to notify Claude Code hook event")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _notify_plain_text(raw_stdin: str) -> None:
|
|
53
|
-
# Reason: same broad failure surface as _notify_claude_code_hook above, but this branch is a
|
|
54
|
-
# direct, interactive invocation, so the failure is surfaced to the caller by re-raising as
|
|
55
|
-
# ClickException instead of only logged. Click's standalone mode ignores this
|
|
56
|
-
# command callback's own return value for the process exit code (only a raised exception, or an
|
|
57
|
-
# explicit ctx.exit(), controls it), and ClickException is the one exception type Click always
|
|
58
|
-
# turns into a clean "Error: ..." message on stderr plus a guaranteed exit code of exactly 1:
|
|
59
|
-
# - Pylint broad-exception-caught (W0718): no narrower alternative fits an evolving surface
|
|
60
|
-
# https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/broad-exception-caught.html
|
|
61
|
-
try:
|
|
62
|
-
NotifierFactory.from_environment().notify(PlainTextNotification(raw_stdin).text)
|
|
63
|
-
except Exception as error: # pylint: disable=broad-exception-caught
|
|
64
|
-
raise click.ClickException(str(error)) from error
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if __name__ == "__main__":
|
|
68
|
-
sys.exit(main()) # pragma: no cover
|
hookbell-0.2.0/tests/test_cli.py
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2026 Yukihiko Shinoda
|
|
2
|
-
"""Tests for hookbell.cli."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
from click.testing import CliRunner
|
|
11
|
-
|
|
12
|
-
from hookbell import cli
|
|
13
|
-
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from unittest.mock import MagicMock
|
|
17
|
-
|
|
18
|
-
from pytest_mock import MockerFixture
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@pytest.fixture(autouse=True)
|
|
22
|
-
def _slack_webhook_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
23
|
-
monkeypatch.setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/T000/B000/XXX")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@pytest.fixture(name="urlopen")
|
|
27
|
-
def _urlopen_fixture(mocker: MockerFixture) -> MagicMock:
|
|
28
|
-
mock = mocker.patch("hookbell.notifiers.slack.request.urlopen")
|
|
29
|
-
mock.return_value.__enter__.return_value.read.return_value = b"ok"
|
|
30
|
-
return mock
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class TestMainHelp:
|
|
34
|
-
def test_shows_usage(self) -> None:
|
|
35
|
-
result = CliRunner().invoke(cli.main, ["--help"])
|
|
36
|
-
|
|
37
|
-
assert result.exit_code == 0
|
|
38
|
-
assert "--help Show this message and exit." in result.output
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class TestMainPlainTextMode:
|
|
42
|
-
"""Tests for main() when stdin is not a Claude Code hook payload."""
|
|
43
|
-
|
|
44
|
-
def test_notifies_the_default_text_when_stdin_was_not_piped(self, urlopen: MagicMock) -> None:
|
|
45
|
-
result = CliRunner().invoke(cli.main, input=None)
|
|
46
|
-
|
|
47
|
-
assert result.exit_code == 0
|
|
48
|
-
posted = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
|
49
|
-
assert posted["blocks"][0]["text"]["text"] == "Finished!"
|
|
50
|
-
|
|
51
|
-
def test_notifies_the_piped_plain_text(self, urlopen: MagicMock) -> None:
|
|
52
|
-
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
53
|
-
|
|
54
|
-
assert result.exit_code == 0
|
|
55
|
-
posted = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
|
56
|
-
assert "Hello world" in posted["blocks"][0]["text"]["text"]
|
|
57
|
-
|
|
58
|
-
def test_reports_notification_failures_as_a_clean_error_and_exits_1(self, mocker: MockerFixture) -> None:
|
|
59
|
-
mocker.patch("hookbell.notifiers.slack.request.urlopen", side_effect=OSError("boom"))
|
|
60
|
-
|
|
61
|
-
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
62
|
-
|
|
63
|
-
assert result.exit_code == 1
|
|
64
|
-
assert "Error: boom" in result.output
|
|
65
|
-
|
|
66
|
-
def test_reports_both_destinations_configured_as_a_clean_error_and_exits_1(
|
|
67
|
-
self,
|
|
68
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
69
|
-
) -> None:
|
|
70
|
-
"""Report the NotifierFactory conflict error the same way as any other notification failure."""
|
|
71
|
-
monkeypatch.setenv("HOOKBELL_SNS_TOPIC_ARN", "arn:aws:sns:us-east-1:123456789012:hookbell")
|
|
72
|
-
|
|
73
|
-
result = CliRunner().invoke(cli.main, input="Hello world\n")
|
|
74
|
-
|
|
75
|
-
assert result.exit_code == 1
|
|
76
|
-
assert "exactly one destination" in result.output
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class TestMainClaudeCodeHookMode:
|
|
80
|
-
"""Tests for main() when stdin is a Claude Code hook payload."""
|
|
81
|
-
|
|
82
|
-
def test_notifies_through_the_referenced_transcript(self, tmp_path: Path, urlopen: MagicMock) -> None:
|
|
83
|
-
"""Notify with text composed from the payload's referenced transcript."""
|
|
84
|
-
entry = {"message": {"content": [{"type": "text", "text": "Hello from assistant"}]}}
|
|
85
|
-
transcript_path = tmp_path / "transcript.jsonl"
|
|
86
|
-
transcript_path.write_text(json.dumps(entry) + "\n", encoding="utf-8")
|
|
87
|
-
payload = json.dumps({"hook_event_name": "Stop", "transcript_path": str(transcript_path)})
|
|
88
|
-
|
|
89
|
-
result = CliRunner().invoke(cli.main, input=payload)
|
|
90
|
-
|
|
91
|
-
assert result.exit_code == 0
|
|
92
|
-
posted = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
|
93
|
-
assert "Hello from assistant" in posted["blocks"][0]["text"]["text"]
|
|
94
|
-
|
|
95
|
-
def test_swallows_notification_failures_and_still_exits_0(self, tmp_path: Path) -> None:
|
|
96
|
-
payload = json.dumps({"hook_event_name": "Stop", "transcript_path": str(tmp_path / "missing.jsonl")})
|
|
97
|
-
|
|
98
|
-
result = CliRunner().invoke(cli.main, input=payload)
|
|
99
|
-
|
|
100
|
-
assert result.exit_code == 0
|
|
101
|
-
|
|
102
|
-
def test_swallows_both_destinations_configured_and_still_exits_0(
|
|
103
|
-
self,
|
|
104
|
-
tmp_path: Path,
|
|
105
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
106
|
-
) -> None:
|
|
107
|
-
"""Swallow the NotifierFactory conflict error the same way as any other notification failure."""
|
|
108
|
-
monkeypatch.setenv("HOOKBELL_SNS_TOPIC_ARN", "arn:aws:sns:us-east-1:123456789012:hookbell")
|
|
109
|
-
entry = {"message": {"content": [{"type": "text", "text": "Hello from assistant"}]}}
|
|
110
|
-
transcript_path = tmp_path / "transcript.jsonl"
|
|
111
|
-
transcript_path.write_text(json.dumps(entry) + "\n", encoding="utf-8")
|
|
112
|
-
payload = json.dumps({"hook_event_name": "Stop", "transcript_path": str(transcript_path)})
|
|
113
|
-
|
|
114
|
-
result = CliRunner().invoke(cli.main, input=payload)
|
|
115
|
-
|
|
116
|
-
assert result.exit_code == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|