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.
Files changed (31) hide show
  1. {hookbell-0.2.0/hookbell.egg-info → hookbell-0.3.0}/PKG-INFO +22 -3
  2. {hookbell-0.2.0 → hookbell-0.3.0}/README.md +21 -2
  3. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/__init__.py +1 -1
  4. hookbell-0.3.0/hookbell/cli.py +145 -0
  5. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/sns.py +2 -2
  6. {hookbell-0.2.0 → hookbell-0.3.0/hookbell.egg-info}/PKG-INFO +22 -3
  7. {hookbell-0.2.0 → hookbell-0.3.0}/pyproject.toml +2 -2
  8. hookbell-0.3.0/tests/test_cli.py +251 -0
  9. hookbell-0.2.0/hookbell/cli.py +0 -68
  10. hookbell-0.2.0/tests/test_cli.py +0 -116
  11. {hookbell-0.2.0 → hookbell-0.3.0}/LICENSE +0 -0
  12. {hookbell-0.2.0 → hookbell-0.3.0}/MANIFEST.in +0 -0
  13. {hookbell-0.2.0 → hookbell-0.3.0}/docs/CONTRIBUTING.md +0 -0
  14. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/__init__.py +0 -0
  15. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/event.py +0 -0
  16. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/stdin.py +0 -0
  17. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/claude_code/transcript.py +0 -0
  18. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/__init__.py +0 -0
  19. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/base.py +0 -0
  20. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/factory.py +0 -0
  21. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notifiers/slack.py +0 -0
  22. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/notify_style.py +0 -0
  23. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell/py.typed +0 -0
  24. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/SOURCES.txt +0 -0
  25. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/dependency_links.txt +0 -0
  26. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/entry_points.txt +0 -0
  27. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/not-zip-safe +0 -0
  28. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/requires.txt +0 -0
  29. {hookbell-0.2.0 → hookbell-0.3.0}/hookbell.egg-info/top_level.txt +0 -0
  30. {hookbell-0.2.0 → hookbell-0.3.0}/setup.cfg +0 -0
  31. {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.2.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 logged (see `slack.log`)
151
- rather than raised, so a flaky network never turns into hook-failure noise.
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 logged (see `slack.log`)
95
- rather than raised, so a flaky network never turns into hook-failure noise.
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
 
@@ -3,4 +3,4 @@
3
3
 
4
4
  __author__ = """Yukihiko Shinoda"""
5
5
  __email__ = "yuk.hik.future@gmail.com"
6
- __version__ = "0.2.0"
6
+ __version__ = "0.3.0"
@@ -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/hookbell-sns-topic-arn")
24
- AWS_PROFILE_SECRET_PATH = Path("/run/secrets/hookbell-aws-profile")
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.2.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 logged (see `slack.log`)
151
- rather than raised, so a flaky network never turns into hook-failure noise.
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.2.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.2.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
@@ -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
@@ -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